diff --git a/.codex/skills/moonbridge-webui-design/SKILL.md b/.codex/skills/moonbridge-webui-design/SKILL.md new file mode 100644 index 00000000..66faf28e --- /dev/null +++ b/.codex/skills/moonbridge-webui-design/SKILL.md @@ -0,0 +1,74 @@ +--- +name: moonbridge-webui-design +description: Moon Bridge webui design and component rules. Use whenever changing files under webui/src that affect UI structure, controls, styling, theme tokens, layouts, or interaction behavior. +--- + +# Moon Bridge Webui Design + +## Core Rule + +Prefer official Material Web components from `@material/web` for all common controls. + +Do not hand-roll controls such as switches, buttons, icon buttons, checkboxes, radio buttons, menus, tabs, dialogs, sliders, text fields, or progress indicators unless the user explicitly approves a custom control for that specific case. If a custom control is approved, document why Material Web is insufficient and keep the custom surface isolated. + +Before changing webui UI, read `docs/webui/material-component-debt.md` when it exists. Treat it as the ordered migration backlog for known violations. + +Native ` + `}function oP(e){this.disabled||this.softDisabled||(e.stopPropagation(),!this.dispatchEvent(new Event("remove",{cancelable:!0})))||this.remove()}class Uo extends B3{constructor(){super(...arguments),this.avatar=!1,this.href="",this.target="",this.removeOnly=!1,this.selected=!1}get primaryId(){return this.href?"link":this.removeOnly?"":"button"}get rippleDisabled(){return!this.href&&(this.disabled||this.softDisabled)}get primaryAction(){return this.removeOnly?null:this.renderRoot.querySelector(".primary.action")}getContainerClasses(){return{...super.getContainerClasses(),avatar:this.avatar,disabled:!this.href&&(this.disabled||this.softDisabled),link:!!this.href,selected:this.selected,"has-trailing":!0}}renderPrimaryAction(t){const{ariaLabel:i}=this;return this.href?ue` + ${t} + `:this.removeOnly?ue` + + ${t} + + `:ue` + + `}renderTrailingAction(t){return U3({focusListener:t,ariaLabel:this.ariaLabelRemove,disabled:!this.href&&(this.disabled||this.softDisabled),tabbable:this.removeOnly})}}j([K({type:Boolean})],Uo.prototype,"avatar",void 0);j([K()],Uo.prototype,"href",void 0);j([K()],Uo.prototype,"target",void 0);j([K({type:Boolean,attribute:"remove-only"})],Uo.prototype,"removeOnly",void 0);j([K({type:Boolean,reflect:!0})],Uo.prototype,"selected",void 0);j([at(".trailing.action")],Uo.prototype,"trailingAction",void 0);const aP=Ye`:host{--_container-height: var(--md-input-chip-container-height, 32px);--_disabled-label-text-color: var(--md-input-chip-disabled-label-text-color, var(--md-sys-color-on-surface, #1d1b20));--_disabled-label-text-opacity: var(--md-input-chip-disabled-label-text-opacity, 0.38);--_disabled-selected-container-color: var(--md-input-chip-disabled-selected-container-color, var(--md-sys-color-on-surface, #1d1b20));--_disabled-selected-container-opacity: var(--md-input-chip-disabled-selected-container-opacity, 0.12);--_label-text-font: var(--md-input-chip-label-text-font, var(--md-sys-typescale-label-large-font, var(--md-ref-typeface-plain, Roboto)));--_label-text-line-height: var(--md-input-chip-label-text-line-height, var(--md-sys-typescale-label-large-line-height, 1.25rem));--_label-text-size: var(--md-input-chip-label-text-size, var(--md-sys-typescale-label-large-size, 0.875rem));--_label-text-weight: var(--md-input-chip-label-text-weight, var(--md-sys-typescale-label-large-weight, var(--md-ref-typeface-weight-medium, 500)));--_selected-container-color: var(--md-input-chip-selected-container-color, var(--md-sys-color-secondary-container, #e8def8));--_selected-focus-label-text-color: var(--md-input-chip-selected-focus-label-text-color, var(--md-sys-color-on-secondary-container, #1d192b));--_selected-hover-label-text-color: var(--md-input-chip-selected-hover-label-text-color, var(--md-sys-color-on-secondary-container, #1d192b));--_selected-hover-state-layer-color: var(--md-input-chip-selected-hover-state-layer-color, var(--md-sys-color-on-secondary-container, #1d192b));--_selected-hover-state-layer-opacity: var(--md-input-chip-selected-hover-state-layer-opacity, 0.08);--_selected-label-text-color: var(--md-input-chip-selected-label-text-color, var(--md-sys-color-on-secondary-container, #1d192b));--_selected-outline-width: var(--md-input-chip-selected-outline-width, 0px);--_selected-pressed-label-text-color: var(--md-input-chip-selected-pressed-label-text-color, var(--md-sys-color-on-secondary-container, #1d192b));--_selected-pressed-state-layer-color: var(--md-input-chip-selected-pressed-state-layer-color, var(--md-sys-color-on-secondary-container, #1d192b));--_selected-pressed-state-layer-opacity: var(--md-input-chip-selected-pressed-state-layer-opacity, 0.12);--_disabled-outline-color: var(--md-input-chip-disabled-outline-color, var(--md-sys-color-on-surface, #1d1b20));--_disabled-outline-opacity: var(--md-input-chip-disabled-outline-opacity, 0.12);--_focus-label-text-color: var(--md-input-chip-focus-label-text-color, var(--md-sys-color-on-surface-variant, #49454f));--_focus-outline-color: var(--md-input-chip-focus-outline-color, var(--md-sys-color-on-surface-variant, #49454f));--_hover-label-text-color: var(--md-input-chip-hover-label-text-color, var(--md-sys-color-on-surface-variant, #49454f));--_hover-state-layer-color: var(--md-input-chip-hover-state-layer-color, var(--md-sys-color-on-surface-variant, #49454f));--_hover-state-layer-opacity: var(--md-input-chip-hover-state-layer-opacity, 0.08);--_label-text-color: var(--md-input-chip-label-text-color, var(--md-sys-color-on-surface-variant, #49454f));--_outline-color: var(--md-input-chip-outline-color, var(--md-sys-color-outline, #79747e));--_outline-width: var(--md-input-chip-outline-width, 1px);--_pressed-label-text-color: var(--md-input-chip-pressed-label-text-color, var(--md-sys-color-on-surface-variant, #49454f));--_pressed-state-layer-color: var(--md-input-chip-pressed-state-layer-color, var(--md-sys-color-on-surface-variant, #49454f));--_pressed-state-layer-opacity: var(--md-input-chip-pressed-state-layer-opacity, 0.12);--_avatar-shape: var(--md-input-chip-avatar-shape, var(--md-sys-shape-corner-full, 9999px));--_avatar-size: var(--md-input-chip-avatar-size, 24px);--_disabled-avatar-opacity: var(--md-input-chip-disabled-avatar-opacity, 0.38);--_disabled-leading-icon-color: var(--md-input-chip-disabled-leading-icon-color, var(--md-sys-color-on-surface, #1d1b20));--_disabled-leading-icon-opacity: var(--md-input-chip-disabled-leading-icon-opacity, 0.38);--_icon-size: var(--md-input-chip-icon-size, 18px);--_selected-focus-leading-icon-color: var(--md-input-chip-selected-focus-leading-icon-color, var(--md-sys-color-primary, #6750a4));--_selected-hover-leading-icon-color: var(--md-input-chip-selected-hover-leading-icon-color, var(--md-sys-color-primary, #6750a4));--_selected-leading-icon-color: var(--md-input-chip-selected-leading-icon-color, var(--md-sys-color-primary, #6750a4));--_selected-pressed-leading-icon-color: var(--md-input-chip-selected-pressed-leading-icon-color, var(--md-sys-color-primary, #6750a4));--_focus-leading-icon-color: var(--md-input-chip-focus-leading-icon-color, var(--md-sys-color-primary, #6750a4));--_hover-leading-icon-color: var(--md-input-chip-hover-leading-icon-color, var(--md-sys-color-primary, #6750a4));--_leading-icon-color: var(--md-input-chip-leading-icon-color, var(--md-sys-color-primary, #6750a4));--_pressed-leading-icon-color: var(--md-input-chip-pressed-leading-icon-color, var(--md-sys-color-primary, #6750a4));--_disabled-trailing-icon-color: var(--md-input-chip-disabled-trailing-icon-color, var(--md-sys-color-on-surface, #1d1b20));--_disabled-trailing-icon-opacity: var(--md-input-chip-disabled-trailing-icon-opacity, 0.38);--_selected-focus-trailing-icon-color: var(--md-input-chip-selected-focus-trailing-icon-color, var(--md-sys-color-on-secondary-container, #1d192b));--_selected-hover-trailing-icon-color: var(--md-input-chip-selected-hover-trailing-icon-color, var(--md-sys-color-on-secondary-container, #1d192b));--_selected-pressed-trailing-icon-color: var(--md-input-chip-selected-pressed-trailing-icon-color, var(--md-sys-color-on-secondary-container, #1d192b));--_selected-trailing-icon-color: var(--md-input-chip-selected-trailing-icon-color, var(--md-sys-color-on-secondary-container, #1d192b));--_focus-trailing-icon-color: var(--md-input-chip-focus-trailing-icon-color, var(--md-sys-color-on-surface-variant, #49454f));--_hover-trailing-icon-color: var(--md-input-chip-hover-trailing-icon-color, var(--md-sys-color-on-surface-variant, #49454f));--_pressed-trailing-icon-color: var(--md-input-chip-pressed-trailing-icon-color, var(--md-sys-color-on-surface-variant, #49454f));--_trailing-icon-color: var(--md-input-chip-trailing-icon-color, var(--md-sys-color-on-surface-variant, #49454f));--_container-shape-start-start: var(--md-input-chip-container-shape-start-start, var(--md-input-chip-container-shape, var(--md-sys-shape-corner-small, 8px)));--_container-shape-start-end: var(--md-input-chip-container-shape-start-end, var(--md-input-chip-container-shape, var(--md-sys-shape-corner-small, 8px)));--_container-shape-end-end: var(--md-input-chip-container-shape-end-end, var(--md-input-chip-container-shape, var(--md-sys-shape-corner-small, 8px)));--_container-shape-end-start: var(--md-input-chip-container-shape-end-start, var(--md-input-chip-container-shape, var(--md-sys-shape-corner-small, 8px)));--_leading-space: var(--md-input-chip-leading-space, 16px);--_trailing-space: var(--md-input-chip-trailing-space, 16px);--_icon-label-space: var(--md-input-chip-icon-label-space, 8px);--_with-leading-icon-leading-space: var(--md-input-chip-with-leading-icon-leading-space, 8px);--_with-trailing-icon-trailing-space: var(--md-input-chip-with-trailing-icon-trailing-space, 8px)}:host([avatar]){--_container-shape-start-start: var( --md-input-chip-container-shape-start-start, var(--md-input-chip-container-shape, calc(var(--_container-height) / 2)) );--_container-shape-start-end: var( --md-input-chip-container-shape-start-end, var(--md-input-chip-container-shape, calc(var(--_container-height) / 2)) );--_container-shape-end-end: var( --md-input-chip-container-shape-end-end, var(--md-input-chip-container-shape, calc(var(--_container-height) / 2)) );--_container-shape-end-start: var( --md-input-chip-container-shape-end-start, var(--md-input-chip-container-shape, calc(var(--_container-height) / 2)) )}.avatar .primary.action{padding-inline-start:4px}.avatar .leading.icon ::slotted(:first-child){border-radius:var(--_avatar-shape);height:var(--_avatar-size);width:var(--_avatar-size)}.disabled.avatar .leading.icon{opacity:var(--_disabled-avatar-opacity)}@media(forced-colors: active){.link .outline{border-color:ActiveText}.disabled.avatar .leading.icon{opacity:1}} +`;const I3=Ye`.selected{--md-ripple-hover-color: var(--_selected-hover-state-layer-color);--md-ripple-hover-opacity: var(--_selected-hover-state-layer-opacity);--md-ripple-pressed-color: var(--_selected-pressed-state-layer-color);--md-ripple-pressed-opacity: var(--_selected-pressed-state-layer-opacity)}:where(.selected)::before{background:var(--_selected-container-color)}:where(.selected) .outline{border-width:var(--_selected-outline-width)}:where(.selected.disabled)::before{background:var(--_disabled-selected-container-color);opacity:var(--_disabled-selected-container-opacity)}:where(.selected) .label{color:var(--_selected-label-text-color)}:where(.selected:hover) .label{color:var(--_selected-hover-label-text-color)}:where(.selected:focus) .label{color:var(--_selected-focus-label-text-color)}:where(.selected:active) .label{color:var(--_selected-pressed-label-text-color)}:where(.selected) .leading.icon{color:var(--_selected-leading-icon-color)}:where(.selected:hover) .leading.icon{color:var(--_selected-hover-leading-icon-color)}:where(.selected:focus) .leading.icon{color:var(--_selected-focus-leading-icon-color)}:where(.selected:active) .leading.icon{color:var(--_selected-pressed-leading-icon-color)}@media(forced-colors: active){:where(.selected:not(.elevated))::before{border:1px solid CanvasText}:where(.selected) .outline{border-width:1px}} +`;const iy=Ye`:host{border-start-start-radius:var(--_container-shape-start-start);border-start-end-radius:var(--_container-shape-start-end);border-end-start-radius:var(--_container-shape-end-start);border-end-end-radius:var(--_container-shape-end-end);display:inline-flex;height:var(--_container-height);cursor:pointer;-webkit-tap-highlight-color:rgba(0,0,0,0);--md-ripple-hover-color: var(--_hover-state-layer-color);--md-ripple-hover-opacity: var(--_hover-state-layer-opacity);--md-ripple-pressed-color: var(--_pressed-state-layer-color);--md-ripple-pressed-opacity: var(--_pressed-state-layer-opacity)}:host(:is([disabled],[soft-disabled])){pointer-events:none}:host([touch-target=wrapper]){margin:max(0px,(48px - var(--_container-height))/2) 0}md-focus-ring{--md-focus-ring-shape-start-start: var(--_container-shape-start-start);--md-focus-ring-shape-start-end: var(--_container-shape-start-end);--md-focus-ring-shape-end-end: var(--_container-shape-end-end);--md-focus-ring-shape-end-start: var(--_container-shape-end-start)}.container{border-radius:inherit;box-sizing:border-box;display:flex;height:100%;position:relative;width:100%}.container::before{border-radius:inherit;content:"";inset:0;pointer-events:none;position:absolute}.container:not(.disabled){cursor:pointer}.container.disabled{pointer-events:none}.cell{display:flex}.action{align-items:baseline;appearance:none;background:none;border:none;border-radius:inherit;display:flex;outline:none;padding:0;position:relative;text-decoration:none}.primary.action{min-width:0;padding-inline-start:var(--_leading-space);padding-inline-end:var(--_trailing-space)}.has-icon .primary.action{padding-inline-start:var(--_with-leading-icon-leading-space)}.touch{height:48px;inset:50% 0 0;position:absolute;transform:translateY(-50%);width:100%}:host([touch-target=none]) .touch{display:none}.outline{border:var(--_outline-width) solid var(--_outline-color);border-radius:inherit;inset:0;pointer-events:none;position:absolute}:where(:focus) .outline{border-color:var(--_focus-outline-color)}:where(.disabled) .outline{border-color:var(--_disabled-outline-color);opacity:var(--_disabled-outline-opacity)}md-ripple{border-radius:inherit}.label,.icon,.touch{z-index:1}.label{align-items:center;color:var(--_label-text-color);display:flex;font-family:var(--_label-text-font);font-size:var(--_label-text-size);font-weight:var(--_label-text-weight);height:100%;line-height:var(--_label-text-line-height);overflow:hidden;user-select:none}.label-text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}:where(:hover) .label{color:var(--_hover-label-text-color)}:where(:focus) .label{color:var(--_focus-label-text-color)}:where(:active) .label{color:var(--_pressed-label-text-color)}:where(.disabled) .label{color:var(--_disabled-label-text-color);opacity:var(--_disabled-label-text-opacity)}.icon{align-self:center;display:flex;fill:currentColor;position:relative}.icon ::slotted(:first-child){font-size:var(--_icon-size);height:var(--_icon-size);width:var(--_icon-size)}.leading.icon{color:var(--_leading-icon-color)}.leading.icon ::slotted(*),.leading.icon svg{margin-inline-end:var(--_icon-label-space)}:where(:hover) .leading.icon{color:var(--_hover-leading-icon-color)}:where(:focus) .leading.icon{color:var(--_focus-leading-icon-color)}:where(:active) .leading.icon{color:var(--_pressed-leading-icon-color)}:where(.disabled) .leading.icon{color:var(--_disabled-leading-icon-color);opacity:var(--_disabled-leading-icon-opacity)}@media(forced-colors: active){:where(.disabled) :is(.label,.outline,.leading.icon){color:GrayText;opacity:1}}a,button{text-transform:inherit}a,button:not(:disabled,[aria-disabled=true]){cursor:inherit} +`;const V3=Ye`.trailing.action{align-items:center;justify-content:center;padding-inline-start:var(--_icon-label-space);padding-inline-end:var(--_with-trailing-icon-trailing-space)}.trailing.action :is(md-ripple,md-focus-ring){border-radius:50%;height:calc(1.3333333333*var(--_icon-size));width:calc(1.3333333333*var(--_icon-size))}.trailing.action md-focus-ring{inset:unset}.has-trailing .primary.action{padding-inline-end:0}.trailing.icon{color:var(--_trailing-icon-color);height:var(--_icon-size);width:var(--_icon-size)}:where(:hover) .trailing.icon{color:var(--_hover-trailing-icon-color)}:where(:focus) .trailing.icon{color:var(--_focus-trailing-icon-color)}:where(:active) .trailing.icon{color:var(--_pressed-trailing-icon-color)}:where(.disabled) .trailing.icon{color:var(--_disabled-trailing-icon-color);opacity:var(--_disabled-trailing-icon-opacity)}:where(.selected) .trailing.icon{color:var(--_selected-trailing-icon-color)}:where(.selected:hover) .trailing.icon{color:var(--_selected-hover-trailing-icon-color)}:where(.selected:focus) .trailing.icon{color:var(--_selected-focus-trailing-icon-color)}:where(.selected:active) .trailing.icon{color:var(--_selected-pressed-trailing-icon-color)}@media(forced-colors: active){.trailing.icon{color:ButtonText}:where(.disabled) .trailing.icon{color:GrayText;opacity:1}} +`;let jv=class extends Uo{};jv.styles=[iy,V3,I3,aP];jv=j([bt("md-input-chip")],jv);function F3({children:e,className:t,disabled:i=!1,label:o,onRemove:s}){const l=w.useRef(null);return w.useEffect(()=>{const d=l.current;if(!d)throw new Error("MaterialInputChip rendered before md-input-chip was registered.");const f=()=>s();return d.addEventListener("remove",f),()=>d.removeEventListener("remove",f)},[s]),w.useEffect(()=>{const d=l.current;if(!d)throw new Error("MaterialInputChip rendered before md-input-chip was registered.");d.disabled=i,d.removeOnly=!0,d.setAttribute("aria-label",o)},[i,o]),v.jsx("md-input-chip",{"aria-label":o,className:t,disabled:i,ref:l,"remove-only":!0,children:e})}function H3(e,t=dn){const i=ny(e,t);return i&&(i.tabIndex=0,i.focus()),i}function q3(e,t=dn){const i=K3(e,t);return i&&(i.tabIndex=0,i.focus()),i}function Ol(e,t=dn){for(let i=0;i=0;i--){const o=e[i];if(t(o))return o}return null}function sP(e,t,i=dn,o=!0){for(let s=1;st&&!o)return null;const d=e[l];if(i(d))return d}return e[t]?e[t]:null}function Q2(e,t,i=dn,o=!0){if(t){const s=sP(e,t.index,i,o);return s&&(s.tabIndex=0,s.focus()),s}else return H3(e,i)}function X2(e,t,i=dn,o=!0){if(t){const s=lP(e,t.index,i,o);return s&&(s.tabIndex=0,s.focus()),s}else return q3(e,i)}function dn(e){return!e.disabled}const Zt={ArrowDown:"ArrowDown",ArrowLeft:"ArrowLeft",ArrowUp:"ArrowUp",ArrowRight:"ArrowRight",Home:"Home",End:"End"};class cP{constructor(t){this.handleKeydown=g=>{const y=g.key;if(g.defaultPrevented||!this.isNavigableKey(y))return;const b=this.items;if(!b.length)return;const _=Ol(b,this.isActivatable);g.preventDefault();const E=this.isRtl(),T=E?Zt.ArrowRight:Zt.ArrowLeft,O=E?Zt.ArrowLeft:Zt.ArrowRight;let D=null;switch(y){case Zt.ArrowDown:case O:D=Q2(b,_,this.isActivatable,this.wrapNavigation());break;case Zt.ArrowUp:case T:D=X2(b,_,this.isActivatable,this.wrapNavigation());break;case Zt.Home:D=H3(b,this.isActivatable);break;case Zt.End:D=q3(b,this.isActivatable);break}D&&_&&_.item!==D&&(_.item.tabIndex=-1)},this.onDeactivateItems=()=>{const g=this.items;for(const y of g)this.deactivateItem(y)},this.onRequestActivation=g=>{this.onDeactivateItems();const y=g.target;this.activateItem(y),y.focus()},this.onSlotchange=()=>{const g=this.items;let y=!1;for(const _ of g){if(!_.disabled&&_.tabIndex>-1&&!y){y=!0,_.tabIndex=0;continue}_.tabIndex=-1}if(y)return;const b=ny(g,this.isActivatable);b&&(b.tabIndex=0)};const{isItem:i,getPossibleItems:o,isRtl:s,deactivateItem:l,activateItem:d,isNavigableKey:f,isActivatable:h,wrapNavigation:m}=t;this.isItem=i,this.getPossibleItems=o,this.isRtl=s,this.deactivateItem=l,this.activateItem=d,this.isNavigableKey=f,this.isActivatable=h,this.wrapNavigation=m??(()=>!0)}get items(){const t=this.getPossibleItems(),i=[];for(const o of t){if(this.isItem(o)){i.push(o);continue}const l=o.item;l&&this.isItem(l)&&i.push(l)}return i}activateNextItem(){const t=this.items,i=Ol(t,this.isActivatable);return i&&(i.item.tabIndex=-1),Q2(t,i,this.isActivatable,this.wrapNavigation())}activatePreviousItem(){const t=this.items,i=Ol(t,this.isActivatable);return i&&(i.item.tabIndex=-1),X2(t,i,this.isActivatable,this.wrapNavigation())}}function dP(e,t){return new CustomEvent("close-menu",{bubbles:!0,composed:!0,detail:{initiator:e,reason:t,itemPath:[e]}})}const W2=dP,kv={SPACE:"Space",ENTER:"Enter"},Z2={CLICK_SELECTION:"click-selection",KEYDOWN:"keydown"},uP={ESCAPE:"Escape",SPACE:kv.SPACE,ENTER:kv.ENTER};function G3(e){return Object.values(uP).some(t=>t===e)}function fP(e){return Object.values(kv).some(t=>t===e)}function Mv(e,t){const i=new Event("md-contains",{bubbles:!0,composed:!0});let o=[];const s=d=>{o=d.composedPath()};return t.addEventListener("md-contains",s),e.dispatchEvent(i),t.removeEventListener("md-contains",s),o.length>0}const ui={NONE:"none",LIST_ROOT:"list-root",FIRST_ITEM:"first-item",LAST_ITEM:"last-item"};const J2={END_START:"end-start",START_START:"start-start"};class hP{constructor(t,i){this.host=t,this.getProperties=i,this.surfaceStylesInternal={display:"none"},this.lastValues={isOpen:!1},this.host.addController(this)}get surfaceStyles(){return this.surfaceStylesInternal}async position(){const{surfaceEl:t,anchorEl:i,anchorCorner:o,surfaceCorner:s,positioning:l,xOffset:d,yOffset:f,disableBlockFlip:h,disableInlineFlip:m,repositionStrategy:g}=this.getProperties(),y=o.toLowerCase().trim(),b=s.toLowerCase().trim();if(!t||!i)return;const _=window.innerWidth,E=window.innerHeight,T=document.createElement("div");T.style.opacity="0",T.style.position="fixed",T.style.display="block",T.style.inset="0",document.body.appendChild(T);const O=T.getBoundingClientRect();T.remove();const D=window.innerHeight-O.bottom,C=window.innerWidth-O.right;this.surfaceStylesInternal={display:"block",opacity:"0"},this.host.requestUpdate(),await this.host.updateComplete,t.popover&&t.isConnected&&t.showPopover();const z=t.getSurfacePositionClientRect?t.getSurfacePositionClientRect():t.getBoundingClientRect(),B=i.getSurfacePositionClientRect?i.getSurfacePositionClientRect():i.getBoundingClientRect(),[H,ne]=b.split("-"),[X,A]=y.split("-"),le=getComputedStyle(t).direction==="ltr";let{blockInset:ee,blockOutOfBoundsCorrection:J,surfaceBlockProperty:ce}=this.calculateBlock({surfaceRect:z,anchorRect:B,anchorBlock:X,surfaceBlock:H,yOffset:f,positioning:l,windowInnerHeight:E,blockScrollbarHeight:D});if(J&&!h){const N=H==="start"?"end":"start",Q=X==="start"?"end":"start",se=this.calculateBlock({surfaceRect:z,anchorRect:B,anchorBlock:Q,surfaceBlock:N,yOffset:f,positioning:l,windowInnerHeight:E,blockScrollbarHeight:D});J>se.blockOutOfBoundsCorrection&&(ee=se.blockInset,J=se.blockOutOfBoundsCorrection,ce=se.surfaceBlockProperty)}let{inlineInset:_e,inlineOutOfBoundsCorrection:ie,surfaceInlineProperty:xe}=this.calculateInline({surfaceRect:z,anchorRect:B,anchorInline:A,surfaceInline:ne,xOffset:d,positioning:l,isLTR:le,windowInnerWidth:_,inlineScrollbarWidth:C});if(ie&&!m){const N=ne==="start"?"end":"start",Q=A==="start"?"end":"start",se=this.calculateInline({surfaceRect:z,anchorRect:B,anchorInline:Q,surfaceInline:N,xOffset:d,positioning:l,isLTR:le,windowInnerWidth:_,inlineScrollbarWidth:C});Math.abs(ie)>Math.abs(se.inlineOutOfBoundsCorrection)&&(_e=se.inlineInset,ie=se.inlineOutOfBoundsCorrection,xe=se.surfaceInlineProperty)}g==="move"&&(ee=ee-J,_e=_e-ie),this.surfaceStylesInternal={display:"block",opacity:"1",[ce]:`${ee}px`,[xe]:`${_e}px`},g==="resize"&&(J&&(this.surfaceStylesInternal.height=`${z.height-J}px`),ie&&(this.surfaceStylesInternal.width=`${z.width-ie}px`)),this.host.requestUpdate()}calculateBlock(t){const{surfaceRect:i,anchorRect:o,anchorBlock:s,surfaceBlock:l,yOffset:d,positioning:f,windowInnerHeight:h,blockScrollbarHeight:m}=t,g=f==="fixed"||f==="document"?1:0,y=f==="document"?1:0,b=l==="start"?1:0,_=l==="end"?1:0,T=(s!==l?1:0)*o.height+d,O=b*o.top+_*(h-o.bottom-m),D=b*window.scrollY-_*window.scrollY,C=Math.abs(Math.min(0,h-O-T-i.height));return{blockInset:g*O+y*D+T,blockOutOfBoundsCorrection:C,surfaceBlockProperty:l==="start"?"inset-block-start":"inset-block-end"}}calculateInline(t){const{isLTR:i,surfaceInline:o,anchorInline:s,anchorRect:l,surfaceRect:d,xOffset:f,positioning:h,windowInnerWidth:m,inlineScrollbarWidth:g}=t,y=h==="fixed"||h==="document"?1:0,b=h==="document"?1:0,_=i?1:0,E=i?0:1,T=o==="start"?1:0,O=o==="end"?1:0,C=(s!==o?1:0)*l.width+f,z=T*l.left+O*(m-l.right-g),B=T*(m-l.right-g)+O*l.left,H=_*z+E*B,ne=T*window.scrollX-O*window.scrollX,X=O*window.scrollX-T*window.scrollX,A=_*ne+E*X,le=Math.abs(Math.min(0,m-H-C-d.width)),ee=y*H+C+b*A;let J=o==="start"?"inset-inline-start":"inset-inline-end";return(h==="document"||h==="fixed")&&(o==="start"&&i||o==="end"&&!i?J="left":J="right"),{inlineInset:ee,inlineOutOfBoundsCorrection:le,surfaceInlineProperty:J}}hostUpdate(){this.onUpdate()}hostUpdated(){this.onUpdate()}async onUpdate(){const t=this.getProperties();let i=!1;for(const[d,f]of Object.entries(t))if(i=i||f!==this.lastValues[d],i)break;const o=this.lastValues.isOpen!==t.isOpen,s=!!t.anchorEl,l=!!t.surfaceEl;i&&s&&l&&(this.lastValues.isOpen=t.isOpen,t.isOpen?(this.lastValues=t,await this.position(),t.onOpen()):o&&(await t.beforeClose(),this.close(),t.onClose()))}close(){this.surfaceStylesInternal={display:"none"},this.host.requestUpdate();const t=this.getProperties().surfaceEl;t?.popover&&t?.isConnected&&t.hidePopover()}}const Hr={INDEX:0,ITEM:1,TEXT:2};class pP{constructor(t){this.getProperties=t,this.typeaheadRecords=[],this.typaheadBuffer="",this.cancelTypeaheadTimeout=0,this.isTypingAhead=!1,this.lastActiveRecord=null,this.onKeydown=i=>{this.isTypingAhead?this.typeahead(i):this.beginTypeahead(i)},this.endTypeahead=()=>{this.isTypingAhead=!1,this.typaheadBuffer="",this.typeaheadRecords=[]}}get items(){return this.getProperties().getItems()}get active(){return this.getProperties().active}beginTypeahead(t){this.active&&(t.code==="Space"||t.code==="Enter"||t.code.startsWith("Arrow")||t.code==="Escape"||(this.isTypingAhead=!0,this.typeaheadRecords=this.items.map((i,o)=>[o,i,i.typeaheadText.trim().toLowerCase()]),this.lastActiveRecord=this.typeaheadRecords.find(i=>i[Hr.ITEM].tabIndex===0)??null,this.lastActiveRecord&&(this.lastActiveRecord[Hr.ITEM].tabIndex=-1),this.typeahead(t)))}typeahead(t){if(t.defaultPrevented)return;if(clearTimeout(this.cancelTypeaheadTimeout),t.code==="Enter"||t.code.startsWith("Arrow")||t.code==="Escape"){this.endTypeahead(),this.lastActiveRecord&&(this.lastActiveRecord[Hr.ITEM].tabIndex=-1);return}t.code==="Space"&&t.preventDefault(),this.cancelTypeaheadTimeout=setTimeout(this.endTypeahead,this.getProperties().typeaheadBufferTime),this.typaheadBuffer+=t.key.toLowerCase();const i=this.lastActiveRecord?this.lastActiveRecord[Hr.INDEX]:-1,o=this.typeaheadRecords.length,s=h=>(h[Hr.INDEX]+o-i)%o,l=this.typeaheadRecords.filter(h=>!h[Hr.ITEM].disabled&&h[Hr.TEXT].startsWith(this.typaheadBuffer)).sort((h,m)=>s(h)-s(m));if(l.length===0){clearTimeout(this.cancelTypeaheadTimeout),this.lastActiveRecord&&(this.lastActiveRecord[Hr.ITEM].tabIndex=-1),this.endTypeahead();return}const d=this.typaheadBuffer.length===1;let f;this.lastActiveRecord===l[0]&&d?f=l[1]??l[0]:f=l[0],this.lastActiveRecord&&(this.lastActiveRecord[Hr.ITEM].tabIndex=-1),this.lastActiveRecord=f,f[Hr.ITEM].tabIndex=0,f[Hr.ITEM].focus()}}const Y3=200,Q3=new Set([Zt.ArrowDown,Zt.ArrowUp,Zt.Home,Zt.End]),mP=new Set([Zt.ArrowLeft,Zt.ArrowRight,...Q3]);function vP(e=document){let t=e.activeElement;for(;t&&t?.shadowRoot?.activeElement;)t=t.shadowRoot.activeElement;return t}class Et extends ft{get openDirection(){return this.menuCorner.split("-")[0]==="start"?"DOWN":"UP"}get anchorElement(){return this.anchor?this.getRootNode().querySelector(`#${this.anchor}`):this.currentAnchorElement}set anchorElement(t){this.currentAnchorElement=t,this.requestUpdate("anchorElement")}constructor(){super(),this.anchor="",this.positioning="absolute",this.quick=!1,this.hasOverflow=!1,this.open=!1,this.xOffset=0,this.yOffset=0,this.noHorizontalFlip=!1,this.noVerticalFlip=!1,this.typeaheadDelay=Y3,this.anchorCorner=J2.END_START,this.menuCorner=J2.START_START,this.stayOpenOnOutsideClick=!1,this.stayOpenOnFocusout=!1,this.skipRestoreFocus=!1,this.defaultFocus=ui.FIRST_ITEM,this.noNavigationWrap=!1,this.typeaheadActive=!0,this.isSubmenu=!1,this.pointerPath=[],this.isRepositioning=!1,this.openCloseAnimationSignal=y9(),this.listController=new cP({isItem:t=>t.hasAttribute("md-menu-item"),getPossibleItems:()=>this.slotItems,isRtl:()=>getComputedStyle(this).direction==="rtl",deactivateItem:t=>{t.selected=!1,t.tabIndex=-1},activateItem:t=>{t.selected=!0,t.tabIndex=0},isNavigableKey:t=>{if(!this.isSubmenu)return mP.has(t);const o=getComputedStyle(this).direction==="rtl"?Zt.ArrowLeft:Zt.ArrowRight;return t===o?!0:Q3.has(t)},wrapNavigation:()=>!this.noNavigationWrap}),this.lastFocusedElement=null,this.typeaheadController=new pP(()=>({getItems:()=>this.items,typeaheadBufferTime:this.typeaheadDelay,active:this.typeaheadActive})),this.currentAnchorElement=null,this.internals=this.attachInternals(),this.menuPositionController=new hP(this,()=>({anchorCorner:this.anchorCorner,surfaceCorner:this.menuCorner,surfaceEl:this.surfaceEl,anchorEl:this.anchorElement,positioning:this.positioning==="popover"?"document":this.positioning,isOpen:this.open,xOffset:this.xOffset,yOffset:this.yOffset,disableBlockFlip:this.noVerticalFlip,disableInlineFlip:this.noHorizontalFlip,onOpen:this.onOpened,beforeClose:this.beforeClose,onClose:this.onClosed,repositionStrategy:this.hasOverflow&&this.positioning!=="popover"?"move":"resize"})),this.onWindowResize=()=>{this.isRepositioning||this.positioning!=="document"&&this.positioning!=="fixed"&&this.positioning!=="popover"||(this.isRepositioning=!0,this.reposition(),this.isRepositioning=!1)},this.handleFocusout=async t=>{const i=this.anchorElement;if(this.stayOpenOnFocusout||!this.open||this.pointerPath.includes(i))return;if(t.relatedTarget){if(Mv(t.relatedTarget,this)||this.pointerPath.length!==0&&Mv(t.relatedTarget,i))return}else if(this.pointerPath.includes(this))return;const o=this.skipRestoreFocus;this.skipRestoreFocus=!0,this.close(),await this.updateComplete,this.skipRestoreFocus=o},this.onOpened=async()=>{this.lastFocusedElement=vP();const t=this.items,i=Ol(t);i&&this.defaultFocus!==ui.NONE&&(i.item.tabIndex=-1);let o=!this.quick;switch(this.quick?this.dispatchEvent(new Event("opening")):o=!!await this.animateOpen(),this.defaultFocus){case ui.FIRST_ITEM:const s=ny(t);s&&(s.tabIndex=0,s.focus(),await s.updateComplete);break;case ui.LAST_ITEM:const l=K3(t);l&&(l.tabIndex=0,l.focus(),await l.updateComplete);break;case ui.LIST_ROOT:this.focus();break;default:case ui.NONE:break}o||this.dispatchEvent(new Event("opened"))},this.beforeClose=async()=>{this.open=!1,this.skipRestoreFocus||this.lastFocusedElement?.focus?.(),this.quick||await this.animateClose()},this.onClosed=()=>{this.quick&&(this.dispatchEvent(new Event("closing")),this.dispatchEvent(new Event("closed")))},this.onWindowPointerdown=t=>{this.pointerPath=t.composedPath()},this.onDocumentClick=t=>{if(!this.open)return;const i=t.composedPath();!this.stayOpenOnOutsideClick&&!i.includes(this)&&!i.includes(this.anchorElement)&&(this.open=!1)},this.internals.role="menu",this.addEventListener("keydown",this.handleKeydown),this.addEventListener("keydown",this.captureKeydown,{capture:!0}),this.addEventListener("focusout",this.handleFocusout)}get items(){return this.listController.items}willUpdate(t){if(t.has("open")){if(this.open){this.removeAttribute("aria-hidden");return}this.setAttribute("aria-hidden","true")}}update(t){t.has("open")&&(this.open?this.setUpGlobalEventListeners():this.cleanUpGlobalEventListeners()),t.has("positioning")&&this.positioning==="popover"&&!this.showPopover&&(this.positioning="fixed"),super.update(t)}connectedCallback(){super.connectedCallback(),this.open&&this.setUpGlobalEventListeners()}disconnectedCallback(){super.disconnectedCallback(),this.cleanUpGlobalEventListeners()}getBoundingClientRect(){return this.surfaceEl?this.surfaceEl.getBoundingClientRect():super.getBoundingClientRect()}getClientRects(){return this.surfaceEl?this.surfaceEl.getClientRects():super.getClientRects()}render(){return this.renderSurface()}renderSurface(){return ue` + + `}renderMenuItems(){return ue``}renderElevation(){return ue``}getSurfaceClasses(){return{open:this.open,fixed:this.positioning==="fixed","has-overflow":this.hasOverflow}}captureKeydown(t){t.target===this&&!t.defaultPrevented&&G3(t.code)&&(t.preventDefault(),this.close()),this.typeaheadController.onKeydown(t)}async animateOpen(){const t=this.surfaceEl,i=this.slotEl;if(!t||!i)return!0;const o=this.openDirection;this.dispatchEvent(new Event("opening")),t.classList.toggle("animating",!0);const s=this.openCloseAnimationSignal.start(),l=t.offsetHeight,d=o==="UP",f=this.items,h=500,m=50,g=250,y=(h-g)/f.length,b=t.animate([{height:"0px"},{height:`${l}px`}],{duration:h,easing:Oi.EMPHASIZED}),_=i.animate([{transform:d?`translateY(-${l}px)`:""},{transform:""}],{duration:h,easing:Oi.EMPHASIZED}),E=t.animate([{opacity:0},{opacity:1}],m),T=[];for(let C=0;C{B.classList.toggle("md-menu-hidden",!1)}),T.push([B,H])}let O=C=>{};const D=new Promise(C=>{O=C});return s.addEventListener("abort",()=>{b.cancel(),_.cancel(),E.cancel(),T.forEach(([C,z])=>{C.classList.toggle("md-menu-hidden",!1),z.cancel()}),O(!0)}),b.addEventListener("finish",()=>{t.classList.toggle("animating",!1),this.openCloseAnimationSignal.finish(),O(!1)}),await D}animateClose(){let t;const i=new Promise(H=>{t=H}),o=this.surfaceEl,s=this.slotEl;if(!o||!s)return t(!1),i;const d=this.openDirection==="UP";this.dispatchEvent(new Event("closing")),o.classList.toggle("animating",!0);const f=this.openCloseAnimationSignal.start(),h=o.offsetHeight,m=this.items,g=150,y=50,b=g-y,_=50,E=50,T=.35,O=(g-E-_)/m.length,D=o.animate([{height:`${h}px`},{height:`${h*T}px`}],{duration:g,easing:Oi.EMPHASIZED_ACCELERATE}),C=s.animate([{transform:""},{transform:d?`translateY(-${h*(1-T)}px)`:""}],{duration:g,easing:Oi.EMPHASIZED_ACCELERATE}),z=o.animate([{opacity:1},{opacity:0}],{duration:y,delay:b}),B=[];for(let H=0;H{X.classList.toggle("md-menu-hidden",!0)}),B.push([X,A])}return f.addEventListener("abort",()=>{D.cancel(),C.cancel(),z.cancel(),B.forEach(([H,ne])=>{ne.cancel(),H.classList.toggle("md-menu-hidden",!1)}),t(!1)}),D.addEventListener("finish",()=>{o.classList.toggle("animating",!1),B.forEach(([H])=>{H.classList.toggle("md-menu-hidden",!1)}),this.openCloseAnimationSignal.finish(),this.dispatchEvent(new Event("closed")),t(!0)}),i}handleKeydown(t){this.pointerPath=[],this.listController.handleKeydown(t)}setUpGlobalEventListeners(){document.addEventListener("click",this.onDocumentClick,{capture:!0}),window.addEventListener("pointerdown",this.onWindowPointerdown),document.addEventListener("resize",this.onWindowResize,{passive:!0}),window.addEventListener("resize",this.onWindowResize,{passive:!0})}cleanUpGlobalEventListeners(){document.removeEventListener("click",this.onDocumentClick,{capture:!0}),window.removeEventListener("pointerdown",this.onWindowPointerdown),document.removeEventListener("resize",this.onWindowResize),window.removeEventListener("resize",this.onWindowResize)}onCloseMenu(){this.close()}onDeactivateItems(t){t.stopPropagation(),this.listController.onDeactivateItems()}onRequestActivation(t){t.stopPropagation(),this.listController.onRequestActivation(t)}handleDeactivateTypeahead(t){t.stopPropagation(),this.typeaheadActive=!1}handleActivateTypeahead(t){t.stopPropagation(),this.typeaheadActive=!0}handleStayOpenOnFocusout(t){t.stopPropagation(),this.stayOpenOnFocusout=!0}handleCloseOnFocusout(t){t.stopPropagation(),this.stayOpenOnFocusout=!1}close(){this.open=!1,this.slotItems.forEach(i=>{i.close?.()})}show(){this.open=!0}activateNextItem(){return this.listController.activateNextItem()??null}activatePreviousItem(){return this.listController.activatePreviousItem()??null}reposition(){this.open&&this.menuPositionController.position()}}j([at(".menu")],Et.prototype,"surfaceEl",void 0);j([at("slot")],Et.prototype,"slotEl",void 0);j([K()],Et.prototype,"anchor",void 0);j([K()],Et.prototype,"positioning",void 0);j([K({type:Boolean})],Et.prototype,"quick",void 0);j([K({type:Boolean,attribute:"has-overflow"})],Et.prototype,"hasOverflow",void 0);j([K({type:Boolean,reflect:!0})],Et.prototype,"open",void 0);j([K({type:Number,attribute:"x-offset"})],Et.prototype,"xOffset",void 0);j([K({type:Number,attribute:"y-offset"})],Et.prototype,"yOffset",void 0);j([K({type:Boolean,attribute:"no-horizontal-flip"})],Et.prototype,"noHorizontalFlip",void 0);j([K({type:Boolean,attribute:"no-vertical-flip"})],Et.prototype,"noVerticalFlip",void 0);j([K({type:Number,attribute:"typeahead-delay"})],Et.prototype,"typeaheadDelay",void 0);j([K({attribute:"anchor-corner"})],Et.prototype,"anchorCorner",void 0);j([K({attribute:"menu-corner"})],Et.prototype,"menuCorner",void 0);j([K({type:Boolean,attribute:"stay-open-on-outside-click"})],Et.prototype,"stayOpenOnOutsideClick",void 0);j([K({type:Boolean,attribute:"stay-open-on-focusout"})],Et.prototype,"stayOpenOnFocusout",void 0);j([K({type:Boolean,attribute:"skip-restore-focus"})],Et.prototype,"skipRestoreFocus",void 0);j([K({attribute:"default-focus"})],Et.prototype,"defaultFocus",void 0);j([K({type:Boolean,attribute:"no-navigation-wrap"})],Et.prototype,"noNavigationWrap",void 0);j([sn({flatten:!0})],Et.prototype,"slotItems",void 0);j([yt()],Et.prototype,"typeaheadActive",void 0);const gP=Ye`:host{--md-elevation-level: var(--md-menu-container-elevation, 2);--md-elevation-shadow-color: var(--md-menu-container-shadow-color, var(--md-sys-color-shadow, #000));min-width:112px;color:unset;display:contents}md-focus-ring{--md-focus-ring-shape: var(--md-menu-container-shape, var(--md-sys-shape-corner-extra-small, 4px))}.menu{border-radius:var(--md-menu-container-shape, var(--md-sys-shape-corner-extra-small, 4px));display:none;inset:auto;border:none;padding:0px;overflow:visible;background-color:rgba(0,0,0,0);color:inherit;opacity:0;z-index:20;position:absolute;user-select:none;max-height:inherit;height:inherit;min-width:inherit;max-width:inherit;scrollbar-width:inherit}.menu::backdrop{display:none}.fixed{position:fixed}.items{display:block;list-style-type:none;margin:0;outline:none;box-sizing:border-box;background-color:var(--md-menu-container-color, var(--md-sys-color-surface-container, #f3edf7));height:inherit;max-height:inherit;overflow:auto;min-width:inherit;max-width:inherit;border-radius:inherit;scrollbar-width:inherit}.item-padding{padding-block:var(--md-menu-top-space, 8px) var(--md-menu-bottom-space, 8px)}.has-overflow:not([popover]) .items{overflow:visible}.has-overflow.animating .items,.animating .items{overflow:hidden}.has-overflow.animating .items{pointer-events:none}.animating ::slotted(.md-menu-hidden){opacity:0}slot{display:block;height:inherit;max-height:inherit}::slotted(:is(md-divider,[role=separator])){margin:8px 0}@media(forced-colors: active){.menu{border-style:solid;border-color:CanvasText;border-width:1px}} +`;let Dv=class extends Et{};Dv.styles=[gP];Dv=j([bt("md-menu")],Dv);class yP extends Jg{computeValidity(t){return this.selectControl||(this.selectControl=document.createElement("select")),qg(ue``,this.selectControl),this.selectControl.value=t.value,this.selectControl.required=t.required,{validity:this.selectControl.validity,validationMessage:this.selectControl.validationMessage}}equals(t,i){return t.value===i.value&&t.required===i.required}copy({value:t,required:i}){return{value:t,required:i}}}function bP(e){const t=[];for(let i=0;it)}get hasError(){return this.error||this.nativeError}constructor(){super(),this.quick=!1,this.required=!1,this.errorText="",this.label="",this.noAsterisk=!1,this.supportingText="",this.error=!1,this.menuPositioning="popover",this.clampMenuWidth=!1,this.typeaheadDelay=Y3,this.hasLeadingIcon=!1,this.displayText="",this.menuAlign="start",this[e_]="",this.lastUserSetValue=null,this.lastUserSetSelectedIndex=null,this.lastSelectedOption=null,this.lastSelectedOptionRecords=[],this.nativeError=!1,this.nativeErrorText="",this.focused=!1,this.open=!1,this.defaultFocus=ui.NONE,this.prevOpen=this.open,this.selectWidth=0,this.addEventListener("focus",this.handleFocus.bind(this)),this.addEventListener("blur",this.handleBlur.bind(this))}select(t){const i=this.options.find(o=>o.value===t);i&&this.selectItem(i)}selectIndex(t){const i=this.options[t];i&&this.selectItem(i)}reset(){for(const t of this.options)t.selected=t.hasAttribute("selected");this.updateValueAndDisplayText(),this.nativeError=!1,this.nativeErrorText=""}showPicker(){this.open=!0}[(e_=iu,Iu)](t){t?.preventDefault();const i=this.getErrorText();this.nativeError=!!t,this.nativeErrorText=this.validationMessage,i===this.getErrorText()&&this.field?.reannounceError()}update(t){if(this.hasUpdated||this.initUserSelection(),this.prevOpen!==this.open&&this.open){const i=this.getBoundingClientRect();this.selectWidth=i.width}this.prevOpen=this.open,super.update(t)}render(){return ue` + + ${this.renderField()} ${this.renderMenu()} + + `}async firstUpdated(t){await this.menu?.updateComplete,this.lastSelectedOptionRecords.length||this.initUserSelection(),!this.lastSelectedOptionRecords.length&&!this.options.length&&setTimeout(()=>{this.updateValueAndDisplayText()}),super.firstUpdated(t)}getRenderClasses(){return{disabled:this.disabled,error:this.error,open:this.open}}renderField(){const t=this.ariaLabel||this.label;return Zg` + <${this.fieldTag} + aria-haspopup="listbox" + role="combobox" + part="field" + id="field" + tabindex=${this.disabled?"-1":"0"} + aria-label=${t||ae} + aria-describedby="description" + aria-expanded=${this.open?"true":"false"} + aria-controls="listbox" + class="field" + label=${this.label} + ?no-asterisk=${this.noAsterisk} + .focused=${this.focused||this.open} + .populated=${!!this.displayText} + .disabled=${this.disabled} + .required=${this.required} + .error=${this.hasError} + ?has-start=${this.hasLeadingIcon} + has-end + supporting-text=${this.supportingText} + error-text=${this.getErrorText()} + @keydown=${this.handleKeydown} + @click=${this.handleClick}> + ${this.renderFieldContent()} +
+ `}renderFieldContent(){return[this.renderLeadingIcon(),this.renderLabel(),this.renderTrailingIcon()]}renderLeadingIcon(){return ue` + + + + `}renderTrailingIcon(){return ue` + + + + + + + + + `}renderLabel(){return ue`
${this.displayText||ue` `}
`}renderMenu(){const t=this.label||this.ariaLabel;return ue``}renderMenuContent(){return ue``}handleKeydown(t){if(this.open||this.disabled||!this.menu)return;const i=this.menu.typeaheadController,o=t.code==="Space"||t.code==="ArrowDown"||t.code==="ArrowUp"||t.code==="End"||t.code==="Home"||t.code==="Enter";if(!i.isTypingAhead&&o){switch(t.preventDefault(),this.open=!0,t.code){case"Space":case"ArrowDown":case"Enter":this.defaultFocus=ui.NONE;break;case"End":this.defaultFocus=ui.LAST_ITEM;break;case"ArrowUp":case"Home":this.defaultFocus=ui.FIRST_ITEM;break}return}if(t.key.length===1){i.onKeydown(t),t.preventDefault();const{lastActiveRecord:l}=i;if(!l)return;this.labelEl?.setAttribute?.("aria-live","polite"),this.selectItem(l[Hr.ITEM])&&this.dispatchInteractionEvents()}}handleClick(){this.open=!this.open}handleFocus(){this.focused=!0}handleBlur(){this.focused=!1}handleFocusout(t){t.relatedTarget&&Mv(t.relatedTarget,this)||(this.open=!1)}getSelectedOptions(){if(!this.menu)return this.lastSelectedOptionRecords=[],null;const t=this.menu.items;return this.lastSelectedOptionRecords=bP(t),this.lastSelectedOptionRecords}async getUpdateComplete(){return await this.menu?.updateComplete,super.getUpdateComplete()}updateValueAndDisplayText(){const t=this.getSelectedOptions()??[];let i=!1;if(t.length){const[o]=t[0];i=this.lastSelectedOption!==o,this.lastSelectedOption=o,this[iu]=o.value,this.displayText=o.displayText}else i=this.lastSelectedOption!==null,this.lastSelectedOption=null,this[iu]="",this.displayText="";return i}async handleOpening(t){if(this.labelEl?.removeAttribute?.("aria-live"),this.redispatchEvent(t),this.defaultFocus!==ui.NONE)return;const i=this.menu.items,o=Ol(i)?.item;let[s]=this.lastSelectedOptionRecords[0]??[null];o&&o!==s&&(o.tabIndex=-1),s=s??i[0],s&&(s.tabIndex=0,s.focus())}redispatchEvent(t){as(this,t)}handleClosed(t){this.open=!1,this.redispatchEvent(t)}handleCloseMenu(t){const i=t.detail.reason,o=t.detail.itemPath[0];this.open=!1;let s=!1;i.kind==="click-selection"?s=this.selectItem(o):i.kind==="keydown"&&fP(i.key)?s=this.selectItem(o):(o.tabIndex=-1,o.blur()),s&&this.dispatchInteractionEvents()}selectItem(t){return(this.getSelectedOptions()??[]).forEach(([o])=>{t!==o&&(o.selected=!1)}),t.selected=!0,this.updateValueAndDisplayText()}handleRequestSelection(t){const i=t.target;this.lastSelectedOptionRecords.some(([o])=>o===i)||this.selectItem(i)}handleRequestDeselection(t){const i=t.target;this.lastSelectedOptionRecords.some(([o])=>o===i)&&this.updateValueAndDisplayText()}initUserSelection(){this.lastUserSetValue&&!this.lastSelectedOptionRecords.length?this.select(this.lastUserSetValue):this.lastUserSetSelectedIndex!==null&&!this.lastSelectedOptionRecords.length?this.selectIndex(this.lastUserSetSelectedIndex):this.updateValueAndDisplayText()}handleIconChange(){this.hasLeadingIcon=this.leadingIcons.length>0}dispatchInteractionEvents(){this.dispatchEvent(new Event("input",{bubbles:!0,composed:!0})),this.dispatchEvent(new Event("change",{bubbles:!0}))}getErrorText(){return this.error?this.errorText:this.nativeErrorText}[Mo](){return this.value}formResetCallback(){this.reset()}formStateRestoreCallback(t){this.value=t}click(){this.field?.click()}[Wa](){return new yP(()=>this)}[Za](){return this.field}}ht.shadowRootOptions={...ft.shadowRootOptions,delegatesFocus:!0};j([K({type:Boolean})],ht.prototype,"quick",void 0);j([K({type:Boolean})],ht.prototype,"required",void 0);j([K({type:String,attribute:"error-text"})],ht.prototype,"errorText",void 0);j([K()],ht.prototype,"label",void 0);j([K({type:Boolean,attribute:"no-asterisk"})],ht.prototype,"noAsterisk",void 0);j([K({type:String,attribute:"supporting-text"})],ht.prototype,"supportingText",void 0);j([K({type:Boolean,reflect:!0})],ht.prototype,"error",void 0);j([K({attribute:"menu-positioning"})],ht.prototype,"menuPositioning",void 0);j([K({type:Boolean,attribute:"clamp-menu-width"})],ht.prototype,"clampMenuWidth",void 0);j([K({type:Number,attribute:"typeahead-delay"})],ht.prototype,"typeaheadDelay",void 0);j([K({type:Boolean,attribute:"has-leading-icon"})],ht.prototype,"hasLeadingIcon",void 0);j([K({attribute:"display-text"})],ht.prototype,"displayText",void 0);j([K({attribute:"menu-align"})],ht.prototype,"menuAlign",void 0);j([K()],ht.prototype,"value",null);j([K({type:Number,attribute:"selected-index"})],ht.prototype,"selectedIndex",null);j([yt()],ht.prototype,"nativeError",void 0);j([yt()],ht.prototype,"nativeErrorText",void 0);j([yt()],ht.prototype,"focused",void 0);j([yt()],ht.prototype,"open",void 0);j([yt()],ht.prototype,"defaultFocus",void 0);j([at(".field")],ht.prototype,"field",void 0);j([at("md-menu")],ht.prototype,"menu",void 0);j([at("#label")],ht.prototype,"labelEl",void 0);j([sn({slot:"leading-icon",flatten:!0})],ht.prototype,"leadingIcons",void 0);class wP extends ht{constructor(){super(...arguments),this.fieldTag=Lo`md-outlined-field`}}const _P=Ye`:host{--_text-field-disabled-input-text-color: var(--md-outlined-select-text-field-disabled-input-text-color, var(--md-sys-color-on-surface, #1d1b20));--_text-field-disabled-input-text-opacity: var(--md-outlined-select-text-field-disabled-input-text-opacity, 0.38);--_text-field-disabled-label-text-color: var(--md-outlined-select-text-field-disabled-label-text-color, var(--md-sys-color-on-surface, #1d1b20));--_text-field-disabled-label-text-opacity: var(--md-outlined-select-text-field-disabled-label-text-opacity, 0.38);--_text-field-disabled-leading-icon-color: var(--md-outlined-select-text-field-disabled-leading-icon-color, var(--md-sys-color-on-surface, #1d1b20));--_text-field-disabled-leading-icon-opacity: var(--md-outlined-select-text-field-disabled-leading-icon-opacity, 0.38);--_text-field-disabled-outline-color: var(--md-outlined-select-text-field-disabled-outline-color, var(--md-sys-color-on-surface, #1d1b20));--_text-field-disabled-outline-opacity: var(--md-outlined-select-text-field-disabled-outline-opacity, 0.12);--_text-field-disabled-outline-width: var(--md-outlined-select-text-field-disabled-outline-width, 1px);--_text-field-disabled-supporting-text-color: var(--md-outlined-select-text-field-disabled-supporting-text-color, var(--md-sys-color-on-surface, #1d1b20));--_text-field-disabled-supporting-text-opacity: var(--md-outlined-select-text-field-disabled-supporting-text-opacity, 0.38);--_text-field-disabled-trailing-icon-color: var(--md-outlined-select-text-field-disabled-trailing-icon-color, var(--md-sys-color-on-surface, #1d1b20));--_text-field-disabled-trailing-icon-opacity: var(--md-outlined-select-text-field-disabled-trailing-icon-opacity, 0.38);--_text-field-error-focus-input-text-color: var(--md-outlined-select-text-field-error-focus-input-text-color, var(--md-sys-color-on-surface, #1d1b20));--_text-field-error-focus-label-text-color: var(--md-outlined-select-text-field-error-focus-label-text-color, var(--md-sys-color-error, #b3261e));--_text-field-error-focus-leading-icon-color: var(--md-outlined-select-text-field-error-focus-leading-icon-color, var(--md-sys-color-on-surface-variant, #49454f));--_text-field-error-focus-outline-color: var(--md-outlined-select-text-field-error-focus-outline-color, var(--md-sys-color-error, #b3261e));--_text-field-error-focus-supporting-text-color: var(--md-outlined-select-text-field-error-focus-supporting-text-color, var(--md-sys-color-error, #b3261e));--_text-field-error-focus-trailing-icon-color: var(--md-outlined-select-text-field-error-focus-trailing-icon-color, var(--md-sys-color-error, #b3261e));--_text-field-error-hover-input-text-color: var(--md-outlined-select-text-field-error-hover-input-text-color, var(--md-sys-color-on-surface, #1d1b20));--_text-field-error-hover-label-text-color: var(--md-outlined-select-text-field-error-hover-label-text-color, var(--md-sys-color-on-error-container, #410e0b));--_text-field-error-hover-leading-icon-color: var(--md-outlined-select-text-field-error-hover-leading-icon-color, var(--md-sys-color-on-surface-variant, #49454f));--_text-field-error-hover-outline-color: var(--md-outlined-select-text-field-error-hover-outline-color, var(--md-sys-color-on-error-container, #410e0b));--_text-field-error-hover-supporting-text-color: var(--md-outlined-select-text-field-error-hover-supporting-text-color, var(--md-sys-color-error, #b3261e));--_text-field-error-hover-trailing-icon-color: var(--md-outlined-select-text-field-error-hover-trailing-icon-color, var(--md-sys-color-on-error-container, #410e0b));--_text-field-error-input-text-color: var(--md-outlined-select-text-field-error-input-text-color, var(--md-sys-color-on-surface, #1d1b20));--_text-field-error-label-text-color: var(--md-outlined-select-text-field-error-label-text-color, var(--md-sys-color-error, #b3261e));--_text-field-error-leading-icon-color: var(--md-outlined-select-text-field-error-leading-icon-color, var(--md-sys-color-on-surface-variant, #49454f));--_text-field-error-outline-color: var(--md-outlined-select-text-field-error-outline-color, var(--md-sys-color-error, #b3261e));--_text-field-error-supporting-text-color: var(--md-outlined-select-text-field-error-supporting-text-color, var(--md-sys-color-error, #b3261e));--_text-field-error-trailing-icon-color: var(--md-outlined-select-text-field-error-trailing-icon-color, var(--md-sys-color-error, #b3261e));--_text-field-focus-input-text-color: var(--md-outlined-select-text-field-focus-input-text-color, var(--md-sys-color-on-surface, #1d1b20));--_text-field-focus-label-text-color: var(--md-outlined-select-text-field-focus-label-text-color, var(--md-sys-color-primary, #6750a4));--_text-field-focus-leading-icon-color: var(--md-outlined-select-text-field-focus-leading-icon-color, var(--md-sys-color-on-surface-variant, #49454f));--_text-field-focus-outline-color: var(--md-outlined-select-text-field-focus-outline-color, var(--md-sys-color-primary, #6750a4));--_text-field-focus-outline-width: var(--md-outlined-select-text-field-focus-outline-width, 3px);--_text-field-focus-supporting-text-color: var(--md-outlined-select-text-field-focus-supporting-text-color, var(--md-sys-color-on-surface-variant, #49454f));--_text-field-focus-trailing-icon-color: var(--md-outlined-select-text-field-focus-trailing-icon-color, var(--md-sys-color-primary, #6750a4));--_text-field-hover-input-text-color: var(--md-outlined-select-text-field-hover-input-text-color, var(--md-sys-color-on-surface, #1d1b20));--_text-field-hover-label-text-color: var(--md-outlined-select-text-field-hover-label-text-color, var(--md-sys-color-on-surface, #1d1b20));--_text-field-hover-leading-icon-color: var(--md-outlined-select-text-field-hover-leading-icon-color, var(--md-sys-color-on-surface-variant, #49454f));--_text-field-hover-outline-color: var(--md-outlined-select-text-field-hover-outline-color, var(--md-sys-color-on-surface, #1d1b20));--_text-field-hover-outline-width: var(--md-outlined-select-text-field-hover-outline-width, 1px);--_text-field-hover-supporting-text-color: var(--md-outlined-select-text-field-hover-supporting-text-color, var(--md-sys-color-on-surface-variant, #49454f));--_text-field-hover-trailing-icon-color: var(--md-outlined-select-text-field-hover-trailing-icon-color, var(--md-sys-color-on-surface-variant, #49454f));--_text-field-input-text-color: var(--md-outlined-select-text-field-input-text-color, var(--md-sys-color-on-surface, #1d1b20));--_text-field-input-text-font: var(--md-outlined-select-text-field-input-text-font, var(--md-sys-typescale-body-large-font, var(--md-ref-typeface-plain, Roboto)));--_text-field-input-text-line-height: var(--md-outlined-select-text-field-input-text-line-height, var(--md-sys-typescale-body-large-line-height, 1.5rem));--_text-field-input-text-size: var(--md-outlined-select-text-field-input-text-size, var(--md-sys-typescale-body-large-size, 1rem));--_text-field-input-text-weight: var(--md-outlined-select-text-field-input-text-weight, var(--md-sys-typescale-body-large-weight, var(--md-ref-typeface-weight-regular, 400)));--_text-field-label-text-color: var(--md-outlined-select-text-field-label-text-color, var(--md-sys-color-on-surface-variant, #49454f));--_text-field-label-text-font: var(--md-outlined-select-text-field-label-text-font, var(--md-sys-typescale-body-large-font, var(--md-ref-typeface-plain, Roboto)));--_text-field-label-text-line-height: var(--md-outlined-select-text-field-label-text-line-height, var(--md-sys-typescale-body-large-line-height, 1.5rem));--_text-field-label-text-populated-line-height: var(--md-outlined-select-text-field-label-text-populated-line-height, var(--md-sys-typescale-body-small-line-height, 1rem));--_text-field-label-text-populated-size: var(--md-outlined-select-text-field-label-text-populated-size, var(--md-sys-typescale-body-small-size, 0.75rem));--_text-field-label-text-size: var(--md-outlined-select-text-field-label-text-size, var(--md-sys-typescale-body-large-size, 1rem));--_text-field-label-text-weight: var(--md-outlined-select-text-field-label-text-weight, var(--md-sys-typescale-body-large-weight, var(--md-ref-typeface-weight-regular, 400)));--_text-field-leading-icon-color: var(--md-outlined-select-text-field-leading-icon-color, var(--md-sys-color-on-surface-variant, #49454f));--_text-field-leading-icon-size: var(--md-outlined-select-text-field-leading-icon-size, 24px);--_text-field-outline-color: var(--md-outlined-select-text-field-outline-color, var(--md-sys-color-outline, #79747e));--_text-field-outline-width: var(--md-outlined-select-text-field-outline-width, 1px);--_text-field-supporting-text-color: var(--md-outlined-select-text-field-supporting-text-color, var(--md-sys-color-on-surface-variant, #49454f));--_text-field-supporting-text-font: var(--md-outlined-select-text-field-supporting-text-font, var(--md-sys-typescale-body-small-font, var(--md-ref-typeface-plain, Roboto)));--_text-field-supporting-text-line-height: var(--md-outlined-select-text-field-supporting-text-line-height, var(--md-sys-typescale-body-small-line-height, 1rem));--_text-field-supporting-text-size: var(--md-outlined-select-text-field-supporting-text-size, var(--md-sys-typescale-body-small-size, 0.75rem));--_text-field-supporting-text-weight: var(--md-outlined-select-text-field-supporting-text-weight, var(--md-sys-typescale-body-small-weight, var(--md-ref-typeface-weight-regular, 400)));--_text-field-trailing-icon-color: var(--md-outlined-select-text-field-trailing-icon-color, var(--md-sys-color-on-surface-variant, #49454f));--_text-field-trailing-icon-size: var(--md-outlined-select-text-field-trailing-icon-size, 24px);--_text-field-container-shape-start-start: var(--md-outlined-select-text-field-container-shape-start-start, var(--md-outlined-select-text-field-container-shape, var(--md-sys-shape-corner-extra-small, 4px)));--_text-field-container-shape-start-end: var(--md-outlined-select-text-field-container-shape-start-end, var(--md-outlined-select-text-field-container-shape, var(--md-sys-shape-corner-extra-small, 4px)));--_text-field-container-shape-end-end: var(--md-outlined-select-text-field-container-shape-end-end, var(--md-outlined-select-text-field-container-shape, var(--md-sys-shape-corner-extra-small, 4px)));--_text-field-container-shape-end-start: var(--md-outlined-select-text-field-container-shape-end-start, var(--md-outlined-select-text-field-container-shape, var(--md-sys-shape-corner-extra-small, 4px)));--md-outlined-field-container-shape-end-end: var(--_text-field-container-shape-end-end);--md-outlined-field-container-shape-end-start: var(--_text-field-container-shape-end-start);--md-outlined-field-container-shape-start-end: var(--_text-field-container-shape-start-end);--md-outlined-field-container-shape-start-start: var(--_text-field-container-shape-start-start);--md-outlined-field-content-color: var(--_text-field-input-text-color);--md-outlined-field-content-font: var(--_text-field-input-text-font);--md-outlined-field-content-line-height: var(--_text-field-input-text-line-height);--md-outlined-field-content-size: var(--_text-field-input-text-size);--md-outlined-field-content-weight: var(--_text-field-input-text-weight);--md-outlined-field-disabled-content-color: var(--_text-field-disabled-input-text-color);--md-outlined-field-disabled-content-opacity: var(--_text-field-disabled-input-text-opacity);--md-outlined-field-disabled-label-text-color: var(--_text-field-disabled-label-text-color);--md-outlined-field-disabled-label-text-opacity: var(--_text-field-disabled-label-text-opacity);--md-outlined-field-disabled-leading-content-color: var(--_text-field-disabled-leading-icon-color);--md-outlined-field-disabled-leading-content-opacity: var(--_text-field-disabled-leading-icon-opacity);--md-outlined-field-disabled-outline-color: var(--_text-field-disabled-outline-color);--md-outlined-field-disabled-outline-opacity: var(--_text-field-disabled-outline-opacity);--md-outlined-field-disabled-outline-width: var(--_text-field-disabled-outline-width);--md-outlined-field-disabled-supporting-text-color: var(--_text-field-disabled-supporting-text-color);--md-outlined-field-disabled-supporting-text-opacity: var(--_text-field-disabled-supporting-text-opacity);--md-outlined-field-disabled-trailing-content-color: var(--_text-field-disabled-trailing-icon-color);--md-outlined-field-disabled-trailing-content-opacity: var(--_text-field-disabled-trailing-icon-opacity);--md-outlined-field-error-content-color: var(--_text-field-error-input-text-color);--md-outlined-field-error-focus-content-color: var(--_text-field-error-focus-input-text-color);--md-outlined-field-error-focus-label-text-color: var(--_text-field-error-focus-label-text-color);--md-outlined-field-error-focus-leading-content-color: var(--_text-field-error-focus-leading-icon-color);--md-outlined-field-error-focus-outline-color: var(--_text-field-error-focus-outline-color);--md-outlined-field-error-focus-supporting-text-color: var(--_text-field-error-focus-supporting-text-color);--md-outlined-field-error-focus-trailing-content-color: var(--_text-field-error-focus-trailing-icon-color);--md-outlined-field-error-hover-content-color: var(--_text-field-error-hover-input-text-color);--md-outlined-field-error-hover-label-text-color: var(--_text-field-error-hover-label-text-color);--md-outlined-field-error-hover-leading-content-color: var(--_text-field-error-hover-leading-icon-color);--md-outlined-field-error-hover-outline-color: var(--_text-field-error-hover-outline-color);--md-outlined-field-error-hover-supporting-text-color: var(--_text-field-error-hover-supporting-text-color);--md-outlined-field-error-hover-trailing-content-color: var(--_text-field-error-hover-trailing-icon-color);--md-outlined-field-error-label-text-color: var(--_text-field-error-label-text-color);--md-outlined-field-error-leading-content-color: var(--_text-field-error-leading-icon-color);--md-outlined-field-error-outline-color: var(--_text-field-error-outline-color);--md-outlined-field-error-supporting-text-color: var(--_text-field-error-supporting-text-color);--md-outlined-field-error-trailing-content-color: var(--_text-field-error-trailing-icon-color);--md-outlined-field-focus-content-color: var(--_text-field-focus-input-text-color);--md-outlined-field-focus-label-text-color: var(--_text-field-focus-label-text-color);--md-outlined-field-focus-leading-content-color: var(--_text-field-focus-leading-icon-color);--md-outlined-field-focus-outline-color: var(--_text-field-focus-outline-color);--md-outlined-field-focus-outline-width: var(--_text-field-focus-outline-width);--md-outlined-field-focus-supporting-text-color: var(--_text-field-focus-supporting-text-color);--md-outlined-field-focus-trailing-content-color: var(--_text-field-focus-trailing-icon-color);--md-outlined-field-hover-content-color: var(--_text-field-hover-input-text-color);--md-outlined-field-hover-label-text-color: var(--_text-field-hover-label-text-color);--md-outlined-field-hover-leading-content-color: var(--_text-field-hover-leading-icon-color);--md-outlined-field-hover-outline-color: var(--_text-field-hover-outline-color);--md-outlined-field-hover-outline-width: var(--_text-field-hover-outline-width);--md-outlined-field-hover-supporting-text-color: var(--_text-field-hover-supporting-text-color);--md-outlined-field-hover-trailing-content-color: var(--_text-field-hover-trailing-icon-color);--md-outlined-field-label-text-color: var(--_text-field-label-text-color);--md-outlined-field-label-text-font: var(--_text-field-label-text-font);--md-outlined-field-label-text-line-height: var(--_text-field-label-text-line-height);--md-outlined-field-label-text-populated-line-height: var(--_text-field-label-text-populated-line-height);--md-outlined-field-label-text-populated-size: var(--_text-field-label-text-populated-size);--md-outlined-field-label-text-size: var(--_text-field-label-text-size);--md-outlined-field-label-text-weight: var(--_text-field-label-text-weight);--md-outlined-field-leading-content-color: var(--_text-field-leading-icon-color);--md-outlined-field-outline-color: var(--_text-field-outline-color);--md-outlined-field-outline-width: var(--_text-field-outline-width);--md-outlined-field-supporting-text-color: var(--_text-field-supporting-text-color);--md-outlined-field-supporting-text-font: var(--_text-field-supporting-text-font);--md-outlined-field-supporting-text-line-height: var(--_text-field-supporting-text-line-height);--md-outlined-field-supporting-text-size: var(--_text-field-supporting-text-size);--md-outlined-field-supporting-text-weight: var(--_text-field-supporting-text-weight);--md-outlined-field-trailing-content-color: var(--_text-field-trailing-icon-color)}[has-start] .icon.leading{font-size:var(--_text-field-leading-icon-size);height:var(--_text-field-leading-icon-size);width:var(--_text-field-leading-icon-size)}.icon.trailing{font-size:var(--_text-field-trailing-icon-size);height:var(--_text-field-trailing-icon-size);width:var(--_text-field-trailing-icon-size)} +`;const SP=Ye`:host{color:unset;min-width:210px;display:flex}.field{cursor:default;outline:none}.select{position:relative;flex-direction:column}.icon.trailing svg,.icon ::slotted(*){fill:currentColor}.icon ::slotted(*){width:inherit;height:inherit;font-size:inherit}.icon slot{display:flex;height:100%;width:100%;align-items:center;justify-content:center}.icon.trailing :is(.up,.down){opacity:0;transition:opacity 75ms linear 75ms}.select:not(.open) .down,.select.open .up{opacity:1}.field,.select,md-menu{min-width:inherit;width:inherit;max-width:inherit;display:flex}md-menu{min-width:var(--__menu-min-width);max-width:var(--__menu-max-width, inherit)}.menu-wrapper{width:0px;height:0px;max-width:inherit}md-menu ::slotted(:not[disabled]){cursor:pointer}.field,.select{width:100%}:host{display:inline-flex}:host([disabled]){pointer-events:none} +`;let zv=class extends wP{};zv.styles=[SP,_P];zv=j([bt("md-outlined-select")],zv);const EP=Ye`:host{display:flex;--md-ripple-hover-color: var(--md-menu-item-hover-state-layer-color, var(--md-sys-color-on-surface, #1d1b20));--md-ripple-hover-opacity: var(--md-menu-item-hover-state-layer-opacity, 0.08);--md-ripple-pressed-color: var(--md-menu-item-pressed-state-layer-color, var(--md-sys-color-on-surface, #1d1b20));--md-ripple-pressed-opacity: var(--md-menu-item-pressed-state-layer-opacity, 0.12)}:host([disabled]){opacity:var(--md-menu-item-disabled-opacity, 0.3);pointer-events:none}md-focus-ring{z-index:1;--md-focus-ring-shape: 8px}a,button,li{background:none;border:none;padding:0;margin:0;text-align:unset;text-decoration:none}.list-item{border-radius:inherit;display:flex;flex:1;max-width:inherit;min-width:inherit;outline:none;-webkit-tap-highlight-color:rgba(0,0,0,0)}.list-item:not(.disabled){cursor:pointer}[slot=container]{pointer-events:none}md-ripple{border-radius:inherit}md-item{border-radius:inherit;flex:1;color:var(--md-menu-item-label-text-color, var(--md-sys-color-on-surface, #1d1b20));font-family:var(--md-menu-item-label-text-font, var(--md-sys-typescale-body-large-font, var(--md-ref-typeface-plain, Roboto)));font-size:var(--md-menu-item-label-text-size, var(--md-sys-typescale-body-large-size, 1rem));line-height:var(--md-menu-item-label-text-line-height, var(--md-sys-typescale-body-large-line-height, 1.5rem));font-weight:var(--md-menu-item-label-text-weight, var(--md-sys-typescale-body-large-weight, var(--md-ref-typeface-weight-regular, 400)));min-height:var(--md-menu-item-one-line-container-height, 56px);padding-top:var(--md-menu-item-top-space, 12px);padding-bottom:var(--md-menu-item-bottom-space, 12px);padding-inline-start:var(--md-menu-item-leading-space, 16px);padding-inline-end:var(--md-menu-item-trailing-space, 16px)}md-item[multiline]{min-height:var(--md-menu-item-two-line-container-height, 72px)}[slot=supporting-text]{color:var(--md-menu-item-supporting-text-color, var(--md-sys-color-on-surface-variant, #49454f));font-family:var(--md-menu-item-supporting-text-font, var(--md-sys-typescale-body-medium-font, var(--md-ref-typeface-plain, Roboto)));font-size:var(--md-menu-item-supporting-text-size, var(--md-sys-typescale-body-medium-size, 0.875rem));line-height:var(--md-menu-item-supporting-text-line-height, var(--md-sys-typescale-body-medium-line-height, 1.25rem));font-weight:var(--md-menu-item-supporting-text-weight, var(--md-sys-typescale-body-medium-weight, var(--md-ref-typeface-weight-regular, 400)))}[slot=trailing-supporting-text]{color:var(--md-menu-item-trailing-supporting-text-color, var(--md-sys-color-on-surface-variant, #49454f));font-family:var(--md-menu-item-trailing-supporting-text-font, var(--md-sys-typescale-label-small-font, var(--md-ref-typeface-plain, Roboto)));font-size:var(--md-menu-item-trailing-supporting-text-size, var(--md-sys-typescale-label-small-size, 0.6875rem));line-height:var(--md-menu-item-trailing-supporting-text-line-height, var(--md-sys-typescale-label-small-line-height, 1rem));font-weight:var(--md-menu-item-trailing-supporting-text-weight, var(--md-sys-typescale-label-small-weight, var(--md-ref-typeface-weight-medium, 500)))}:is([slot=start],[slot=end])::slotted(*){fill:currentColor}[slot=start]{color:var(--md-menu-item-leading-icon-color, var(--md-sys-color-on-surface-variant, #49454f))}[slot=end]{color:var(--md-menu-item-trailing-icon-color, var(--md-sys-color-on-surface-variant, #49454f))}.list-item{background-color:var(--md-menu-item-container-color, transparent)}.list-item.selected{background-color:var(--md-menu-item-selected-container-color, var(--md-sys-color-secondary-container, #e8def8))}.selected:not(.disabled) ::slotted(*){color:var(--md-menu-item-selected-label-text-color, var(--md-sys-color-on-secondary-container, #1d192b))}@media(forced-colors: active){:host([disabled]),:host([disabled]) slot{color:GrayText;opacity:1}.list-item{position:relative}.list-item.selected::before{content:"";position:absolute;inset:0;box-sizing:border-box;border-radius:inherit;pointer-events:none;border:3px double CanvasText}} +`;class oy extends ft{constructor(){super(...arguments),this.multiline=!1}render(){return ue` + + +
+ + + + +
+ + + `}handleTextSlotChange(){let t=!1,i=0;for(const o of this.textSlots)if(TP(o)&&(i+=1),i>1){t=!0;break}this.multiline=t}}j([K({type:Boolean,reflect:!0})],oy.prototype,"multiline",void 0);j([i9(".text slot")],oy.prototype,"textSlots",void 0);function TP(e){for(const t of e.assignedNodes({flatten:!0})){const i=t.nodeType===Node.ELEMENT_NODE,o=t.nodeType===Node.TEXT_NODE&&t.textContent?.match(/\S/);if(i||o)return!0}return!1}const OP=Ye`:host{color:var(--md-sys-color-on-surface, #1d1b20);font-family:var(--md-sys-typescale-body-large-font, var(--md-ref-typeface-plain, Roboto));font-size:var(--md-sys-typescale-body-large-size, 1rem);font-weight:var(--md-sys-typescale-body-large-weight, var(--md-ref-typeface-weight-regular, 400));line-height:var(--md-sys-typescale-body-large-line-height, 1.5rem);align-items:center;box-sizing:border-box;display:flex;gap:16px;min-height:56px;overflow:hidden;padding:12px 16px;position:relative;text-overflow:ellipsis}:host([multiline]){min-height:72px}[name=overline]{color:var(--md-sys-color-on-surface-variant, #49454f);font-family:var(--md-sys-typescale-label-small-font, var(--md-ref-typeface-plain, Roboto));font-size:var(--md-sys-typescale-label-small-size, 0.6875rem);font-weight:var(--md-sys-typescale-label-small-weight, var(--md-ref-typeface-weight-medium, 500));line-height:var(--md-sys-typescale-label-small-line-height, 1rem)}[name=supporting-text]{color:var(--md-sys-color-on-surface-variant, #49454f);font-family:var(--md-sys-typescale-body-medium-font, var(--md-ref-typeface-plain, Roboto));font-size:var(--md-sys-typescale-body-medium-size, 0.875rem);font-weight:var(--md-sys-typescale-body-medium-weight, var(--md-ref-typeface-weight-regular, 400));line-height:var(--md-sys-typescale-body-medium-line-height, 1.25rem)}[name=trailing-supporting-text]{color:var(--md-sys-color-on-surface-variant, #49454f);font-family:var(--md-sys-typescale-label-small-font, var(--md-ref-typeface-plain, Roboto));font-size:var(--md-sys-typescale-label-small-size, 0.6875rem);font-weight:var(--md-sys-typescale-label-small-weight, var(--md-ref-typeface-weight-medium, 500));line-height:var(--md-sys-typescale-label-small-line-height, 1rem)}[name=container]::slotted(*){inset:0;position:absolute}.default-slot{display:inline}.default-slot,.text ::slotted(*){overflow:hidden;text-overflow:ellipsis}.text{display:flex;flex:1;flex-direction:column;overflow:hidden} +`;let Pv=class extends oy{};Pv.styles=[OP];Pv=j([bt("md-item")],Pv);class CP{constructor(t,i){this.host=t,this.internalTypeaheadText=null,this.onClick=()=>{this.host.keepOpen||this.host.dispatchEvent(W2(this.host,{kind:Z2.CLICK_SELECTION}))},this.onKeydown=o=>{if(this.host.href&&o.code==="Enter"){const l=this.getInteractiveElement();l instanceof HTMLAnchorElement&&l.click()}if(o.defaultPrevented)return;const s=o.code;this.host.keepOpen&&s!=="Escape"||G3(s)&&(o.preventDefault(),this.host.dispatchEvent(W2(this.host,{kind:Z2.KEYDOWN,key:s})))},this.getHeadlineElements=i.getHeadlineElements,this.getSupportingTextElements=i.getSupportingTextElements,this.getDefaultElements=i.getDefaultElements,this.getInteractiveElement=i.getInteractiveElement,this.host.addController(this)}get typeaheadText(){if(this.internalTypeaheadText!==null)return this.internalTypeaheadText;const t=this.getHeadlineElements(),i=[];return t.forEach(o=>{o.textContent&&o.textContent.trim()&&i.push(o.textContent.trim())}),i.length===0&&this.getDefaultElements().forEach(o=>{o.textContent&&o.textContent.trim()&&i.push(o.textContent.trim())}),i.length===0&&this.getSupportingTextElements().forEach(o=>{o.textContent&&o.textContent.trim()&&i.push(o.textContent.trim())}),i.join(" ")}get tagName(){switch(this.host.type){case"link":return"a";case"button":return"button";default:case"menuitem":case"option":return"li"}}get role(){return this.host.type==="option"?"option":"menuitem"}hostConnected(){this.host.toggleAttribute("md-menu-item",!0)}hostUpdate(){this.host.href&&(this.host.type="link")}setTypeaheadText(t){this.internalTypeaheadText=t}}function AP(){return new Event("request-selection",{bubbles:!0,composed:!0})}function RP(){return new Event("request-deselection",{bubbles:!0,composed:!0})}class jP{get role(){return this.menuItemController.role}get typeaheadText(){return this.menuItemController.typeaheadText}setTypeaheadText(t){this.menuItemController.setTypeaheadText(t)}get displayText(){return this.internalDisplayText!==null?this.internalDisplayText:this.menuItemController.typeaheadText}setDisplayText(t){this.internalDisplayText=t}constructor(t,i){this.host=t,this.internalDisplayText=null,this.firstUpdate=!0,this.onClick=()=>{this.menuItemController.onClick()},this.onKeydown=o=>{this.menuItemController.onKeydown(o)},this.lastSelected=this.host.selected,this.menuItemController=new CP(t,i),t.addController(this)}hostUpdate(){this.lastSelected!==this.host.selected&&(this.host.ariaSelected=this.host.selected?"true":"false")}hostUpdated(){this.lastSelected!==this.host.selected&&!this.firstUpdate&&(this.host.selected?this.host.dispatchEvent(AP()):this.host.dispatchEvent(RP())),this.lastSelected=this.host.selected,this.firstUpdate=!1}}const kP=ln(ft);class Jr extends kP{constructor(){super(...arguments),this.disabled=!1,this.isMenuItem=!0,this.selected=!1,this.value="",this.type="option",this.selectOptionController=new jP(this,{getHeadlineElements:()=>this.headlineElements,getSupportingTextElements:()=>this.supportingTextElements,getDefaultElements:()=>this.defaultElements,getInteractiveElement:()=>this.listItemRoot})}get typeaheadText(){return this.selectOptionController.typeaheadText}set typeaheadText(t){this.selectOptionController.setTypeaheadText(t)}get displayText(){return this.selectOptionController.displayText}set displayText(t){this.selectOptionController.setDisplayText(t)}render(){return this.renderListItem(ue` + +
+ ${this.renderRipple()} ${this.renderFocusRing()} +
+ + + ${this.renderBody()} +
+ `)}renderListItem(t){return ue` +
  • ${t}
  • + `}renderRipple(){return ue` `}renderFocusRing(){return ue` `}getRenderClasses(){return{disabled:this.disabled,selected:this.selected}}renderBody(){return ue` + + + + + + `}focus(){this.listItemRoot?.focus()}}Jr.shadowRootOptions={...ft.shadowRootOptions,delegatesFocus:!0};j([K({type:Boolean,reflect:!0})],Jr.prototype,"disabled",void 0);j([K({type:Boolean,attribute:"md-menu-item",reflect:!0})],Jr.prototype,"isMenuItem",void 0);j([K({type:Boolean})],Jr.prototype,"selected",void 0);j([K()],Jr.prototype,"value",void 0);j([at(".list-item")],Jr.prototype,"listItemRoot",void 0);j([sn({slot:"headline"})],Jr.prototype,"headlineElements",void 0);j([sn({slot:"supporting-text"})],Jr.prototype,"supportingTextElements",void 0);j([n9({slot:""})],Jr.prototype,"defaultElements",void 0);j([K({attribute:"typeahead-text"})],Jr.prototype,"typeaheadText",null);j([K({attribute:"display-text"})],Jr.prototype,"displayText",null);let Lv=class extends Jr{};Lv.styles=[EP];Lv=j([bt("md-select-option")],Lv);function Tc({ariaLabel:e,className:t,describedBy:i,disabled:o=!1,error:s=!1,errorText:l,label:d,leadingIcon:f,onChange:h,options:m,required:g=!1,supportingText:y,value:b}){const _=w.useRef(null);w.useEffect(()=>{const T=_.current;if(!T)throw new Error("MaterialSelect rendered before md-outlined-select was registered.");const O=()=>h(MP(T));return T.addEventListener("change",O),()=>T.removeEventListener("change",O)},[h]),w.useEffect(()=>{const T=_.current;if(!T)throw new Error("MaterialSelect rendered before md-outlined-select was registered.");T.label=d,T.disabled=o,T.error=s,T.errorText=l??"",T.required=g,T.supportingText=y??"",T.menuPositioning="popover",T.clampMenuWidth=!0,T.value=b,d?T.setAttribute("label",d):T.removeAttribute("label"),e?T.setAttribute("aria-label",e):T.removeAttribute("aria-label"),i?T.setAttribute("aria-describedby",i):T.removeAttribute("aria-describedby")},[e,i,o,s,l,d,g,y,b,m]);const E=DP(t,"material-select--single-line");return v.jsxs("md-outlined-select",{ref:_,className:E,children:[f?v.jsx("span",{"aria-hidden":"true",className:"material-select-leading-node",slot:"leading-icon",children:f}):null,m.map(T=>v.jsx(zP,{label:T.label,leadingIcon:T.leadingIcon,selected:T.value===b,value:T.value},T.value))]})}function MP(e){if(e.value)return e.value;const t=Array.from(e.querySelectorAll("md-select-option")).find(i=>i.selected);return t?.value||t?.getAttribute("value")||t?.getAttribute("data-value")||""}function DP(...e){return e.filter(Boolean).join(" ")}function zP({label:e,leadingIcon:t,selected:i,value:o}){const s=w.useRef(null);return w.useEffect(()=>{const l=s.current;if(!l)throw new Error("MaterialSelectOption rendered before md-select-option was registered.");l.value=o,l.displayText=e,l.selected=i},[e,i,o]),v.jsxs("md-select-option",{ref:s,"data-value":o,"display-text":e,selected:i,value:o,children:[t?v.jsx("span",{"aria-hidden":"true",className:"material-select-option-icon",slot:"start",children:t}):null,v.jsx("span",{slot:"headline",children:e})]})}const X3=Symbol("dispatchHooks");function PP(e,t){const i=e[X3];if(!i)throw new Error(`'${e.type}' event needs setupDispatchHooks().`);i.addEventListener("after",t)}const t_=new WeakMap;function LP(e,...t){let i=t_.get(e);i||(i=new Set,t_.set(e,i));for(const o of t){if(i.has(o))continue;let s=!1;e.addEventListener(o,l=>{if(s)return;l.stopImmediatePropagation();const d=Reflect.construct(l.constructor,[l.type,l]),f=new EventTarget;d[X3]=f,s=!0;const h=e.dispatchEvent(d);s=!1,h||l.preventDefault(),f.dispatchEvent(new Event("after"))},{capture:!0}),i.add(o)}}const NP=ln(nf(of(os(ft))));class Zn extends NP{constructor(){super(),this.selected=!1,this.icons=!1,this.showOnlySelectedIcon=!1,this.required=!1,this.value="on",this.addEventListener("click",t=>{!Wg(t)||!this.input||(this.focus(),Xg(this.input))}),LP(this,"keydown"),this.addEventListener("keydown",t=>{PP(t,()=>{t.defaultPrevented||t.key!=="Enter"||this.disabled||!this.input||this.input.click()})})}render(){return ue` +
    + + + + ${this.renderHandle()} +
    + `}getRenderClasses(){return{selected:this.selected,unselected:!this.selected,disabled:this.disabled}}renderHandle(){const t={"with-icon":this.showOnlySelectedIcon?this.selected:this.icons};return ue` + ${this.renderTouchTarget()} + + + + ${this.shouldShowIcons()?this.renderIcons():ue``} + + + `}renderIcons(){return ue` +
    + ${this.renderOnIcon()} + ${this.showOnlySelectedIcon?ue``:this.renderOffIcon()} +
    + `}renderOnIcon(){return ue` + + + + + + `}renderOffIcon(){return ue` + + + + + + `}renderTouchTarget(){return ue``}shouldShowIcons(){return this.icons||this.showOnlySelectedIcon}handleInput(t){const i=t.target;this.selected=i.checked}handleChange(t){as(this,t)}[Mo](){return this.selected?this.value:null}[Bu](){return String(this.selected)}formResetCallback(){this.selected=this.hasAttribute("selected")}formStateRestoreCallback(t){this.selected=t==="true"}[Wa](){return new O3(()=>({checked:this.selected,required:this.required}))}[Za](){return this.input}}Zn.shadowRootOptions={mode:"open",delegatesFocus:!0};j([K({type:Boolean})],Zn.prototype,"selected",void 0);j([K({type:Boolean})],Zn.prototype,"icons",void 0);j([K({type:Boolean,attribute:"show-only-selected-icon"})],Zn.prototype,"showOnlySelectedIcon",void 0);j([K({type:Boolean})],Zn.prototype,"required",void 0);j([K()],Zn.prototype,"value",void 0);j([at("input")],Zn.prototype,"input",void 0);const $P=Ye`@layer styles, hcm;@layer styles{:host{display:inline-flex;outline:none;vertical-align:top;-webkit-tap-highlight-color:rgba(0,0,0,0);cursor:pointer}:host([disabled]){cursor:default}:host([touch-target=wrapper]){margin:max(0px,(48px - var(--md-switch-track-height, 32px))/2) 0px}md-focus-ring{--md-focus-ring-shape-start-start: var(--md-switch-track-shape-start-start, var(--md-switch-track-shape, var(--md-sys-shape-corner-full, 9999px)));--md-focus-ring-shape-start-end: var(--md-switch-track-shape-start-end, var(--md-switch-track-shape, var(--md-sys-shape-corner-full, 9999px)));--md-focus-ring-shape-end-end: var(--md-switch-track-shape-end-end, var(--md-switch-track-shape, var(--md-sys-shape-corner-full, 9999px)));--md-focus-ring-shape-end-start: var(--md-switch-track-shape-end-start, var(--md-switch-track-shape, var(--md-sys-shape-corner-full, 9999px)))}.switch{align-items:center;display:inline-flex;flex-shrink:0;position:relative;width:var(--md-switch-track-width, 52px);height:var(--md-switch-track-height, 32px);border-start-start-radius:var(--md-switch-track-shape-start-start, var(--md-switch-track-shape, var(--md-sys-shape-corner-full, 9999px)));border-start-end-radius:var(--md-switch-track-shape-start-end, var(--md-switch-track-shape, var(--md-sys-shape-corner-full, 9999px)));border-end-end-radius:var(--md-switch-track-shape-end-end, var(--md-switch-track-shape, var(--md-sys-shape-corner-full, 9999px)));border-end-start-radius:var(--md-switch-track-shape-end-start, var(--md-switch-track-shape, var(--md-sys-shape-corner-full, 9999px)))}input{appearance:none;height:max(100%,var(--md-switch-touch-target-size, 48px));outline:none;margin:0;position:absolute;width:max(100%,var(--md-switch-touch-target-size, 48px));z-index:1;cursor:inherit;top:50%;left:50%;transform:translate(-50%, -50%)}:host([touch-target=none]) input{display:none}}@layer styles{.track{position:absolute;width:100%;height:100%;box-sizing:border-box;border-radius:inherit;display:flex;justify-content:center;align-items:center}.track::before{content:"";display:flex;position:absolute;height:100%;width:100%;border-radius:inherit;box-sizing:border-box;transition-property:opacity,background-color;transition-timing-function:linear;transition-duration:67ms}.disabled .track{background-color:rgba(0,0,0,0);border-color:rgba(0,0,0,0)}.disabled .track::before,.disabled .track::after{transition:none;opacity:var(--md-switch-disabled-track-opacity, 0.12)}.disabled .track::before{background-clip:content-box}.selected .track::before{background-color:var(--md-switch-selected-track-color, var(--md-sys-color-primary, #6750a4))}.selected:hover .track::before{background-color:var(--md-switch-selected-hover-track-color, var(--md-sys-color-primary, #6750a4))}.selected:focus-within .track::before{background-color:var(--md-switch-selected-focus-track-color, var(--md-sys-color-primary, #6750a4))}.selected:active .track::before{background-color:var(--md-switch-selected-pressed-track-color, var(--md-sys-color-primary, #6750a4))}.selected.disabled .track{background-clip:border-box}.selected.disabled .track::before{background-color:var(--md-switch-disabled-selected-track-color, var(--md-sys-color-on-surface, #1d1b20))}.unselected .track::before{background-color:var(--md-switch-track-color, var(--md-sys-color-surface-container-highest, #e6e0e9));border-color:var(--md-switch-track-outline-color, var(--md-sys-color-outline, #79747e));border-style:solid;border-width:var(--md-switch-track-outline-width, 2px)}.unselected:hover .track::before{background-color:var(--md-switch-hover-track-color, var(--md-sys-color-surface-container-highest, #e6e0e9));border-color:var(--md-switch-hover-track-outline-color, var(--md-sys-color-outline, #79747e))}.unselected:focus-visible .track::before{background-color:var(--md-switch-focus-track-color, var(--md-sys-color-surface-container-highest, #e6e0e9));border-color:var(--md-switch-focus-track-outline-color, var(--md-sys-color-outline, #79747e))}.unselected:active .track::before{background-color:var(--md-switch-pressed-track-color, var(--md-sys-color-surface-container-highest, #e6e0e9));border-color:var(--md-switch-pressed-track-outline-color, var(--md-sys-color-outline, #79747e))}.unselected.disabled .track::before{background-color:var(--md-switch-disabled-track-color, var(--md-sys-color-surface-container-highest, #e6e0e9));border-color:var(--md-switch-disabled-track-outline-color, var(--md-sys-color-on-surface, #1d1b20))}}@layer hcm{@media(forced-colors: active){.selected .track::before{background:ButtonText;border-color:ButtonText}.disabled .track::before{border-color:GrayText;opacity:1}.disabled.selected .track::before{background:GrayText}}}@layer styles{.handle-container{display:flex;place-content:center;place-items:center;position:relative;transition:margin 300ms cubic-bezier(0.175, 0.885, 0.32, 1.275)}.selected .handle-container{margin-inline-start:calc(var(--md-switch-track-width, 52px) - var(--md-switch-track-height, 32px))}.unselected .handle-container{margin-inline-end:calc(var(--md-switch-track-width, 52px) - var(--md-switch-track-height, 32px))}.disabled .handle-container{transition:none}.handle{border-start-start-radius:var(--md-switch-handle-shape-start-start, var(--md-switch-handle-shape, var(--md-sys-shape-corner-full, 9999px)));border-start-end-radius:var(--md-switch-handle-shape-start-end, var(--md-switch-handle-shape, var(--md-sys-shape-corner-full, 9999px)));border-end-end-radius:var(--md-switch-handle-shape-end-end, var(--md-switch-handle-shape, var(--md-sys-shape-corner-full, 9999px)));border-end-start-radius:var(--md-switch-handle-shape-end-start, var(--md-switch-handle-shape, var(--md-sys-shape-corner-full, 9999px)));height:var(--md-switch-handle-height, 16px);width:var(--md-switch-handle-width, 16px);transform-origin:center;transition-property:height,width;transition-duration:250ms,250ms;transition-timing-function:cubic-bezier(0.2, 0, 0, 1),cubic-bezier(0.2, 0, 0, 1);z-index:0}.handle::before{content:"";display:flex;inset:0;position:absolute;border-radius:inherit;box-sizing:border-box;transition:background-color 67ms linear}.disabled .handle,.disabled .handle::before{transition:none}.selected .handle{height:var(--md-switch-selected-handle-height, 24px);width:var(--md-switch-selected-handle-width, 24px)}.handle.with-icon{height:var(--md-switch-with-icon-handle-height, 24px);width:var(--md-switch-with-icon-handle-width, 24px)}.selected:not(.disabled):active .handle,.unselected:not(.disabled):active .handle{height:var(--md-switch-pressed-handle-height, 28px);width:var(--md-switch-pressed-handle-width, 28px);transition-timing-function:linear;transition-duration:100ms}.selected .handle::before{background-color:var(--md-switch-selected-handle-color, var(--md-sys-color-on-primary, #fff))}.selected:hover .handle::before{background-color:var(--md-switch-selected-hover-handle-color, var(--md-sys-color-primary-container, #eaddff))}.selected:focus-within .handle::before{background-color:var(--md-switch-selected-focus-handle-color, var(--md-sys-color-primary-container, #eaddff))}.selected:active .handle::before{background-color:var(--md-switch-selected-pressed-handle-color, var(--md-sys-color-primary-container, #eaddff))}.selected.disabled .handle::before{background-color:var(--md-switch-disabled-selected-handle-color, var(--md-sys-color-surface, #fef7ff));opacity:var(--md-switch-disabled-selected-handle-opacity, 1)}.unselected .handle::before{background-color:var(--md-switch-handle-color, var(--md-sys-color-outline, #79747e))}.unselected:hover .handle::before{background-color:var(--md-switch-hover-handle-color, var(--md-sys-color-on-surface-variant, #49454f))}.unselected:focus-within .handle::before{background-color:var(--md-switch-focus-handle-color, var(--md-sys-color-on-surface-variant, #49454f))}.unselected:active .handle::before{background-color:var(--md-switch-pressed-handle-color, var(--md-sys-color-on-surface-variant, #49454f))}.unselected.disabled .handle::before{background-color:var(--md-switch-disabled-handle-color, var(--md-sys-color-on-surface, #1d1b20));opacity:var(--md-switch-disabled-handle-opacity, 0.38)}md-ripple{border-radius:var(--md-switch-state-layer-shape, var(--md-sys-shape-corner-full, 9999px));height:var(--md-switch-state-layer-size, 40px);inset:unset;width:var(--md-switch-state-layer-size, 40px)}.selected md-ripple{--md-ripple-hover-color: var(--md-switch-selected-hover-state-layer-color, var(--md-sys-color-primary, #6750a4));--md-ripple-pressed-color: var(--md-switch-selected-pressed-state-layer-color, var(--md-sys-color-primary, #6750a4));--md-ripple-hover-opacity: var(--md-switch-selected-hover-state-layer-opacity, 0.08);--md-ripple-pressed-opacity: var(--md-switch-selected-pressed-state-layer-opacity, 0.12)}.unselected md-ripple{--md-ripple-hover-color: var(--md-switch-hover-state-layer-color, var(--md-sys-color-on-surface, #1d1b20));--md-ripple-pressed-color: var(--md-switch-pressed-state-layer-color, var(--md-sys-color-on-surface, #1d1b20));--md-ripple-hover-opacity: var(--md-switch-hover-state-layer-opacity, 0.08);--md-ripple-pressed-opacity: var(--md-switch-pressed-state-layer-opacity, 0.12)}}@layer hcm{@media(forced-colors: active){.unselected .handle::before{background:ButtonText}.disabled .handle::before{opacity:1}.disabled.unselected .handle::before{background:GrayText}}}@layer styles{.icons{position:relative;height:100%;width:100%}.icon{position:absolute;inset:0;margin:auto;display:flex;align-items:center;justify-content:center;fill:currentColor;transition:fill 67ms linear,opacity 33ms linear,transform 167ms cubic-bezier(0.2, 0, 0, 1);opacity:0}.disabled .icon{transition:none}.selected .icon--on,.unselected .icon--off{opacity:1}.unselected .handle:not(.with-icon) .icon--on{transform:rotate(-45deg)}.icon--off{width:var(--md-switch-icon-size, 16px);height:var(--md-switch-icon-size, 16px);color:var(--md-switch-icon-color, var(--md-sys-color-surface-container-highest, #e6e0e9))}.unselected:hover .icon--off{color:var(--md-switch-hover-icon-color, var(--md-sys-color-surface-container-highest, #e6e0e9))}.unselected:focus-within .icon--off{color:var(--md-switch-focus-icon-color, var(--md-sys-color-surface-container-highest, #e6e0e9))}.unselected:active .icon--off{color:var(--md-switch-pressed-icon-color, var(--md-sys-color-surface-container-highest, #e6e0e9))}.unselected.disabled .icon--off{color:var(--md-switch-disabled-icon-color, var(--md-sys-color-surface-container-highest, #e6e0e9));opacity:var(--md-switch-disabled-icon-opacity, 0.38)}.icon--on{width:var(--md-switch-selected-icon-size, 16px);height:var(--md-switch-selected-icon-size, 16px);color:var(--md-switch-selected-icon-color, var(--md-sys-color-on-primary-container, #21005d))}.selected:hover .icon--on{color:var(--md-switch-selected-hover-icon-color, var(--md-sys-color-on-primary-container, #21005d))}.selected:focus-within .icon--on{color:var(--md-switch-selected-focus-icon-color, var(--md-sys-color-on-primary-container, #21005d))}.selected:active .icon--on{color:var(--md-switch-selected-pressed-icon-color, var(--md-sys-color-on-primary-container, #21005d))}.selected.disabled .icon--on{color:var(--md-switch-disabled-selected-icon-color, var(--md-sys-color-on-surface, #1d1b20));opacity:var(--md-switch-disabled-selected-icon-opacity, 0.38)}}@layer hcm{@media(forced-colors: active){.icon--off{fill:Canvas}.icon--on{fill:ButtonText}.disabled.unselected .icon--off,.disabled.selected .icon--on{opacity:1}.disabled .icon--on{fill:GrayText}}} +`;let Nv=class extends Zn{};Nv.styles=[$P];Nv=j([bt("md-switch")],Nv);function Io({disabled:e=!1,label:t,onChange:i,selected:o}){const s=w.useRef(null);return w.useEffect(()=>{const l=s.current;if(!l)throw new Error("MaterialSwitch rendered before md-switch was registered.");const d=()=>i(l.selected);return l.addEventListener("change",d),()=>l.removeEventListener("change",d)},[i]),w.useEffect(()=>{if(!s.current)throw new Error("MaterialSwitch rendered before md-switch was registered.");s.current.selected=o},[o]),w.useEffect(()=>{if(!s.current)throw new Error("MaterialSwitch rendered before md-switch was registered.");s.current.disabled=e},[e]),v.jsx("md-switch",{ref:s,"aria-label":t})}const W3=w.createContext(null);function BP({report:e,children:t}){return v.jsx(W3.Provider,{value:e,children:t})}function ss(e,t){const i=w.useContext(W3);w.useEffect(()=>{i?.(e,t)},[i,e,t]),w.useEffect(()=>()=>{i?.(e,"idle")},[i,e])}function UP({options:e,value:t,onChange:i,disabled:o=!1,describedBy:s,error:l,errorText:d,ariaLabel:f,leadingIcon:h,placeholder:m,required:g,supportingText:y}){return v.jsx(Tc,{ariaLabel:f,describedBy:s,disabled:o,error:l,errorText:d,label:f??m??"",leadingIcon:h,options:e.map(b=>({value:b.value,label:b.label,leadingIcon:b.leadingIcon})),required:g,supportingText:y,value:t,onChange:i})}const nu=12,IP=8,r_=320;function ay(e,t){const[i,o]=w.useState();return w.useEffect(()=>{if(!t){o(void 0);return}function s(){const l=e.current;if(!l){o(void 0);return}o(VP(l.getBoundingClientRect()))}return s(),window.addEventListener("resize",s),window.addEventListener("scroll",s,!0),()=>{window.removeEventListener("resize",s),window.removeEventListener("scroll",s,!0)}},[e,t]),i}function VP(e){const t=Math.max(window.innerWidth||r_,nu*2),i=Math.max(0,t-nu*2),o=Math.min(r_,i),s=e.right-o,l=t-nu-o;return{left:Math.round(FP(s,nu,l)),maxWidth:Math.round(i),top:Math.round(e.bottom+IP)}}function FP(e,t,i){return Math.min(Math.max(e,t),Math.max(t,i))}function HP({field:e,value:t,onChange:i,onCommit:o,onCommitValue:s,clearSecretDraft:l=!1,disabled:d=!1,idPrefix:f,docPath:h,error:m,options:g,warning:y,leadingIconNode:b,objectDisplay:_="collapsible"}){const{locale:E,t:T}=Oe(),O=w.useMemo(()=>`schema-field-${f?`${f}-`:""}${e.path}`.replace(/[^a-zA-Z0-9_-]/g,"-"),[e.path,f]),[D,C]=w.useState(a_(e,t)),[z,B]=w.useState(""),[H,ne]=w.useState(!1),[X,A]=w.useState(!1),le=w.useRef(void 0),ee=w.useRef(null),J=GP(e,h,E);w.useEffect(()=>{if(Ha(e)){typeof t=="string"&&t===le.current?C(t):(le.current=void 0,C("")),B("");return}C(a_(e,t)),B("")},[e,t]),w.useEffect(()=>{!l||!Ha(e)||(le.current=void 0,C(""),B(""))},[l,e]);const ce=tL(e),_e=`${O}-error`,ie=`${O}-warning`,xe=`${O}-help`,N=rL(e,J,h,E,{default:T("configDoc.default"),defaultEmpty:T("configDoc.default.empty"),optional:T("configDoc.optional"),required:T("configDoc.required"),restartMayBeRequired:T("configDoc.restartMayBeRequired"),savedRealtime:T("configDoc.savedRealtime"),sensitive:T("configDoc.sensitive"),type:T("configDoc.type"),typeArray:T("configDoc.type.array"),typeBoolean:T("configDoc.type.boolean"),typeHostPort:T("configDoc.type.hostPort"),typeNumber:T("configDoc.type.number"),typeObject:T("configDoc.type.object"),typeString:T("configDoc.type.string"),typeUrl:T("configDoc.type.url")}),Q=m||z,se=[H?xe:void 0,Q?_e:void 0].filter(Boolean).join(" ")||void 0,he=o?()=>o():void 0,Te=s??i;if(e.control==="select"||(e.enum?.length??0)>0||(g?.length??0)>0){const q=typeof t=="string"?t:"",oe=KP(e,h),de=g&&g.length>0?g:(e.enum??[]).map(Ee=>({value:Ee,label:iL(Ee,T),leadingIcon:oe?Fu(Ee):void 0})),Se=de.find(Ee=>Ee.value===q);return v.jsxs("div",{className:ce?"mb-field mb-field--wide":"mb-field","data-variant":"select",children:[v.jsx("div",{className:"mb-field__control",children:v.jsx(UP,{id:O,options:de,value:q,onChange:Ee=>Te(Ee),disabled:d,ariaLabel:J,describedBy:Q?_e:y?ie:void 0,error:!!Q,errorText:Q,leadingIcon:oe?Fu(q):Se?.leadingIcon,required:e.required})}),v.jsx(fl,{errorId:_e,error:Q}),y&&!Q?v.jsx("p",{className:"field-warning",id:ie,role:"note",children:y}):null]})}if(e.type==="boolean"||e.control==="switch")return v.jsxs("div",{className:"schema-field schema-field--inline",children:[v.jsxs("div",{className:"schema-field__switch-line",children:[v.jsxs("span",{className:"schema-field__label-row",children:[v.jsxs("span",{className:"schema-field__label",children:[J,e.required?v.jsx("span",{className:"schema-field__required","aria-hidden":"true",children:"*"}):null]}),v.jsx(Z3,{field:e,label:J,helpId:xe,helpOpen:H,helpParts:N,setHelpOpen:ne})]}),v.jsx(Io,{disabled:d,label:J,selected:!!t,onChange:q=>Te(q)})]}),Q?v.jsx("p",{className:"field-error",id:_e,role:"alert",children:Q}):null]});if(e.control==="textarea")return v.jsxs("div",{className:ce?"mb-field mb-field--wide":"mb-field","data-variant":"textarea",children:[v.jsxs("div",{className:"mb-field__control",children:[v.jsx(Yt,{ariaDescribedBy:se,ariaLabel:J,ariaInvalid:!!Q,className:"schema-text-field",disabled:d,error:!!Q,errorText:Q,id:O,label:J,required:e.required,rows:6,supportingText:n_(e,T("field.secretReplacementHint")),trailingIcon:v.jsx(Cl,{anchorRef:ee,field:e,label:J,helpId:xe,helpOpen:H,setHelpOpen:ne,slot:"trailing-icon"}),type:"textarea",value:D,onInput:q=>M(q,i,e),onBlur:he}),v.jsx(yl,{anchorRef:ee,helpId:xe,helpOpen:H,helpParts:N})]}),v.jsx(fl,{errorId:_e,error:Q})]});if(e.type==="object"||e.type==="array"||e.control==="object"||e.control==="array"){const q=nL(t,e,J,T);return oL(t)?v.jsxs("div",{className:o_(ce),children:[_==="expandedFixed"?null:v.jsx(i_,{field:e,label:J,helpId:xe,helpOpen:H,helpParts:N,id:O,setHelpOpen:ne}),v.jsx(YP,{describedBy:se,disabled:d,id:O,label:J,objectDisplay:_,onCommit:Te,summary:q,value:t,helpButton:_==="expandedFixed"?v.jsx(Cl,{anchorRef:ee,field:e,label:J,helpId:xe,helpOpen:H,setHelpOpen:ne}):null}),_==="expandedFixed"?v.jsx(yl,{anchorRef:ee,helpId:xe,helpOpen:H,helpParts:N}):null,v.jsx(fl,{errorId:_e,error:Q})]}):v.jsxs("div",{className:o_(ce),children:[_==="expandedFixed"?null:v.jsx(i_,{field:e,label:J,helpId:xe,helpOpen:H,helpParts:N,id:O,setHelpOpen:ne}),v.jsxs("div",{"aria-describedby":se,"aria-label":T("field.structuredSummaryLabel",{label:J}),className:"schema-structured-summary",id:O,children:[v.jsxs("div",{className:"schema-structured-summary__header",children:[v.jsx("span",{children:q.title}),v.jsx("strong",{children:T("field.structuredReadonly")}),_==="expandedFixed"?v.jsx(Cl,{anchorRef:ee,field:e,label:J,helpId:xe,helpOpen:H,setHelpOpen:ne}):null]}),q.rows.length?v.jsx("dl",{className:"schema-structured-summary__rows",children:q.rows.map(oe=>v.jsxs("div",{className:"schema-structured-summary__row",children:[v.jsx("dt",{children:oe.key}),v.jsx("dd",{children:oe.value})]},oe.key))}):v.jsx("p",{className:"schema-structured-summary__empty",children:T("field.structuredEmpty")})]}),_==="expandedFixed"?v.jsx(yl,{anchorRef:ee,helpId:xe,helpOpen:H,helpParts:N}):null,v.jsx(fl,{errorId:_e,error:Q})]})}return v.jsxs("div",{className:ce?"mb-field mb-field--wide":"mb-field","data-variant":"input",children:[v.jsxs("div",{className:"mb-field__control",children:[v.jsx(Yt,{ariaDescribedBy:se,ariaLabel:J,ariaInvalid:!!Q,autoComplete:e.secret?"new-password":void 0,className:"schema-text-field",disabled:d,error:!!Q,errorText:Q,id:O,label:J,leadingIcon:qP(e),leadingIconNode:b,required:e.required,supportingText:n_(e,T("field.secretReplacementHint")),trailingIcon:JP({field:e,revealed:X,setRevealed:A,revealLabel:T("auth.revealToken"),hideLabel:T("auth.hideToken"),displayLabel:J,helpId:xe,helpOpen:H,setHelpOpen:ne,trailingHelpAnchorRef:ee}),type:Ha(e)&&!X?"password":"text",value:D,onInput:q=>M(q,i,e),onBlur:he}),v.jsx(yl,{anchorRef:ee,helpId:xe,helpOpen:H,helpParts:N})]}),v.jsx(fl,{errorId:_e,error:Q})]});function M(q,oe,de){if(C(q),Ha(de)&&(le.current=q),de.type==="number"||de.control==="number"){if(q===""){B(""),oe(void 0);return}const Se=Number(q);if(!Number.isFinite(Se)){B(T("field.invalidNumber"));return}B(""),oe(Se);return}B(""),oe(q)}}function qP(e){if(e.secret||e.control==="secret")return"key";const t=e.path.toLowerCase();if(t.includes("url")||t.includes("endpoint")||t.includes("addr"))return"link";if(t.includes("model"))return"smart_toy";if(t.includes("agent"))return"badge";if(e.type==="number"||e.control==="number")return"tag"}function KP(e,t){return e.path==="protocol"&&t==="providers..protocol"}function GP(e,t,i){return(t?Di[t]:void 0)?.title[i]??e.label}function i_({field:e,helpId:t,helpOpen:i,helpParts:o,id:s,label:l,labelForControl:d=!0,labelId:f,setHelpOpen:h}){const m=v.jsxs(v.Fragment,{children:[l,e.required?v.jsx("span",{className:"schema-field__required","aria-hidden":"true",children:"*"}):null]});return v.jsx("div",{className:"schema-field__topline",children:v.jsxs("span",{className:"schema-field__label-row",children:[d?v.jsx("label",{className:"schema-field__label",htmlFor:s,children:m}):v.jsx("span",{className:"schema-field__label",id:f,children:m}),v.jsx(Z3,{field:e,label:l,helpId:t,helpOpen:i,helpParts:o,setHelpOpen:h})]})})}function YP({describedBy:e,disabled:t,helpButton:i,id:o,label:s,objectDisplay:l,onCommit:d,summary:f,value:h}){const{t:m}=Oe(),g=Object.entries(h).filter(([,b])=>c_(b)),y=Object.entries(h).filter(([,b])=>!c_(b));return v.jsxs("div",{"aria-describedby":e,"aria-label":m("field.structuredEditorLabel",{label:s}),className:"schema-structured-object",id:o,children:[v.jsxs("div",{className:"schema-structured-object__header",children:[v.jsx("span",{children:s}),i]}),g.length?v.jsx("div",{className:"schema-structured-object__grid",children:g.map(([b,_])=>v.jsx(QP,{disabled:t,label:b,value:_,onCommit:E=>d({...h,[b]:E})},b))}):null,y.length||g.length===0?v.jsx(ZP,{objectDisplay:l,summary:{title:g.length===0?f.title:m("field.structuredNestedValues"),rows:g.length===0?f.rows:y.map(([b,_])=>({key:b,value:$v(_,void 0,m)}))}}):null]})}function QP({disabled:e,label:t,onCommit:i,value:o}){return typeof o=="boolean"?v.jsx("div",{className:"schema-field schema-field--inline schema-structured-object__boolean",children:v.jsxs("div",{className:"schema-field__switch-line",children:[v.jsx("span",{className:"schema-field__label-row",children:v.jsx("span",{className:"schema-field__label",children:t})}),v.jsx(Io,{disabled:e,label:t,selected:o,onChange:i})]})}):typeof o=="number"?v.jsx(WP,{disabled:e,label:t,value:o,onCommit:i}):v.jsx(XP,{disabled:e,label:t,value:typeof o=="string"?o:"",onCommit:i})}function XP({disabled:e,label:t,onCommit:i,value:o}){const[s,l]=w.useState(o);return w.useEffect(()=>{l(o)},[o]),v.jsx("div",{className:"mb-field","data-variant":"input",children:v.jsx("div",{className:"mb-field__control",children:v.jsx(Yt,{disabled:e,label:t,spellCheck:!1,type:"text",value:s,onBlur:()=>{s!==o&&i(s)},onInput:l})})})}function WP({disabled:e,label:t,onCommit:i,value:o}){const{t:s}=Oe(),[l,d]=w.useState(String(o)),[f,h]=w.useState("");w.useEffect(()=>{d(String(o)),h("")},[o]);function m(){const g=l.trim();if(g===String(o)){h("");return}const y=Number(g);if(!Number.isFinite(y)){h(s("field.invalidNumber"));return}h(""),i(y)}return v.jsxs("div",{className:"mb-field","data-variant":"input",children:[v.jsx("div",{className:"mb-field__control",children:v.jsx(Yt,{ariaInvalid:!!f,disabled:e,error:!!f,errorText:f,inputMode:"decimal",label:t,spellCheck:!1,type:"text",value:l,onBlur:m,onInput:g=>{d(g),f&&g.trim()===String(o)&&h("")}})}),f?v.jsx("p",{className:"field-error field-error--sr",role:"alert",children:f}):null]})}function ZP({objectDisplay:e,summary:t}){const{t:i}=Oe();return v.jsxs("div",{className:"schema-structured-summary schema-structured-summary--nested",children:[v.jsxs("div",{className:"schema-structured-summary__header",children:[v.jsx("span",{children:t.title}),v.jsx("strong",{children:i(e==="expandedFixed"?"field.structuredReadonly":"field.structuredSummary")})]}),t.rows.length?v.jsx("dl",{className:"schema-structured-summary__rows",children:t.rows.map(o=>v.jsxs("div",{className:"schema-structured-summary__row",children:[v.jsx("dt",{children:o.key}),v.jsx("dd",{children:o.value})]},o.key))}):v.jsx("p",{className:"schema-structured-summary__empty",children:i("field.structuredEmpty")})]})}function Z3({field:e,helpId:t,helpOpen:i,helpParts:o,label:s,setHelpOpen:l}){const d=w.useRef(null);return v.jsxs("span",{className:"schema-field__help-wrap",children:[v.jsx(Cl,{anchorRef:d,field:e,label:s,helpId:t,helpOpen:i,setHelpOpen:l}),v.jsx(yl,{anchorRef:d,helpId:t,helpOpen:i,helpParts:o})]})}function JP({field:e,revealed:t,setRevealed:i,revealLabel:o,hideLabel:s,displayLabel:l,helpId:d,helpOpen:f,setHelpOpen:h,trailingHelpAnchorRef:m}){return Ha(e)?v.jsx(pi,{className:"field-visibility-toggle",icon:t?"visibility_off":"visibility",label:t?s:o,onClick:()=>i(g=>!g),onMouseDown:g=>g.preventDefault(),slot:"trailing-icon"}):v.jsx(Cl,{anchorRef:m,field:e,label:l,helpId:d,helpOpen:f,setHelpOpen:h,slot:"trailing-icon"})}function Cl({anchorRef:e,field:t,helpId:i,helpOpen:o,label:s,setHelpOpen:l,slot:d}){const{t:f}=Oe(),h=w.useRef(!1);return v.jsx(pi,{className:"schema-field__help",describedBy:o?i:void 0,icon:"help",label:f("field.helpFor",{label:s}),onBlur:()=>l(!1),onClick:()=>{if(h.current){h.current=!1,l(!0);return}l(m=>!m)},onFocus:()=>l(!0),onKeyDown:m=>{m.key==="Escape"&&l(!1)},onMouseDown:m=>m.preventDefault(),onMouseEnter:()=>{h.current=!0,l(!0)},onMouseLeave:()=>{h.current=!1,l(!1)},ref:e,slot:d})}function yl({anchorRef:e,helpId:t,helpOpen:i,helpParts:o}){const s=ay(e,i),l=eL(s);return i?v.jsxs("span",{className:"rich-tooltip",id:t,role:"tooltip",style:l,children:[o.subhead?v.jsx("span",{className:"rich-tooltip__subhead",children:o.subhead}):null,o.body?v.jsx("span",{className:"rich-tooltip__body",children:o.body}):null,o.metas.length?v.jsx("span",{className:"rich-tooltip__metas",children:o.metas.map((d,f)=>v.jsx("span",{className:"rich-tooltip__chip",children:d.label?`${d.label}: ${d.value}`:d.value},f))}):null]}):null}function eL(e){if(e)return{left:`${e.left}px`,maxWidth:`${e.maxWidth}px`,position:"fixed",top:`${e.top}px`}}function fl({errorId:e,error:t}){return v.jsx(v.Fragment,{children:t?v.jsx("p",{className:"field-error field-error--sr",id:e,role:"alert",children:t}):null})}function n_(e,t){return e.secret?t:""}function o_(e){return e?"schema-field schema-field--wide":"schema-field"}function tL(e){return e.control==="textarea"||e.control==="object"||e.control==="array"||e.type==="object"||e.type==="array"}function a_(e,t){return Ha(e)||t==null?"":e.type==="object"||e.type==="array"||e.control==="object"||e.control==="array"?JSON.stringify(t,null,2):String(t)}function Ha(e){return e.secret||e.control==="secret"}function rL(e,t,i,o,s){const l=i?Di[i]:void 0,d=[];return l?(d.push({label:s.type,value:s_(l.type,s)}),l.defaultValue&&d.push({label:s.default,value:s_(String(l.defaultValue),s)}),(l.sensitive||e.secret)&&d.push({value:s.sensitive}),{subhead:t,body:l.description[o],metas:d}):(d.push({label:s.type,value:e.type}),d.push({value:e.required?s.required:s.optional}),e.secret&&d.push({value:s.sensitive}),d.push({value:e.hotReloadable?s.savedRealtime:s.restartMayBeRequired}),{subhead:t,body:"",metas:d})}function s_(e,t){const i=e.trim().toLowerCase();return{array:t.typeArray,boolean:t.typeBoolean,empty:t.defaultEmpty,"host:port":t.typeHostPort,number:t.typeNumber,object:t.typeObject,string:t.typeString,url:t.typeUrl}[i]??e}function iL(e,t){const o={anthropic:"provider.protocol.anthropic","openai-response":"provider.protocol.openaiResponses","openai-chat":"provider.protocol.openaiChat","google-genai":"provider.protocol.googleGenai"}[e];return o?t(o):e}function nL(e,t,i,o){if(Array.isArray(e))return{title:i,rows:e.slice(0,6).map((s,l)=>({key:String(l+1),value:$v(s,t,o)}))};if(e&&typeof e=="object"){const s=Object.entries(e);return{title:i,rows:s.slice(0,6).map(([l,d])=>({key:l,value:$v(d,t,o)}))}}return{title:i,rows:[]}}function $v(e,t,i){if(e==null||e==="")return i("field.structuredEmptyValue");if(Array.isArray(e))return i(l_("field.summary.items",e.length),{count:e.length});if(e&&typeof e=="object"){const o=Object.keys(e).length;return i(l_("field.summary.keys",o),{count:o})}return t?.secret?"******":String(e)}function l_(e,t){return`${e}.${t===1?"one":"many"}`}function oL(e){return!!e&&typeof e=="object"&&!Array.isArray(e)}function c_(e){return e==null||typeof e=="string"||typeof e=="number"||typeof e=="boolean"}function Vo(e,t){switch(e.kind){case"mode":return t.path==="mode"?"mode":void 0;case"trace":return Bn("trace",t.path);case"log":return Bn("log",t.path);case"server":return Bn("server",t.path);case"defaults":return Bn("defaults",t.path);case"web_search":return Bn("web_search",t.path);case"cache":return Bn("cache",t.path);case"persistence":return Bn("persistence",t.path);case"proxy":return Bn("proxy",t.path);case"provider":return aL(t.path);case"provider_offer":return sL(t.path);case"model":return lL(t.path);case"route":return cL(t.path);case"extension":return dL(t.path);default:return}}function Bn(e,t){return`${e}.${t}`}function aL(e){return`providers..${e}`}function sL(e){return`providers..offers[].${e}`}function lL(e){return`models..${e}`}function cL(e){return`routes..${e}`}function dL(e){return`extensions..${e}`}function ls({resourceKind:e,resourceId:t,field:i,committedValue:o,revision:s,save:l,disabled:d=!1,configUpdateFailedMessage:f,requestFailedMessage:h}){const[m,g]=w.useState(o),[y,b]=w.useState("idle"),[_,E]=w.useState(),T=w.useRef(0),O=w.useRef(o),D=w.useRef(m),C=w.useRef(y),z=w.useRef(s);D.current=m,C.current=y,w.useEffect(()=>{z.current=s},[s]),w.useEffect(()=>{hl(o,O.current)||(O.current=o,!(C.current==="dirty"||C.current==="saving")&&(g(o),E(void 0),b("idle")))},[o]);const B=w.useCallback((J,ce)=>{if(d)return;const _e=++T.current;b("saving"),E(void 0),l({baseRevision:z.current,change:{kind:e,id:t,field:i,value:ce}}).then(ie=>{_e===T.current&&ee(ie,J)}).catch(ie=>{_e===T.current&&(E({resourceKind:e,resourceId:t,field:i,code:"requestFailed",message:ie instanceof Error?ie.message:h}),b("error"))})},[d,i,h,t,e,l]),H=w.useCallback(J=>{g(J),E(void 0),b(hl(J,O.current)?"idle":"dirty")},[]),ne=w.useCallback(()=>{C.current==="dirty"&&B(D.current,D.current)},[B]),X=w.useCallback(J=>{if(g(J),hl(J,O.current)){E(void 0),b("idle");return}B(J,J)},[B]),A=w.useCallback((J,ce)=>{if(g(J),hl(J,O.current)){E(void 0),b("idle");return}B(J,ce)},[B]),le=w.useCallback(()=>{g(O.current),E(void 0),b("idle")},[]);return{value:m,status:y,error:_,setValue:H,commit:ne,commitValue:X,commitSerializedValue:A,reset:le};function ee(J,ce){const _e=uL(J.errors,e,t,i);switch(J.result){case"committed":case"restartRequired":if(z.current=J.revision,O.current=ce,E(void 0),!hl(D.current,ce)){b("dirty");return}b("saved");return;case"draftRejected":case"validationRejected":case"revisionConflict":E(_e??Sm(J,e,t,i,f)),b("error");return;case"runtimeRejected":{const ie=J.rollbackValue===void 0?O.current:J.rollbackValue;g(ie),E(_e??Sm(J,e,t,i,f)),b("error");return}default:E(Sm(J,e,t,i,f)),b("error")}}}function uL(e,t,i,o){return e?.find(s=>(s.resourceKind===t||s.resourceKind==="")&&(s.resourceId===i||s.resourceId==="")&&(!s.field||s.field===o))??e?.[0]}function Sm(e,t,i,o,s){return{resourceKind:t,resourceId:i,field:o,code:e.result,message:s(e.result)}}function hl(e,t){return Object.is(e,t)?!0:typeof e!="object"||e===null||typeof t!="object"||t===null?!1:JSON.stringify(e)===JSON.stringify(t)}const fL=()=>Wn("/config/graph"),hL=e=>Wn("/config/graph",{method:"PATCH",body:e}),pL=(e,t)=>Wn(`/config/resources/${encodeURIComponent(e)}`,{method:"POST",body:{...t,value:t.value??{}}}),mL=(e,t,i)=>Wn(`/config/resources/${encodeURIComponent(e)}/${encodeURIComponent(t)}`,{method:"DELETE",body:{baseRevision:i}}),sc={configGraph:["config","graph"],extensions:["extensions"],usageStats:["stats","usage"]};function Jn(){return Zv({queryKey:sc.configGraph,queryFn:fL})}function vL(){const e=es();return Jv({mutationFn:t=>hL(t),onSuccess:t=>sy(e,t)})}function gL(){const e=es();return Jv({mutationFn:({kind:t,body:i})=>pL(t,i),onSuccess:t=>sy(e,t)})}function yL(){const e=es();return Jv({mutationFn:({kind:t,id:i,baseRevision:o})=>mL(t,i,o),onSuccess:t=>sy(e,t)})}function cs(){const e=vL();return t=>e.mutateAsync({baseRevision:t.baseRevision,changes:[t.change]})}function sy(e,t){if(t.graph){e.setQueryData(sc.configGraph,t.graph);return}e.invalidateQueries({queryKey:sc.configGraph})}function ly({resource:e,field:t,objectDisplay:i,revision:o,modelDisplayNames:s={}}){const{t:l}=Oe(),d=Jn(),f=cs(),h=w.useCallback(b=>f(b),[f]),m=ls({resourceKind:e.kind,resourceId:e.id,field:t.path,committedValue:e.value[t.path],revision:o,save:h,configUpdateFailedMessage:b=>l("field.configUpdateFailed",{result:b}),requestFailedMessage:l("error.requestFailed")});ss(`${e.kind}:${e.id}:${t.path}`,m.status);const g={...e,value:{...e.value,[t.path]:m.value}},y=e.kind==="route"&&(t.path==="model"||t.path==="provider")?bL(t.path,m.value,d.data?.resources??[],l):void 0;return v.jsx(HP,{error:m.error?.message,field:t,idPrefix:`${e.kind}-${e.id}`,leadingIconNode:tP(g,t,s),docPath:Vo(e,t),objectDisplay:i,options:y?.options,warning:y?.warning,onChange:m.setValue,onCommit:m.commit,onCommitValue:m.commitValue,clearSecretDraft:m.status==="saved",value:m.value})}function bL(e,t,i,o){const s=e==="model"?$3(i):rP(i),l=typeof t=="string"?t.trim():"",d=s.some(h=>h.value===l);let f;return l?d||(f=e==="model"?o("route.warning.modelUnknown",{value:l}):o("route.warning.providerUnknown",{value:l})):f=o(e==="model"?"route.warning.modelMissing":"route.warning.providerMissing"),{options:s,warning:f}}const xL={saved:"resource.status.saved",needsAttention:"resource.status.needsAttention",restartRequired:"resource.status.restartRequired"},wL={normal:"resource.impact.normal",critical:"resource.impact.critical"},_L={saved:"check_circle",needsAttention:"report",restartRequired:"restart_alt"},SL={normal:"info",critical:"priority_high"},EL=new Set(["extension","model","provider","provider_offer","route"]);function hi({ariaLabel:e,children:t,embedded:i=!1,modelDisplayNames:o={},onOpenEditor:s,resource:l,revision:d,title:f,variant:h="full"}){const{t:m}=Oe(),g=yL(),[y,b]=w.useState(!1),[_,E]=w.useState(""),[T,O]=w.useState({}),D=w.useCallback((ie,xe)=>{O(N=>N[ie]===xe?N:{...N,[ie]:xe})},[]),C=w.useMemo(()=>UL(T),[T]),z=l.schema.fields.length,B=l.hotReloadable?m("resource.reload.hot"):m("resource.reload.restart"),H=e??l.id,ne=FL(l.kind,l.schema.fields),X=f??l.label,A=EL.has(l.kind),le=h==="summary";async function ee(){E("");try{await g.mutateAsync({kind:l.kind,id:l.id,baseRevision:d})}catch(ie){E(qN(ie,m("error.requestFailed")))}}const J=["resource-editor-card",le?"resource-editor-card--summary":"",i?"resource-editor-card--embedded":""].filter(Boolean).join(" "),ce=l.status!=="saved",_e=!le||ce?v.jsxs("span",{className:"resource-editor-card__status-group","aria-label":m("resource.statusGroupLabel",{label:H}),children:[v.jsxs("span",{className:`resource-meta-pill status-pill status-pill--${l.status}`,children:[v.jsx("span",{className:"material-symbol","aria-hidden":"true",children:$L(l.status)}),m(xL[l.status])]}),!le&&l.runtimeImpact==="critical"?v.jsxs("span",{className:"resource-meta-pill status-pill status-pill--critical",children:[v.jsx("span",{className:"material-symbol","aria-hidden":"true",children:BL(l.runtimeImpact)}),m(wL[l.runtimeImpact])]}):null,!le&&C?v.jsxs(Wr.span,{className:`resource-meta-pill editor-live-status editor-live-status--${C}`,initial:{opacity:0,scale:.85,y:-2},animate:{opacity:1,scale:1,y:0},transition:ur.spatialFast,children:[v.jsx("span",{className:"material-symbol","aria-hidden":"true",children:VL(C)}),m(IL[C])]},C):null]}):null;return v.jsxs(Wr.section,{"aria-label":H,className:J,initial:{opacity:0,y:8},animate:{opacity:1,y:0},transition:ur.spatial,children:[v.jsxs("div",{className:`resource-editor-card__header${le?" resource-editor-card__header--summary":""}`,children:[v.jsxs("div",{className:"resource-editor-card__identity",children:[v.jsxs("div",{className:"resource-editor-card__identity-line",children:[v.jsx("span",{className:"resource-kind-icon material-symbol","aria-hidden":"true",children:NL(l.kind)}),le?TL(l):null,v.jsx("h3",{children:l.id})]}),v.jsxs("div",{className:"resource-editor-card__facts",children:[_e,le?OL(l,m).map(ie=>v.jsxs("span",{className:"resource-meta-pill resource-fact",children:[v.jsx("span",{className:"material-symbol","aria-hidden":"true",children:ie.icon}),ie.text]},ie.key)):v.jsxs("span",{className:"resource-meta-pill resource-fact",children:[v.jsx("span",{className:"material-symbol","aria-hidden":"true",children:"list_alt"}),m(z===1?"resource.fieldCount.one":"resource.fieldCount.many",{count:z})]}),le?null:v.jsxs("span",{className:`resource-meta-pill resource-fact resource-fact--${l.hotReloadable?"hot":"restart"}`,children:[v.jsx("span",{className:"material-symbol","aria-hidden":"true",children:l.hotReloadable?"bolt":"restart_alt"}),B]})]})]}),v.jsxs("div",{className:"resource-editor-card__meta",children:[le&&s?v.jsx(Xa,{ariaLabel:m("resource.openEditor",{title:X,id:l.id}),icon:"tune",onClick:()=>s(),children:m("resource.editShort")}):null,A?v.jsx(Qn,{ariaLabel:m("resource.delete",{title:X,id:l.id}),className:"fab-button fab-button--danger",icon:"delete",onClick:()=>{b(!0),E("")},children:m("resource.deleteShort")}):null]})]}),y?v.jsxs(Wr.div,{className:"resource-delete-confirmation",initial:{opacity:0,y:-4},animate:{opacity:1,y:0},transition:ur.spatial,children:[v.jsx("p",{children:m("resource.deletePrompt",{id:l.id})}),_?v.jsx("p",{className:"field-error",role:"alert",children:_}):null,v.jsxs("div",{className:"resource-delete-confirmation__actions",children:[v.jsx(Qn,{ariaLabel:m("resource.confirmDelete",{id:l.id}),className:"resource-delete-confirmation__confirm",disabled:g.isPending,onClick:ee,children:m("resource.confirmDeleteShort")}),v.jsx(Xa,{ariaLabel:m("resource.cancelDelete"),className:"secondary-button",onClick:()=>{b(!1),E("")},children:m("resource.cancelDelete")})]})]}):null,le?null:v.jsx(BP,{report:D,children:v.jsx(RL,{alwaysExpanded:i,fieldGroups:ne,modelDisplayNames:o,resource:l,revision:d,children:t})})]})}function TL(e){const t=e.kind==="route"?Ei(e.value.model)||e.id:e.kind==="model"&&Ei(e.value.display_name)||e.id;return Ec(t)??null}function OL(e,t){const i=e.value??{},o=[],s=(l,d,f)=>{f&&o.push({key:l,icon:d,text:f})};if(e.kind==="provider"){const l=Ei(i.protocol);s("protocol","swap_horiz",CL(l,t)),s("host","link",AL(Ei(i.base_url))),Ei(i.api_key)&&s("key","vpn_key",t("resource.fact.keySet"));const d=Ei(i.version);d&&s("version","history",d)}else if(e.kind==="model"){const l=Ei(i.display_name);l&&l!==e.id&&s("displayName","label",l);const d=ou(i.context_window);typeof d=="number"&&s("context","memory",Em(d));const f=ou(i.max_output_tokens);typeof f=="number"&&s("maxout","output",Em(f))}else if(e.kind==="route"){const l=Ei(i.model),d=Ei(i.provider);l&&s("model","smart_toy",l),d&&s("provider","cloud",d);const f=ou(i.context_window);typeof f=="number"&&s("context","memory",Em(f))}else if(e.kind==="provider_offer"){const l=ou(i.priority);typeof l=="number"&&s("priority","format_list_numbered",`#${l}`);const d=Ei(i.upstream_name);d&&s("upstream","arrow_forward",d)}return o}function CL(e,t){switch(e){case"anthropic":return t("provider.protocol.anthropic");case"google-genai":case"googleGenai":return t("provider.protocol.googleGenai");case"openai-chat":case"openaiChat":return t("provider.protocol.openaiChat");case"openai-response":case"openaiResponses":return t("provider.protocol.openaiResponses");default:return e}}function AL(e){if(!e)return"";try{return new URL(e).host}catch{return e.replace(/^https?:\/\//,"").split("/")[0]}}function Em(e){return e>=1e3?`${Math.round(e/1e3)}k ctx`:`${e} ctx`}function Ei(e){return typeof e=="string"?e:""}function ou(e){return typeof e=="number"&&Number.isFinite(e)?e:void 0}function RL({alwaysExpanded:e,children:t,fieldGroups:i,modelDisplayNames:o,resource:s,revision:l}){const f=s.kind==="model"&&s.schema.fields.some(m=>m.path==="supports_reasoning")?s.schema.fields.find(m=>m.path==="supported_reasoning_levels"):void 0,h=kN(s,l,f);return v.jsxs("div",{className:"resource-field-groups",children:[i.map(m=>v.jsx(jL,{alwaysExpanded:e,group:m,modelDisplayNames:o,modelReasoningLevels:h,resource:s,revision:l},m.key)),t]})}function jL({alwaysExpanded:e,group:t,modelDisplayNames:i,modelReasoningLevels:o,resource:s,revision:l}){const{t:d}=Oe(),f=!e&&QL(s.kind,t),[h,m]=w.useState(!f);if(t.key==="reasoning")return v.jsx(kL,{group:t,modelReasoningLevels:o,resource:s,revision:l});if(t.key==="billing")return v.jsx(iN,{group:t,resource:s,revision:l});const g=t.fields.filter(Hu),y=t.fields.filter(E=>!Hu(E)),b=`${s.kind}-${s.id}-${t.key}-fields`.replace(/[^a-zA-Z0-9_-]/g,"-"),_=d(t.labelKey);return v.jsxs("div",{"aria-label":_,className:dy(s.kind,t,f&&!h),role:"group",children:[v.jsxs("div",{className:"resource-field-group__header",children:[v.jsxs("h4",{children:[v.jsx("span",{className:"material-symbol","aria-hidden":"true",children:uy(t)}),_]}),f?v.jsx(pi,{ariaExpanded:h,className:"resource-field-group__toggle",controls:b,icon:"chevron_right",label:d("resource.group.toggle",{label:_}),onClick:()=>m(E=>!E)}):null]}),h?v.jsxs("div",{className:"resource-field-group__body",id:b,children:[y.length?v.jsx("div",{className:tE(s.kind,t),children:rE(s,l,t,y,i,o)}):null,g.length?v.jsx("div",{className:"switch-bank",children:g.map(E=>v.jsx(ly,{field:E,modelDisplayNames:i,objectDisplay:fy(s.kind,E,t),resource:s,revision:l},`${s.kind}-${s.id}-${E.path}`))}):null]}):null]})}function kL({group:e,modelReasoningLevels:t,resource:i,revision:o}){const{t:s}=Oe(),l=e.key==="reasoning"?e.fields.find(y=>y.path==="supports_reasoning"):void 0,d=RN(i,o,l),f=y_(d).value,h=e.fields.filter(y=>y.path!=="supports_reasoning"),m=h.filter(Hu),g=h.filter(y=>!Hu(y));return v.jsxs("div",{"aria-label":s(e.labelKey),className:dy(i.kind,e),role:"group",children:[v.jsxs("div",{className:"resource-field-group__header",children:[v.jsxs("h4",{children:[v.jsx("span",{className:"material-symbol","aria-hidden":"true",children:uy(e)}),s(e.labelKey)]}),l?v.jsx(rN,{autosave:y_(d),field:l,resource:i}):null]}),f&&g.length?v.jsx("div",{className:tE(i.kind,e),children:rE(i,o,e,g,{},t)}):null,f&&m.length?v.jsx("div",{className:"switch-bank",children:m.map(y=>v.jsx(ly,{field:y,objectDisplay:fy(i.kind,y,e),resource:i,revision:o},`${i.kind}-${i.id}-${y.path}`))}):null]})}const ML=new Set(["web_search","extensions"]),DL=new Set(["input_modalities","supports_image_detail_original"]),J3=new Set(["supports_reasoning","default_reasoning_level","supported_reasoning_levels","supports_reasoning_summaries","default_reasoning_summary"]),eE=["default_reasoning_level","default_reasoning_summary"],zL=new Set(["input_modalities"]),cy=new Set(["model","provider","route"]),PL=new Set(["pricing"]),LL={provider:"dns",offer:"smart_toy",model:"smart_toy",route:"alt_route",defaults:"tune",server:"lan",cache:"database",persistence:"save",store:"database",proxy:"swap_horiz",plugin:"extension",extension:"extension"};function NL(e){return LL[e]??"tune"}function $L(e){return _L[e]}function BL(e){return SL[e]}function Hu(e){return e.type==="boolean"||e.control==="switch"}function UL(e){const t=Object.values(e);return t.includes("saving")?"saving":t.includes("error")?"error":t.includes("dirty")?"dirty":null}const IL={saving:"editor.liveSaving",error:"editor.liveError",dirty:"editor.liveUnsaved"};function VL(e){return e==="saving"?"progress_activity":e==="error"?"error":"edit"}function FL(e,t){const i=e==="model"&&t.some(l=>l.path==="supports_reasoning"),o={identity:{key:"identity",labelKey:"resource.group.identity",fields:[]},basic:{key:"basic",labelKey:"resource.group.basic",fields:[]},billing:{key:"billing",labelKey:"resource.group.billing",fields:[]},multimodal:{key:"multimodal",labelKey:"resource.group.multimodal",fields:[]},reasoning:{key:"reasoning",labelKey:"resource.group.reasoning",fields:[]},advancedFeatures:{key:"advancedFeatures",labelKey:"resource.group.advancedFeatures",fields:[]}},s=e==="model"?["identity","basic","reasoning","multimodal","advancedFeatures","billing"]:["identity","basic","billing","multimodal","advancedFeatures","reasoning"];for(const l of t)if(HL(l))o.identity.fields.push(l);else if(GL(e,l))o.billing.fields.push(l);else if(KL(e,l))o.multimodal.fields.push(l);else if(d_(e,l)&&i)o.reasoning.fields.push(l);else if(qL(e,l))o.advancedFeatures.fields.push(l);else{if(d_(e,l))continue;o.basic.fields.push(l)}return e==="model"&&(o.basic.fields=UN(o.basic.fields),o.reasoning.fields=VN(o.reasoning.fields)),s.map(l=>o[l]).filter(l=>l.fields.length>0)}function HL(e){return["addr","base_url","display_name","model","mode","provider","protocol","to","upstream_name"].includes(e.path)}function qL(e,t){return cy.has(e)&&ML.has(t.path)}function d_(e,t){return e==="model"&&J3.has(t.path)}function KL(e,t){return e==="model"&&DL.has(t.path)}function GL(e,t){return e==="provider_offer"&&PL.has(t.path)}function YL(e,t){return e==="provider_offer"&&t.path==="overrides"}function dy(e,t,i=!1){const s=[`resource-field-group resource-field-group--${t.key}`];return(t.key==="advancedFeatures"||t.key==="billing"||t.key==="multimodal"||t.key==="reasoning")&&s.push("resource-field-group--advanced"),t.key==="multimodal"&&s.push("resource-field-group--multimodal"),t.key==="reasoning"&&s.push("resource-field-group--reasoning"),e==="route"&&t.key==="identity"&&s.push("resource-field-group--route-identity"),i&&s.push("resource-field-group--collapsed"),s.join(" ")}function QL(e,t){return e==="model"&&(t.key==="multimodal"||t.key==="advancedFeatures")}function tE(e,t){return e==="route"&&t.key==="identity"?"form-grid form-grid--route-identity":"form-grid"}function uy(e){return e.key==="identity"?"badge":e.key==="advancedFeatures"?"extension":e.key==="multimodal"?"image":e.key==="reasoning"?"psychology":e.key==="billing"?"payments":"tune"}function rE(e,t,i,o,s,l){const d=[];let f=0;for(;fu_(e,t,i,m,s,l))},`${e.kind}-${e.id}-reasoning-defaults`)),f+=h.length;continue}d.push(u_(e,t,i,o[f],s,l)),f+=1}return d}function u_(e,t,i,o,s,l){return WL(e.kind,o)?v.jsx("div",{className:"form-grid__wide",children:v.jsx(qu,{field:o,autosave:b_(l),valueFromDraft:sE,valueFromInput:lE})},`${e.kind}-${e.id}-${o.path}`):XL(e.kind,o)?v.jsx("div",{className:"form-grid__wide",children:v.jsx(AN,{field:o,resource:e,revision:t})},`${e.kind}-${e.id}-${o.path}`):YL(e.kind,o)?v.jsx("div",{className:"form-grid__wide",children:v.jsx(fN,{field:o,resource:e,revision:t})},`${e.kind}-${e.id}-${o.path}`):ZL(e.kind,o)?v.jsx("div",{className:w_(o),children:v.jsx(MN,{field:o,levels:b_(l).value,resource:e,revision:t})},`${e.kind}-${e.id}-${o.path}`):JL(e.kind,o)?v.jsx("div",{className:"form-grid__wide",children:v.jsx(bN,{field:o,resource:e,revision:t})},`${e.kind}-${e.id}-${o.path}`):eN(e.kind,o)?v.jsx("div",{className:"form-grid__wide",children:v.jsx(SN,{field:o,resource:e,revision:t})},`${e.kind}-${e.id}-${o.path}`):v.jsx("div",{className:w_(o),children:v.jsx(ly,{field:o,modelDisplayNames:s,objectDisplay:fy(e.kind,o,i),resource:e,revision:t})},`${e.kind}-${e.id}-${o.path}`)}function XL(e,t){return e==="model"&&zL.has(t.path)}function WL(e,t){return e==="model"&&t.path==="supported_reasoning_levels"}function ZL(e,t){return e==="model"&&t.path==="default_reasoning_level"}function JL(e,t){return cy.has(e)&&t.path==="web_search"}function eN(e,t){return cy.has(e)&&t.path==="extensions"}function tN(e,t,i){return e==="model"&&eE.every((o,s)=>t[i+s]?.path===o)}function fy(e,t,i){if(i.key==="advancedFeatures")return"expandedFixed"}function rN({autosave:e,field:t,resource:i}){const{locale:o}=Oe(),s=Vo(i,t),l=s?Di[s].title[o]:t.label;return v.jsx("span",{className:"resource-field-group__switch","aria-label":l,children:v.jsx(Io,{disabled:e.status==="saving",label:l,selected:e.value,onChange:e.commitValue})})}const iE=["input_price","output_price","cache_write_price","cache_read_price"];function iN({group:e,resource:t,revision:i}){const{t:o}=Oe(),s=e.fields.find(g=>g.path==="pricing");if(!s)throw new Error("Provider offer billing group requires a pricing field.");const l=af(t,i,s),[d,f]=w.useState(()=>f_(l.value)),h=w.useRef(!1);w.useEffect(()=>{if(f_(l.value)){f(!0);return}!h.current&&l.status!=="dirty"&&f(!1)},[l.status,l.value]);function m(g){h.current=g,f(g),g||l.commitSerializedValue({},null)}return v.jsxs("div",{"aria-label":o(e.labelKey),className:dy(t.kind,e),role:"group",children:[v.jsxs("div",{className:"resource-field-group__header",children:[v.jsxs("h4",{children:[v.jsx("span",{className:"material-symbol","aria-hidden":"true",children:uy(e)}),o(e.labelKey)]}),v.jsx("span",{className:"resource-field-group__switch","aria-label":o(e.labelKey),children:v.jsx(Io,{disabled:l.status==="saving",label:o(e.labelKey),selected:d,onChange:m})})]}),d?v.jsxs("div",{className:"structured-feature-field structured-feature-field--billing","aria-label":o(e.labelKey),children:[v.jsx("div",{className:"structured-feature-field__grid",children:iE.map(g=>v.jsx(nN,{autosave:l,fieldKey:g,label:oN(g,o)},g))}),l.error?v.jsx("p",{className:"field-error",role:"alert",children:l.error.message}):null]}):null]})}function nN({autosave:e,fieldKey:t,label:i}){const{t:o}=Oe(),s=sN(e.value[t]),[l,d]=w.useState(s),[f,h]=w.useState("");w.useEffect(()=>{d(s),h("")},[s]);function m(){const g=l.trim();if(g===s){h("");return}const y=Number(g);if(!Number.isFinite(y)){h(o("field.invalidNumber"));return}h(""),e.commitValue({...aN(e.value),[t]:y})}return v.jsxs("div",{className:"mb-field","data-variant":"input",children:[v.jsx("div",{className:"mb-field__control",children:v.jsx(Yt,{ariaInvalid:!!f,className:"structured-feature-field__number",disabled:e.status==="saving",error:!!f,errorText:f,inputMode:"decimal",label:i,spellCheck:!1,type:"text",value:l,onBlur:m,onInput:g=>{d(g),f&&g.trim()===s&&h("")}})}),f?v.jsx("p",{className:"field-error field-error--sr",role:"alert",children:f}):null]})}function oN(e,t){return t({input_price:"create.offer.inputPrice",output_price:"create.offer.outputPrice",cache_write_price:"create.offer.cacheWritePrice",cache_read_price:"create.offer.cacheReadPrice"}[e])}function f_(e){return iE.some(t=>e[t]!==void 0)}function aN(e){return{input_price:bl(e.input_price),output_price:bl(e.output_price),cache_write_price:bl(e.cache_write_price),cache_read_price:bl(e.cache_read_price)}}function sN(e){return String(bl(e))}function bl(e){if(e==null||e==="")return 0;if(typeof e!="number"||!Number.isFinite(e))throw new Error("Provider offer price requires a finite number.");return e}const lN=["context_window","max_output_tokens"],cN=["display_name","default_reasoning_level","default_reasoning_summary"],dN=["description","base_instructions"],uN=["supports_reasoning","supports_reasoning_summaries","supports_image_detail_original"];function fN({field:e,resource:t,revision:i}){const{t:o}=Oe(),s=af(t,i,e);function l(h,m){const g=m===void 0?lc(s.value,h):{...s.value,[h]:m};mN(s,g)}const d=gN(s.value.input_modalities),f=aE(s.value.supported_reasoning_levels);return v.jsxs("div",{className:"provider-overrides-editor structured-feature-field","aria-label":o("field.providerOverrides.title"),children:[v.jsx("div",{className:"schema-structured-object__header",children:v.jsx("span",{children:o("field.providerOverrides.title")})}),v.jsxs("div",{className:"structured-feature-field__grid",children:[lN.map(h=>v.jsx(hN,{disabled:s.status==="saving",label:au(h,o),value:s.value[h],onCommit:m=>l(h,m)},h)),cN.map(h=>v.jsx(h_,{disabled:s.status==="saving",label:au(h,o),value:s.value[h],onCommit:m=>l(h,m)},h)),uN.map(h=>v.jsx(pN,{disabled:s.status==="saving",label:au(h,o),value:s.value[h],onCommit:m=>l(h,m)},h))]}),v.jsx("div",{className:"structured-feature-field__grid structured-feature-field__grid--wide",children:dN.map(h=>v.jsx(h_,{disabled:s.status==="saving",label:au(h,o),multiline:!0,value:s.value[h],onCommit:m=>l(h,m)},h))}),v.jsx(qu,{autosave:{commitValue:h=>l("input_modalities",h.length?h:void 0),error:s.error,label:p_(o("field.providerOverrides.inputModalities")),status:s.status,value:d},field:e,valueFromDraft:uE,valueFromInput:h=>h}),v.jsx(qu,{autosave:{commitValue:h=>l("supported_reasoning_levels",h.length?h:void 0),error:s.error,label:p_(o("field.providerOverrides.supportedReasoningLevels")),status:s.status,value:f},field:e,valueFromDraft:sE,valueFromInput:lE}),s.error?v.jsx("p",{className:"field-error",role:"alert",children:s.error.message}):null]})}function hN({disabled:e,label:t,onCommit:i,value:o}){const{t:s}=Oe(),l=Bv(o),[d,f]=w.useState(l),[h,m]=w.useState("");w.useEffect(()=>{f(l),m("")},[l]);function g(){const y=d.trim();if(y===l){m("");return}if(y===""){m(""),i(void 0);return}const b=Number(y);if(!Number.isFinite(b)){m(s("field.invalidNumber"));return}m(""),i(b)}return v.jsxs("div",{className:"mb-field","data-variant":"input",children:[v.jsx("div",{className:"mb-field__control",children:v.jsx(Yt,{ariaInvalid:!!h,disabled:e,error:!!h,errorText:h,inputMode:"numeric",label:t,spellCheck:!1,type:"text",value:d,onBlur:g,onInput:y=>{f(y),h&&y.trim()===l&&m("")}})}),h?v.jsx("p",{className:"field-error field-error--sr",role:"alert",children:h}):null]})}function h_({disabled:e,label:t,multiline:i=!1,onCommit:o,value:s}){const l=cc(s,!1),[d,f]=w.useState(l);w.useEffect(()=>{f(l)},[l]);function h(){d!==l&&o(d.trim()===""?void 0:d)}return v.jsx("div",{className:"mb-field","data-variant":i?"textarea":"input",children:v.jsx("div",{className:"mb-field__control",children:v.jsx(Yt,{disabled:e,label:t,rows:i?4:void 0,spellCheck:i,type:i?"textarea":"text",value:d,onBlur:h,onInput:f})})})}function pN({disabled:e,label:t,onCommit:i,value:o}){const{t:s}=Oe(),l=typeof o=="boolean"?String(o):"inherit";return v.jsx("div",{className:"mb-field","data-variant":"select",children:v.jsx("div",{className:"mb-field__control",children:v.jsx(Tc,{ariaLabel:t,disabled:e,label:t,options:[{value:"inherit",label:s("field.providerOverrides.inherit")},{value:"true",label:s("field.providerOverrides.enabled")},{value:"false",label:s("field.providerOverrides.disabled")}],value:l,onChange:d=>{if(d==="inherit"){i(void 0);return}i(d==="true")}})})})}function au(e,t){return t({base_instructions:"field.providerOverrides.baseInstructions",context_window:"field.providerOverrides.contextWindow",default_reasoning_level:"field.providerOverrides.defaultReasoningLevel",default_reasoning_summary:"field.providerOverrides.defaultReasoningSummary",description:"field.providerOverrides.description",display_name:"field.providerOverrides.displayName",max_output_tokens:"field.providerOverrides.maxOutputTokens",supports_image_detail_original:"field.providerOverrides.supportsImageDetailOriginal",supports_reasoning:"field.providerOverrides.supportsReasoning",supports_reasoning_summaries:"field.providerOverrides.supportsReasoningSummaries"}[e])}function mN(e,t){const i=vN(t);e.commitSerializedValue(i,Object.keys(i).length?i:null)}function vN(e){const t={};for(const[i,o]of Object.entries(e))o==null||o===""||Array.isArray(o)&&o.length===0||typeof o=="object"&&!Array.isArray(o)&&Object.keys(o).length===0||(t[i]=o);return t}function gN(e){return Array.isArray(e)?e.filter(t=>typeof t=="string"):[]}function p_(e){return{"en-US":e,"zh-CN":e}}const yN={max_uses:"web_search.max_uses",search_max_rounds:"web_search.search_max_rounds",tavily_api_key:"web_search.tavily_api_key",firecrawl_api_key:"web_search.firecrawl_api_key"};function bN({field:e,resource:t,revision:i}){const{locale:o,t:s}=Oe(),l=af(t,i,e),d=l.value,f=Vo(t,e),h=f?Di[f].title[o]:e.label,m=s("field.webSearch.support",{label:h}),g=`${t.kind}-${t.id}-${e.path}`,y=s("field.webSearch.maxUses",{label:h}),b=s("field.webSearch.tavilyAPIKey",{label:h}),_=s("field.webSearch.firecrawlAPIKey",{label:h}),E=s("field.webSearch.searchMaxRounds",{label:h});function T(O,D){l.commitValue(D===void 0?lc(d,O):{...d,[O]:D})}return v.jsxs("div",{className:"structured-feature-field structured-feature-field--web-search","aria-label":h,children:[v.jsxs("div",{className:"structured-feature-field__grid",children:[v.jsx("div",{className:"mb-field","data-variant":"select",children:v.jsx("div",{className:"mb-field__control",children:v.jsx(Tc,{ariaLabel:m,disabled:l.status==="saving",label:m,options:oE,value:zN(d.support),onChange:O=>T("support",O)})})}),v.jsx(g_,{autosave:l,fieldKey:"max_uses",helpScope:g,label:y}),v.jsx(g_,{autosave:l,fieldKey:"search_max_rounds",helpScope:g,label:E}),v.jsx(v_,{autosave:l,fieldKey:"tavily_api_key",helpScope:g,label:b}),v.jsx(v_,{autosave:l,fieldKey:"firecrawl_api_key",helpScope:g,label:_})]}),l.error?v.jsx("p",{className:"field-error",role:"alert",children:l.error.message}):null]})}function nE(e,t,i){const o=Oe(),s=w.useRef(null),l=w.useRef(!1),[d,f]=w.useState(!1),h=yN[e],m=`structured-help-${t}-${h}`.replace(/[^a-zA-Z0-9_-]/g,"-"),g=ay(s,d),y=wN(h,i,o);return{button:v.jsx(pi,{className:"schema-field__help",describedBy:d?m:void 0,icon:"help",label:o.t("field.helpFor",{label:i}),onBlur:()=>f(!1),onClick:()=>{if(l.current){l.current=!1,f(!0);return}f(b=>!b)},onFocus:()=>f(!0),onKeyDown:b=>{b.key==="Escape"&&f(!1)},onMouseDown:b=>b.preventDefault(),onMouseEnter:()=>{l.current=!0,f(!0)},onMouseLeave:()=>{l.current=!1,f(!1)},ref:s,slot:"trailing-icon"}),tooltip:d?v.jsx(xN,{helpId:m,helpParts:y,position:g}):null}}function xN({helpId:e,helpParts:t,position:i}){return v.jsxs("span",{className:"rich-tooltip",id:e,role:"tooltip",style:_N(i),children:[v.jsx("span",{className:"rich-tooltip__subhead",children:t.subhead}),v.jsx("span",{className:"rich-tooltip__body",children:t.body}),v.jsx("span",{className:"rich-tooltip__metas",children:t.metas.map((o,s)=>v.jsx("span",{className:"rich-tooltip__chip",children:o.label?`${o.label}: ${o.value}`:o.value},s))})]})}function wN(e,t,{locale:i,t:o}){const s=Di[e],l=[{label:o("configDoc.type"),value:m_(s.type,o)}];return s.defaultValue&&l.push({label:o("configDoc.default"),value:m_(String(s.defaultValue),o)}),s.sensitive&&l.push({value:o("configDoc.sensitive")}),{subhead:t,body:s.description[i],metas:l}}function m_(e,t){const i=e.trim().toLowerCase(),o={array:"configDoc.type.array",boolean:"configDoc.type.boolean",empty:"configDoc.default.empty","host:port":"configDoc.type.hostPort",number:"configDoc.type.number",object:"configDoc.type.object",string:"configDoc.type.string",url:"configDoc.type.url"};return o[i]?t(o[i]):e}function _N(e){if(e)return{left:`${e.left}px`,maxWidth:`${e.maxWidth}px`,position:"fixed",top:`${e.top}px`}}function v_({autosave:e,fieldKey:t,helpScope:i,label:o}){const s=nE(t,i,o),[l,d]=w.useState(()=>cc(e.value[t],!0)),f=cc(e.value[t],!0);w.useEffect(()=>{d(f)},[f]);const h=w.useCallback(()=>{l!==f&&e.commitValue({...e.value,[t]:l})},[e,f,l,t]);return v.jsxs("div",{className:"mb-field","data-variant":"input",children:[v.jsx("div",{className:"mb-field__control",children:v.jsx(Yt,{autoComplete:"new-password",className:"structured-feature-field__secret",disabled:e.status==="saving",label:o,spellCheck:!1,trailingIcon:s.button,type:"password",value:l,onBlur:h,onInput:d})}),s.tooltip]})}function g_({autosave:e,fieldKey:t,helpScope:i,label:o}){const{t:s}=Oe(),l=nE(t,i,o),[d,f]=w.useState(()=>x_(e.value[t])),[h,m]=w.useState(""),g=x_(e.value[t]);w.useEffect(()=>{f(g),m("")},[g]);const y=w.useCallback(()=>{const b=d.trim();if(b===""){m(""),e.commitValue(lc(e.value,t));return}if(!/^\d+$/.test(b)){m(s("field.invalidNumber"));return}const _=Number.parseInt(b,10);m(""),e.commitValue({...e.value,[t]:_})},[e,d,t,s]);return v.jsxs("div",{className:"mb-field","data-variant":"input",children:[v.jsx("div",{className:"mb-field__control",children:v.jsx(Yt,{ariaInvalid:!!h,className:"structured-feature-field__number",disabled:e.status==="saving",error:!!h,errorText:h,inputMode:"numeric",label:o,spellCheck:!1,trailingIcon:l.button,type:"text",value:d,onBlur:y,onInput:b=>{f(b),h&&b.trim()===g&&m("")}})}),l.tooltip,h?v.jsx("p",{className:"field-error field-error--sr",role:"alert",children:h}):null]})}function SN({field:e,resource:t,revision:i}){const{locale:o,t:s}=Oe(),[l,d]=w.useState(""),f=af(t,i,e),h=Vo(t,e),m=h?Di[h].title[o]:e.label,g=LN(f.value),y=g.map(z=>z.name),b=l.trim(),_=y.includes(b),E=!b||_||f.status==="saving";function T(){E||(d(""),f.commitValue({...f.value,[b]:{enabled:!0}}))}function O(z){f.commitValue(lc(f.value,z))}function D(z,B){f.commitValue({...f.value,[z]:{...Ku(f.value[z]),enabled:B}})}function C(z,B,H){const ne=Ku(f.value[z]),X=dE(ne.config);f.commitValue({...f.value,[z]:{...ne,config:H===void 0?lc(X,B):{...X,[B]:H}}})}return v.jsxs("div",{className:"structured-feature-field structured-feature-field--extensions","aria-label":m,children:[v.jsx("div",{className:"extension-feature-list",role:"list","aria-label":m,children:g.map(z=>v.jsxs("div",{className:"extension-feature-row","data-extension-name":z.name,role:"listitem",children:[v.jsx(F3,{className:"extension-feature-row__chip",disabled:f.status==="saving",label:s("field.extensions.remove",{name:z.name}),onRemove:()=>O(z.name),children:z.name}),v.jsx("span",{className:"extension-feature-row__switch","aria-label":s("field.extensions.enable",{name:z.name}),children:v.jsx(Io,{disabled:f.status==="saving",label:s("field.extensions.enable",{name:z.name}),selected:z.enabled,onChange:B=>D(z.name,B)})}),v.jsx(EN,{disabled:f.status==="saving",entry:z,onCommit:(B,H)=>C(z.name,B,H)})]},z.name))}),v.jsxs("div",{className:"editable-list-field__composer",children:[v.jsx(Yt,{ariaLabel:s("field.extensions.addInput",{label:m}),className:"editable-list-field__input",label:s("field.extensions.addInput",{label:m}),spellCheck:!1,value:l,onInput:d,onBlur:()=>{}}),v.jsx(Qn,{ariaLabel:s("field.extensions.addAction",{label:m}),className:"editable-list-field__add",disabled:E,icon:"add",onClick:T,children:s("field.editableList.add")})]}),f.error?v.jsx("p",{className:"field-error",role:"alert",children:f.error.message}):null]})}function EN({disabled:e,entry:t,onCommit:i}){const o=NN(t);return o.length===0?null:v.jsx("div",{className:"extension-config-grid",children:o.map(s=>v.jsx(TN,{disabled:e,field:s,value:t.config[s.key],onCommit:l=>i(s.key,l)},s.key))})}function TN({disabled:e,field:t,onCommit:i,value:o}){return t.type==="boolean"?v.jsx("div",{className:"schema-field schema-field--inline",children:v.jsxs("div",{className:"schema-field__switch-line",children:[v.jsx("span",{className:"schema-field__label-row",children:v.jsx("span",{className:"schema-field__label",children:t.label})}),v.jsx(Io,{disabled:e,label:t.label,selected:o===!0,onChange:i})]})}):t.type==="number"?v.jsx(CN,{disabled:e,field:t,value:o,onCommit:i}):v.jsx(ON,{disabled:e,field:t,value:o,onCommit:i})}function ON({disabled:e,field:t,onCommit:i,value:o}){const[s,l]=w.useState(()=>cc(o,!1)),d=cc(o,!1);return w.useEffect(()=>{l(d)},[d]),v.jsx("div",{className:"mb-field","data-variant":"input",children:v.jsx("div",{className:"mb-field__control",children:v.jsx(Yt,{disabled:e,label:t.label,spellCheck:!1,type:"text",value:s,onBlur:()=>i(s.trim()===""?void 0:s),onInput:l})})})}function CN({disabled:e,field:t,onCommit:i,value:o}){const{t:s}=Oe(),[l,d]=w.useState(()=>Bv(o)),[f,h]=w.useState(""),m=Bv(o);w.useEffect(()=>{d(m),h("")},[m]);function g(){const y=l.trim();if(y===""){h(""),i(void 0);return}const b=Number(y);if(!Number.isFinite(b)){h(s("field.invalidNumber"));return}h(""),i(b)}return v.jsxs("div",{className:"mb-field","data-variant":"input",children:[v.jsx("div",{className:"mb-field__control",children:v.jsx(Yt,{ariaInvalid:!!f,disabled:e,error:!!f,errorText:f,inputMode:"numeric",label:t.label,spellCheck:!1,type:"text",value:l,onBlur:g,onInput:y=>{d(y),f&&y.trim()===m&&h("")}})}),f?v.jsx("p",{className:"field-error field-error--sr",role:"alert",children:f}):null]})}function AN({field:e,resource:t,revision:i}){const o=jN(t,i,e);return v.jsx(qu,{autosave:o,field:e,valueFromDraft:uE,valueFromInput:s=>s})}function qu({autosave:e,field:t,valueFromDraft:i,valueFromInput:o}){const{locale:s,t:l}=Oe(),[d,f]=w.useState(""),h=e.label[s],m=e.value,g=d.trim(),y=w.useMemo(()=>m.map(D=>i(D)),[m,i]),b=g?y.includes(g):!1,_=!g||b||e.status==="saving";function E(D){e.commitValue(D)}function T(){!g||b||(f(""),E([...m,o(g)]))}function O(D){const C=i(D);E(m.filter(z=>i(z)!==C))}return v.jsxs("div",{className:"editable-list-field","aria-label":h,children:[v.jsx("div",{className:"editable-list-field__header",children:v.jsx("span",{className:"editable-list-field__title",children:h})}),v.jsx("md-chip-set",{className:"editable-list-field__items",role:"list","aria-label":h,children:m.map(D=>{const C=i(D);return v.jsx(F3,{className:"editable-list-field__chip",disabled:e.status==="saving",label:l("field.editableList.remove",{item:C,label:h}),onRemove:()=>O(D),children:C},C)})}),v.jsxs("div",{className:"editable-list-field__composer",children:[v.jsx(Yt,{ariaLabel:l("field.editableList.addInput",{label:h}),className:"editable-list-field__input",label:l("field.editableList.addInput",{label:h}),spellCheck:!1,value:d,onInput:f,onBlur:()=>{}}),v.jsx(Qn,{ariaLabel:l("field.editableList.addAction",{label:h}),className:"editable-list-field__add",disabled:_,icon:"add",onClick:T,children:l("field.editableList.add")})]}),e.error?v.jsx("p",{className:"field-error",role:"alert",children:e.error.message}):null]})}const oE=[{value:"auto",label:"auto"},{value:"enabled",label:"enabled"},{value:"disabled",label:"disabled"},{value:"injected",label:"injected"}];function af(e,t,i){const{t:o}=Oe(),s=e.value[i.path],l=JSON.stringify(cE(s)),d=w.useMemo(()=>JSON.parse(l),[l]),f=cs(),h=w.useCallback(g=>f(g),[f]),m=ls({resourceKind:e.kind,resourceId:e.id,field:i.path,committedValue:d,revision:t,save:h,configUpdateFailedMessage:g=>o("field.configUpdateFailed",{result:g}),requestFailedMessage:o("error.requestFailed")});return ss(`${e.kind}:${e.id}:${i.path}`,m.status),{commitValue:m.commitValue,commitSerializedValue:m.commitSerializedValue,error:m.error,status:m.status,value:m.value}}function RN(e,t,i){const{t:o}=Oe(),s=i?e.value[i.path]===!0:!1,l=cs(),d=w.useCallback(h=>l(h),[l]),f=ls({resourceKind:e.kind,resourceId:e.id,field:"supports_reasoning",committedValue:s,revision:t,save:d,disabled:!i,configUpdateFailedMessage:h=>o("field.configUpdateFailed",{result:h}),requestFailedMessage:o("error.requestFailed")});if(ss(`${e.kind}:${e.id}:supports_reasoning`,f.status),!!i)return{commitValue:f.commitValue,status:f.status,value:f.value}}function y_(e){if(!e)throw new Error("Model reasoning support field is required to render model reasoning controls.");return e}function jN(e,t,i){const{t:o}=Oe(),s=e.value[i.path],l=Array.isArray(s)?JSON.stringify(s.map(b=>String(b))):"[]",d=w.useMemo(()=>JSON.parse(l),[l]),f=cs(),h=w.useCallback(b=>f(b),[f]),m=ls({resourceKind:e.kind,resourceId:e.id,field:i.path,committedValue:d,revision:t,save:h,configUpdateFailedMessage:b=>o("field.configUpdateFailed",{result:b}),requestFailedMessage:o("error.requestFailed")});ss(`${e.kind}:${e.id}:${i.path}`,m.status);const g=Vo(e,i),y=g?Di[g].title:fE(i.label);return{commitValue:m.commitValue,error:m.error,label:y,status:m.status,value:m.value}}function kN(e,t,i){const{t:o}=Oe(),s=i?e.value[i.path]:void 0,l=JSON.stringify(aE(s)),d=w.useMemo(()=>JSON.parse(l),[l]),f=cs(),h=w.useCallback(y=>f(y),[f]),m=ls({resourceKind:e.kind,resourceId:e.id,field:"supported_reasoning_levels",committedValue:d,revision:t,save:h,disabled:!i,configUpdateFailedMessage:y=>o("field.configUpdateFailed",{result:y}),requestFailedMessage:o("error.requestFailed")});if(ss(`${e.kind}:${e.id}:supported_reasoning_levels`,m.status),!i)return;const g=Vo(e,i);return{commitValue:m.commitValue,error:m.error,label:g?Di[g].title:fE(i.label),status:m.status,value:m.value}}function b_(e){if(!e)throw new Error("Model reasoning levels field is required to render model reasoning controls.");return e}function MN({field:e,levels:t,resource:i,revision:o}){const{locale:s,t:l}=Oe(),d=i.value[e.path],f=typeof d=="string"?d:"",h=cs(),m=w.useCallback(E=>h(E),[h]),g=ls({resourceKind:i.kind,resourceId:i.id,field:e.path,committedValue:f,revision:o,save:m,configUpdateFailedMessage:E=>l("field.configUpdateFailed",{result:E}),requestFailedMessage:l("error.requestFailed")});ss(`${i.kind}:${i.id}:${e.path}`,g.status);const y=Vo(i,e),b=y?Di[y].title[s]:e.label,_=w.useMemo(()=>DN(t,g.value),[g.value,t]);return v.jsxs("div",{className:"mb-field","data-variant":"select",children:[v.jsx("div",{className:"mb-field__control",children:v.jsx(Tc,{ariaLabel:b,disabled:g.status==="saving",error:!!g.error,errorText:g.error?.message,label:b,options:_,required:e.required,value:g.value,onChange:g.commitValue})}),g.error?v.jsx("p",{className:"field-error",role:"alert",children:g.error.message}):null]})}function aE(e){return Array.isArray(e)?e.map(t=>{if(typeof t=="string")return{effort:t};if(!t||typeof t!="object")return;const i="effort"in t?t.effort:void 0;if(typeof i!="string"||i.trim()==="")return;const o="description"in t?t.description:void 0;return typeof o=="string"&&o.trim()?{effort:i,description:o}:{effort:i}}).filter(t=>t!==void 0):[]}function DN(e,t){const i=e.map(o=>({value:o.effort,label:o.effort}));return t&&!i.some(o=>o.value===t)?[{value:t,label:t},...i]:i}function sE(e){if(!e||typeof e!="object"||!("effort"in e)||typeof e.effort!="string")throw new Error("Reasoning level preset requires a string effort.");return e.effort}function lE(e){return{effort:e}}function cE(e){if(e==null)return{};if(typeof e!="object"||Array.isArray(e))throw new Error("Structured feature field requires an object value.");return e}function lc(e,t){const i={...e};return delete i[t],i}function zN(e){if(typeof e!="string"||e.trim()==="")return"auto";if(!oE.some(t=>t.value===e))throw new Error(`Unknown web search support mode: ${e}`);return e}function x_(e){if(e==null||e==="")return"";if(typeof e!="number"||!Number.isInteger(e))throw new Error("Structured integer field requires an integer value.");return String(e)}const PN={deepseek_v4:[{key:"reinforce_instructions",label:"deepseek_v4 reinforce instructions",type:"boolean"},{key:"reinforce_prompt",label:"deepseek_v4 reinforce prompt",type:"string"}],db_d1:[{key:"binding",label:"db_d1 binding",type:"string"}],db_sqlite:[{key:"path",label:"db_sqlite path",type:"string"},{key:"wal",label:"db_sqlite WAL",type:"boolean"},{key:"busy_timeout_ms",label:"db_sqlite busy timeout ms",type:"number"},{key:"max_open_conns",label:"db_sqlite max open conns",type:"number"}],kimi_workaround:[{key:"max_tool_rounds",label:"kimi_workaround max tool rounds",type:"number"},{key:"convergence_margin",label:"kimi_workaround convergence margin",type:"number"}],metrics:[{key:"default_limit",label:"metrics default limit",type:"number"},{key:"max_limit",label:"metrics max limit",type:"number"}],visual:[{key:"provider",label:"visual provider",type:"string"},{key:"model",label:"visual model",type:"string"},{key:"max_rounds",label:"visual max rounds",type:"number"},{key:"max_tokens",label:"visual max tokens",type:"number"}]};function LN(e){return Object.keys(e).sort((t,i)=>t.localeCompare(i)).map(t=>{const i=Ku(e[t]);return{name:t,config:dE(i.config),enabled:BN(e[t])}})}function NN(e){const t=PN[e.name]??[],i=new Set(t.map(s=>s.key)),o=Object.keys(e.config).filter(s=>!i.has(s)).sort((s,l)=>s.localeCompare(l)).flatMap(s=>{const l=$N(e.config[s]);return l?[{key:s,label:`${e.name} ${s}`,type:l}]:[]});return t.concat(o)}function $N(e){if(e==null)return"string";if(typeof e=="boolean")return"boolean";if(typeof e=="number")return"number";if(typeof e=="string")return"string"}function BN(e){const t=Ku(e);if(!("enabled"in t))return!0;if(typeof t.enabled!="boolean")throw new Error("Extension enabled value must be boolean when present.");return t.enabled}function Ku(e){if(e==null)return{};if(typeof e!="object"||Array.isArray(e))throw new Error("Extension entry must be an object.");return e}function dE(e){return e==null?{}:cE(e)}function cc(e,t){if(t&&e==="******"||e==null)return"";if(typeof e!="string")throw new Error("Structured string field requires a string value.");return e}function Bv(e){if(e==null||e==="")return"";if(typeof e!="number")throw new Error("Structured number field requires a number value.");return String(e)}function uE(e){if(typeof e!="string")throw new Error("Editable string list item must be a string.");return e}function fE(e){return{"en-US":e,"zh-CN":e}}function UN(e){return["context_window","max_output_tokens","description","base_instructions"].flatMap(t=>e.filter(i=>i.path===t)).concat(e.filter(t=>!IN.has(t.path)))}const IN=new Set(["context_window","max_output_tokens","description","base_instructions"]);function VN(e){return["supports_reasoning","default_reasoning_level","default_reasoning_summary","supported_reasoning_levels","supports_reasoning_summaries"].flatMap(t=>e.filter(i=>i.path===t)).concat(e.filter(t=>!J3.has(t.path)))}function FN(e){return e.control==="textarea"||e.control==="object"||e.control==="array"||e.type==="object"||e.type==="array"}function w_(e){return FN(e)?"form-grid__wide":e.type==="number"||e.type==="boolean"||e.control==="number"||e.control==="switch"||HN.has(e.path)?"form-grid__compact":"form-grid__medium"}const HN=new Set(["active_provider","addr","default_reasoning_level","default_reasoning_summary","format","level","max_sessions","mode","priority","search_max_rounds","session_ttl","support","ttl","version"]);function qN(e,t){const i=KN(e);return i.length>0&&typeof i[0]?.message=="string"?i[0].message:e instanceof Error?e.message:t}function KN(e){if(!e||typeof e!="object")return[];const t="raw"in e?e.raw:void 0;if(!t||typeof t!="object"||!("errors"in t))return[];const i=t.errors;return Array.isArray(i)?i:[]}function hE({title:e,message:t}){const{t:i}=Oe();return v.jsxs("section",{className:"state-panel",role:"alert",children:[v.jsx("p",{className:"eyebrow",children:i("common.error")}),v.jsx("h2",{children:e??i("error.requestFailed")}),v.jsx("p",{children:t})]})}function Fo({title:e}){return v.jsx("header",{className:"page-header",children:v.jsx("h1",{children:e})})}function GN(){const{t:e}=Oe();return v.jsx(hE,{title:e("error.storeTitle"),message:e("error.storeMessage")})}function Ho({error:e}){if(e instanceof No&&(e.code==="store_unavailable"||e.status===404))return v.jsx(GN,{});const{t}=Oe(),i=e instanceof Error?e.message:t("error.unknownRequest");return v.jsx(hE,{title:t("error.requestFailed"),message:i})}const YN=["defaults","trace","log"],QN={defaults:"resource.kind.defaults",trace:"resource.kind.trace",log:"resource.kind.log"};function XN(){const{t:e}=Oe(),t=Jn();if(t.error)return v.jsx(Ho,{error:t.error});if(t.isLoading||!t.data)return v.jsx(Bo,{label:e("common.loading")});const i=YN.map(s=>t.data.resources.find(l=>l.kind===s)).filter(s=>!!s),o=ry(t.data.resources);return v.jsxs("section",{className:"page-stack","aria-labelledby":"defaults-title",children:[v.jsx(Fo,{eyebrow:e("pageEyebrow.config"),title:e("nav.defaults"),children:e("config.description")}),i.map(s=>v.jsx(WN,{modelDisplayNames:o,resource:s,revision:t.data.revision},s.kind))]})}function WN({resource:e,revision:t,modelDisplayNames:i}){const{t:o}=Oe(),s=QN[e.kind],l=s?o(s):e.label;return v.jsxs("section",{className:"resource-section","aria-label":l,children:[v.jsx("h2",{children:l}),v.jsx(hi,{ariaLabel:o("resource.cardLabel",{title:l,id:e.id}),modelDisplayNames:i,resource:e,revision:t,title:l})]})}class Oc extends cn{constructor(){super(...arguments),this.elevated=!1,this.href="",this.download="",this.target=""}get primaryId(){return this.href?"link":"button"}get rippleDisabled(){return!this.href&&(this.disabled||this.softDisabled)}getContainerClasses(){return{...super.getContainerClasses(),disabled:!this.href&&(this.disabled||this.softDisabled),elevated:this.elevated,link:!!this.href}}renderPrimaryAction(t){const{ariaLabel:i}=this;return this.href?ue` + ${t} + `:ue` + + `}renderOutline(){return this.elevated?ue``:super.renderOutline()}}j([K({type:Boolean})],Oc.prototype,"elevated",void 0);j([K()],Oc.prototype,"href",void 0);j([K()],Oc.prototype,"download",void 0);j([K()],Oc.prototype,"target",void 0);const ZN=Ye`:host{--_container-height: var(--md-assist-chip-container-height, 32px);--_disabled-label-text-color: var(--md-assist-chip-disabled-label-text-color, var(--md-sys-color-on-surface, #1d1b20));--_disabled-label-text-opacity: var(--md-assist-chip-disabled-label-text-opacity, 0.38);--_elevated-container-color: var(--md-assist-chip-elevated-container-color, var(--md-sys-color-surface-container-low, #f7f2fa));--_elevated-container-elevation: var(--md-assist-chip-elevated-container-elevation, 1);--_elevated-container-shadow-color: var(--md-assist-chip-elevated-container-shadow-color, var(--md-sys-color-shadow, #000));--_elevated-disabled-container-color: var(--md-assist-chip-elevated-disabled-container-color, var(--md-sys-color-on-surface, #1d1b20));--_elevated-disabled-container-elevation: var(--md-assist-chip-elevated-disabled-container-elevation, 0);--_elevated-disabled-container-opacity: var(--md-assist-chip-elevated-disabled-container-opacity, 0.12);--_elevated-focus-container-elevation: var(--md-assist-chip-elevated-focus-container-elevation, 1);--_elevated-hover-container-elevation: var(--md-assist-chip-elevated-hover-container-elevation, 2);--_elevated-pressed-container-elevation: var(--md-assist-chip-elevated-pressed-container-elevation, 1);--_focus-label-text-color: var(--md-assist-chip-focus-label-text-color, var(--md-sys-color-on-surface, #1d1b20));--_hover-label-text-color: var(--md-assist-chip-hover-label-text-color, var(--md-sys-color-on-surface, #1d1b20));--_hover-state-layer-color: var(--md-assist-chip-hover-state-layer-color, var(--md-sys-color-on-surface, #1d1b20));--_hover-state-layer-opacity: var(--md-assist-chip-hover-state-layer-opacity, 0.08);--_label-text-color: var(--md-assist-chip-label-text-color, var(--md-sys-color-on-surface, #1d1b20));--_label-text-font: var(--md-assist-chip-label-text-font, var(--md-sys-typescale-label-large-font, var(--md-ref-typeface-plain, Roboto)));--_label-text-line-height: var(--md-assist-chip-label-text-line-height, var(--md-sys-typescale-label-large-line-height, 1.25rem));--_label-text-size: var(--md-assist-chip-label-text-size, var(--md-sys-typescale-label-large-size, 0.875rem));--_label-text-weight: var(--md-assist-chip-label-text-weight, var(--md-sys-typescale-label-large-weight, var(--md-ref-typeface-weight-medium, 500)));--_pressed-label-text-color: var(--md-assist-chip-pressed-label-text-color, var(--md-sys-color-on-surface, #1d1b20));--_pressed-state-layer-color: var(--md-assist-chip-pressed-state-layer-color, var(--md-sys-color-on-surface, #1d1b20));--_pressed-state-layer-opacity: var(--md-assist-chip-pressed-state-layer-opacity, 0.12);--_disabled-outline-color: var(--md-assist-chip-disabled-outline-color, var(--md-sys-color-on-surface, #1d1b20));--_disabled-outline-opacity: var(--md-assist-chip-disabled-outline-opacity, 0.12);--_focus-outline-color: var(--md-assist-chip-focus-outline-color, var(--md-sys-color-on-surface, #1d1b20));--_outline-color: var(--md-assist-chip-outline-color, var(--md-sys-color-outline, #79747e));--_outline-width: var(--md-assist-chip-outline-width, 1px);--_disabled-leading-icon-color: var(--md-assist-chip-disabled-leading-icon-color, var(--md-sys-color-on-surface, #1d1b20));--_disabled-leading-icon-opacity: var(--md-assist-chip-disabled-leading-icon-opacity, 0.38);--_focus-leading-icon-color: var(--md-assist-chip-focus-leading-icon-color, var(--md-sys-color-primary, #6750a4));--_hover-leading-icon-color: var(--md-assist-chip-hover-leading-icon-color, var(--md-sys-color-primary, #6750a4));--_leading-icon-color: var(--md-assist-chip-leading-icon-color, var(--md-sys-color-primary, #6750a4));--_icon-size: var(--md-assist-chip-icon-size, 18px);--_pressed-leading-icon-color: var(--md-assist-chip-pressed-leading-icon-color, var(--md-sys-color-primary, #6750a4));--_container-shape-start-start: var(--md-assist-chip-container-shape-start-start, var(--md-assist-chip-container-shape, var(--md-sys-shape-corner-small, 8px)));--_container-shape-start-end: var(--md-assist-chip-container-shape-start-end, var(--md-assist-chip-container-shape, var(--md-sys-shape-corner-small, 8px)));--_container-shape-end-end: var(--md-assist-chip-container-shape-end-end, var(--md-assist-chip-container-shape, var(--md-sys-shape-corner-small, 8px)));--_container-shape-end-start: var(--md-assist-chip-container-shape-end-start, var(--md-assist-chip-container-shape, var(--md-sys-shape-corner-small, 8px)));--_leading-space: var(--md-assist-chip-leading-space, 16px);--_trailing-space: var(--md-assist-chip-trailing-space, 16px);--_icon-label-space: var(--md-assist-chip-icon-label-space, 8px);--_with-leading-icon-leading-space: var(--md-assist-chip-with-leading-icon-leading-space, 8px)}@media(forced-colors: active){.link .outline{border-color:ActiveText}} +`;const pE=Ye`.elevated{--md-elevation-level: var(--_elevated-container-elevation);--md-elevation-shadow-color: var(--_elevated-container-shadow-color)}.elevated::before{background:var(--_elevated-container-color)}.elevated:hover{--md-elevation-level: var(--_elevated-hover-container-elevation)}.elevated:focus-within{--md-elevation-level: var(--_elevated-focus-container-elevation)}.elevated:active{--md-elevation-level: var(--_elevated-pressed-container-elevation)}.elevated.disabled{--md-elevation-level: var(--_elevated-disabled-container-elevation)}.elevated.disabled::before{background:var(--_elevated-disabled-container-color);opacity:var(--_elevated-disabled-container-opacity)}@media(forced-colors: active){.elevated md-elevation{border:1px solid CanvasText}.elevated.disabled md-elevation{border-color:GrayText}} +`;let Uv=class extends Oc{};Uv.styles=[iy,pE,ZN];Uv=j([bt("md-assist-chip")],Uv);class mE extends ft{get chips(){return this.childElements.filter(t=>t instanceof cn)}constructor(){super(),this.internals=this.attachInternals(),this.addEventListener("focusin",this.updateTabIndices.bind(this)),this.addEventListener("update-focus",this.updateTabIndices.bind(this)),this.addEventListener("keydown",this.handleKeyDown.bind(this)),this.internals.role="toolbar"}render(){return ue``}handleKeyDown(t){const i=t.key==="ArrowLeft",o=t.key==="ArrowRight",s=t.key==="Home",l=t.key==="End";if(!i&&!o&&!s&&!l)return;const{chips:d}=this;if(d.length<2)return;if(t.preventDefault(),s||l){const b=s?0:d.length-1;d[b].focus({trailing:l}),this.updateTabIndices();return}const h=getComputedStyle(this).direction==="rtl"?i:o,m=d.find(b=>b.matches(":focus-within"));if(!m){(h?d[0]:d[d.length-1]).focus({trailing:!h}),this.updateTabIndices();return}const g=d.indexOf(m);let y=h?g+1:g-1;for(;y!==g;){y>=d.length?y=0:y<0&&(y=d.length-1);const b=d[y];if(b.disabled&&!b.alwaysFocusable){h?y++:y--;continue}b.focus({trailing:!h}),this.updateTabIndices();break}}updateTabIndices(){const{chips:t}=this;let i;for(const o of t){const s=o.alwaysFocusable||!o.disabled;if(o.matches(":focus-within")&&s){i=o;continue}s&&!i&&(i=o),o.tabIndex=-1}i&&(i.tabIndex=0)}}j([sn()],mE.prototype,"childElements",void 0);const JN=Ye`:host{display:flex;flex-wrap:wrap;gap:8px} +`;let Iv=class extends mE{};Iv.styles=[JN];Iv=j([bt("md-chip-set")],Iv);class qo extends B3{constructor(){super(...arguments),this.elevated=!1,this.removable=!1,this.selected=!1,this.hasSelectedIcon=!1}get primaryId(){return"button"}getContainerClasses(){return{...super.getContainerClasses(),elevated:this.elevated,selected:this.selected,"has-trailing":this.removable,"has-icon":this.hasIcon||this.selected}}renderPrimaryAction(t){const{ariaLabel:i}=this;return ue` + + `}renderLeadingIcon(){return this.selected?ue` + + + + `:super.renderLeadingIcon()}renderTrailingAction(t){return this.removable?U3({focusListener:t,ariaLabel:this.ariaLabelRemove,disabled:this.disabled||this.softDisabled}):ae}renderOutline(){return this.elevated?ue``:super.renderOutline()}handleClickOnChild(t){if(this.disabled||this.softDisabled)return;const i=this.selected;if(this.selected=!this.selected,!as(this,t)){this.selected=i;return}}}j([K({type:Boolean})],qo.prototype,"elevated",void 0);j([K({type:Boolean})],qo.prototype,"removable",void 0);j([K({type:Boolean,reflect:!0})],qo.prototype,"selected",void 0);j([K({type:Boolean,reflect:!0,attribute:"has-selected-icon"})],qo.prototype,"hasSelectedIcon",void 0);j([at(".primary.action")],qo.prototype,"primaryAction",void 0);j([at(".trailing.action")],qo.prototype,"trailingAction",void 0);const e$=Ye`:host{--_container-height: var(--md-filter-chip-container-height, 32px);--_disabled-label-text-color: var(--md-filter-chip-disabled-label-text-color, var(--md-sys-color-on-surface, #1d1b20));--_disabled-label-text-opacity: var(--md-filter-chip-disabled-label-text-opacity, 0.38);--_elevated-container-elevation: var(--md-filter-chip-elevated-container-elevation, 1);--_elevated-container-shadow-color: var(--md-filter-chip-elevated-container-shadow-color, var(--md-sys-color-shadow, #000));--_elevated-disabled-container-color: var(--md-filter-chip-elevated-disabled-container-color, var(--md-sys-color-on-surface, #1d1b20));--_elevated-disabled-container-elevation: var(--md-filter-chip-elevated-disabled-container-elevation, 0);--_elevated-disabled-container-opacity: var(--md-filter-chip-elevated-disabled-container-opacity, 0.12);--_elevated-focus-container-elevation: var(--md-filter-chip-elevated-focus-container-elevation, 1);--_elevated-hover-container-elevation: var(--md-filter-chip-elevated-hover-container-elevation, 2);--_elevated-pressed-container-elevation: var(--md-filter-chip-elevated-pressed-container-elevation, 1);--_elevated-selected-container-color: var(--md-filter-chip-elevated-selected-container-color, var(--md-sys-color-secondary-container, #e8def8));--_label-text-font: var(--md-filter-chip-label-text-font, var(--md-sys-typescale-label-large-font, var(--md-ref-typeface-plain, Roboto)));--_label-text-line-height: var(--md-filter-chip-label-text-line-height, var(--md-sys-typescale-label-large-line-height, 1.25rem));--_label-text-size: var(--md-filter-chip-label-text-size, var(--md-sys-typescale-label-large-size, 0.875rem));--_label-text-weight: var(--md-filter-chip-label-text-weight, var(--md-sys-typescale-label-large-weight, var(--md-ref-typeface-weight-medium, 500)));--_selected-focus-label-text-color: var(--md-filter-chip-selected-focus-label-text-color, var(--md-sys-color-on-secondary-container, #1d192b));--_selected-hover-label-text-color: var(--md-filter-chip-selected-hover-label-text-color, var(--md-sys-color-on-secondary-container, #1d192b));--_selected-hover-state-layer-color: var(--md-filter-chip-selected-hover-state-layer-color, var(--md-sys-color-on-secondary-container, #1d192b));--_selected-hover-state-layer-opacity: var(--md-filter-chip-selected-hover-state-layer-opacity, 0.08);--_selected-label-text-color: var(--md-filter-chip-selected-label-text-color, var(--md-sys-color-on-secondary-container, #1d192b));--_selected-pressed-label-text-color: var(--md-filter-chip-selected-pressed-label-text-color, var(--md-sys-color-on-secondary-container, #1d192b));--_selected-pressed-state-layer-color: var(--md-filter-chip-selected-pressed-state-layer-color, var(--md-sys-color-on-surface-variant, #49454f));--_selected-pressed-state-layer-opacity: var(--md-filter-chip-selected-pressed-state-layer-opacity, 0.12);--_elevated-container-color: var(--md-filter-chip-elevated-container-color, var(--md-sys-color-surface-container-low, #f7f2fa));--_disabled-outline-color: var(--md-filter-chip-disabled-outline-color, var(--md-sys-color-on-surface, #1d1b20));--_disabled-outline-opacity: var(--md-filter-chip-disabled-outline-opacity, 0.12);--_disabled-selected-container-color: var(--md-filter-chip-disabled-selected-container-color, var(--md-sys-color-on-surface, #1d1b20));--_disabled-selected-container-opacity: var(--md-filter-chip-disabled-selected-container-opacity, 0.12);--_focus-outline-color: var(--md-filter-chip-focus-outline-color, var(--md-sys-color-on-surface-variant, #49454f));--_outline-color: var(--md-filter-chip-outline-color, var(--md-sys-color-outline, #79747e));--_outline-width: var(--md-filter-chip-outline-width, 1px);--_selected-container-color: var(--md-filter-chip-selected-container-color, var(--md-sys-color-secondary-container, #e8def8));--_selected-outline-width: var(--md-filter-chip-selected-outline-width, 0px);--_focus-label-text-color: var(--md-filter-chip-focus-label-text-color, var(--md-sys-color-on-surface-variant, #49454f));--_hover-label-text-color: var(--md-filter-chip-hover-label-text-color, var(--md-sys-color-on-surface-variant, #49454f));--_hover-state-layer-color: var(--md-filter-chip-hover-state-layer-color, var(--md-sys-color-on-surface-variant, #49454f));--_hover-state-layer-opacity: var(--md-filter-chip-hover-state-layer-opacity, 0.08);--_label-text-color: var(--md-filter-chip-label-text-color, var(--md-sys-color-on-surface-variant, #49454f));--_pressed-label-text-color: var(--md-filter-chip-pressed-label-text-color, var(--md-sys-color-on-surface-variant, #49454f));--_pressed-state-layer-color: var(--md-filter-chip-pressed-state-layer-color, var(--md-sys-color-on-secondary-container, #1d192b));--_pressed-state-layer-opacity: var(--md-filter-chip-pressed-state-layer-opacity, 0.12);--_icon-size: var(--md-filter-chip-icon-size, 18px);--_disabled-leading-icon-color: var(--md-filter-chip-disabled-leading-icon-color, var(--md-sys-color-on-surface, #1d1b20));--_disabled-leading-icon-opacity: var(--md-filter-chip-disabled-leading-icon-opacity, 0.38);--_selected-focus-leading-icon-color: var(--md-filter-chip-selected-focus-leading-icon-color, var(--md-sys-color-on-secondary-container, #1d192b));--_selected-hover-leading-icon-color: var(--md-filter-chip-selected-hover-leading-icon-color, var(--md-sys-color-on-secondary-container, #1d192b));--_selected-leading-icon-color: var(--md-filter-chip-selected-leading-icon-color, var(--md-sys-color-on-secondary-container, #1d192b));--_selected-pressed-leading-icon-color: var(--md-filter-chip-selected-pressed-leading-icon-color, var(--md-sys-color-on-secondary-container, #1d192b));--_focus-leading-icon-color: var(--md-filter-chip-focus-leading-icon-color, var(--md-sys-color-primary, #6750a4));--_hover-leading-icon-color: var(--md-filter-chip-hover-leading-icon-color, var(--md-sys-color-primary, #6750a4));--_leading-icon-color: var(--md-filter-chip-leading-icon-color, var(--md-sys-color-primary, #6750a4));--_pressed-leading-icon-color: var(--md-filter-chip-pressed-leading-icon-color, var(--md-sys-color-primary, #6750a4));--_disabled-trailing-icon-color: var(--md-filter-chip-disabled-trailing-icon-color, var(--md-sys-color-on-surface, #1d1b20));--_disabled-trailing-icon-opacity: var(--md-filter-chip-disabled-trailing-icon-opacity, 0.38);--_selected-focus-trailing-icon-color: var(--md-filter-chip-selected-focus-trailing-icon-color, var(--md-sys-color-on-secondary-container, #1d192b));--_selected-hover-trailing-icon-color: var(--md-filter-chip-selected-hover-trailing-icon-color, var(--md-sys-color-on-secondary-container, #1d192b));--_selected-pressed-trailing-icon-color: var(--md-filter-chip-selected-pressed-trailing-icon-color, var(--md-sys-color-on-secondary-container, #1d192b));--_selected-trailing-icon-color: var(--md-filter-chip-selected-trailing-icon-color, var(--md-sys-color-on-secondary-container, #1d192b));--_focus-trailing-icon-color: var(--md-filter-chip-focus-trailing-icon-color, var(--md-sys-color-on-surface-variant, #49454f));--_hover-trailing-icon-color: var(--md-filter-chip-hover-trailing-icon-color, var(--md-sys-color-on-surface-variant, #49454f));--_pressed-trailing-icon-color: var(--md-filter-chip-pressed-trailing-icon-color, var(--md-sys-color-on-surface-variant, #49454f));--_trailing-icon-color: var(--md-filter-chip-trailing-icon-color, var(--md-sys-color-on-surface-variant, #49454f));--_container-shape-start-start: var(--md-filter-chip-container-shape-start-start, var(--md-filter-chip-container-shape, var(--md-sys-shape-corner-small, 8px)));--_container-shape-start-end: var(--md-filter-chip-container-shape-start-end, var(--md-filter-chip-container-shape, var(--md-sys-shape-corner-small, 8px)));--_container-shape-end-end: var(--md-filter-chip-container-shape-end-end, var(--md-filter-chip-container-shape, var(--md-sys-shape-corner-small, 8px)));--_container-shape-end-start: var(--md-filter-chip-container-shape-end-start, var(--md-filter-chip-container-shape, var(--md-sys-shape-corner-small, 8px)));--_leading-space: var(--md-filter-chip-leading-space, 16px);--_trailing-space: var(--md-filter-chip-trailing-space, 16px);--_icon-label-space: var(--md-filter-chip-icon-label-space, 8px);--_with-leading-icon-leading-space: var(--md-filter-chip-with-leading-icon-leading-space, 8px);--_with-trailing-icon-trailing-space: var(--md-filter-chip-with-trailing-icon-trailing-space, 8px)}.selected.elevated::before{background:var(--_elevated-selected-container-color)}.checkmark{height:var(--_icon-size);width:var(--_icon-size)}.disabled .checkmark{opacity:var(--_disabled-leading-icon-opacity)}@media(forced-colors: active){.disabled .checkmark{opacity:1}} +`;let Vv=class extends qo{};Vv.styles=[iy,pE,V3,I3,e$];Vv=j([bt("md-filter-chip")],Vv);function Al({children:e,onSelect:t,selected:i,value:o}){const s=w.useRef(null);return w.useEffect(()=>{const l=s.current;if(!l)throw new Error("MaterialFilterChip rendered before md-filter-chip was registered.");const d=()=>{t(o),i&&window.requestAnimationFrame(()=>{s.current&&(s.current.selected=!0)})};return l.addEventListener("click",d),()=>l.removeEventListener("click",d)},[t,i,o]),w.useEffect(()=>{if(!s.current)throw new Error("MaterialFilterChip rendered before md-filter-chip was registered.");s.current.selected=i},[i]),v.jsx("md-filter-chip",{ref:s,children:e})}function t$({children:e}){return w.createElement("md-assist-chip",null,e)}const __={id:"",baseUrl:"",apiKey:"",protocol:"openai-response",displayName:"",contextWindow:"128000",model:"",provider:"",upstreamName:"",priority:"1",inputPrice:"0",outputPrice:"0",cacheWritePrice:"0",cacheReadPrice:"0",billingEnabled:!1,enabled:!0},Tm={extension:{add:"create.extension.add",submit:"create.extension.submit",title:"create.extension.title"},model:{add:"create.model.add",submit:"create.model.submit",title:"create.model.title"},provider:{add:"create.provider.add",submit:"create.provider.submit",title:"create.provider.title"},provider_offer:{add:"create.offer.add",submit:"create.offer.submit",title:"create.offer.title"},route:{add:"create.route.add",submit:"create.route.submit",title:"create.route.title"}};function dc({availableExtensionIds:e,graph:t,kind:i,modelId:o,providerId:s}){const{t:l}=Oe(),d=gL(),f=e??[],h=i==="extension"&&f.length===0,[m,g]=w.useState(!1),[y,b]=w.useState(()=>Cm(i,t,s,o,f)),[_,E]=w.useState(""),T=l(Tm[i].title),O=l(Tm[i].add),D=l(Tm[i].submit);function C(){b(Cm(i,t,s,o,f)),E(""),g(!0)}async function z(B){B.preventDefault(),E("");const H=c$(i,y,{cacheReadPrice:l("create.offer.cacheReadPrice"),cacheWritePrice:l("create.offer.cacheWritePrice"),contextWindow:l("create.model.contextWindow"),inputPrice:l("create.offer.inputPrice"),outputPrice:l("create.offer.outputPrice"),priority:l("create.offer.priority")},ne=>l("create.invalidNumber",{field:ne}),ne=>l("create.positiveNumber",{field:ne}));if(!H.ok){E(H.error);return}try{await d.mutateAsync({kind:i,body:{baseRevision:t.revision,id:H.id,value:H.value}}),g(!1),b(Cm(i,t,s,o,f))}catch(ne){E(f$(ne,l("error.requestFailed")))}}return v.jsxs("div",{className:"create-resource",children:[v.jsx(Qn,{type:"button",className:"create-resource__add",disabled:h,onClick:C,icon:"add",children:O}),m?v.jsxs("form",{className:"create-resource__panel","aria-label":T,onSubmit:z,children:[v.jsxs("div",{className:"create-resource__header",children:[v.jsx("h3",{children:T}),v.jsx(pi,{className:"icon-button",icon:"close",label:l("create.close"),onClick:()=>g(!1)})]}),_?v.jsx("p",{className:"field-error",role:"alert",children:_}):null,v.jsx("div",{className:"form-grid create-resource__fields",children:v.jsx(r$,{graph:t,kind:i,availableExtensionIds:f,values:y,modelId:o,providerId:s,setValues:b})}),v.jsxs("div",{className:"form-actions",children:[v.jsx(Qn,{type:"submit",disabled:d.isPending,children:D}),v.jsx(Xa,{className:"secondary-button",onClick:()=>g(!1),children:l("create.cancel")})]})]}):null]})}function r$({availableExtensionIds:e=[],graph:t,kind:i,modelId:o,providerId:s,values:l,setValues:d}){const{locale:f,t:h}=Oe(),m=t.resources.filter(b=>b.kind==="model"),g=t.resources.filter(b=>b.kind==="provider"),y=(b,_)=>Di[b]?.description[f]??h(_);return i==="provider"?v.jsxs(v.Fragment,{children:[v.jsx(Fr,{helpText:y("providers..key","create.help.providerId"),label:h("create.provider.id"),path:"key",value:l.id,onChange:b=>d({...l,id:b})}),v.jsx(Fr,{helpText:y("providers..base_url","create.help.providerBaseUrl"),label:h("create.provider.baseUrl"),path:"base_url",value:l.baseUrl,onChange:b=>d({...l,baseUrl:b})}),v.jsx(Fr,{helpText:y("providers..api_key","create.help.providerApiKey"),label:h("create.provider.apiKey"),path:"api_key",value:l.apiKey,onChange:b=>d({...l,apiKey:b}),secret:!0}),v.jsx(pl,{helpText:y("providers..protocol","create.help.providerProtocol"),label:h("create.provider.protocol"),options:s$(h),value:l.protocol,onChange:b=>d({...l,protocol:b})})]}):i==="model"?v.jsxs(v.Fragment,{children:[v.jsx(Fr,{helpText:y("models..slug","create.help.modelId"),label:h("create.model.id"),path:"slug",value:l.id,onChange:b=>d({...l,id:b})}),v.jsx(Fr,{helpText:h("create.help.modelDisplayName"),label:h("create.model.displayName"),leadingIconNode:Ec(l.displayName||l.id),path:"display_name",value:l.displayName,onChange:b=>d({...l,displayName:b})}),v.jsx(i$,{helpText:y("models..context_window","create.help.modelContextWindow"),label:h("create.model.contextWindow"),value:l.contextWindow,onChange:b=>d({...l,contextWindow:b})})]}):i==="route"?v.jsxs(v.Fragment,{children:[v.jsx(Fr,{helpText:y("routes..alias","create.help.routeId"),label:h("create.route.id"),path:"alias",value:l.id,onChange:b=>d({...l,id:b})}),v.jsx(pl,{helpText:y("routes..model","create.help.routeModel"),label:h("create.route.model"),options:$3(m),value:l.model,onChange:b=>d({...l,model:b})}),v.jsx(pl,{helpText:y("routes..provider","create.help.routeProvider"),label:h("create.route.provider"),options:Om(g.map(b=>b.id)),value:l.provider,onChange:b=>d({...l,provider:b})})]}):i==="provider_offer"?v.jsxs(v.Fragment,{children:[v.jsxs("div",{className:"schema-field form-field--create-track",children:[v.jsx(vE,{helpText:y("providers..offers[].model","create.help.offerModel"),label:h("create.offer.model")}),v.jsx(t$,{children:o??l.model})]}),v.jsx(pl,{helpText:y("providers..key","create.help.offerProvider"),label:h("create.offer.provider"),options:Om(g.map(b=>b.id)),value:l.provider,onChange:b=>d({...l,provider:b})}),v.jsx(Fr,{helpText:y("providers..offers[].upstream_name","create.help.offerUpstreamName"),label:h("create.offer.upstreamName"),path:"upstream_name",value:l.upstreamName,onChange:b=>d({...l,upstreamName:b})}),v.jsx(Fr,{helpText:h("create.help.offerPriority"),label:h("create.offer.priority"),path:"priority",value:l.priority,onChange:b=>d({...l,priority:b})}),v.jsx(S_,{helpText:h("create.help.offerBilling"),label:h("create.offer.billing"),value:l.billingEnabled,onChange:b=>d({...l,billingEnabled:b})}),l.billingEnabled?v.jsxs(v.Fragment,{children:[v.jsx(Fr,{helpText:y("providers..offers[].pricing","create.help.offerInputPrice"),label:h("create.offer.inputPrice"),path:"input_price",value:l.inputPrice,onChange:b=>d({...l,inputPrice:b})}),v.jsx(Fr,{helpText:y("providers..offers[].pricing","create.help.offerOutputPrice"),label:h("create.offer.outputPrice"),path:"output_price",value:l.outputPrice,onChange:b=>d({...l,outputPrice:b})}),v.jsx(Fr,{helpText:y("providers..offers[].pricing","create.help.offerCacheWritePrice"),label:h("create.offer.cacheWritePrice"),path:"cache_write_price",value:l.cacheWritePrice,onChange:b=>d({...l,cacheWritePrice:b})}),v.jsx(Fr,{helpText:y("providers..offers[].pricing","create.help.offerCacheReadPrice"),label:h("create.offer.cacheReadPrice"),path:"cache_read_price",value:l.cacheReadPrice,onChange:b=>d({...l,cacheReadPrice:b})})]}):null]}):v.jsxs(v.Fragment,{children:[v.jsx(pl,{helpText:h("create.help.extensionId"),label:h("create.extension.id"),options:Om(e),value:l.id,onChange:b=>d({...l,id:b})}),v.jsx(S_,{helpText:y("extensions..enabled","create.help.extensionEnabled"),label:h("create.extension.enabled"),value:l.enabled,onChange:b=>d({...l,enabled:b})})]})}function Fr({helpText:e,label:t,leadingIconNode:i,onChange:o,path:s,secret:l,value:d}){const f=hy(t),h=sf(t);return v.jsx("div",{className:"mb-field form-field--create-track","data-variant":"input",children:v.jsxs("div",{className:"mb-field__control",children:[v.jsx(Yt,{ariaDescribedBy:h.open?h.helpId:void 0,ariaLabel:t,autoComplete:l?"new-password":void 0,id:f,label:t,leadingIcon:n$(s,l),leadingIconNode:i,trailingIcon:h.button("trailing-icon"),type:l?"password":"text",value:d,onInput:o}),v.jsx(lf,{anchorRef:h.anchorRef,helpId:h.helpId,helpText:e,open:h.open})]})})}function pl({helpText:e,label:t,onChange:i,options:o,value:s}){const l=sf(t);return v.jsxs("div",{className:"mb-field form-field--create-track","data-variant":"select",children:[v.jsx("div",{className:"mb-field__select-actions",children:l.button(void 0,"mb-field__select-help")}),v.jsx("div",{className:"mb-field__control",children:v.jsx(Tc,{describedBy:l.open?l.helpId:void 0,ariaLabel:t,label:t,leadingIcon:o.find(d=>d.value===s)?.leadingIcon,onChange:i,options:o,value:s})}),v.jsx(lf,{anchorRef:l.anchorRef,helpId:l.helpId,helpText:e,open:l.open})]})}function i$({helpText:e,label:t,onChange:i,value:o}){const{t:s}=Oe(),l=hy(t),d=sf(t),f=[[s("create.contextWindowPreset.128k"),"128000"],[s("create.contextWindowPreset.400k"),"400000"],[s("create.contextWindowPreset.1m"),"1000000"]];return v.jsxs("div",{className:"mb-field form-field--create-track form-grid__wide create-resource__context-window-row","data-variant":"input",children:[v.jsxs("div",{className:"mb-field__control",children:[v.jsx(Yt,{ariaDescribedBy:d.open?d.helpId:void 0,ariaLabel:t,id:l,inputMode:"numeric",label:t,leadingIcon:"tag",trailingIcon:d.button("trailing-icon"),type:"text",value:o,onInput:i}),v.jsx(lf,{anchorRef:d.anchorRef,helpId:d.helpId,helpText:e,open:d.open})]}),v.jsx("md-chip-set",{className:"material-chip-group create-resource__context-window-presets",role:"group","aria-label":s("create.contextWindowPresets",{label:t}),children:f.map(([h,m])=>v.jsx(Al,{selected:o===m,value:m,onSelect:i,children:h},m))})]})}function S_({helpText:e,label:t,onChange:i,value:o}){return v.jsx("div",{className:"schema-field schema-field--inline form-field--create-track",children:v.jsxs("div",{className:"schema-field__switch-line",children:[v.jsx(vE,{helpText:e,label:t}),v.jsx(Io,{label:t,selected:o,onChange:i})]})})}function vE({helpText:e,label:t}){return v.jsxs("span",{className:"schema-field__label-row",children:[v.jsx("span",{className:"schema-field__label",children:t}),v.jsx(o$,{helpText:e,label:t})]})}function n$(e,t=!1){if(t)return"key";const i=e.toLowerCase();if(i.includes("url")||i.includes("endpoint")||i.includes("addr"))return"link";if(i.includes("model"))return"smart_toy";if(i.includes("agent"))return"badge";if(i.includes("price")||i.includes("priority")||i.includes("window"))return"tag"}function sf(e){const{t}=Oe(),[i,o]=w.useState(!1),s=w.useRef(!1),l=w.useRef(null),d=`${hy(e)}-help`;return{anchorRef:l,button:(f,h="schema-field__help")=>v.jsx(pi,{className:h,describedBy:i?d:void 0,icon:"help",label:t("field.helpFor",{label:e}),onBlur:()=>o(!1),onClick:m=>{if(m.stopPropagation(),s.current){s.current=!1,o(!0);return}o(g=>!g)},onFocus:()=>o(!0),onKeyDown:m=>{m.key==="Escape"&&o(!1)},onMouseDown:m=>m.preventDefault(),onMouseEnter:()=>{s.current=!0,o(!0)},onMouseLeave:()=>{s.current=!1,o(!1)},ref:l,slot:f}),helpId:d,open:i}}function o$({helpText:e,label:t}){const i=sf(t);return v.jsxs("span",{className:"schema-field__help-wrap",children:[i.button(),v.jsx(lf,{anchorRef:i.anchorRef,helpId:i.helpId,helpText:e,open:i.open})]})}function lf({anchorRef:e,helpId:t,helpText:i,open:o}){const s=ay(e,o);return o?v.jsx("span",{className:"rich-tooltip",id:t,role:"tooltip",style:a$(s),children:i}):null}function a$(e){if(e)return{left:`${e.left}px`,maxWidth:`${e.maxWidth}px`,position:"fixed",top:`${e.top}px`}}function hy(e){return`create-resource-${w.useId()}-${e}`.replace(/[^a-zA-Z0-9_-]/g,"-")}function Om(e){return e.map(t=>({label:t,value:t}))}function s$(e){return["openai-response","openai-chat","anthropic","google-genai"].map(t=>({label:p$(t,e),leadingIcon:Fu(t),value:t}))}function Cm(e,t,i,o,s=[]){const l=t.resources.find(f=>f.kind==="model")?.id??"",d=t.resources.find(f=>f.kind==="provider")?.id??"";return{...__,id:e==="extension"?s[0]??"":__.id,model:e==="provider_offer"?o??l:e==="route"?l:"",provider:e==="route"||e==="provider_offer"?i??d:""}}function l$(e,t){return e==="provider_offer"?`${t.provider}/${t.model}`:t.id}function c$(e,t,i,o,s){const l=d$(e,t,i,o,s);return l.ok?{ok:!0,id:l$(e,t),value:l.value}:l}function d$(e,t,i,o,s){if(e==="provider")return{ok:!0,value:{base_url:t.baseUrl,api_key:t.apiKey,protocol:t.protocol}};if(e==="model"){const l=u$(t.contextWindow,i.contextWindow,o,s);return l.ok?{ok:!0,value:{display_name:t.displayName,context_window:l.value}}:l}if(e==="route")return{ok:!0,value:{model:t.model,provider:t.provider}};if(e==="provider_offer"){const l=Ba(t.priority,i.priority,o);if(!l.ok)return l;const d={model:t.model,upstream_name:t.upstreamName,priority:l.value};if(!t.billingEnabled)return{ok:!0,value:d};const f=Ba(t.inputPrice,i.inputPrice,o),h=Ba(t.outputPrice,i.outputPrice,o),m=Ba(t.cacheWritePrice,i.cacheWritePrice,o),g=Ba(t.cacheReadPrice,i.cacheReadPrice,o);return f.ok?h.ok?m.ok?g.ok?{ok:!0,value:{...d,pricing:{input_price:f.value,output_price:h.value,cache_write_price:m.value,cache_read_price:g.value}}}:g:m:h:f}return{ok:!0,value:{enabled:t.enabled}}}function Ba(e,t,i){if(e.trim()==="")return{ok:!0,value:0};const o=Number(e);return Number.isFinite(o)?{ok:!0,value:o}:{ok:!1,error:i(t)}}function u$(e,t,i,o){const s=Ba(e,t,i);return s.ok&&s.value<=0?{ok:!1,error:o(t)}:s}function f$(e,t){const i=h$(e);return i.length>0&&typeof i[0]?.message=="string"?i[0].message:e instanceof Error?e.message:t}function h$(e){if(!e||typeof e!="object")return[];const t="raw"in e?e.raw:void 0;if(!t||typeof t!="object"||!("errors"in t))return[];const i=t.errors;return Array.isArray(i)?i:[]}function p$(e,t){const o={anthropic:"provider.protocol.anthropic","openai-response":"provider.protocol.openaiResponses","openai-chat":"provider.protocol.openaiChat","google-genai":"provider.protocol.googleGenai"}[e];return o?t(o):e}class cf extends ft{constructor(){super(...arguments),this.inset=!1,this.insetStart=!1,this.insetEnd=!1}}j([K({type:Boolean,reflect:!0})],cf.prototype,"inset",void 0);j([K({type:Boolean,reflect:!0,attribute:"inset-start"})],cf.prototype,"insetStart",void 0);j([K({type:Boolean,reflect:!0,attribute:"inset-end"})],cf.prototype,"insetEnd",void 0);const m$=Ye`:host{box-sizing:border-box;color:var(--md-divider-color, var(--md-sys-color-outline-variant, #cac4d0));display:flex;height:var(--md-divider-thickness, 1px);width:100%}:host([inset]),:host([inset-start]){padding-inline-start:16px}:host([inset]),:host([inset-end]){padding-inline-end:16px}:host::before{background:currentColor;content:"";height:100%;width:100%}@media(forced-colors: active){:host::before{background:CanvasText}} +`;let Fv=class extends cf{};Fv.styles=[m$];Fv=j([bt("md-divider")],Fv);const v$={dialog:[[[{transform:"translateY(-50px)"},{transform:"translateY(0)"}],{duration:500,easing:Oi.EMPHASIZED}]],scrim:[[[{opacity:0},{opacity:.32}],{duration:500,easing:"linear"}]],container:[[[{opacity:0},{opacity:1}],{duration:50,easing:"linear",pseudoElement:"::before"}],[[{height:"35%"},{height:"100%"}],{duration:500,easing:Oi.EMPHASIZED,pseudoElement:"::before"}]],headline:[[[{opacity:0},{opacity:0,offset:.2},{opacity:1}],{duration:250,easing:"linear",fill:"forwards"}]],content:[[[{opacity:0},{opacity:0,offset:.2},{opacity:1}],{duration:250,easing:"linear",fill:"forwards"}]],actions:[[[{opacity:0},{opacity:0,offset:.5},{opacity:1}],{duration:300,easing:"linear",fill:"forwards"}]]},g$={dialog:[[[{transform:"translateY(0)"},{transform:"translateY(-50px)"}],{duration:150,easing:Oi.EMPHASIZED_ACCELERATE}]],scrim:[[[{opacity:.32},{opacity:0}],{duration:150,easing:"linear"}]],container:[[[{height:"100%"},{height:"35%"}],{duration:150,easing:Oi.EMPHASIZED_ACCELERATE,pseudoElement:"::before"}],[[{opacity:"1"},{opacity:"0"}],{delay:100,duration:50,easing:"linear",pseudoElement:"::before"}]],headline:[[[{opacity:1},{opacity:0}],{duration:100,easing:"linear",fill:"forwards"}]],content:[[[{opacity:1},{opacity:0}],{duration:100,easing:"linear",fill:"forwards"}]],actions:[[[{opacity:1},{opacity:0}],{duration:100,easing:"linear",fill:"forwards"}]]};const y$=ln(ft);class Rt extends y${get open(){return this.isOpen}set open(t){t!==this.isOpen&&(this.isOpen=t,t?(this.setAttribute("open",""),this.show()):(this.removeAttribute("open"),this.close()))}constructor(){super(),this.quick=!1,this.returnValue="",this.noFocusTrap=!1,this.getOpenAnimation=()=>v$,this.getCloseAnimation=()=>g$,this.isOpen=!1,this.isOpening=!1,this.isConnectedPromise=this.getIsConnectedPromise(),this.isAtScrollTop=!1,this.isAtScrollBottom=!1,this.nextClickIsFromContent=!1,this.hasHeadline=!1,this.hasActions=!1,this.hasIcon=!1,this.escapePressedWithoutCancel=!1,this.treewalker=document.createTreeWalker(this,NodeFilter.SHOW_ELEMENT),this.addEventListener("submit",this.handleSubmit)}async show(){this.isOpening=!0,await this.isConnectedPromise,await this.updateComplete;const t=this.dialog;if(t.open||!this.isOpening){this.isOpening=!1;return}if(!this.dispatchEvent(new Event("open",{cancelable:!0}))){this.open=!1,this.isOpening=!1;return}t.showModal(),this.open=!0,this.scroller&&(this.scroller.scrollTop=0),this.querySelector("[autofocus]")?.focus(),await this.animateDialog(this.getOpenAnimation()),this.dispatchEvent(new Event("opened")),this.isOpening=!1}async close(t=this.returnValue){if(this.isOpening=!1,!this.isConnected){this.open=!1;return}await this.updateComplete;const i=this.dialog;if(!i.open||this.isOpening){this.open=!1;return}const o=this.returnValue;if(this.returnValue=t,!this.dispatchEvent(new Event("close",{cancelable:!0}))){this.returnValue=o;return}await this.animateDialog(this.getCloseAnimation()),i.close(t),this.open=!1,this.dispatchEvent(new Event("closed"))}connectedCallback(){super.connectedCallback(),this.isConnectedPromiseResolve()}disconnectedCallback(){super.disconnectedCallback(),this.isConnectedPromise=this.getIsConnectedPromise()}render(){const t=this.open&&!(this.isAtScrollTop&&this.isAtScrollBottom),i={"has-headline":this.hasHeadline,"has-actions":this.hasActions,"has-icon":this.hasIcon,scrollable:t,"show-top-divider":t&&!this.isAtScrollTop,"show-bottom-divider":t&&!this.isAtScrollBottom},o=this.open&&!this.noFocusTrap,s=ue` + + `,{ariaLabel:l}=this;return ue` +
    + + ${o?s:ae} +
    +
    + +

    + +

    + +
    +
    +
    +
    + +
    +
    +
    +
    + + +
    +
    + ${o?s:ae} +
    + `}firstUpdated(){this.intersectionObserver=new IntersectionObserver(t=>{for(const i of t)this.handleAnchorIntersection(i)},{root:this.scroller}),this.intersectionObserver.observe(this.topAnchor),this.intersectionObserver.observe(this.bottomAnchor)}handleDialogClick(){if(this.nextClickIsFromContent){this.nextClickIsFromContent=!1;return}this.dispatchEvent(new Event("cancel",{cancelable:!0}))&&this.close()}handleContentClick(){this.nextClickIsFromContent=!0}handleSubmit(t){const i=t.target,{submitter:o}=t;i.getAttribute("method")!=="dialog"||!o||this.close(o.getAttribute("value")??this.returnValue)}handleCancel(t){if(t.target!==this.dialog)return;this.escapePressedWithoutCancel=!1;const i=!as(this,t);t.preventDefault(),!i&&this.close()}handleClose(){this.escapePressedWithoutCancel&&(this.escapePressedWithoutCancel=!1,this.dialog?.dispatchEvent(new Event("cancel",{cancelable:!0})))}handleKeydown(t){t.key==="Escape"&&(this.escapePressedWithoutCancel=!0,setTimeout(()=>{this.escapePressedWithoutCancel=!1}))}async animateDialog(t){if(this.cancelAnimations?.abort(),this.cancelAnimations=new AbortController,this.quick)return;const{dialog:i,scrim:o,container:s,headline:l,content:d,actions:f}=this;if(!i||!o||!s||!l||!d||!f)return;const{container:h,dialog:m,scrim:g,headline:y,content:b,actions:_}=t,E=[[i,m??[]],[o,g??[]],[s,h??[]],[l,y??[]],[d,b??[]],[f,_??[]]],T=[];for(const[O,D]of E)for(const C of D){const z=O.animate(...C);this.cancelAnimations.signal.addEventListener("abort",()=>{z.cancel()}),T.push(z)}await Promise.all(T.map(O=>O.finished.catch(()=>{})))}handleHeadlineChange(t){const i=t.target;this.hasHeadline=i.assignedElements().length>0}handleActionsChange(t){const i=t.target;this.hasActions=i.assignedElements().length>0}handleIconChange(t){const i=t.target;this.hasIcon=i.assignedElements().length>0}handleAnchorIntersection(t){const{target:i,isIntersecting:o}=t;i===this.topAnchor&&(this.isAtScrollTop=o),i===this.bottomAnchor&&(this.isAtScrollBottom=o)}getIsConnectedPromise(){return new Promise(t=>{this.isConnectedPromiseResolve=t})}handleFocusTrapFocus(t){const[i,o]=this.getFirstAndLastFocusableChildren();if(!i||!o){this.dialog?.focus();return}const s=t.target===this.firstFocusTrap,l=!s,d=t.relatedTarget===i,f=t.relatedTarget===o,h=!d&&!f;if(l&&f||s&&h){i.focus();return}if(s&&d||l&&h){o.focus();return}}getFirstAndLastFocusableChildren(){if(!this.treewalker)return[null,null];let t=null,i=null;for(this.treewalker.currentNode=this.treewalker.root;this.treewalker.nextNode();){const o=this.treewalker.currentNode;b$(o)&&(t||(t=o),i=o)}return[t,i]}}j([K({type:Boolean})],Rt.prototype,"open",null);j([K({type:Boolean})],Rt.prototype,"quick",void 0);j([K({attribute:!1})],Rt.prototype,"returnValue",void 0);j([K()],Rt.prototype,"type",void 0);j([K({type:Boolean,attribute:"no-focus-trap"})],Rt.prototype,"noFocusTrap",void 0);j([at("dialog")],Rt.prototype,"dialog",void 0);j([at(".scrim")],Rt.prototype,"scrim",void 0);j([at(".container")],Rt.prototype,"container",void 0);j([at(".headline")],Rt.prototype,"headline",void 0);j([at(".content")],Rt.prototype,"content",void 0);j([at(".actions")],Rt.prototype,"actions",void 0);j([yt()],Rt.prototype,"isAtScrollTop",void 0);j([yt()],Rt.prototype,"isAtScrollBottom",void 0);j([at(".scroller")],Rt.prototype,"scroller",void 0);j([at(".top.anchor")],Rt.prototype,"topAnchor",void 0);j([at(".bottom.anchor")],Rt.prototype,"bottomAnchor",void 0);j([at(".focus-trap")],Rt.prototype,"firstFocusTrap",void 0);j([yt()],Rt.prototype,"hasHeadline",void 0);j([yt()],Rt.prototype,"hasActions",void 0);j([yt()],Rt.prototype,"hasIcon",void 0);function b$(e){const t=":is(button,input,select,textarea,object,:is(a,area)[href],[tabindex],[contenteditable=true])",i=":not(:disabled,[disabled])";return e.matches(t+i+':not([tabindex^="-"])')?!0:!e.localName.includes("-")||!e.matches(i)?!1:e.shadowRoot?.delegatesFocus??!1}const x$=Ye`:host{border-start-start-radius:var(--md-dialog-container-shape-start-start, var(--md-dialog-container-shape, var(--md-sys-shape-corner-extra-large, 28px)));border-start-end-radius:var(--md-dialog-container-shape-start-end, var(--md-dialog-container-shape, var(--md-sys-shape-corner-extra-large, 28px)));border-end-end-radius:var(--md-dialog-container-shape-end-end, var(--md-dialog-container-shape, var(--md-sys-shape-corner-extra-large, 28px)));border-end-start-radius:var(--md-dialog-container-shape-end-start, var(--md-dialog-container-shape, var(--md-sys-shape-corner-extra-large, 28px)));display:contents;margin:auto;max-height:min(560px,100% - 48px);max-width:min(560px,100% - 48px);min-height:140px;min-width:280px;position:fixed;height:fit-content;width:fit-content}dialog{background:rgba(0,0,0,0);border:none;border-radius:inherit;flex-direction:column;height:inherit;margin:inherit;max-height:inherit;max-width:inherit;min-height:inherit;min-width:inherit;outline:none;overflow:visible;padding:0;width:inherit}dialog[open]{display:flex}::backdrop{background:none}.scrim{background:var(--md-sys-color-scrim, #000);display:none;inset:0;opacity:32%;pointer-events:none;position:fixed;z-index:1}:host([open]) .scrim{display:flex}h2{all:unset;align-self:stretch}.headline{align-items:center;color:var(--md-dialog-headline-color, var(--md-sys-color-on-surface, #1d1b20));display:flex;flex-direction:column;font-family:var(--md-dialog-headline-font, var(--md-sys-typescale-headline-small-font, var(--md-ref-typeface-brand, Roboto)));font-size:var(--md-dialog-headline-size, var(--md-sys-typescale-headline-small-size, 1.5rem));line-height:var(--md-dialog-headline-line-height, var(--md-sys-typescale-headline-small-line-height, 2rem));font-weight:var(--md-dialog-headline-weight, var(--md-sys-typescale-headline-small-weight, var(--md-ref-typeface-weight-regular, 400)));position:relative}slot[name=headline]::slotted(*){align-items:center;align-self:stretch;box-sizing:border-box;display:flex;gap:8px;padding:24px 24px 0}.icon{display:flex}slot[name=icon]::slotted(*){color:var(--md-dialog-icon-color, var(--md-sys-color-secondary, #625b71));fill:currentColor;font-size:var(--md-dialog-icon-size, 24px);margin-top:24px;height:var(--md-dialog-icon-size, 24px);width:var(--md-dialog-icon-size, 24px)}.has-icon slot[name=headline]::slotted(*){justify-content:center;padding-top:16px}.scrollable slot[name=headline]::slotted(*){padding-bottom:16px}.scrollable.has-headline slot[name=content]::slotted(*){padding-top:8px}.container{border-radius:inherit;display:flex;flex-direction:column;flex-grow:1;overflow:hidden;position:relative;transform-origin:top}.container::before{background:var(--md-dialog-container-color, var(--md-sys-color-surface-container-high, #ece6f0));border-radius:inherit;content:"";inset:0;position:absolute}.scroller{display:flex;flex:1;flex-direction:column;overflow:hidden;z-index:1}.scrollable .scroller{overflow-y:scroll}.content{color:var(--md-dialog-supporting-text-color, var(--md-sys-color-on-surface-variant, #49454f));font-family:var(--md-dialog-supporting-text-font, var(--md-sys-typescale-body-medium-font, var(--md-ref-typeface-plain, Roboto)));font-size:var(--md-dialog-supporting-text-size, var(--md-sys-typescale-body-medium-size, 0.875rem));line-height:var(--md-dialog-supporting-text-line-height, var(--md-sys-typescale-body-medium-line-height, 1.25rem));flex:1;font-weight:var(--md-dialog-supporting-text-weight, var(--md-sys-typescale-body-medium-weight, var(--md-ref-typeface-weight-regular, 400)));height:min-content;position:relative}slot[name=content]::slotted(*){box-sizing:border-box;padding:24px}.anchor{position:absolute}.top.anchor{top:0}.bottom.anchor{bottom:0}.actions{position:relative}slot[name=actions]::slotted(*){box-sizing:border-box;display:flex;gap:8px;justify-content:flex-end;padding:16px 24px 24px}.has-actions slot[name=content]::slotted(*){padding-bottom:8px}md-divider{display:none;position:absolute}.has-headline.show-top-divider .headline md-divider,.has-actions.show-bottom-divider .actions md-divider{display:flex}.headline md-divider{bottom:0}.actions md-divider{top:0}@media(forced-colors: active){dialog{outline:2px solid WindowText}} +`;let Hv=class extends Rt{};Hv.styles=[x$];Hv=j([bt("md-dialog")],Hv);function w$({open:e,onClose:t,ariaLabel:i,headline:o,actions:s,className:l,children:d}){const f=w.useRef(null),h=w.useRef(t);return h.current=t,w.useEffect(()=>{const m=f.current;m&&(e&&typeof m.show=="function"&&!m.open?m.show().catch(()=>{}):!e&&typeof m.close=="function"&&m.open&&m.close())},[e]),w.useEffect(()=>{const m=f.current;if(!m)return;const g=()=>h.current();return m.addEventListener("close",g),()=>m.removeEventListener("close",g)},[]),v.jsxs("md-dialog",{ref:f,"aria-label":i,className:l,children:[o?v.jsxs("div",{slot:"headline",className:"material-dialog__headline",children:[v.jsx("span",{className:"material-dialog__headline-text",children:o}),v.jsx(pi,{className:"material-dialog__close",icon:"close",label:"Close",onClick:t})]}):null,v.jsx("div",{slot:"content",className:"material-dialog__content",children:d}),s?v.jsx("div",{slot:"actions",children:s}):null]})}function gE({onClose:e,open:t,resource:i,revision:o,modelDisplayNames:s,title:l,children:d}){const{t:f}=Oe(),h=l??i.label;return v.jsx(w$,{open:t,onClose:e,ariaLabel:f("resource.editorAriaLabel",{title:h,id:i.id}),headline:f("resource.editorHeading",{title:h}),className:"resource-editor-dialog",children:v.jsx(hi,{embedded:!0,resource:i,revision:o,modelDisplayNames:s,title:l,children:d})})}function _$(){const{t:e}=Oe(),t=Jn(),[i,o]=w.useState(null),s=w.useMemo(()=>t.data?ry(t.data.resources):{},[t.data]);if(t.error)return v.jsx(Ho,{error:t.error});if(t.isLoading||!t.data)return v.jsx(Bo,{label:e("loading.providers")});const l=t.data.resources,d=Am(l,"provider"),f=Am(l,"provider_offer"),h=Am(l,"model"),m=E$(f),g=f.filter(b=>!h.some(_=>_.id===yE(b))),y=i?l.find(b=>b.kind===i.kind&&b.id===i.id):void 0;return v.jsxs("section",{className:"page-stack","aria-labelledby":"models-providers-title",children:[v.jsx(Fo,{eyebrow:e("pageEyebrow.upstream"),title:e("nav.modelsProviders"),children:e("modelsProviders.description")}),v.jsxs("section",{className:"resource-section","aria-labelledby":"providers-heading",children:[v.jsxs("div",{className:"section-heading",children:[v.jsx("h2",{id:"providers-heading",children:e("modelsProviders.providers",{count:d.length})}),v.jsx(dc,{graph:t.data,kind:"provider"})]}),v.jsx("div",{className:"resource-card-list resource-card-list--summary",children:d.map(b=>v.jsx(hi,{ariaLabel:e("modelsProviders.providerRegion",{id:b.id}),onOpenEditor:()=>o({kind:"provider",id:b.id}),resource:b,revision:t.data.revision,title:e("resource.kind.provider"),variant:"summary"},b.id))})]}),g.length>0?v.jsxs("section",{className:"resource-section","aria-labelledby":"offers-heading",children:[v.jsx("h2",{id:"offers-heading",children:e("modelsProviders.unmatchedSupplies",{count:g.length})}),v.jsx("div",{className:"resource-card-list resource-card-list--summary",children:g.map(b=>v.jsx(hi,{modelDisplayNames:s,onOpenEditor:()=>o({kind:"provider_offer",id:b.id}),resource:b,revision:t.data.revision,title:e("resource.kind.offer"),variant:"summary"},b.id))})]}):null,v.jsxs("section",{className:"resource-section","aria-labelledby":"models-heading",children:[v.jsxs("div",{className:"section-heading",children:[v.jsx("h2",{id:"models-heading",children:e("modelsProviders.models",{count:h.length})}),v.jsx(dc,{graph:t.data,kind:"model"})]}),v.jsx("div",{className:"resource-card-list resource-card-list--summary",children:h.map(b=>v.jsx(hi,{ariaLabel:e("modelsProviders.modelRegion",{id:b.id}),modelDisplayNames:s,onOpenEditor:()=>o({kind:"model",id:b.id}),resource:b,revision:t.data.revision,title:e("resource.kind.model"),variant:"summary"},b.id))})]}),y?v.jsx(gE,{open:!0,onClose:()=>o(null),modelDisplayNames:s,resource:y,revision:t.data.revision,title:S$(y.kind,e),children:y.kind==="model"?v.jsx(T$,{graph:t.data,modelDisplayNames:s,modelId:y.id,offers:m.get(y.id)??[]}):null}):null]})}function S$(e,t){switch(e){case"model":return t("resource.kind.model");case"provider":return t("resource.kind.provider");case"provider_offer":return t("resource.kind.offer");default:return""}}function Am(e,t){return e.filter(i=>i.kind===t)}function E$(e){const t=new Map;for(const i of e){const o=yE(i),s=t.get(o)??[];s.push(i),t.set(o,s)}return t}function yE(e){const t=e.value.model;if(typeof t=="string"&&t.trim())return t;const i=e.id.indexOf("/");return i>=0?e.id.slice(i+1):""}function T$({graph:e,modelDisplayNames:t,modelId:i,offers:o}){const{t:s}=Oe(),l=`model-${i}-providers-heading`,d=`model-${i}-providers-body`.replace(/[^a-zA-Z0-9_-]/g,"-"),f=s("modelsProviders.modelProviders",{count:o.length});return v.jsxs("section",{className:"resource-field-group resource-field-group--advanced model-provider-bindings","aria-labelledby":l,"aria-label":f,children:[v.jsxs("div",{className:"resource-field-group__header",children:[v.jsxs("h4",{id:l,children:[v.jsx("span",{className:"material-symbol","aria-hidden":"true",children:"cloud_sync"}),f]}),v.jsx("div",{className:"resource-field-group__header-actions",children:v.jsx(dc,{graph:e,kind:"provider_offer",modelId:i})})]}),v.jsx("div",{className:"resource-card-list resource-card-list--compact resource-field-group__body",id:d,children:o.map(h=>v.jsx(hi,{modelDisplayNames:t,resource:h,revision:e.revision,title:s("resource.kind.offer")},h.id))})]})}const O$=()=>Wn("/extensions"),C$=(e="session")=>Wn(e==="session"?"/stats/usage":`/stats/usage?range=${e}`),A$=(e={})=>{const t=new URLSearchParams;e.limit!==void 0&&t.set("limit",String(e.limit));const i=t.toString();return Wn(`/logs/recent${i?`?${i}`:""}`)};async function R$(e={}){const t={Accept:"text/event-stream"},i=M3();i&&(t.Authorization=`Bearer ${i}`);const o=await fetch(D3("/logs/stream"),{method:"GET",headers:t,signal:e.signal});if(!o.ok)throw new No(o.status,"log_stream_error",$u("logs.streamFailedWithStatus",{status:o.status}));if(!o.body)throw new No(o.status,"log_stream_error",$u("logs.streamBodyEmpty"));return o}const j$=["ALL","ERROR","WARN","INFO","DEBUG"];function k$({labelledBy:e,embedded:t}){const{t:i}=Oe(),[o,s]=w.useState([]),[l,d]=w.useState(""),[f,h]=w.useState("ALL"),[m,g]=w.useState(!0),[y,b]=w.useState(!1),[_,E]=w.useState(!0),[T,O]=w.useState();w.useEffect(()=>{let C=!1;return A$({limit:200}).then(z=>{C||(s(z),E(!1))}).catch(z=>{C||(O(z),E(!1))}),()=>{C=!0}},[]),w.useEffect(()=>{if(!m)return;b(!1);const C=new AbortController;return z$(C.signal,z=>{s(B=>[...B,z])}).catch(z=>{C.signal.aborted||(b(!0),console.error("log stream failed",z))}),()=>C.abort()},[m]);const D=w.useMemo(()=>{const C=l.trim().toLowerCase();return o.filter(z=>f!=="ALL"&&bE(z.level)!==f.toLowerCase()?!1:C?uc(z).toLowerCase().includes(C):!0)},[o,l,f]);return v.jsxs("section",{"aria-label":e?void 0:i("logs.panelLabel"),"aria-labelledby":e,className:t?"logs-panel":"content-panel logs-panel",children:[v.jsxs("div",{className:"logs-panel__header",children:[e?v.jsx("span",{"aria-hidden":"true"}):v.jsx("h2",{children:i("logs.panelTitle")}),v.jsxs("div",{className:"logs-panel__actions",children:[v.jsx(Xa,{disabled:D.length===0,icon:"content_copy",onClick:()=>P$(D),children:i("logs.copy")}),v.jsx(Xa,{disabled:D.length===0,icon:"download",onClick:()=>L$(D),children:i("logs.download")})]})]}),v.jsxs("div",{className:"logs-toolbar",children:[v.jsx("div",{className:"logs-toolbar__actions",children:v.jsxs("md-chip-set",{className:"logs-chip-set logs-follow-mode",role:"group","aria-label":i("logs.followMode"),children:[v.jsx(Al,{value:"follow",selected:m,onSelect:()=>g(!0),children:i("logs.follow")}),v.jsx(Al,{value:"pause",selected:!m,onSelect:()=>g(!1),children:i("logs.pause")})]})}),v.jsx("p",{className:"logs-count",children:i("logs.visibleCount",{visible:D.length,total:o.length})})]}),v.jsx("md-chip-set",{className:"logs-chip-set log-level-filter",role:"group","aria-label":i("logs.levelFilter"),children:j$.map(C=>v.jsx(Al,{value:C,selected:f===C,onSelect:h,children:C==="ALL"?i("logs.levelAll"):C},C))}),y?v.jsx("p",{className:"logs-stream-status",role:"status",children:i("logs.streamDisconnected")}):null,T?v.jsx("p",{className:"logs-stream-status",role:"status",children:T instanceof Error?T.message:i("error.unknownRequest")}):null,v.jsxs("div",{className:"logs-search",children:[v.jsx(Yt,{className:"logs-search__field",label:i("logs.search"),type:"search",value:l,onInput:d}),v.jsx(pi,{disabled:l.length===0,icon:"close",label:i("logs.clearSearch"),onClick:()=>d("")})]}),v.jsx("div",{className:"log-output","aria-label":i("logs.output"),children:_?v.jsx("p",{className:"log-empty-state",role:"status",children:i("common.loading")}):D.length===0?v.jsx("p",{className:"log-empty-state",role:"status",children:o.length===0?i("logs.empty"):i("logs.emptyFiltered")}):D.map((C,z)=>v.jsx(M$,{entry:C,index:z},`${C.timestamp}-${z}-${uc(C)}`))})]})}function M$({entry:e,index:t}){const{t:i}=Oe(),o=bE(e.level);return v.jsxs(Wr.article,{className:`log-row log-row--${o}`,"aria-label":i("logs.rowLabel",{index:t+1}),initial:{opacity:0,y:6},animate:{opacity:1,y:0},transition:ur.effects,children:[v.jsx("span",{className:`log-row__level log-row__level--${o}`,"aria-hidden":"true",children:e.level}),v.jsx("time",{className:"log-row__time",dateTime:e.timestamp,children:D$(e.timestamp)}),v.jsx("p",{className:"log-row__message",children:e.message||uc(e)})]})}function D$(e){const t=e.split("T")[1];return t?t.replace(/\.\d{3,}.*$/,"").replace(/Z$/i,""):e}async function z$(e,t){const o=(await R$({signal:e})).body?.getReader();if(!o)throw new Error("log stream response body is empty");const s=new TextDecoder;let l="";for(;;){const{done:f,value:h}=await o.read();if(f)break;l+=s.decode(h,{stream:!0});const m=l.split(` + +`);l=m.pop()??"";for(const g of m){const y=E_(g);y&&t(y)}}l+=s.decode();const d=E_(l);d&&t(d)}function E_(e){const t=e.split(` +`).filter(i=>i.startsWith("data:")).map(i=>i.slice(5).trimStart()).join(` +`);if(t)return JSON.parse(t)}function uc(e){return e.raw||`${e.timestamp} ${e.level} ${e.message}`}function bE(e){return e.trim().toLowerCase()}function P$(e){const t=e.map(i=>uc(i)).join(` +`);if(!navigator.clipboard){console.error("clipboard API unavailable");return}navigator.clipboard.writeText(t).catch(i=>{console.error("copy logs failed",i)})}function L$(e){const t=new Blob([e.map(s=>uc(s)).join(` +`)],{type:"text/plain"}),i=URL.createObjectURL(t),o=document.createElement("a");o.href=i,o.download="moonbridge-logs.txt",o.click(),URL.revokeObjectURL(i)}const N$=[{model:"claude-sonnet",actual:"claude-3-5-sonnet"},{model:"claude-opus",actual:"claude-3-opus"},{model:"claude-haiku",actual:"claude-3-5-haiku"},{model:"gpt-4o",actual:"gpt-4o-2024-08-06"},{model:"gpt-4o-mini",actual:"gpt-4o-mini"},{model:"o3-mini",actual:"o3-mini"},{model:"gemini-pro",actual:"gemini-1.5-pro"},{model:"gemini-flash",actual:"gemini-1.5-flash"}],$$={session:1,"24h":4,"7d":26,"30d":110,all:340},B$={session:"1h 12m","24h":"23h 48m","7d":"167h","30d":"718h",all:"2400h"};function U$(e="session"){const t=$$[e]??1,i=N$.map((o,s)=>{const l=Math.max(1,Math.round((38+s*19+s*7%5*11)*t)),d=l*(1400+s%4*520),f=l*(260+s%3*180),h=Math.round(d*(.22+s%3*.05)),m=Math.round(d*(.7+s%4*.12)),g=Math.min(99,Math.round((58+s*6%38)*10)/10),y=Math.round((d*3e-6+f*15e-6+m*4e-7)*100)/100;return{model:o.model,actual_model:o.actual,requests:l,input_tokens:d,output_tokens:f,cache_creation:h,cache_read:m,cache_hit_rate:g,cost:y,avg_cost_per_mtoken:Math.round(y/Math.max(1,d+f)*1e6*100)/100}});return{totals:I$(i,e),by_model:i}}function I$(e,t){const i=m=>e.reduce((g,y)=>g+m(y),0),o=i(m=>m.requests),s=i(m=>m.input_tokens),l=i(m=>m.output_tokens),d=i(m=>m.cache_creation),f=i(m=>m.cache_read),h=Math.round(i(m=>m.cost)*100)/100;return{requests:o,input_tokens:s,output_tokens:l,cache_creation:d,cache_read:f,cache_hit_rate:s+f>0?Math.round(f/(s+f)*1e3)/10:0,cache_write_rate:s>0?Math.round(d/s*1e3)/10:0,cache_rw_ratio:d>0?Math.round(f/d*100)/100:0,total_cost:h,duration:B$[t]??"0s"}}const V$=["session","24h","7d","30d","all"],F$={session:"overview.range.session","24h":"overview.range.24h","7d":"overview.range.7d","30d":"overview.range.30d",all:"overview.range.all"};function H$(){const{t:e}=Oe(),t=Jn(),[i,o]=w.useState("session"),[s,l]=w.useState(!1),d=Zv({queryKey:[...sc.usageStats,i,s?"demo":"live"],queryFn:()=>s?U$(i):C$(i),placeholderData:X6}),f=q$(d.data?.totals.duration,i==="session"&&!d.isPlaceholderData&&!s);return v.jsxs("section",{className:"page-stack","aria-labelledby":"overview-title",children:[v.jsx(Fo,{eyebrow:e("pageEyebrow.analytics"),title:e("nav.overview"),children:e("overview.description")}),t.error?v.jsxs("section",{className:"state-panel state-panel--inline",role:"status",children:[v.jsx("p",{className:"eyebrow",children:e("common.error")}),v.jsx("h2",{children:e("overview.graphUnavailableTitle")}),v.jsx("p",{children:e("overview.graphUnavailableDescription")})]}):null,v.jsxs("section",{className:"usage-dashboard","aria-labelledby":"usage-title",children:[v.jsxs("div",{className:"panel-heading",children:[v.jsxs("div",{children:[v.jsx("h2",{id:"usage-title",children:e("overview.usageTitle")}),v.jsx("p",{children:e("overview.usageDescription")})]}),v.jsxs("div",{className:"usage-heading-controls",children:[v.jsx("md-chip-set",{className:"usage-range",role:"group","aria-label":e("overview.rangeLabel"),children:V$.map(h=>v.jsx(Al,{value:h,selected:i===h,onSelect:o,children:e(F$[h])},h))}),d.data?v.jsx("span",{className:"status-pill status-pill--muted",children:f}):null,null]})]}),d.isLoading?v.jsx(Bo,{label:e("common.loading")}):d.error?v.jsx(Ho,{error:d.error}):d.data?v.jsx(K$,{stats:d.data}):null]}),v.jsxs("section",{id:"logs",className:"overview-logs",children:[v.jsx("div",{className:"panel-heading",children:v.jsxs("div",{children:[v.jsx("h2",{id:"overview-logs-title",children:e("logs.panelTitle")}),v.jsx("p",{children:e("logs.description")})]})}),v.jsx(k$,{labelledBy:"overview-logs-title",embedded:!0})]})]})}function q$(e,t){const{t:i}=Oe(),[o,s]=w.useState(0),l=e?xE(e):void 0;if(w.useEffect(()=>{if(s(0),!t||l===void 0)return;const f=window.setInterval(()=>{s(h=>h+1)},1e3);return()=>window.clearInterval(f)},[t,l,e]),!e)return"";if(!t||l===void 0)return Y$(e,d);return wE(l+o,d);function d(f,h){return i(f,{count:h})}}function K$({stats:e}){const{t}=Oe(),i=e.totals.requests>0||e.by_model.length>0;return v.jsxs(v.Fragment,{children:[i?null:v.jsx("div",{className:"usage-empty-state",children:v.jsx("p",{children:t("overview.usageEmpty")})}),v.jsxs(Wr.div,{className:"usage-summary-grid",variants:lk,initial:"hidden",animate:"show",children:[v.jsx(Da,{icon:"swap_horiz",tone:"primary",value:t("overview.requestsValue",{count:e.totals.requests}),label:t("overview.requests")}),v.jsx(Da,{icon:"south_west",tone:"primary",value:t("overview.inputValue",{count:qa(e.totals.input_tokens)}),label:t("overview.inputTokens")}),v.jsx(Da,{icon:"north_east",tone:"tertiary",value:t("overview.outputValue",{count:qa(e.totals.output_tokens)}),label:t("overview.outputTokens")}),v.jsx(Da,{icon:"bolt",tone:"secondary",value:t("overview.cacheHitValue",{rate:_E(e.totals.cache_hit_rate)}),label:t("overview.cacheHit")}),v.jsx(Da,{icon:"sync_alt",tone:"secondary",value:t("overview.cacheRatioValue",{ratio:Q$(e.totals.cache_rw_ratio)}),label:t("overview.cacheReadWrite")}),v.jsx(Da,{icon:"payments",tone:"tertiary",value:t("overview.totalCostValue",{cost:qv(e.totals.total_cost)}),label:t("overview.totalCost")})]}),v.jsxs("div",{className:"usage-chart-grid",children:[v.jsx(Rm,{ariaLabel:jm(t,t("overview.tokenSplitChart"),[[t("overview.inputTokens"),e.totals.input_tokens],[t("overview.outputTokens"),e.totals.output_tokens]],t("overview.noData")),title:t("overview.tokenSplit"),segments:[{label:t("overview.inputTokens"),value:e.totals.input_tokens,className:"usage-segment--input"},{label:t("overview.outputTokens"),value:e.totals.output_tokens,className:"usage-segment--output"}]}),v.jsx(Rm,{ariaLabel:jm(t,t("overview.cacheSplitChart"),[[t("overview.cacheWrite"),e.totals.cache_creation],[t("overview.cacheRead"),e.totals.cache_read]],t("overview.noData")),title:t("overview.cacheSplit"),segments:[{label:t("overview.cacheWrite"),value:e.totals.cache_creation,className:"usage-segment--cache-write"},{label:t("overview.cacheRead"),value:e.totals.cache_read,className:"usage-segment--cache-read"}]}),v.jsx(Rm,{ariaLabel:jm(t,t("overview.costByModelChart"),e.by_model.map(o=>[o.model,o.cost]),t("overview.noData")),title:t("overview.costByModel"),segments:e.by_model.map((o,s)=>({label:o.model,value:o.cost,className:`usage-segment--cost-${s%6+1}`}))})]}),v.jsx("div",{className:"table-scroll",children:v.jsxs("table",{className:"resource-table usage-table","aria-label":t("overview.modelUsageTable"),children:[v.jsx("thead",{children:v.jsxs("tr",{children:[v.jsx("th",{children:t("overview.model")}),v.jsx("th",{children:t("overview.actualModel")}),v.jsx("th",{children:t("overview.requests")}),v.jsx("th",{children:t("overview.inputTokens")}),v.jsx("th",{children:t("overview.outputTokens")}),v.jsx("th",{children:t("overview.cacheWrite")}),v.jsx("th",{children:t("overview.cacheRead")}),v.jsx("th",{children:t("overview.cacheHit")}),v.jsx("th",{children:t("overview.cost")}),v.jsx("th",{children:t("overview.avgCost")})]})}),v.jsx("tbody",{children:e.by_model.map(o=>v.jsx(G$,{row:o},o.model))})]})})]})}function Da({label:e,value:t,icon:i,tone:o="primary"}){return v.jsxs(Wr.article,{className:`usage-metric usage-metric--${o}`,variants:ck,children:[v.jsx("span",{className:"usage-metric__icon material-symbol","aria-hidden":"true",children:i}),v.jsx("span",{className:"usage-metric__label",children:e}),v.jsx("strong",{children:t})]})}function Rm({ariaLabel:e,title:t,segments:i}){const o=i.reduce((s,l)=>s+Math.max(0,l.value),0);return v.jsxs("section",{className:"usage-chart",role:"img","aria-label":e,tabIndex:0,children:[v.jsxs("div",{className:"usage-chart__header",children:[v.jsx("h3",{children:t}),v.jsx("span",{children:fc(o)})]}),v.jsx("div",{className:"usage-chart__bar","aria-hidden":"true",children:i.map(s=>v.jsx("span",{className:`usage-chart__segment ${s.className}`,style:{inlineSize:`${o>0?Math.max(0,s.value)/o*100:0}%`}},s.label))}),v.jsx("ul",{className:"usage-chart__legend",children:i.map(s=>v.jsxs("li",{children:[v.jsx("span",{className:`usage-chart__dot ${s.className}`,"aria-hidden":"true"}),v.jsx("span",{children:s.label}),v.jsx("strong",{children:fc(s.value)})]},s.label))})]})}function G$({row:e}){const{t}=Oe();return v.jsxs("tr",{"aria-label":t("overview.modelUsageRow",{model:e.model}),children:[v.jsx("td",{children:e.model}),v.jsx("td",{children:e.actual_model||"-"}),v.jsx("td",{children:fc(e.requests)}),v.jsx("td",{children:qa(e.input_tokens)}),v.jsx("td",{children:qa(e.output_tokens)}),v.jsx("td",{children:qa(e.cache_creation)}),v.jsx("td",{children:qa(e.cache_read)}),v.jsx("td",{children:_E(e.cache_hit_rate)}),v.jsx("td",{children:qv(e.cost)}),v.jsxs("td",{children:[qv(e.avg_cost_per_mtoken),t("overview.costPerMillionSuffix")]})]})}function fc(e){return new Intl.NumberFormat().format(e)}function Y$(e,t){if(!e||e==="N/A")return"—";const i=xE(e);return i===void 0?e.trim():wE(i,t)}function xE(e){const t=/^(?:(\d+)h)?(?:(\d+)m)?(?:([\d.]+)s)?$/.exec(e.trim());if(!t||!t[1]&&!t[2]&&!t[3])return;const i=t[1]?parseInt(t[1],10):0,o=t[2]?parseInt(t[2],10):0,s=t[3]?Math.round(parseFloat(t[3])):0;return i*3600+o*60+s}function wE(e,t){const i=Math.max(0,Math.round(e)),o=Math.floor(i/3600),s=Math.floor(i%3600/60),l=i%60,d=[];return o&&d.push(t("overview.duration.hoursShort",o)),s&&d.push(t("overview.duration.minutesShort",s)),(l||d.length===0)&&d.push(t("overview.duration.secondsShort",l)),d.join(" ")}function qa(e){return fc(e)}function _E(e){return`${new Intl.NumberFormat(void 0,{maximumFractionDigits:1}).format(e)}%`}function Q$(e){return new Intl.NumberFormat(void 0,{maximumFractionDigits:2}).format(e)}function qv(e){return new Intl.NumberFormat(void 0,{style:"currency",currency:"CNY",currencyDisplay:"narrowSymbol",minimumFractionDigits:2,maximumFractionDigits:2}).format(e)}function jm(e,t,i,o){const s=i.length>0?i.map(([l,d])=>e("overview.chartAriaItem",{label:l,value:fc(d)})).join(e("overview.chartAriaSeparator")):o;return e("overview.chartAriaLabel",{title:t,summary:s})}function X$(){const{t:e}=Oe(),t=Jn(),[i,o]=w.useState(null);if(t.error)return v.jsx(Ho,{error:t.error});if(t.isLoading||!t.data)return v.jsx(Bo,{label:e("loading.routes")});const s=t.data.resources.filter(h=>h.kind==="route"),l=e("routes.resourceTitle"),d=ry(t.data.resources),f=i?s.find(h=>h.id===i):void 0;return v.jsxs("section",{className:"page-stack","aria-labelledby":"routes-title",children:[v.jsx(Fo,{eyebrow:e("pageEyebrow.aliases"),title:e("nav.routes"),children:e("routes.description")}),v.jsxs("section",{className:"resource-section","aria-labelledby":"routes-list-heading",children:[v.jsxs("div",{className:"section-heading",children:[v.jsx("h2",{id:"routes-list-heading",children:e("routes.listTitle",{count:s.length})}),v.jsx(dc,{graph:t.data,kind:"route"})]}),v.jsx("div",{className:"resource-card-list resource-card-list--summary",children:s.map(h=>v.jsx(hi,{ariaLabel:e("resource.cardLabel",{title:l,id:h.id}),modelDisplayNames:d,onOpenEditor:()=>o(h.id),resource:h,revision:t.data.revision,title:l,variant:"summary"},h.id))})]}),f?v.jsx(gE,{open:!0,onClose:()=>o(null),modelDisplayNames:d,resource:f,revision:t.data.revision,title:l}):null]})}function W$(){const{t:e}=Oe(),t=Jn(),i=Zv({queryKey:sc.extensions,queryFn:O$});if(t.error)return v.jsx(Ho,{error:t.error});if(t.isLoading||!t.data)return v.jsx(Bo,{label:e("common.loading")});const o=t.data.resources.find(h=>h.kind==="web_search"),s=t.data.resources.filter(h=>h.kind==="extension"),l=t.data.resources.find(h=>h.kind==="proxy"),d=new Set(s.map(h=>h.id)),f=(i.data??[]).filter(h=>!d.has(h));return v.jsxs("section",{className:"page-stack","aria-labelledby":"search-tools-title",children:[v.jsx(Fo,{eyebrow:e("pageEyebrow.config"),title:e("nav.searchTools"),children:e("searchTools.description")}),o?v.jsx(T_,{resource:o,revision:t.data.revision,title:e("searchTools.webSearch")}):null,v.jsxs("section",{className:"resource-section","aria-labelledby":"extensions-heading",children:[v.jsxs("div",{className:"section-heading",children:[v.jsx("h2",{id:"extensions-heading",children:e("searchTools.extensions")}),i.isLoading?v.jsx("span",{className:"status-pill status-pill--muted",children:e("common.loading")}):i.error?v.jsx("span",{className:"field-error",role:"alert",children:i.error instanceof Error?i.error.message:e("error.unknownRequest")}):v.jsx(dc,{availableExtensionIds:f,graph:t.data,kind:"extension"})]}),v.jsx("div",{className:"resource-card-list",children:s.map(h=>v.jsx(hi,{ariaLabel:e("resource.cardLabel",{title:e("searchTools.extension"),id:h.id}),resource:h,revision:t.data.revision,title:e("searchTools.extension")},h.id))})]}),l?v.jsx(T_,{resource:l,revision:t.data.revision,title:e("searchTools.proxy")}):null]})}function T_({resource:e,revision:t,title:i}){const{t:o}=Oe();return v.jsxs("section",{className:"resource-section","aria-label":i,children:[v.jsx("h2",{children:i}),v.jsx(hi,{ariaLabel:o("resource.cardLabel",{title:i,id:e.id}),resource:e,revision:t,title:i})]})}function Z$(){const{t:e}=Oe(),t=Jn();if(t.error)return v.jsx(Ho,{error:t.error});if(t.isLoading||!t.data)return v.jsx(Bo,{label:e("common.loading")});const i=t.data.resources.find(o=>o.kind==="server");return v.jsxs("section",{className:"page-stack","aria-labelledby":"security-title",children:[v.jsx(Fo,{eyebrow:e("pageEyebrow.config"),title:e("nav.security"),children:e("security.description")}),i?v.jsx(J$,{resource:i,revision:t.data.revision}):null]})}function J$({resource:e,revision:t}){const{t:i}=Oe(),o=i("security.server");return v.jsxs("section",{className:"resource-section","aria-label":o,children:[v.jsx("h2",{children:o}),v.jsx(hi,{ariaLabel:i("resource.cardLabel",{title:o,id:e.id}),resource:e,revision:t,title:o})]})}const eB=new Set(["cache","persistence"]);function tB(){const{t:e}=Oe(),t=Jn();if(t.error)return v.jsx(Ho,{error:t.error});if(t.isLoading||!t.data)return v.jsx(Bo,{label:e("common.loading")});const i=t.data.resources.find(l=>l.kind==="cache"),o=t.data.resources.find(l=>l.kind==="persistence"),s=(t.data.runtime.errors??[]).filter(l=>eB.has(l.resourceKind));return v.jsxs("section",{className:"page-stack","aria-labelledby":"storage-title",children:[v.jsx(Fo,{eyebrow:e("pageEyebrow.config"),title:e("nav.storage"),children:e("storage.description")}),s.length>0?v.jsx(rB,{errors:s}):null,i?v.jsx(O_,{resource:i,revision:t.data.revision,title:e("storage.cache")}):null,o?v.jsx(O_,{resource:o,revision:t.data.revision,title:e("storage.persistence")}):null]})}function rB({errors:e}){const{t}=Oe();return v.jsxs("section",{className:"content-panel","aria-label":t("storage.errors"),children:[v.jsx("h2",{children:t("storage.status")}),v.jsx("ul",{className:"compact-list",children:e.map(i=>v.jsxs("li",{children:[v.jsx("strong",{children:i.resourceId||i.resourceKind}),v.jsx("span",{children:i.message})]},`${i.resourceKind}-${i.resourceId}-${i.field}-${i.code}`))})]})}function O_({resource:e,revision:t,title:i}){const{t:o}=Oe();return v.jsxs("section",{className:"resource-section","aria-label":i,children:[v.jsx("h2",{children:i}),v.jsx(hi,{ariaLabel:o("resource.cardLabel",{title:i,id:e.id}),resource:e,revision:t,title:i})]})}const iB=[{index:!0,element:v.jsx(K1,{to:"/overview",replace:!0})},{path:"overview",element:v.jsx(H$,{})},{path:"models-providers",element:v.jsx(_$,{})},{path:"routes",element:v.jsx(X$,{})},{path:"defaults",element:v.jsx(XN,{})},{path:"search-tools",element:v.jsx(W$,{})},{path:"storage",element:v.jsx(tB,{})},{path:"security",element:v.jsx(Z$,{})},{path:"logs",element:v.jsx(K1,{to:"/overview#logs",replace:!0})}],nB=NC([{path:"/",element:v.jsx(Zk,{}),children:iB}],{basename:"/console"}),oB=new pT({defaultOptions:{queries:{retry:1,staleTime:15e3,refetchOnWindowFocus:!1}}}),SE=document.getElementById("root");if(!SE)throw new Error("Root element #root was not found");I6.createRoot(SE).render(v.jsx(w.StrictMode,{children:v.jsx(mT,{client:oB,children:v.jsx(Z9,{children:v.jsx(ak,{children:v.jsx(IR,{reducedMotion:"user",children:v.jsx(Qk,{children:v.jsx(QC,{router:nB})})})})})})})); diff --git a/internal/service/webui/dist/index.html b/internal/service/webui/dist/index.html new file mode 100644 index 00000000..323dc065 --- /dev/null +++ b/internal/service/webui/dist/index.html @@ -0,0 +1,31 @@ + + + + + + + Moon Bridge Console + + + + + + + + +
    + + diff --git a/internal/service/webui/embed.go b/internal/service/webui/embed.go new file mode 100644 index 00000000..09b17a4e --- /dev/null +++ b/internal/service/webui/embed.go @@ -0,0 +1,70 @@ +package webui + +import ( + "embed" + "io/fs" + "mime" + "net/http" + "path" + "strings" +) + +//go:embed dist +var embedded embed.FS + +// Embedded returns the production console handler backed by embedded assets. +func Embedded() http.Handler { + dist, err := fs.Sub(embedded, "dist") + if err != nil { + panic(err) + } + return NewHandler(dist) +} + +// NewHandler serves a console SPA rooted at /console/. +func NewHandler(content fs.FS) http.Handler { + return &handler{content: content} +} + +type handler struct { + content fs.FS +} + +func (h *handler) ServeHTTP(writer http.ResponseWriter, request *http.Request) { + name := strings.TrimPrefix(request.URL.Path, "/console/") + name = strings.TrimPrefix(name, "/") + if name == "" { + h.serveFile(writer, request, "index.html") + return + } + + name = path.Clean(name) + if name == "." || strings.HasPrefix(name, "../") || !fs.ValidPath(name) { + http.NotFound(writer, request) + return + } + + if info, err := fs.Stat(h.content, name); err == nil && !info.IsDir() { + h.serveFile(writer, request, name) + return + } + + h.serveFile(writer, request, "index.html") +} + +func (h *handler) serveFile(writer http.ResponseWriter, request *http.Request, name string) { + data, err := fs.ReadFile(h.content, name) + if err != nil { + http.NotFound(writer, request) + return + } + if contentType := mime.TypeByExtension(path.Ext(name)); contentType != "" { + writer.Header().Set("Content-Type", contentType) + } else { + writer.Header().Set("Content-Type", http.DetectContentType(data)) + } + if request.Method == http.MethodHead { + return + } + _, _ = writer.Write(data) +} diff --git a/internal/service/webui/embed_test.go b/internal/service/webui/embed_test.go new file mode 100644 index 00000000..50a0f69d --- /dev/null +++ b/internal/service/webui/embed_test.go @@ -0,0 +1,158 @@ +package webui_test + +import ( + "io/fs" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "testing" + "testing/fstest" + + "moonbridge/internal/service/webui" +) + +func TestHandlerServesConsoleIndex(t *testing.T) { + handler := webui.NewHandler(testFS()) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/console/", nil) + handler.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) + } + if body := recorder.Body.String(); !strings.Contains(body, `
    `) { + t.Fatalf("body does not contain index marker: %s", body) + } + if contentType := recorder.Header().Get("Content-Type"); !strings.Contains(contentType, "text/html") { + t.Fatalf("Content-Type = %q, want text/html", contentType) + } +} + +func TestHandlerFallsBackToIndexForClientRoute(t *testing.T) { + handler := webui.NewHandler(testFS()) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/console/providers/openai", nil) + handler.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) + } + if body := recorder.Body.String(); !strings.Contains(body, "Moon Bridge Console") { + t.Fatalf("body does not contain index title: %s", body) + } +} + +func TestHandlerServesStaticAsset(t *testing.T) { + handler := webui.NewHandler(testFS()) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/console/assets/app.js", nil) + handler.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) + } + if body := recorder.Body.String(); body != `console.log("console asset");` { + t.Fatalf("body = %q", body) + } + if contentType := recorder.Header().Get("Content-Type"); !strings.Contains(contentType, "javascript") { + t.Fatalf("Content-Type = %q, want javascript", contentType) + } +} + +func TestEmbeddedReturnsHandler(t *testing.T) { + handler := webui.Embedded() + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/console/", nil) + handler.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) + } +} + +func TestEmbeddedIndexReferencesExistingAssets(t *testing.T) { + handler := webui.Embedded() + + index := httptest.NewRecorder() + handler.ServeHTTP(index, httptest.NewRequest(http.MethodGet, "/console/", nil)) + if index.Code != http.StatusOK { + t.Fatalf("index status = %d, body = %s", index.Code, index.Body.String()) + } + + scriptSrcs := regexp.MustCompile(`src="(/console/assets/[^"]+)"`).FindAllStringSubmatch(index.Body.String(), -1) + if len(scriptSrcs) == 0 { + t.Fatalf("index does not reference any console script asset: %s", index.Body.String()) + } + + for _, match := range scriptSrcs { + assetPath := match[1] + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, assetPath, nil)) + if recorder.Code != http.StatusOK { + t.Fatalf("asset %s status = %d, body = %s", assetPath, recorder.Code, recorder.Body.String()) + } + if contentType := recorder.Header().Get("Content-Type"); !strings.Contains(contentType, "javascript") { + t.Fatalf("asset %s Content-Type = %q, want javascript", assetPath, contentType) + } + if recorder.Body.Len() == 0 { + t.Fatalf("asset %s is empty", assetPath) + } + } +} + +func TestEmbeddedConsoleIncludesModelReasoningSupportSwitch(t *testing.T) { + scripts := embeddedScriptBodies(t) + combined := strings.Join(scripts, "\n") + + if !strings.Contains(combined, "supports_reasoning") { + t.Fatalf("embedded console scripts do not include supports_reasoning; run make webui-build to sync the bundled webui") + } + if !strings.Contains(combined, "Model reasoning support field") { + t.Fatalf("embedded console scripts do not include the model reasoning switch guard; run make webui-build to sync the bundled webui") + } +} + +func embeddedScriptBodies(t *testing.T) []string { + t.Helper() + + handler := webui.Embedded() + + index := httptest.NewRecorder() + handler.ServeHTTP(index, httptest.NewRequest(http.MethodGet, "/console/", nil)) + if index.Code != http.StatusOK { + t.Fatalf("index status = %d, body = %s", index.Code, index.Body.String()) + } + + scriptSrcs := regexp.MustCompile(`src="(/console/assets/[^"]+)"`).FindAllStringSubmatch(index.Body.String(), -1) + if len(scriptSrcs) == 0 { + t.Fatalf("index does not reference any console script asset: %s", index.Body.String()) + } + + scripts := make([]string, 0, len(scriptSrcs)) + for _, match := range scriptSrcs { + assetPath := match[1] + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, assetPath, nil)) + if recorder.Code != http.StatusOK { + t.Fatalf("asset %s status = %d, body = %s", assetPath, recorder.Code, recorder.Body.String()) + } + scripts = append(scripts, recorder.Body.String()) + } + return scripts +} + +func testFS() fs.FS { + return fstest.MapFS{ + "index.html": &fstest.MapFile{ + Data: []byte(`Moon Bridge Console
    `), + }, + "assets/app.js": &fstest.MapFile{ + Data: []byte(`console.log("console asset");`), + }, + } +} diff --git a/packaging/arch/.gitignore b/packaging/arch/.gitignore new file mode 100644 index 00000000..a516be71 --- /dev/null +++ b/packaging/arch/.gitignore @@ -0,0 +1,5 @@ +/*.pkg.tar* +/*.src.tar* +/*.tar.gz +/src/ +/pkg/ diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD new file mode 100644 index 00000000..0ddf284e --- /dev/null +++ b/packaging/arch/PKGBUILD @@ -0,0 +1,61 @@ +# Maintainer: Moon Bridge local package +pkgname=moon-bridge +pkgver=0.1.0 +pkgrel=5 +pkgdesc='Protocol conversion and model routing proxy for OpenAI Responses-compatible clients' +arch=('x86_64') +url='https://github.com/moon-bridge/moon-bridge' +license=('GPL-3.0-only') +depends=('glibc') +makedepends=('go' 'npm') +install="${pkgname}.install" +source=( + "${pkgname}-${pkgver}.tar.gz" + "config.local.yml" +) +sha256sums=('SKIP' 'SKIP') + +build() { + cd "${srcdir}/${pkgname}-${pkgver}" + + export CGO_ENABLED=0 + export GOCACHE="${srcdir}/go-build" + export GOMODCACHE="${srcdir}/go-mod" + export GOFLAGS="-modcacherw" + export npm_config_cache="${srcdir}/npm-cache" + export npm_config_legacy_peer_deps=true + + npm --prefix webui ci + npm --prefix webui run build + + rm -rf internal/service/webui/dist + mkdir -p internal/service/webui/dist + cp -R webui/dist/. internal/service/webui/dist/ + + go build -buildvcs=false -trimpath -ldflags="-s -w" -o moonbridge ./cmd/moonbridge +} + +check() { + cd "${srcdir}/${pkgname}-${pkgver}" + + export CGO_ENABLED=0 + export GOCACHE="${srcdir}/go-build" + export GOMODCACHE="${srcdir}/go-mod" + export GOFLAGS="-modcacherw" + export npm_config_cache="${srcdir}/npm-cache" + export npm_config_legacy_peer_deps=true + + npm --prefix webui test + go test ./... + ./moonbridge -config "${srcdir}/config.local.yml" -print-addr +} + +package() { + cd "${srcdir}/${pkgname}-${pkgver}" + + install -Dm755 moonbridge "${pkgdir}/usr/bin/moonbridge" + install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" + install -Dm644 README.md "${pkgdir}/usr/share/doc/${pkgname}/README.md" + install -Dm644 config.example.yml "${pkgdir}/usr/share/doc/${pkgname}/config.example.yml" + install -Dm644 "${srcdir}/config.local.yml" "${pkgdir}/usr/share/doc/${pkgname}/config.local.yml" +} diff --git a/packaging/arch/config.local.yml b/packaging/arch/config.local.yml new file mode 100644 index 00000000..e2aee512 --- /dev/null +++ b/packaging/arch/config.local.yml @@ -0,0 +1,49 @@ +mode: "Transform" + +log: + level: "info" + format: "text" + +server: + addr: "127.0.0.1:38440" + +persistence: + active_provider: db_sqlite + +extensions: + db_sqlite: + enabled: true + config: + path: ./data/moonbridge.db + wal: true + busy_timeout_ms: 5000 + max_open_conns: 1 + +cache: + mode: "off" + +defaults: + model: "moonbridge" + max_tokens: 1024 + +models: + local-test-model: + context_window: 128000 + max_output_tokens: 4096 + display_name: "Local Test Model" + description: "Minimal local configuration placeholder. Replace provider credentials before sending real requests." + +providers: + local: + base_url: "https://api.example.invalid" + api_key: "replace-with-real-api-key" + protocol: "openai-chat" + user_agent: "moonbridge/1.0" + offers: + - model: local-test-model + upstream_name: "replace-with-upstream-model" + +routes: + moonbridge: + model: "replace-with-upstream-model" + provider: "local" diff --git a/packaging/arch/moon-bridge.install b/packaging/arch/moon-bridge.install new file mode 100644 index 00000000..76fc6c92 --- /dev/null +++ b/packaging/arch/moon-bridge.install @@ -0,0 +1,26 @@ +post_install() { + cat <<'EOF' +Moon Bridge has been installed as /usr/bin/moonbridge. + +Default config path: + ~/moonbridge/config.yml + +Start Moon Bridge after installation: + moonbridge + +Open Web Console: + http://127.0.0.1:38440/console/ + +On first run without an existing default config, Moon Bridge creates: + ~/moonbridge/config.yml + +Example configs remain available under: + /usr/share/doc/moon-bridge/ + +Replace provider, model, and API key placeholders before sending real upstream requests. +EOF +} + +post_upgrade() { + post_install +} diff --git a/webui/index.html b/webui/index.html new file mode 100644 index 00000000..c60ccfd6 --- /dev/null +++ b/webui/index.html @@ -0,0 +1,31 @@ + + + + + + + Moon Bridge Console + + + + + + + +
    + + + diff --git a/webui/package-lock.json b/webui/package-lock.json new file mode 100644 index 00000000..d2b95804 --- /dev/null +++ b/webui/package-lock.json @@ -0,0 +1,3921 @@ +{ + "name": "@moonbridge/console", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@moonbridge/console", + "version": "0.1.0", + "dependencies": { + "@lobehub/icons": "^5.10.0", + "@material/web": "^2.4.0", + "@tanstack/react-query": "^5.83.0", + "@tanstack/react-table": "^8.21.3", + "motion": "^12.23.6", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router-dom": "^7.7.0", + "yaml": "^2.8.0" + }, + "devDependencies": { + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^24.0.15", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.6.0", + "jsdom": "^26.1.0", + "typescript": "^5.8.3", + "vite": "^7.0.5", + "vitest": "^3.2.4" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ant-design/cssinjs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-2.1.2.tgz", + "integrity": "sha512-2Hy8BnCEH31xPeSLbhhB2ctCPXE2ZnASdi+KbSeS79BNbUhL9hAEe20SkUk+BR8aKTmqb6+FKFruk7w8z0VoRQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@emotion/hash": "^0.8.0", + "@emotion/unitless": "^0.7.5", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "stylis": "^4.3.4" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/@emotion/babel-plugin/node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/cache/node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, + "node_modules/@emotion/css": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/css/-/css-11.13.5.tgz", + "integrity": "sha512-wQdD0Xhkn3Qy2VNcIzbLP9MR8TafI0MJb7BEAXKp+w4+XqErksWR4OXomuDzPsN4InLdGhVe6EYcn2ZIUCpB8w==", + "license": "MIT", + "dependencies": { + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/serialize/node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/serialize/node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lit-labs/ssr-dom-shim": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.6.0.tgz", + "integrity": "sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@lit/reactive-element": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.2.tgz", + "integrity": "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.5.0" + } + }, + "node_modules/@lobehub/icons": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@lobehub/icons/-/icons-5.10.0.tgz", + "integrity": "sha512-CIpjkISCLRK7haDtSugGFd0o3odaJts8ewJOkUiEFtns3xvsqbl8i24eowBnjw+yMDQVQyNONlhqTD58YC6Ljg==", + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "antd-style": "^4.1.0", + "es-toolkit": "^1.45.1", + "lucide-react": "^0.469.0", + "polished": "^4.3.1" + }, + "peerDependencies": { + "@lobehub/ui": "^5.0.0", + "antd": "^6.1.1", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, + "node_modules/@material/web": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@material/web/-/web-2.4.1.tgz", + "integrity": "sha512-0sk9t25acJ72Qv3r0n9r0lgDbPaAKnpm0p+QmEAAwYyZomHxuVbgrrAdtNXaRm7jFyGh+WsTr8bhtvCnpPRFjw==", + "license": "Apache-2.0", + "workspaces": [ + "catalog" + ], + "dependencies": { + "lit": "^2.8.0 || ^3.0.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@rc-component/util": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@rc-component/util/-/util-1.11.1.tgz", + "integrity": "sha512-awVlI3ub2vqfqkYxOBc/uQ0efm3jw0wcrhtO/YWLyZfxiKXczKwNbVuhlnyxytDt7H9pbbVQiqr+O6MLATtRYg==", + "license": "MIT", + "dependencies": { + "is-mobile": "^5.0.0", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.100.14", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.14.tgz", + "integrity": "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.100.14", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.14.tgz", + "integrity": "sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.100.14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", + "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", + "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/antd-style": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/antd-style/-/antd-style-4.1.0.tgz", + "integrity": "sha512-vnPBGg0OVlSz90KRYZhxd89aZiOImTiesF+9MQqN8jsLGZUQTjbP04X9jTdEfsztKUuMbBWg/RmB/wHTakbtMQ==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^2.0.0", + "@babel/runtime": "^7.24.1", + "@emotion/cache": "^11.11.0", + "@emotion/css": "^11.11.2", + "@emotion/react": "^11.11.4", + "@emotion/serialize": "^1.1.3", + "@emotion/utils": "^1.2.1", + "use-merge-value": "^1.2.0" + }, + "peerDependencies": { + "antd": ">=6.0.0", + "react": ">=18" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", + "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.361", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", + "integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-toolkit": { + "version": "1.47.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.0.tgz", + "integrity": "sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, + "node_modules/framer-motion": { + "version": "12.40.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.40.0.tgz", + "integrity": "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.40.0", + "motion-utils": "^12.39.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-mobile": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-5.0.0.tgz", + "integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==", + "license": "MIT" + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/lit": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.3.tgz", + "integrity": "sha512-fycuvZg/hkpozL00lm1pEJH5nN/lr9ZXd6mJI2HSN4+Bzc+LDNdEApJ6HFbPkdFNHLvOplIIuJvxkS4XUxqirw==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit/reactive-element": "^2.1.0", + "lit-element": "^4.2.0", + "lit-html": "^3.3.0" + } + }, + "node_modules/lit-element": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.2.tgz", + "integrity": "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.5.0", + "@lit/reactive-element": "^2.1.0", + "lit-html": "^3.3.0" + } + }, + "node_modules/lit-html": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.3.tgz", + "integrity": "sha512-el8M6jK2o3RXBnrSHX3ZKrsN8zEV63pSExTO1wYJz7QndGYZ8353e2a5PPX+qHe2aGayfnchQmkAojaWAREOIA==", + "license": "BSD-3-Clause", + "dependencies": { + "@types/trusted-types": "^2.0.2" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.469.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.469.0.tgz", + "integrity": "sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/motion": { + "version": "12.40.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.40.0.tgz", + "integrity": "sha512-yjrHUrBFW6kQvjJwRsoiPSAhC5tRwRqNGJWmiJ4CrGnbKp0V88AdzkhBmDoqIsIPfarOe0Uddd37Xq43/gIocA==", + "license": "MIT", + "dependencies": { + "framer-motion": "^12.40.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "12.40.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz", + "integrity": "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.39.0" + } + }, + "node_modules/motion-utils": { + "version": "12.39.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", + "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/polished": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz", + "integrity": "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.15.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz", + "integrity": "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.15.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.1.tgz", + "integrity": "sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==", + "license": "MIT", + "dependencies": { + "react-router": "7.15.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-merge-value": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-merge-value/-/use-merge-value-1.2.0.tgz", + "integrity": "sha512-DXgG0kkgJN45TcyoXL49vJnn55LehnrmoHc7MbKi+QDBvr8dsesqws8UlyIWGHMR+JXgxc1nvY+jDGMlycsUcw==", + "license": "MIT", + "peerDependencies": { + "react": ">= 16.x" + } + }, + "node_modules/vite": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", + "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 00000000..2c0c5650 --- /dev/null +++ b/webui/package.json @@ -0,0 +1,39 @@ +{ + "name": "@moonbridge/console", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "build": "tsc -p tsconfig.json --noEmit && vite build", + "preview": "vite preview --host 127.0.0.1", + "test": "vitest run", + "test:watch": "vitest", + "e2e": "vitest run --config vite.config.ts --dir src/e2e" + }, + "dependencies": { + "@lobehub/icons": "^5.10.0", + "@material/web": "^2.4.0", + "@tanstack/react-query": "^5.83.0", + "@tanstack/react-table": "^8.21.3", + "motion": "^12.23.6", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router-dom": "^7.7.0", + "yaml": "^2.8.0" + }, + "devDependencies": { + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^24.0.15", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.6.0", + "jsdom": "^26.1.0", + "typescript": "^5.8.3", + "vite": "^7.0.5", + "vitest": "^3.2.4" + } +} diff --git a/webui/src/app/App.test.tsx b/webui/src/app/App.test.tsx new file mode 100644 index 00000000..7f1fe5c6 --- /dev/null +++ b/webui/src/app/App.test.tsx @@ -0,0 +1,193 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { MemoryRouter } from "react-router-dom"; +import { renderWithConsoleProviders } from "../test/renderWithConsoleProviders"; +import { expectPanelElementToBeFlat, expectPanelRuleToAvoidEdges } from "../test/panelStyleAssertions"; +import { AppShell } from "./App"; + +describe("AppShell", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("shows the config graph navigation surface without staged apply", () => { + renderWithConsoleProviders( + + + + ); + + const labels = Array.from( + document.querySelectorAll(".navigation-rail a") + ).map((link) => link.querySelector(".nav-item__label")?.textContent); + + expect(labels).toEqual([ + "Overview", + "Models & Providers", + "Routes", + "Defaults", + "Search & Tools", + "Storage", + "Security" + ]); + expect(document.querySelector(".navigation-rail")?.textContent).not.toContain("Config"); + expect(document.querySelector(".navigation-rail")?.textContent).not.toContain("RPC Test"); + expect(document.querySelector(".navigation-rail")?.textContent).not.toContain("Extensions"); + expect(screen.queryByRole("button", { name: /^apply$/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("dialog", { name: /apply changes/i })).not.toBeInTheDocument(); + }); + + test("keeps shell actions limited to locale and theme controls", () => { + renderWithConsoleProviders( + + Console content} /> + + ); + + expect(screen.getByLabelText(/language/i)).toBeInTheDocument(); + expect(getMaterialIconButton(document, "Switch to light theme")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /apply/i })).not.toBeInTheDocument(); + }); + + test("uses Material Web locale actions instead of a native browser select", () => { + renderWithConsoleProviders( + + Console content} /> + + ); + + expect(document.querySelector(".top-app-bar__meta select")).not.toBeInTheDocument(); + expect(screen.getByRole("group", { name: /language/i })).toBeInTheDocument(); + expect(getMaterialButton(document, "English", "filled")).toHaveAttribute("aria-pressed", "true"); + expect(getMaterialButton(document, "中文", "outlined")).toHaveAttribute("aria-pressed", "false"); + }); + + test("keeps global filled button icon colors aligned with label colors", () => { + renderWithConsoleProviders( + + Console content} /> + + ); + + const selectedLocaleButton = getMaterialButton(document, "English", "filled"); + expectMaterialFilledButtonContentColors(selectedLocaleButton, "var(--mb-color-on-primary)"); + }); + + test("changes locale through Material Web locale actions", () => { + renderWithConsoleProviders( + + Console content} /> + + ); + + fireEvent.click(getMaterialButton(document, "中文", "outlined")); + + expect(screen.getByRole("navigation", { name: "控制台分区" })).toBeInTheDocument(); + expect(getMaterialButton(document, "English", "outlined")).toHaveAttribute("aria-pressed", "false"); + expect(getMaterialButton(document, "中文", "filled")).toHaveAttribute("aria-pressed", "true"); + }); + + test("changes theme through the Material Web icon button", () => { + renderWithConsoleProviders( + + Console content} /> + + ); + + const themeButton = getMaterialIconButton(document, "Switch to light theme"); + expect(themeButton.tagName.toLowerCase()).toBe("md-icon-button"); + expect(document.documentElement).toHaveAttribute("data-theme", "dark"); + + fireEvent.click(themeButton); + + expect(document.documentElement).toHaveAttribute("data-theme", "light"); + expect(getMaterialIconButton(document, "Switch to dark theme")).toBeInTheDocument(); + }); + + test("keeps route content in a named main landmark with mobile-safe nav labels", () => { + renderWithConsoleProviders( + + Console content} /> + + ); + + expect(screen.getByRole("main", { name: "Console route content" })).toHaveTextContent("Console content"); + expect(screen.getByRole("link", { name: /models & providers/i })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /search & tools/i })).toBeInTheDocument(); + expect(screen.getByRole("navigation", { name: /console sections/i })).not.toHaveTextContent("YAML"); + expect(screen.getByRole("navigation", { name: /console sections/i })).not.toHaveTextContent("Diagnostics"); + expect(screen.getByRole("navigation", { name: /console sections/i })).not.toHaveTextContent("Logs"); + }); + + test("gives shell background panels tonal surfaces without borders or glow", () => { + renderWithConsoleProviders( + + +
    Console content
    +
    Placeholder content
    + + )} + /> +
    + ); + + const shellStyle = document.querySelector("style")?.textContent ?? ""; + const railRule = shellStyle.match(/\.navigation-rail \{[^}]+\}/)?.[0] ?? ""; + const rail = document.querySelector(".navigation-rail")!; + const contentPanel = document.querySelector(".content-panel")!; + const placeholderPanel = document.querySelector(".placeholder-panel")!; + + expect(railRule).toContain("background: var(--mb-color-surface-container-low)"); + expect(railRule).not.toContain("box-shadow"); + expect(railRule).not.toContain("background: transparent"); + for (const panel of [rail, contentPanel, placeholderPanel]) { + expectPanelElementToBeFlat(panel); + } + expectPanelRuleToAvoidEdges(".navigation-rail"); + expectPanelRuleToAvoidEdges(".content-panel"); + expectPanelRuleToAvoidEdges(".placeholder-panel"); + }); +}); + +function getMaterialButton( + container: ParentNode, + label: string, + variant: "filled" | "outlined" +) { + const tagName = variant === "filled" ? "md-filled-button" : "md-outlined-button"; + const element = Array.from(container.querySelectorAll(tagName)).find( + (button) => button.textContent?.trim() === label + ); + if (!element) { + throw new Error(`Expected a Material Web ${variant} button labelled "${label}".`); + } + return element as HTMLElement; +} + +function getMaterialIconButton(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-icon-button")).find( + (button) => button.getAttribute("aria-label") === label + ); + if (!element) { + throw new Error(`Expected a Material Web icon button labelled "${label}".`); + } + return element as HTMLElement; +} + +function expectMaterialFilledButtonContentColors(button: HTMLElement, colorToken: string) { + expect(button.tagName.toLowerCase()).toBe("md-filled-button"); + for (const property of [ + "--md-filled-button-label-text-color", + "--md-filled-button-hover-label-text-color", + "--md-filled-button-focus-label-text-color", + "--md-filled-button-pressed-label-text-color", + "--md-filled-button-icon-color", + "--md-filled-button-hover-icon-color", + "--md-filled-button-focus-icon-color", + "--md-filled-button-pressed-icon-color" + ]) { + expect(getComputedStyle(button).getPropertyValue(property).trim()).toBe(colorToken); + } +} diff --git a/webui/src/app/App.tsx b/webui/src/app/App.tsx new file mode 100644 index 00000000..d41d8def --- /dev/null +++ b/webui/src/app/App.tsx @@ -0,0 +1,170 @@ +import "@material/web/icon/icon.js"; +import "@material/web/ripple/ripple.js"; +import { createElement, type ReactNode } from "react"; +import { NavLink, Outlet } from "react-router-dom"; +import { motion } from "motion/react"; +import { MaterialFilledButton, MaterialIconButton, MaterialOutlinedButton } from "../components/MaterialButton"; +import { type Locale, type MessageKey } from "../i18n/messages"; +import { useI18n } from "../i18n/I18nProvider"; +import { useConsoleTheme } from "../theme/ThemeProvider"; +import { pageMotion, springs } from "../theme/motion"; +import { shellStyles } from "./styles/shellStyles"; +import { ConsoleAuthGate } from "./auth/ConsoleAuthGate"; +import { useConsoleAuth } from "./auth/ConsoleAuthContext"; + +const navItems = [ + { to: "/overview", icon: "dashboard", labelKey: "nav.overview" }, + { to: "/models-providers", icon: "hub", labelKey: "nav.modelsProviders" }, + { to: "/routes", icon: "alt_route", labelKey: "nav.routes" }, + { to: "/defaults", icon: "rule_settings", labelKey: "nav.defaults" }, + { to: "/search-tools", icon: "travel_explore", labelKey: "nav.searchTools" }, + { to: "/storage", icon: "database", labelKey: "nav.storage" }, + { to: "/security", icon: "shield", labelKey: "nav.security" } +] as const; + +type NavItem = (typeof navItems)[number]; + +export function App() { + // shellStyles (incl. base tokens + .auth-card) is injected here — not in + // AppShell — so the login card is fully styled even while the shell is + // unmounted behind ConsoleAuthGate. + return ( + <> + + + } /> + + + ); +} + +export function AppShell({ content }: { content?: ReactNode }) { + return ; +} + +function AppShellContent({ content }: { content?: ReactNode }) { + const { theme, toggleTheme } = useConsoleTheme(); + const { locale, setLocale, t } = useI18n(); + const { signOut } = useConsoleAuth(); + const nextTheme = theme === "dark" ? "light" : "dark"; + const themeIcon = theme === "dark" ? "light_mode" : "dark_mode"; + const nextThemeLabel = t(nextTheme === "dark" ? "theme.dark" : "theme.light"); + + return ( +
    +
    +
    +

    Moon Bridge

    + {t("app.console")} +
    +
    +
    + {t("app.language")} + {(["en-US", "zh-CN"] as const).map((nextLocale) => ( + setLocale(nextLocale)} + selected={locale === nextLocale} + /> + ))} +
    + + + + + + +
    +
    + +
    + + + + {content ?? } + +
    +
    + ); +} + +function LocaleButton({ + label, + onClick, + selected +}: { + label: string; + onClick: () => void; + selected: boolean; +}) { + if (selected) { + return ( + + {label} + + ); + } + return ( + + {label} + + ); +} + +function NavRailItem({ item, label }: { item: NavItem; label: string }) { + return ( + (isActive ? "nav-item nav-item--active" : "nav-item")} + > + {({ isActive }) => ( + <> + + {isActive ? ( + + {label} + + )} + + ); +} diff --git a/webui/src/app/PlaceholderPage.tsx b/webui/src/app/PlaceholderPage.tsx new file mode 100644 index 00000000..9fa7de65 --- /dev/null +++ b/webui/src/app/PlaceholderPage.tsx @@ -0,0 +1,15 @@ +import { useI18n } from "../i18n/I18nProvider"; + +export function PlaceholderPage({ title }: { title: string }) { + const { t } = useI18n(); + + return ( +
    +
    +

    {t("placeholder.eyebrow")}

    +

    {title}

    +

    {t("placeholder.description")}

    +
    +
    + ); +} diff --git a/webui/src/app/auth/ConsoleAuthContext.test.tsx b/webui/src/app/auth/ConsoleAuthContext.test.tsx new file mode 100644 index 00000000..d70d3b59 --- /dev/null +++ b/webui/src/app/auth/ConsoleAuthContext.test.tsx @@ -0,0 +1,143 @@ +import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { apiFetch, clearStoredToken, TOKEN_STORAGE_KEY } from "../../rpc/http"; +import { CONSOLE_LOCALE_STORAGE_KEY, I18nProvider } from "../../i18n/I18nProvider"; +import { ThemeProvider } from "../../theme/ThemeProvider"; +import { ConsoleAuthProvider, useConsoleAuth } from "./ConsoleAuthContext"; + +function jsonResponse(body: unknown, init?: ResponseInit) { + return new Response(JSON.stringify(body), { + headers: { "Content-Type": "application/json" }, + ...init + }); +} + +// Returns 200 for `Authorization: Bearer good`, otherwise a 401 — mirroring the server. +function authFetchMock() { + return vi.spyOn(globalThis, "fetch").mockImplementation((input, init) => { + const headers = new Headers(init?.headers); + if (headers.get("Authorization") === "Bearer good") { + return Promise.resolve(jsonResponse({ ok: true })); + } + return Promise.resolve( + jsonResponse( + { error: { code: "invalid_auth", message: "missing or invalid token" } }, + { status: 401 } + ) + ); + }); +} + +function Consumer() { + const auth = useConsoleAuth(); + useQuery({ + queryKey: ["status"], + queryFn: () => apiFetch<{ ok: boolean }>("/status") + }); + return ( + <> + {String(auth.required)} + {String(auth.pending)} + {auth.error?.message ?? ""} + + + + + ); +} + +function renderHarness() { + localStorage.setItem(CONSOLE_LOCALE_STORAGE_KEY, "en-US"); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + + + + + + + ); +} + +const requiredEl = () => screen.getByTestId("required"); +const pendingEl = () => screen.getByTestId("pending"); +const errorEl = () => screen.getByTestId("error"); + +describe("ConsoleAuthProvider", () => { + afterEach(() => { + vi.restoreAllMocks(); + clearStoredToken(); + sessionStorage.clear(); + localStorage.clear(); + }); + + test("locks the console and surfaces the message when a query returns 401", async () => { + authFetchMock(); + renderHarness(); + + await waitFor(() => expect(requiredEl()).toHaveTextContent("true")); + expect(errorEl()).toHaveTextContent("missing or invalid token"); + }); + + test("authenticate opens the gate after verifying a valid token", async () => { + const fetchMock = authFetchMock(); + renderHarness(); + + await waitFor(() => expect(requiredEl()).toHaveTextContent("true")); + + fireEvent.click(screen.getByText("auth-good")); + + await waitFor(() => expect(requiredEl()).toHaveTextContent("false")); + expect(pendingEl()).toHaveTextContent("false"); + + const sentGood = fetchMock.mock.calls.some(([, init]) => { + const headers = new Headers(init?.headers); + return headers.get("Authorization") === "Bearer good"; + }); + expect(sentGood).toBe(true); + }); + + test("authenticate keeps the gate locked and updates the message for a wrong token", async () => { + authFetchMock(); + renderHarness(); + + await waitFor(() => expect(requiredEl()).toHaveTextContent("true")); + + fireEvent.click(screen.getByText("auth-bad")); + + await waitFor(() => expect(pendingEl()).toHaveTextContent("false")); + expect(requiredEl()).toHaveTextContent("true"); + expect(errorEl()).toHaveTextContent("missing or invalid token"); + }); + + test("signOut clears the stored token and locks the console", async () => { + authFetchMock(); + renderHarness(); + + await waitFor(() => expect(requiredEl()).toHaveTextContent("true")); + fireEvent.click(screen.getByText("auth-good")); + await waitFor(() => expect(requiredEl()).toHaveTextContent("false")); + expect(sessionStorage.getItem(TOKEN_STORAGE_KEY)).toBe("good"); + + fireEvent.click(screen.getByText("sign-out")); + + await waitFor(() => expect(requiredEl()).toHaveTextContent("true")); + expect(sessionStorage.getItem(TOKEN_STORAGE_KEY)).toBeNull(); + expect(errorEl()).toHaveTextContent("Signed out"); + }); + + test("useConsoleAuth throws when used outside the provider", () => { + // Silence the expected error from React/error logging. + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + function Orphan() { + useConsoleAuth(); + return null; + } + expect(() => render()).toThrow(/ConsoleAuthProvider/); + spy.mockRestore(); + }); +}); diff --git a/webui/src/app/auth/ConsoleAuthContext.tsx b/webui/src/app/auth/ConsoleAuthContext.tsx new file mode 100644 index 00000000..9e875ca6 --- /dev/null +++ b/webui/src/app/auth/ConsoleAuthContext.tsx @@ -0,0 +1,120 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode +} from "react"; +import { + ApiError, + apiFetch, + clearStoredToken, + isAuthError, + saveToken +} from "../../rpc/http"; +import { useI18n } from "../../i18n/I18nProvider"; + +type ConsoleAuthState = { + /** True while the console is locked behind the login card. */ + required: boolean; + /** Latest 401 error to surface as the card message, when locked. */ + error: ApiError | undefined; + /** True while a submitted token is being verified via refetch. */ + pending: boolean; + /** Persist a candidate token, verify it by refetching, and open the gate on success. */ + authenticate: (token: string, remember: boolean) => Promise; + /** Clear any stored token and lock the console again. */ + signOut: () => void; +}; + +const ConsoleAuthContext = createContext(null); + +export function ConsoleAuthProvider({ children }: { children: ReactNode }) { + const queryClient = useQueryClient(); + const { t } = useI18n(); + const [state, setState] = useState<{ + required: boolean; + error: ApiError | undefined; + pending: boolean; + }>({ required: false, error: undefined, pending: false }); + + // Any query that settles with a 401 locks the console. This also re-arms the + // gate when the server's token is rotated while the console is in use. + useEffect(() => { + const cache = queryClient.getQueryCache(); + const unsubscribe = cache.subscribe((event) => { + // Only react to actual query state changes. react-query also emits + // observerOptionsUpdated/observerResultsUpdated on every useQuery render; + // acting on those while a query sits in a 401 error state would re-render + // in a loop (setState -> render -> setOptions -> notify -> setState ...). + if (event.type !== "updated") { + return; + } + const query = event.query; + // Ignore mid-refetch notifies: react-query keeps the previous error + // visible while fetching, and acting on that stale error would re-lock the + // console the moment a valid token lets the app remount and refetch. + if (query.state.fetchStatus === "fetching") { + return; + } + const error = query.state.error; + if (isAuthError(error)) { + setState((prev) => ({ ...prev, required: true, error, pending: false })); + } + }); + return unsubscribe; + }, [queryClient]); + + const authenticate = useCallback( + async (token: string, remember: boolean) => { + saveToken(token, remember); + setState((prev) => ({ ...prev, pending: true })); + try { + // Verify the token directly against an authenticated endpoint. While the + // console is locked the app shell is unmounted, so its queries are inactive + // and we can't rely on refetching them — a direct probe is deterministic. + await apiFetch("/status"); + // Success — open the gate; pages remount and refetch fresh data. + setState((prev) => ({ ...prev, pending: false, required: false, error: undefined })); + queryClient.invalidateQueries(); + } catch (error) { + setState((prev) => ({ ...prev, pending: false })); + if (isAuthError(error)) { + // Token rejected — keep the gate locked with the server's message. + setState((prev) => ({ ...prev, required: true, error })); + } + // Non-auth errors (network, 5xx): leave the gate as-is without opening. + } + }, + [queryClient] + ); + + const signOut = useCallback(() => { + clearStoredToken(); + setState({ + required: true, + pending: false, + error: new ApiError(401, "signed_out", t("auth.signedOut")) + }); + // No invalidate here: while locked the app shell is unmounted so no queries + // are active, and `authenticate` refetches everything on the next login. + }, [t]); + + const value = useMemo( + () => ({ ...state, authenticate, signOut }), + [state, authenticate, signOut] + ); + + return {children}; +} + +export function useConsoleAuth(): ConsoleAuthState { + const context = useContext(ConsoleAuthContext); + if (!context) { + throw new Error("useConsoleAuth must be used within a ConsoleAuthProvider."); + } + return context; +} diff --git a/webui/src/app/auth/ConsoleAuthGate.test.tsx b/webui/src/app/auth/ConsoleAuthGate.test.tsx new file mode 100644 index 00000000..5e619d61 --- /dev/null +++ b/webui/src/app/auth/ConsoleAuthGate.test.tsx @@ -0,0 +1,61 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { CONSOLE_LOCALE_STORAGE_KEY, I18nProvider } from "../../i18n/I18nProvider"; +import { ThemeProvider } from "../../theme/ThemeProvider"; +import { ConsoleAuthProvider, useConsoleAuth } from "./ConsoleAuthContext"; +import { ConsoleAuthGate } from "./ConsoleAuthGate"; + +function Harness() { + const auth = useConsoleAuth(); + return ( + + App content + + + ); +} + +function renderHarness() { + localStorage.setItem(CONSOLE_LOCALE_STORAGE_KEY, "en-US"); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + + + + + + + ); +} + +describe("ConsoleAuthGate", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("renders the app when the console is unlocked", () => { + renderHarness(); + + expect(screen.getByText("App content")).toBeInTheDocument(); + expect(document.querySelector("md-filled-text-field")).not.toBeInTheDocument(); + }); + + test("replaces the app with the Material login card when locked", () => { + renderHarness(); + + fireEvent.click(screen.getByText("lock")); + + expect(screen.queryByText("App content")).not.toBeInTheDocument(); + const card = document.querySelector(".auth-card"); + expect(card).toBeInTheDocument(); + // Real Material Web controls drive the login (skill requirement). + expect(card?.querySelector("md-outlined-text-field")).toBeInTheDocument(); + expect(card?.querySelector("md-filled-button")).toBeInTheDocument(); + }); +}); diff --git a/webui/src/app/auth/ConsoleAuthGate.tsx b/webui/src/app/auth/ConsoleAuthGate.tsx new file mode 100644 index 00000000..bcdffe04 --- /dev/null +++ b/webui/src/app/auth/ConsoleAuthGate.tsx @@ -0,0 +1,25 @@ +import { type ReactNode } from "react"; +import { AuthGate } from "../../components/AuthGate"; +import { useConsoleAuth } from "./ConsoleAuthContext"; + +type ConsoleAuthGateProps = { + children: ReactNode; +}; + +/** + * Replaces the whole app shell with the login card while the console is locked, + * and renders the app otherwise. Auth state comes from {@link useConsoleAuth}. + */ +export function ConsoleAuthGate({ children }: ConsoleAuthGateProps) { + const { required, error, pending, authenticate } = useConsoleAuth(); + + if (!required) { + return <>{children}; + } + + return ( + + {children} + + ); +} diff --git a/webui/src/app/auth/console-auth-integration.test.tsx b/webui/src/app/auth/console-auth-integration.test.tsx new file mode 100644 index 00000000..8e1a2859 --- /dev/null +++ b/webui/src/app/auth/console-auth-integration.test.tsx @@ -0,0 +1,119 @@ +import { useQuery } from "@tanstack/react-query"; +import { act, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { apiFetch, clearStoredToken } from "../../rpc/http"; +import { renderWithConsoleProviders } from "../../test/renderWithConsoleProviders"; +import { AppShell } from "../App"; +import { ConsoleAuthGate } from "./ConsoleAuthGate"; + +type MaterialTextFieldElement = HTMLElement & { label: string; value: string }; + +function jsonResponse(body: unknown, init?: ResponseInit) { + return new Response(JSON.stringify(body), { + headers: { "Content-Type": "application/json" }, + ...init + }); +} + +function StatusPage() { + const { data } = useQuery({ + queryKey: ["status"], + queryFn: () => apiFetch<{ ok: boolean }>("/status") + }); + return
    {data?.ok ? "status ok" : "status pending"}
    ; +} + +function getOutlinedTextField(label: string) { + const element = Array.from( + document.querySelectorAll("md-outlined-text-field") + ).find((candidate) => candidate.label === label); + if (!element) { + throw new Error(`Expected a Material Web text field labelled "${label}".`); + } + return element; +} + +function setTextFieldValue(element: MaterialTextFieldElement, value: string) { + act(() => { + element.value = value; + element.dispatchEvent(new InputEvent("input", { bubbles: true, composed: true })); + }); +} + +async function submitAuthCard() { + const button = Array.from(document.querySelectorAll("md-filled-button")).find( + (candidate) => candidate.textContent?.trim() === "Save token" + ); + if (!button) { + throw new Error("Expected the Save token button."); + } + const form = button.closest("form"); + if (!form) { + throw new Error("Expected the submit button inside the auth form."); + } + let submitted = false; + form.addEventListener("submit", () => { + submitted = true; + }, { once: true }); + await userEvent.click(button); + await new Promise((resolve) => setTimeout(resolve, 0)); + if (!submitted) { + await act(async () => { + form.requestSubmit(); + await Promise.resolve(); + }); + } +} + +describe("console auth integration", () => { + afterEach(() => { + vi.restoreAllMocks(); + clearStoredToken(); + sessionStorage.clear(); + localStorage.clear(); + }); + + test("a 401 locks the console, then a valid token unlocks it via a Bearer refetch", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation((input, init) => { + const headers = new Headers(init?.headers); + if (headers.get("Authorization") === "Bearer good") { + return Promise.resolve(jsonResponse({ ok: true })); + } + return Promise.resolve( + jsonResponse( + { error: { code: "invalid_auth", message: "missing or invalid token" } }, + { status: 401 } + ) + ); + }); + + renderWithConsoleProviders( + + + } /> + + + ); + + // Initial 401 locks the console and shows the Material login card. + await waitFor(() => expect(document.querySelector(".auth-card")).toBeInTheDocument()); + + // The Material text field's `.label` reflects once Lit upgrades the custom + // element, which happens slightly after the card mounts. + const tokenField = await waitFor(() => getOutlinedTextField("Token")); + setTextFieldValue(tokenField, "good"); + await submitAuthCard(); + + // Valid token unlocks; the page renders with verified data. + await waitFor(() => expect(document.querySelector(".auth-card")).not.toBeInTheDocument()); + await waitFor(() => expect(screen.getByText("status ok")).toBeInTheDocument()); + + const sentGood = fetchMock.mock.calls.some(([, init]) => { + const headers = new Headers(init?.headers); + return headers.get("Authorization") === "Bearer good"; + }); + expect(sentGood).toBe(true); + }); +}); diff --git a/webui/src/app/queryClient.ts b/webui/src/app/queryClient.ts new file mode 100644 index 00000000..f22e754a --- /dev/null +++ b/webui/src/app/queryClient.ts @@ -0,0 +1,11 @@ +import { QueryClient } from "@tanstack/react-query"; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + staleTime: 15_000, + refetchOnWindowFocus: false + } + } +}); diff --git a/webui/src/app/routes.tsx b/webui/src/app/routes.tsx new file mode 100644 index 00000000..8a3a749f --- /dev/null +++ b/webui/src/app/routes.tsx @@ -0,0 +1,38 @@ +import { Navigate, Outlet, createBrowserRouter } from "react-router-dom"; +import { App } from "./App"; +import type { MessageKey } from "../i18n/messages"; +import { useI18n } from "../i18n/I18nProvider"; +import { DefaultsPage } from "../features/defaults/DefaultsPage"; +import { ModelsProvidersPage } from "../features/modelProviders/ModelsProvidersPage"; +import { OverviewPage } from "../features/overview/OverviewPage"; +import { RoutesPage } from "../features/routes/RoutesPage"; +import { SearchToolsPage } from "../features/searchTools/SearchToolsPage"; +import { SecurityPage } from "../features/security/SecurityPage"; +import { StoragePage } from "../features/storage/StoragePage"; + +export function RouteOutlet() { + return ; +} + +export const routes = [ + { index: true, element: }, + { path: "overview", element: }, + { path: "models-providers", element: }, + { path: "routes", element: }, + { path: "defaults", element: }, + { path: "search-tools", element: }, + { path: "storage", element: }, + { path: "security", element: }, + { path: "logs", element: } +]; + +export const router = createBrowserRouter( + [ + { + path: "/", + element: , + children: routes + } + ], + { basename: "/console" } +); diff --git a/webui/src/app/styles/base.ts b/webui/src/app/styles/base.ts new file mode 100644 index 00000000..c9af7923 --- /dev/null +++ b/webui/src/app/styles/base.ts @@ -0,0 +1,402 @@ +export const baseStyles = ` + :root { + color-scheme: dark; + /* Reserve the scrollbar gutter so the layout (and centered dialogs) does not shift + when a scrollbar appears or disappears — keeps left/right spacing symmetric. */ + scrollbar-gutter: stable; + font-family: + "Roboto Flex", Inter, ui-sans-serif, system-ui, -apple-system, + BlinkMacSystemFont, "Segoe UI", sans-serif; + font-optical-sizing: auto; + background: var(--mb-color-surface); + color: var(--mb-color-on-surface); + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + + /* ---- M3 Expressive shape scale ---- */ + --mb-shape-xs: 8px; + --mb-shape-sm: 12px; + --mb-shape-md: 16px; + --mb-shape-lg: 20px; + --mb-shape-xl: 28px; + --mb-shape-2xl: 36px; + --mb-shape-full: 999px; + + /* Buttons use a uniform small-radius (square-ish) shape rather than pills. */ + --mb-button-shape: 8px; + + /* All panel/card/dialog backgrounds share one radius for a consistent surface language. */ + --mb-shape-panel: 20px; + + /* ---- Code/mono font ---- */ + --mb-font-mono: "Roboto Mono", "JetBrains Mono", "SFMono-Regular", Consolas, monospace; + + /* ---- M3 Expressive type scale ---- */ + --mb-type-display: 700 clamp(2rem, 1.4rem + 2.4vw, 2.9rem)/1.06 "Roboto Flex", Inter, system-ui, sans-serif; + --mb-tracking-display: -0.015em; + + /* ---- Content measure: caps the reading/editing width on wide and ultrawide screens so + content does not stretch into unreadable line lengths, while still filling common + 1440–1920px laptops. Section grids inside keep using auto-fit to fill the column. ---- */ + --mb-content-max: 1560px; + --mb-content-gutter: clamp(16px, 3.2vw, 44px); + + /* ---- M3 motion easings + durations ---- */ + --mb-ease-standard: cubic-bezier(0.2, 0, 0, 1); + --mb-ease-decelerate: cubic-bezier(0.05, 0.7, 0.1, 1); + --mb-ease-accelerate: cubic-bezier(0.3, 0, 0.8, 0.15); + --mb-ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); + --mb-duration-short: 140ms; + --mb-duration-medium: 240ms; + --mb-duration-long: 420ms; + --mb-motion-standard: var(--mb-duration-medium) var(--mb-ease-standard); + --mb-motion-emphasized: var(--mb-duration-long) var(--mb-ease-decelerate); + + /* ---- State-layer opacities (M3) ---- */ + --mb-state-hover: 0.08; + --mb-state-focus: 0.10; + --mb-state-press: 0.12; + + /* ---- Tonal elevation shadows for transient floating surfaces. App panels use tonal color instead. ---- */ + --mb-elevation-1: + 0 1px 2px color-mix(in srgb, var(--mb-color-shadow) 30%, transparent), + 0 1px 3px 1px color-mix(in srgb, var(--mb-color-shadow) 15%, transparent); + --mb-elevation-2: + 0 1px 2px color-mix(in srgb, var(--mb-color-shadow) 30%, transparent), + 0 2px 6px 2px color-mix(in srgb, var(--mb-color-shadow) 15%, transparent); + --mb-elevation-3: + 0 4px 8px 3px color-mix(in srgb, var(--mb-color-shadow) 15%, transparent), + 0 1px 3px color-mix(in srgb, var(--mb-color-shadow) 30%, transparent); + --mb-elevation-4: + 0 6px 10px 4px color-mix(in srgb, var(--mb-color-shadow) 15%, transparent), + 0 2px 3px color-mix(in srgb, var(--mb-color-shadow) 30%, transparent); + --mb-elevation-5: + 0 8px 12px 6px color-mix(in srgb, var(--mb-color-shadow) 15%, transparent), + 0 4px 4px color-mix(in srgb, var(--mb-color-shadow) 30%, transparent); + + /* Material Symbols default to an outlined, unfilled glyph. */ + --md-icon-font: "Material Symbols Rounded"; + --md-icon-size: 24px; + } + + :root[data-theme="light"] { + color-scheme: light; + } + + * { + box-sizing: border-box; + } + + body { + margin: 0; + min-width: 320px; + min-height: 100vh; + background: var(--mb-color-surface); + } + + md-icon, + .material-symbol { + font-variation-settings: "FILL" 0, "wght" 400, "GRAD" 0, "opsz" 24; + transition: font-variation-settings var(--mb-duration-medium) var(--mb-ease-standard); + } + + /* Filled icon variant for active/selected expressive states. */ + .nav-item--active md-icon, + .icon--filled { + font-variation-settings: "FILL" 1, "wght" 500, "GRAD" 0, "opsz" 24; + } + + ::selection { + background: color-mix(in srgb, var(--mb-color-primary) 32%, transparent); + color: var(--mb-color-on-surface); + } + + :focus-visible { + outline: none; + } + + /* Themed, slim scrollbars to avoid heavy native chrome. */ + * { + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--mb-color-outline) 55%, transparent) transparent; + } + *::-webkit-scrollbar { + width: 10px; + height: 10px; + } + *::-webkit-scrollbar-thumb { + border: 3px solid transparent; + border-radius: var(--mb-shape-full); + background: color-mix(in srgb, var(--mb-color-outline) 50%, transparent); + background-clip: padding-box; + } + *::-webkit-scrollbar-thumb:hover { + background: color-mix(in srgb, var(--mb-color-outline) 80%, transparent); + background-clip: padding-box; + } + *::-webkit-scrollbar-corner { + background: transparent; + } + + @keyframes mb-spin { + to { transform: rotate(360deg); } + } + @keyframes mb-shimmer { + 0% { background-position: -480px 0; } + 100% { background-position: 480px 0; } + } + @keyframes mb-pulse-ring { + 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--mb-color-primary) 45%, transparent); } + 70% { box-shadow: 0 0 0 7px color-mix(in srgb, var(--mb-color-primary) 0%, transparent); } + 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--mb-color-primary) 0%, transparent); } + } + @keyframes mb-pop-in { + 0% { opacity: 0; transform: scale(0.8); } + 60% { opacity: 1; transform: scale(1.06); } + 100% { opacity: 1; transform: scale(1); } + } + + .app-shell { + min-height: 100vh; + background: + radial-gradient(1200px 420px at 12% -8%, color-mix(in srgb, var(--mb-color-primary) 14%, transparent), transparent 70%), + radial-gradient(900px 360px at 100% -4%, color-mix(in srgb, var(--mb-color-tertiary) 10%, transparent), transparent 72%), + var(--mb-color-surface); + } + + .auth-gate { + min-height: 100vh; + display: grid; + place-items: center; + padding: 24px; + background: + radial-gradient(900px 500px at 50% -12%, color-mix(in srgb, var(--mb-color-primary) 20%, transparent), transparent 70%), + var(--mb-color-surface); + } + + .auth-card { + width: min(420px, 100%); + display: grid; + gap: 14px; + border-radius: var(--mb-shape-panel); + outline: 0; + padding: 32px; + background: var(--mb-color-surface-container-high); + } + + .auth-card__badge { + width: 56px; + height: 56px; + display: grid; + place-items: center; + border-radius: var(--mb-shape-lg); + color: var(--mb-color-on-primary-container); + background: var(--mb-color-primary-container); + --md-icon-size: 30px; + } + + .auth-card h1 { + margin: 0; + font-size: 1.6rem; + line-height: 1.15; + } + + .auth-card__message { + margin: 0; + color: var(--mb-color-on-surface-variant); + font-size: 0.9rem; + line-height: 1.5; + } + + .auth-token-field { + width: 100%; + } + + /* Smaller (~ -25%) visibility-toggle icon button used inside secret/token + text fields, so it doesn't dominate the field's trailing area. */ + .field-visibility-toggle { + --md-icon-button-state-layer-size: 30px; + --md-icon-size: 18px; + } + + .auth-remember { + display: inline-flex; + align-items: center; + gap: 8px; + width: fit-content; + color: var(--mb-color-on-surface); + font-size: 0.9rem; + cursor: pointer; + user-select: none; + } + + .auth-remember md-checkbox { + --md-checkbox-selected-container-color: var(--mb-color-primary); + --md-checkbox-selected-icon-color: var(--mb-color-on-primary); + --md-checkbox-selected-hover-container-color: var(--mb-color-primary); + --md-checkbox-selected-focus-container-color: var(--mb-color-primary); + --md-checkbox-selected-pressed-container-color: var(--mb-color-primary); + --md-checkbox-unselected-outline-color: var(--mb-color-outline); + --md-checkbox-unselected-hover-outline-color: var(--mb-color-on-surface-variant); + --md-checkbox-unselected-focus-outline-color: var(--mb-color-primary); + } + + .auth-submit { + margin-top: 4px; + width: 100%; + min-height: 48px; + } + + .top-app-bar { + position: sticky; + top: 0; + z-index: 2; + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + min-height: 68px; + padding: 10px 24px; + border-bottom: 1px solid color-mix(in srgb, var(--mb-color-outline) 36%, transparent); + background: color-mix(in srgb, var(--mb-color-surface) 92%, transparent); + backdrop-filter: blur(16px); + } + + .top-app-bar p, + .top-app-bar strong { + margin: 0; + } + + .top-app-bar p { + color: var(--mb-color-on-surface-variant); + font-size: 0.75rem; + line-height: 1.2; + } + + .top-app-bar strong { + display: block; + font-size: 1.25rem; + line-height: 1.2; + font-weight: 650; + } + + .top-app-bar__meta { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + color: var(--mb-color-on-surface-variant); + font-size: 0.875rem; + white-space: nowrap; + } + + .top-app-bar__meta > span { + min-height: 32px; + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid color-mix(in srgb, var(--mb-color-outline-variant) 60%, transparent); + border-radius: var(--mb-shape-full); + padding: 0 12px; + color: var(--mb-color-on-surface-variant); + background: var(--mb-color-surface-container); + font-size: 0.78rem; + font-weight: 600; + } + + .locale-switch { + min-height: 38px; + display: inline-flex; + align-items: center; + gap: 4px; + border: 1px solid color-mix(in srgb, var(--mb-color-outline-variant) 60%, transparent); + border-radius: var(--mb-button-shape); + padding: 3px; + background: var(--mb-color-surface-container); + } + + .locale-switch > span { + min-height: 30px; + display: inline-flex; + align-items: center; + padding: 0 7px; + color: var(--mb-color-on-surface-variant); + font-size: 0.75rem; + font-weight: 700; + } + + .locale-switch__button { + min-width: 36px; + min-height: 30px; + --md-filled-button-container-height: 30px; + --md-filled-button-container-shape: var(--mb-button-shape); + --md-filled-button-label-text-size: 0.75rem; + --md-filled-button-label-text-weight: 700; + --md-filled-button-leading-space: 10px; + --md-filled-button-trailing-space: 10px; + --md-outlined-button-container-height: 30px; + --md-outlined-button-container-shape: var(--mb-button-shape); + --md-outlined-button-label-text-size: 0.75rem; + --md-outlined-button-label-text-weight: 700; + --md-outlined-button-leading-space: 10px; + --md-outlined-button-trailing-space: 10px; + } + + md-filled-button { + --md-filled-button-container-color: var(--mb-color-primary); + --md-filled-button-label-text-color: var(--mb-color-on-primary); + --md-filled-button-hover-label-text-color: var(--mb-color-on-primary); + --md-filled-button-focus-label-text-color: var(--mb-color-on-primary); + --md-filled-button-pressed-label-text-color: var(--mb-color-on-primary); + --md-filled-button-icon-color: var(--mb-color-on-primary); + --md-filled-button-hover-icon-color: var(--mb-color-on-primary); + --md-filled-button-focus-icon-color: var(--mb-color-on-primary); + --md-filled-button-pressed-icon-color: var(--mb-color-on-primary); + --md-filled-button-container-shape: var(--mb-button-shape); + } + + md-outlined-button { + --md-outlined-button-container-shape: var(--mb-button-shape); + --md-outlined-button-label-text-color: var(--mb-color-on-surface); + --md-outlined-button-outline-color: var(--mb-color-outline-variant); + --md-outlined-button-hover-label-text-color: var(--mb-color-primary); + --md-outlined-button-hover-outline-color: var(--mb-color-primary); + } + + .secondary-button { + --md-outlined-button-label-text-color: var(--mb-color-on-surface); + --md-outlined-button-outline-color: var(--mb-color-outline-variant); + } + + md-icon-button { + --md-icon-button-icon-color: var(--mb-color-on-surface); + --md-icon-button-hover-icon-color: var(--mb-color-primary); + --md-icon-button-pressed-icon-color: var(--mb-color-primary); + } + + md-switch { + --md-switch-selected-track-color: var(--mb-color-primary); + --md-switch-selected-hover-track-color: var(--mb-color-primary); + --md-switch-selected-focus-track-color: var(--mb-color-primary); + --md-switch-selected-pressed-track-color: var(--mb-color-primary); + --md-switch-selected-handle-color: var(--mb-color-on-primary); + --md-switch-track-color: var(--mb-color-surface-container-highest); + --md-switch-hover-track-color: var(--mb-color-surface-container-highest); + --md-switch-focus-track-color: var(--mb-color-surface-container-highest); + --md-switch-pressed-track-color: var(--mb-color-surface-container-highest); + --md-switch-track-outline-color: var(--mb-color-outline); + --md-switch-hover-track-outline-color: var(--mb-color-outline); + --md-switch-focus-track-outline-color: var(--mb-color-outline); + --md-switch-pressed-track-outline-color: var(--mb-color-outline); + --md-switch-handle-color: var(--mb-color-outline); + --md-switch-hover-handle-color: var(--mb-color-on-surface-variant); + --md-switch-focus-handle-color: var(--mb-color-on-surface-variant); + --md-switch-pressed-handle-color: var(--mb-color-on-surface-variant); + --md-switch-selected-hover-state-layer-color: var(--mb-color-primary); + --md-switch-selected-pressed-state-layer-color: var(--mb-color-primary); + --md-switch-hover-state-layer-color: var(--mb-color-on-surface); + --md-switch-pressed-state-layer-color: var(--mb-color-on-surface); + } + +`; diff --git a/webui/src/app/styles/forms.ts b/webui/src/app/styles/forms.ts new file mode 100644 index 00000000..c75b9c89 --- /dev/null +++ b/webui/src/app/styles/forms.ts @@ -0,0 +1,781 @@ +export const formStyles = ` .form-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px 18px; + align-items: start; + } + + .form-grid label, + .form-field, + .schema-field { + display: grid; + gap: 6px; + color: var(--mb-color-on-surface-variant); + font-size: 0.82rem; + font-weight: 650; + min-width: 0; + } + + .form-field label { + display: grid; + gap: 6px; + color: inherit; + font: inherit; + } + + .schema-field__topline { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-height: 26px; + } + + .schema-field--inline { + gap: 6px; + } + + .schema-field__label-row { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 6px; + } + + .schema-field__label { + color: inherit; + font: inherit; + } + + .schema-field__label { + min-width: 0; + overflow-wrap: anywhere; + } + + .schema-field__required { + margin-left: 3px; + color: var(--mb-color-error); + } + + .schema-field__help-wrap { + position: relative; + display: inline-flex; + align-items: center; + } + + .schema-field__help { + min-height: 0; + flex: 0 0 auto; + --md-icon-button-state-layer-width: 20px; + --md-icon-button-state-layer-height: 20px; + --md-icon-button-state-layer-shape: 999px; + --md-icon-button-icon-size: 16px; + --md-icon-button-icon-color: var(--mb-color-on-surface-variant); + --md-icon-button-hover-icon-color: var(--mb-color-primary); + --md-icon-button-focus-icon-color: var(--mb-color-primary); + --md-icon-button-pressed-icon-color: var(--mb-color-primary); + --md-icon-button-hover-state-layer-color: var(--mb-color-primary); + --md-icon-button-focus-state-layer-color: var(--mb-color-primary); + --md-icon-button-pressed-state-layer-color: var(--mb-color-primary); + --md-icon-button-hover-state-layer-opacity: 0.14; + --md-icon-button-focus-state-layer-opacity: 0.14; + } + + .rich-tooltip { + position: fixed; + z-index: 40; + width: min(320px, calc(100vw - 24px)); + max-width: min(320px, 78vw); + display: grid; + gap: 6px; + border-radius: var(--mb-shape-md); + padding: 16px; + color: var(--mb-color-on-surface-variant); + background: var(--mb-color-surface-container-high); + box-shadow: var(--mb-elevation-3); + text-align: left; + pointer-events: none; + } + + .rich-tooltip__subhead { + color: var(--mb-color-on-surface); + font-size: 0.82rem; + font-weight: 720; + line-height: 1.3; + } + + .rich-tooltip__body { + font-size: 0.8rem; + font-weight: 460; + line-height: 1.5; + } + + .rich-tooltip__metas { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 0; + } + + .rich-tooltip__chip { + display: inline-flex; + align-items: center; + border-radius: var(--mb-shape-full); + padding: 2px 9px; + background: color-mix(in srgb, var(--mb-color-primary) 14%, transparent); + color: var(--mb-color-on-surface); + font-size: 0.7rem; + font-weight: 640; + white-space: nowrap; + } + + .schema-field__switch-line { + width: 100%; + min-width: 0; + min-height: 40px; + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 10px; + border-radius: var(--mb-shape-sm); + outline: 0; + padding: 4px 6px 4px 14px; + background: color-mix(in srgb, var(--mb-color-surface-container-high) 60%, transparent); + } + + .material-chip-group { + display: flex; + align-items: center; + gap: 7px; + flex-wrap: wrap; + min-height: 38px; + } + + .schema-structured-summary { + width: 100%; + display: grid; + gap: 10px; + border-radius: var(--mb-shape-sm); + padding: 12px; + color: var(--mb-color-on-surface-variant); + background: color-mix(in srgb, var(--mb-color-surface-container-high) 58%, transparent); + } + + .schema-structured-summary__header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 8px; + text-align: left; + } + + .schema-structured-summary__header span:first-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--mb-color-on-surface); + } + + .schema-structured-summary__header strong { + min-height: 24px; + display: inline-flex; + align-items: center; + border-radius: var(--mb-shape-full); + padding: 2px 11px; + color: var(--mb-color-on-secondary-container); + background: var(--mb-color-secondary-container); + font-size: 0.76rem; + white-space: nowrap; + } + + .schema-structured-summary__rows { + margin: 0; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 8px; + } + + .schema-structured-summary__row { + min-width: 0; + display: grid; + gap: 3px; + border-radius: var(--mb-shape-sm); + padding: 8px 10px; + background: color-mix(in srgb, var(--mb-color-surface-container-highest) 54%, transparent); + } + + .schema-structured-summary__row dt, + .schema-structured-summary__row dd { + margin: 0; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .schema-structured-summary__row dt { + color: var(--mb-color-on-surface); + font-size: 0.76rem; + font-weight: 700; + } + + .schema-structured-summary__row dd, + .schema-structured-summary__empty { + color: var(--mb-color-on-surface-variant); + font-size: 0.78rem; + font-weight: 520; + } + + .schema-structured-summary__empty { + margin: 0; + } + + .schema-structured-object { + width: 100%; + display: grid; + gap: 10px; + border-radius: var(--mb-shape-sm); + padding: 12px; + color: var(--mb-color-on-surface-variant); + background: color-mix(in srgb, var(--mb-color-surface-container-high) 58%, transparent); + } + + .schema-structured-object__header { + min-height: 24px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + color: var(--mb-color-on-surface-variant); + font-size: 0.82rem; + font-weight: 650; + } + + .schema-structured-object__header span:first-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .schema-structured-object__grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px 18px; + align-items: start; + } + + .schema-structured-object__boolean { + min-height: 44px; + align-self: center; + } + + .schema-structured-summary--nested { + padding: 10px; + background: color-mix(in srgb, var(--mb-color-surface-container-highest) 42%, transparent); + } + + .schema-field--wide { + grid-column: 1 / -1; + } + + .editable-list-field { + display: grid; + gap: 10px; + min-width: 0; + border-radius: var(--mb-shape-sm); + outline: 0; + padding: 12px; + background: color-mix(in srgb, var(--mb-color-surface-container-high) 58%, transparent); + } + + .editable-list-field__header { + min-height: 24px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + } + + .editable-list-field__title { + min-width: 0; + color: var(--mb-color-on-surface-variant); + font-size: 0.82rem; + font-weight: 650; + overflow-wrap: anywhere; + } + + .editable-list-field__items { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + min-height: 32px; + } + + .editable-list-field__chip { + --md-input-chip-container-height: 32px; + --md-input-chip-container-shape: var(--mb-shape-sm); + --md-input-chip-label-text-color: var(--mb-color-on-surface); + --md-input-chip-hover-label-text-color: var(--mb-color-on-surface); + --md-input-chip-focus-label-text-color: var(--mb-color-on-surface); + --md-input-chip-pressed-label-text-color: var(--mb-color-on-surface); + --md-input-chip-outline-color: var(--mb-color-outline-variant); + --md-input-chip-hover-state-layer-color: var(--mb-color-primary); + --md-input-chip-focus-state-layer-color: var(--mb-color-primary); + --md-input-chip-pressed-state-layer-color: var(--mb-color-primary); + --md-input-chip-hover-state-layer-opacity: 0.08; + --md-input-chip-focus-state-layer-opacity: 0.08; + --md-input-chip-icon-size: 18px; + --md-input-chip-trailing-icon-color: var(--mb-color-on-surface-variant); + --md-input-chip-hover-trailing-icon-color: var(--mb-color-primary); + --md-input-chip-focus-trailing-icon-color: var(--mb-color-primary); + --md-input-chip-pressed-trailing-icon-color: var(--mb-color-primary); + } + + .editable-list-field__composer { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: stretch; + gap: 10px; + } + + .structured-feature-field { + display: grid; + gap: 12px; + min-width: 0; + border-radius: var(--mb-shape-sm); + outline: 0; + padding: 12px; + background: color-mix(in srgb, var(--mb-color-surface-container-high) 58%, transparent); + } + + .structured-feature-field__grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px 18px; + align-items: start; + } + + .structured-feature-field__grid--wide { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .structured-feature-field__number { + width: 100%; + } + + .structured-feature-field__secret { + width: 100%; + } + + .extension-feature-list { + display: grid; + grid-template-columns: 1fr; + gap: 8px; + min-height: 32px; + } + + .extension-feature-row { + min-width: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + border-radius: var(--mb-shape-sm); + padding: 8px; + background: color-mix(in srgb, var(--mb-color-surface-container-highest) 48%, transparent); + } + + .extension-config-grid { + grid-column: 1 / -1; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + min-width: 0; + } + + .extension-feature-row__chip { + min-width: 0; + --md-input-chip-container-height: 32px; + --md-input-chip-container-shape: var(--mb-shape-sm); + --md-input-chip-label-text-color: var(--mb-color-on-surface); + --md-input-chip-hover-label-text-color: var(--mb-color-on-surface); + --md-input-chip-focus-label-text-color: var(--mb-color-on-surface); + --md-input-chip-pressed-label-text-color: var(--mb-color-on-surface); + --md-input-chip-outline-color: var(--mb-color-outline-variant); + --md-input-chip-hover-state-layer-color: var(--mb-color-primary); + --md-input-chip-focus-state-layer-color: var(--mb-color-primary); + --md-input-chip-pressed-state-layer-color: var(--mb-color-primary); + --md-input-chip-hover-state-layer-opacity: 0.08; + --md-input-chip-focus-state-layer-opacity: 0.08; + --md-input-chip-icon-size: 18px; + --md-input-chip-trailing-icon-color: var(--mb-color-on-surface-variant); + --md-input-chip-hover-trailing-icon-color: var(--mb-color-primary); + --md-input-chip-focus-trailing-icon-color: var(--mb-color-primary); + --md-input-chip-pressed-trailing-icon-color: var(--mb-color-primary); + } + + .extension-feature-row__switch { + display: inline-flex; + align-items: center; + justify-content: flex-end; + } + + .extension-feature-row__switch md-switch { + --md-switch-selected-track-color: var(--mb-color-primary); + --md-switch-selected-handle-color: var(--mb-color-on-primary); + --md-switch-selected-hover-track-color: var(--mb-color-primary); + --md-switch-selected-focus-track-color: var(--mb-color-primary); + --md-switch-selected-pressed-track-color: var(--mb-color-primary); + } + + .editable-list-field__input { + width: 100%; + } + + .editable-list-field__add { + align-self: stretch; + --md-filled-button-container-height: 44px; + --md-filled-button-container-shape: var(--mb-button-shape); + --md-filled-button-leading-space: 12px; + --md-filled-button-trailing-space: 14px; + } + + .field-status { + display: inline-flex; + align-items: center; + justify-self: end; + gap: 6px; + min-height: 24px; + border-radius: var(--mb-shape-full); + padding: 2px 11px; + color: var(--mb-color-on-surface-variant); + background: color-mix(in srgb, var(--mb-color-surface-container-high) 78%, transparent); + font-size: 0.72rem; + font-weight: 700; + white-space: nowrap; + } + + @keyframes mb-blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.35; } + } + + .field-status--saving .field-status__dot { + animation: mb-blink 1s var(--mb-ease-standard) infinite; + } + + .field-status__dot { + width: 7px; + height: 7px; + border-radius: 999px; + background: currentColor; + } + + .field-status--dirty { + color: var(--mb-color-on-tertiary-container); + background: var(--mb-color-tertiary-container); + } + + .field-status--saving { + color: var(--mb-color-on-primary-container); + background: var(--mb-color-primary-container); + } + + .field-status--saved, + .field-status--idle { + color: var(--mb-color-on-surface-variant); + background: transparent; + font-weight: 600; + } + + .field-status--saved .field-status__dot, + .field-status--idle .field-status__dot { + color: var(--mb-color-success); + } + + .field-status--error { + color: var(--mb-color-on-error-container); + background: var(--mb-color-error-container); + } + + .mb-field { + position: relative; + display: grid; + gap: 4px; + min-width: 0; + } + + .mb-field__control { + position: relative; + display: flex; + align-items: stretch; + min-width: 0; + } + + .mb-field md-outlined-text-field { + width: 100%; + min-width: 0; + --md-outlined-text-field-input-text-color: var(--mb-color-on-surface); + --md-outlined-text-field-input-text-size: 0.88rem; + --md-outlined-text-field-input-text-weight: 640; + --md-outlined-text-field-label-text-color: var(--mb-color-on-surface-variant); + --md-outlined-text-field-focus-label-text-color: var(--mb-color-primary); + --md-outlined-text-field-outline-color: var(--mb-color-outline); + --md-outlined-text-field-hover-outline-color: var(--mb-color-on-surface); + --md-outlined-text-field-focus-outline-color: var(--mb-color-primary); + --md-outlined-text-field-error-outline-color: var(--mb-color-error); + --md-outlined-text-field-error-focus-outline-color: var(--mb-color-error); + --md-outlined-text-field-error-label-text-color: var(--mb-color-error); + --md-outlined-text-field-error-focus-label-text-color: var(--mb-color-error); + --md-outlined-text-field-leading-icon-color: var(--mb-color-on-surface-variant); + --md-outlined-text-field-focus-leading-icon-color: var(--mb-color-on-surface-variant); + --md-outlined-text-field-trailing-icon-color: var(--mb-color-on-surface-variant); + --md-outlined-text-field-focus-trailing-icon-color: var(--mb-color-primary); + --md-outlined-text-field-supporting-text-color: var(--mb-color-on-surface-variant); + --md-outlined-text-field-error-supporting-text-color: var(--mb-color-error); + } + + .material-field-leading-node, + .material-select-leading-node, + .material-select-option-icon { + display: inline-grid; + place-items: center; + width: 20px; + height: 20px; + color: currentColor; + line-height: 1; + } + + .material-field-leading-node svg, + .material-select-leading-node svg, + .material-select-option-icon svg { + display: block; + width: 18px; + height: 18px; + } + + md-outlined-text-field.material-text-field--single-line { + --md-outlined-text-field-top-space: 12px; + --md-outlined-text-field-bottom-space: 12px; + --md-outlined-text-field-input-text-line-height: 1.25rem; + } + + md-filled-text-field.material-text-field--single-line { + --md-filled-text-field-top-space: 12px; + --md-filled-text-field-bottom-space: 12px; + --md-filled-text-field-with-label-top-space: 4px; + --md-filled-text-field-with-label-bottom-space: 4px; + --md-filled-text-field-input-text-line-height: 1.25rem; + } + + .mb-field[data-variant="textarea"] md-outlined-text-field { + --md-outlined-text-field-input-text-font: var(--mb-font-mono); + --md-outlined-text-field-input-text-line-height: 1.45; + } + + .mb-field[data-variant="textarea"] md-outlined-text-field { + min-height: 132px; + } + + .mb-field__required { + margin-left: 2px; + color: var(--mb-color-error); + } + + md-outlined-select.material-select--single-line { + --md-outlined-select-text-field-input-text-line-height: 1.25rem; + --md-outlined-field-top-space: 12px; + --md-outlined-field-bottom-space: 12px; + } + + .mb-field md-outlined-select { + width: 100%; + min-width: 0; + --md-outlined-select-text-field-input-text-color: var(--mb-color-on-surface); + --md-outlined-select-text-field-input-text-size: 0.88rem; + --md-outlined-select-text-field-input-text-weight: 640; + --md-outlined-select-text-field-label-text-color: var(--mb-color-on-surface-variant); + --md-outlined-select-text-field-focus-label-text-color: var(--mb-color-primary); + --md-outlined-select-text-field-outline-color: var(--mb-color-outline); + --md-outlined-select-text-field-hover-outline-color: var(--mb-color-on-surface); + --md-outlined-select-text-field-focus-outline-color: var(--mb-color-primary); + --md-outlined-select-text-field-leading-icon-color: var(--mb-color-on-surface-variant); + --md-outlined-select-text-field-focus-leading-icon-color: var(--mb-color-on-surface-variant); + --md-outlined-select-text-field-trailing-icon-color: var(--mb-color-on-surface-variant); + --md-outlined-select-text-field-focus-trailing-icon-color: var(--mb-color-primary); + --md-menu-container-color: var(--mb-color-surface-container-high); + --md-menu-container-shape: var(--mb-shape-md); + --md-menu-container-elevation: 2; + } + + .mb-field__select-actions { + min-height: 20px; + display: flex; + justify-content: flex-end; + align-items: center; + margin-bottom: -2px; + pointer-events: none; + } + + .mb-field__select-help { + min-height: 0; + flex: 0 0 auto; + pointer-events: auto; + --md-icon-button-state-layer-width: 20px; + --md-icon-button-state-layer-height: 20px; + --md-icon-button-state-layer-shape: 999px; + --md-icon-button-icon-size: 16px; + --md-icon-button-icon-color: var(--mb-color-on-surface-variant); + --md-icon-button-hover-icon-color: var(--mb-color-primary); + --md-icon-button-focus-icon-color: var(--mb-color-primary); + --md-icon-button-pressed-icon-color: var(--mb-color-primary); + --md-icon-button-hover-state-layer-color: var(--mb-color-primary); + --md-icon-button-focus-state-layer-color: var(--mb-color-primary); + --md-icon-button-pressed-state-layer-color: var(--mb-color-primary); + --md-icon-button-hover-state-layer-opacity: 0.14; + --md-icon-button-focus-state-layer-opacity: 0.14; + } + + .form-field--create-track.mb-field { + display: grid; + } + + .form-grid .mb-field--wide, + .mb-field--wide { + min-width: 0; + } + + .form-grid__wide, + .form-actions { + grid-column: 1 / -1; + } + + .form-grid__compact { + grid-column: span 1; + } + + .form-grid__medium { + grid-column: span 1; + } + + .form-grid--route-identity { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .form-grid__reasoning-defaults { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px 18px; + } + + @media (max-width: 1080px) { + .form-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + + @media (max-width: 680px) { + .form-grid { + grid-template-columns: 1fr; + } + + .structured-feature-field__grid { + grid-template-columns: 1fr; + } + + .schema-structured-object__grid { + grid-template-columns: 1fr; + } + + .editable-list-field__composer { + grid-template-columns: 1fr; + } + } + + .form-actions { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + } + + .feedback-inline, + .feedback-banner { + color: var(--mb-color-primary); + font-weight: 650; + } + + .edit-state-banner { + margin: 0; + border: 1px solid color-mix(in srgb, var(--mb-color-primary) 40%, transparent); + border-radius: var(--mb-shape-md); + padding: 14px 16px; + background: color-mix(in srgb, var(--mb-color-primary-container) 42%, var(--mb-color-surface)); + } + + .field-hint { + display: block; + color: var(--mb-color-on-surface-variant); + font-size: 0.76rem; + line-height: 1.45; + font-weight: 500; + overflow-wrap: anywhere; + } + + .field-hint span { + display: inline-block; + } + + .field-error { + margin: 0; + border-radius: var(--mb-shape-sm); + padding: 8px 12px; + color: var(--mb-color-on-error-container); + background: var(--mb-color-error-container); + font-size: 0.76rem; + line-height: 1.4; + font-weight: 650; + overflow-wrap: anywhere; + } + + .field-warning { + margin: 0; + border-radius: var(--mb-shape-sm); + padding: 8px 12px; + color: var(--mb-color-on-warning-container); + background: var(--mb-color-warning-container); + font-size: 0.76rem; + line-height: 1.4; + font-weight: 600; + overflow-wrap: anywhere; + } + + .field-error--sr { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + border: 0; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + } + + .json-block { + max-height: 420px; + overflow: auto; + margin: 0; + border-radius: var(--mb-shape-md); + padding: 16px; + color: var(--mb-color-on-surface); + background: var(--mb-color-surface-container-lowest); + font-family: var(--mb-font-mono); + font-size: 0.82rem; + line-height: 1.45; + } + +`; diff --git a/webui/src/app/styles/logs.ts b/webui/src/app/styles/logs.ts new file mode 100644 index 00000000..249972be --- /dev/null +++ b/webui/src/app/styles/logs.ts @@ -0,0 +1,197 @@ +export const logStyles = ` .logs-panel__header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 14px; + margin-bottom: 14px; + } + + .logs-panel__header h2 { + margin: 0; + } + + .logs-panel__actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; + } + + .logs-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + flex-wrap: wrap; + margin-bottom: 14px; + } + + .logs-toolbar__actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + } + + .logs-count { + min-height: 32px; + display: inline-flex; + align-items: center; + margin: 0; + border-radius: var(--mb-shape-full); + padding: 0 14px; + color: var(--mb-color-on-surface-variant); + background: var(--mb-color-surface-container-high); + font-size: 0.82rem; + font-weight: 650; + } + + .logs-chip-set { + display: flex; + align-items: center; + gap: 7px; + flex-wrap: wrap; + } + + .log-level-filter { + margin-bottom: 14px; + } + + .logs-stream-status { + margin: 0 0 14px; + border: 1px solid color-mix(in srgb, var(--mb-color-error) 45%, transparent); + border-radius: var(--mb-shape-md); + padding: 12px 14px; + color: var(--mb-color-on-error-container); + background: var(--mb-color-error-container); + font-size: 0.85rem; + font-weight: 650; + } + + .logs-search { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + margin-bottom: 14px; + } + + .logs-search__field { + width: 100%; + } + + .material-symbol { + font-family: "Material Symbols Rounded", "Material Symbols Outlined", sans-serif; + font-size: 1.15rem; + line-height: 1; + } + + .log-output { + max-height: min(46vh, 600px); + overflow: auto; + display: grid; + gap: 4px; + border-radius: var(--mb-shape-panel); + outline: 0; + padding: 10px; + background: var(--mb-color-surface-container-lowest); + } + + .log-empty-state { + min-height: 180px; + display: grid; + place-items: center; + margin: 0; + border-radius: var(--mb-shape-panel); + outline: 0; + padding: 18px; + color: var(--mb-color-on-surface-variant); + background: color-mix(in srgb, var(--mb-color-surface-container) 48%, transparent); + text-align: center; + font-size: 0.9rem; + font-weight: 650; + } + + .log-row { + display: grid; + grid-template-columns: auto auto minmax(0, 1fr); + align-items: baseline; + gap: 10px; + border-radius: var(--mb-shape-xs); + padding: 7px 12px; + background: color-mix(in srgb, var(--mb-color-surface-container) 58%, transparent); + transition: background-color var(--mb-duration-short) var(--mb-ease-standard); + } + + .log-row:hover { + background: color-mix(in srgb, var(--mb-color-surface-container) 84%, transparent); + } + + .log-row__level { + display: inline-flex; + align-items: center; + justify-content: center; + justify-self: start; + min-width: 56px; + border-radius: var(--mb-shape-xs); + padding: 2px 8px; + color: var(--mb-color-on-surface-variant); + background: var(--mb-color-surface-container-high); + font-size: 0.66rem; + font-weight: 760; + letter-spacing: 0.05em; + text-align: center; + text-transform: uppercase; + } + + .log-row__level--error { + color: var(--mb-color-on-error-container); + background: var(--mb-color-error-container); + } + + .log-row__level--warn { + color: var(--mb-color-on-warning-container); + background: var(--mb-color-warning-container); + } + + .log-row__level--info { + color: var(--mb-color-on-primary-container); + background: var(--mb-color-primary-container); + } + + .log-row__level--debug { + color: var(--mb-color-on-secondary-container); + background: var(--mb-color-secondary-container); + } + + .log-row__time { + font-family: var(--mb-font-mono); + font-size: 0.72rem; + font-weight: 600; + color: var(--mb-color-on-surface-variant); + white-space: nowrap; + } + + .log-row__message { + margin: 0; + min-width: 0; + color: var(--mb-color-on-surface); + font-family: var(--mb-font-mono); + font-size: 0.8rem; + line-height: 1.4; + white-space: pre-wrap; + overflow-wrap: anywhere; + } + + @media (max-width: 600px) { + .log-row { + grid-template-columns: auto minmax(0, 1fr); + } + .log-row__time { + display: none; + } + } + +`; + diff --git a/webui/src/app/styles/overview.ts b/webui/src/app/styles/overview.ts new file mode 100644 index 00000000..c9cf158b --- /dev/null +++ b/webui/src/app/styles/overview.ts @@ -0,0 +1,295 @@ +export const overviewStyles = ` .metric-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + } + + .usage-dashboard { + display: grid; + gap: 16px; + } + + .panel-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + } + + .panel-heading h2, + .panel-heading p { + margin: 0; + } + + .panel-heading h2 { + font-size: 1rem; + line-height: 1.25; + } + + .panel-heading p { + margin-top: 5px; + color: var(--mb-color-on-surface-variant); + font-size: 0.86rem; + line-height: 1.45; + } + + .usage-heading-controls { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + flex-wrap: wrap; + } + + .usage-range { + display: flex; + align-items: center; + gap: 7px; + flex-wrap: wrap; + } + + .usage-demo-toggle { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 32px; + padding: 0 12px; + border-radius: var(--mb-button-shape); + color: var(--mb-color-on-surface-variant); + background: var(--mb-color-surface-container); + font-size: 0.78rem; + font-weight: 650; + cursor: pointer; + user-select: none; + } + + .usage-summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(168px, 1fr)); + gap: 12px; + } + + .usage-metric { + position: relative; + min-width: 0; + display: grid; + gap: 8px; + border-radius: var(--mb-shape-panel); + outline: 0; + padding: 14px 16px; + background: var(--mb-color-surface-container-high); + transition: + background-color var(--mb-duration-medium) var(--mb-ease-standard); + } + + .usage-metric:hover { + background: var(--mb-color-surface-container-highest); + } + + .usage-metric__icon { + position: absolute; + top: 14px; + right: 14px; + width: 36px; + height: 36px; + display: grid; + place-items: center; + border-radius: var(--mb-shape-md); + color: var(--mb-color-on-primary-container); + background: var(--mb-color-primary-container); + } + + .usage-metric__icon.material-symbol { + font-size: 20px; + } + + .usage-metric--tertiary .usage-metric__icon { + color: var(--mb-color-on-tertiary-container); + background: var(--mb-color-tertiary-container); + } + + .usage-metric--secondary .usage-metric__icon { + color: var(--mb-color-on-secondary-container); + background: var(--mb-color-secondary-container); + } + + .usage-metric__label { + padding-right: 44px; + color: var(--mb-color-on-surface-variant); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + } + + .usage-metric strong { + min-width: 0; + padding-right: 44px; + overflow-wrap: anywhere; + font-size: 1.32rem; + line-height: 1.12; + font-weight: 650; + } + + .usage-chart-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(248px, 1fr)); + gap: 12px; + } + + .usage-chart { + min-width: 0; + display: grid; + gap: 12px; + border-radius: var(--mb-shape-panel); + outline: 0; + padding: 16px; + background: color-mix(in srgb, var(--mb-color-surface-container-high) 76%, var(--mb-color-surface)); + } + + .usage-chart:focus-visible { + background: var(--mb-color-surface-container-high); + } + + .usage-chart__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + } + + .usage-chart__header h3 { + margin: 0; + font-size: 0.88rem; + line-height: 1.25; + } + + .usage-chart__header span { + color: var(--mb-color-on-surface-variant); + font-size: 0.78rem; + font-weight: 700; + } + + .usage-chart__bar { + overflow: hidden; + display: flex; + gap: 0; + /* Fixed height (not min-height) so the bar never stretches when the panel + around it grows; only the outer ends are rounded via the container. */ + block-size: 10px; + border-radius: var(--mb-shape-full); + background: var(--mb-color-surface-container); + } + + .usage-chart__segment { + min-inline-size: 4px; + border-radius: 0; + transition: inline-size var(--mb-duration-long) var(--mb-ease-decelerate); + } + + .usage-segment--input { + background: var(--mb-color-primary); + } + + .usage-segment--output { + background: var(--mb-color-tertiary); + } + + .usage-segment--cache-write { + background: var(--mb-color-secondary); + } + + .usage-segment--cache-read { + background: var(--mb-color-primary-container); + } + + .usage-segment--cost-1 { + background: #7c6fdd; + } + + .usage-segment--cost-2 { + background: #2f8f68; + } + + .usage-segment--cost-3 { + background: #c26a30; + } + + .usage-segment--cost-4 { + background: #b84f76; + } + + .usage-segment--cost-5 { + background: #4f82c8; + } + + .usage-segment--cost-6 { + background: #8a7a24; + } + + .usage-chart__legend { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; + } + + .usage-chart__legend li { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 8px; + align-items: center; + color: var(--mb-color-on-surface-variant); + font-size: 0.78rem; + } + + .usage-chart__legend li > span:not(.usage-chart__dot) { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .usage-chart__legend strong { + color: var(--mb-color-on-surface); + font-weight: 700; + } + + .usage-chart__dot { + width: 10px; + height: 10px; + border-radius: var(--mb-shape-full); + } + + .usage-table td { + white-space: nowrap; + } + + .usage-table td:first-child, + .usage-table td:nth-child(2) { + max-width: 260px; + white-space: normal; + overflow-wrap: anywhere; + } + + .usage-empty-state { + min-height: 180px; + display: grid; + place-items: center; + border-radius: var(--mb-shape-panel); + outline: 0; + color: var(--mb-color-on-surface-variant); + background: color-mix(in srgb, var(--mb-color-surface-container) 48%, transparent); + font-weight: 650; + } + + .usage-empty-state p { + margin: 0; + } + + .overview-logs { + display: grid; + gap: 16px; + } + +`; diff --git a/webui/src/app/styles/resourceEditor.ts b/webui/src/app/styles/resourceEditor.ts new file mode 100644 index 00000000..1a5232f0 --- /dev/null +++ b/webui/src/app/styles/resourceEditor.ts @@ -0,0 +1,420 @@ +export const resourceEditorStyles = ` .resource-editor-card { + position: relative; + display: grid; + gap: 14px; + border-radius: var(--mb-shape-panel); + outline: 0; + padding: 16px 18px; + background: var(--mb-color-surface-container); + transition: + background-color var(--mb-duration-medium) var(--mb-ease-standard); + } + + .resource-editor-card:hover { + background: var(--mb-color-surface-container-high); + } + + .resource-editor-card:focus-within { + background: var(--mb-color-surface-container-high); + } + + .resource-editor-card__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + } + + .resource-editor-card__identity { + min-width: 0; + display: grid; + gap: 8px; + } + + .resource-editor-card__identity-line { + min-width: 0; + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + } + + .resource-editor-card__identity h3 { + margin: 0; + overflow-wrap: anywhere; + color: var(--mb-color-on-surface); + font-size: 1rem; + line-height: 1.2; + font-weight: 720; + } + + .resource-editor-card__facts { + display: flex; + align-items: center; + gap: 8px var(--mb-resource-meta-gap, 12px); + flex-wrap: wrap; + } + + .resource-editor-card__facts .resource-meta-pill { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 30px; + border-radius: var(--mb-shape-full); + padding: 0 12px; + font-size: 0.76rem; + font-weight: 650; + line-height: 1; + white-space: nowrap; + } + + .resource-editor-card__facts .resource-meta-pill .material-symbol { + font-size: 1rem; + line-height: 1; + } + + .resource-fact { + color: var(--mb-color-on-surface-variant); + background: color-mix(in srgb, var(--mb-color-surface-container-highest) 68%, transparent); + } + + .resource-fact .material-symbol { + color: var(--mb-color-on-surface-variant); + } + + .resource-fact--hot .material-symbol { + color: var(--mb-color-primary); + } + + .resource-fact--restart .material-symbol { + color: var(--mb-color-tertiary); + } + + .resource-kind-icon { + flex: 0 0 auto; + display: inline-grid; + place-items: center; + width: 30px; + height: 30px; + border-radius: var(--mb-shape-md); + color: var(--mb-color-on-secondary-container); + background: var(--mb-color-secondary-container); + font-size: 18px; + } + + .resource-editor-card__status-group { + display: inline-flex; + align-items: center; + gap: var(--mb-resource-meta-gap, 12px); + flex-wrap: wrap; + } + + .resource-editor-card__meta { + display: flex; + align-items: flex-start; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; + flex: 0 0 auto; + } + + .editor-live-status { + font-weight: 700; + } + + .editor-live-status--saving { + color: var(--mb-color-on-primary-container); + background: var(--mb-color-primary-container); + } + + .editor-live-status--saving .material-symbol { + animation: mb-spin 0.9s linear infinite; + } + + .editor-live-status--dirty { + color: var(--mb-color-on-tertiary-container); + background: var(--mb-color-tertiary-container); + } + + .editor-live-status--error { + color: var(--mb-color-on-error-container); + background: var(--mb-color-error-container); + } + + @keyframes mb-spin { + to { + transform: rotate(360deg); + } + } + + .fab-button { + min-height: 40px; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 0 18px 0 16px; + font-size: 0.82rem; + font-weight: 680; + white-space: nowrap; + --md-filled-button-container-color: var(--mb-color-primary-container); + --md-filled-button-label-text-color: var(--mb-color-on-primary-container); + --md-filled-button-hover-label-text-color: var(--mb-color-on-primary-container); + --md-filled-button-focus-label-text-color: var(--mb-color-on-primary-container); + --md-filled-button-pressed-label-text-color: var(--mb-color-on-primary-container); + --md-filled-button-icon-color: var(--mb-color-on-primary-container); + --md-filled-button-hover-icon-color: var(--mb-color-on-primary-container); + --md-filled-button-focus-icon-color: var(--mb-color-on-primary-container); + --md-filled-button-pressed-icon-color: var(--mb-color-on-primary-container); + --md-filled-button-container-shape: var(--mb-button-shape); + --md-filled-button-container-elevation: 1; + --md-filled-button-hover-container-elevation: 2; + --md-filled-button-pressed-container-elevation: 1; + --md-filled-button-icon-size: 20px; + transition: + transform var(--mb-duration-short) var(--mb-ease-spring); + } + + .fab-button:hover { + transform: translateY(-1px); + } + + .fab-button:active { + transform: translateY(0); + } + + .fab-button--danger { + --md-filled-button-container-color: var(--mb-color-error-container); + --md-filled-button-label-text-color: var(--mb-color-on-error-container); + --md-filled-button-hover-label-text-color: var(--mb-color-on-error-container); + --md-filled-button-focus-label-text-color: var(--mb-color-on-error-container); + --md-filled-button-pressed-label-text-color: var(--mb-color-on-error-container); + --md-filled-button-icon-color: var(--mb-color-on-error-container); + --md-filled-button-hover-icon-color: var(--mb-color-on-error-container); + --md-filled-button-focus-icon-color: var(--mb-color-on-error-container); + --md-filled-button-pressed-icon-color: var(--mb-color-on-error-container); + --md-filled-button-hover-state-layer-color: var(--mb-color-error); + } + + .switch-bank { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(248px, 1fr)); + gap: 8px 14px; + align-items: start; + } + + .resource-delete-confirmation { + display: grid; + gap: 10px; + border: 1px solid color-mix(in srgb, var(--mb-color-error) 34%, transparent); + border-radius: var(--mb-shape-md); + padding: 14px 16px; + color: var(--mb-color-on-error-container); + background: color-mix(in srgb, var(--mb-color-error-container) 70%, var(--mb-color-surface)); + } + + .resource-delete-confirmation p { + margin: 0; + font-size: 0.82rem; + line-height: 1.45; + font-weight: 650; + } + + .resource-delete-confirmation__actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + } + + .resource-delete-confirmation__confirm { + --md-filled-button-container-color: var(--mb-color-error); + --md-filled-button-label-text-color: var(--mb-color-on-error); + --md-filled-button-hover-label-text-color: var(--mb-color-on-error); + --md-filled-button-focus-label-text-color: var(--mb-color-on-error); + --md-filled-button-pressed-label-text-color: var(--mb-color-on-error); + --md-filled-button-icon-color: var(--mb-color-on-error); + --md-filled-button-hover-icon-color: var(--mb-color-on-error); + --md-filled-button-focus-icon-color: var(--mb-color-on-error); + --md-filled-button-pressed-icon-color: var(--mb-color-on-error); + --md-filled-button-hover-state-layer-color: var(--mb-color-on-error); + --md-filled-button-container-elevation: 1; + } + + .resource-editor-card__summary { + display: none; + } + + .resource-field-groups { + display: grid; + gap: 14px; + } + + .resource-field-group { + display: grid; + gap: 12px; + border-radius: var(--mb-shape-md); + outline: 0; + padding: 16px; + background: color-mix(in srgb, var(--mb-color-surface) 68%, var(--mb-color-surface-container-high)); + } + + .resource-field-group__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + } + + .resource-field-group__header-actions { + min-width: 0; + display: flex; + align-items: flex-start; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; + } + + .resource-field-group__body { + display: grid; + gap: 12px; + } + + .resource-field-group__header h4 { + margin: 0; + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--mb-color-on-surface); + font-size: 0.84rem; + line-height: 1.25; + font-weight: 760; + } + + .resource-field-group__header h4 .material-symbol { + font-size: 1.1rem; + color: var(--mb-color-primary); + } + + .resource-field-group--advanced { + background: color-mix(in srgb, var(--mb-color-surface) 68%, var(--mb-color-surface-container-high)); + } + + .resource-field-group__toggle { + --md-icon-button-icon-size: 20px; + --md-icon-button-icon-color: var(--mb-color-on-surface-variant); + --md-icon-button-hover-icon-color: var(--mb-color-on-surface); + --md-icon-button-focus-icon-color: var(--mb-color-on-surface); + --md-icon-button-pressed-icon-color: var(--mb-color-primary); + flex: 0 0 auto; + transition: transform var(--mb-duration-medium) var(--mb-ease-spring); + } + + .resource-field-group__toggle[aria-expanded="true"] { + --md-icon-button-icon-color: var(--mb-color-primary); + transform: rotate(90deg); + } + + .resource-field-group__switch { + display: inline-flex; + align-items: center; + justify-content: flex-end; + min-width: 64px; + } + + .resource-field-group__switch md-switch { + --md-switch-selected-track-color: var(--mb-color-primary); + --md-switch-selected-handle-color: var(--mb-color-on-primary); + --md-switch-selected-hover-track-color: var(--mb-color-primary); + --md-switch-selected-focus-track-color: var(--mb-color-primary); + --md-switch-selected-pressed-track-color: var(--mb-color-primary); + } + + /* ---- Summary row variant: compact, scannable list item ---- */ + .resource-editor-card--summary { + padding: 12px 16px; + } + + .resource-editor-card--summary .resource-editor-card__header { + align-items: center; + gap: 16px; + } + + .resource-editor-card--summary .resource-editor-card__identity { + gap: 6px; + } + + .resource-editor-card--summary .resource-editor-card__meta { + flex-wrap: nowrap; + align-items: center; + gap: 8px; + } + + /* Brand badge between the kind icon and the id in summary rows */ + .resource-editor-card__identity-line .lobe-brand-icon { + display: inline-flex; + color: var(--mb-color-on-surface-variant); + } + + /* ---- Embedded variant: card sits directly on the dialog surface (no nested card) ---- */ + .resource-editor-card--embedded { + border-radius: 0; + padding: 0; + background: transparent; + } + + .resource-editor-card--embedded:hover, + .resource-editor-card--embedded:focus-within { + background: transparent; + } + + /* ---- Material dialog (resource editor) ---- + md-dialog hard-codes a 560px host max-width. Override on the host element + (layout only) so the editor's multi-column form grid has room to breathe. */ + md-dialog.resource-editor-dialog { + --md-dialog-container-shape: var(--mb-shape-panel); + --md-dialog-container-color: var(--mb-color-surface-container); + --md-dialog-container-elevation: var(--mb-elevation-3); + max-width: min(900px, calc(100% - 48px)); + max-height: min(86vh, calc(100% - 48px)); + } + + md-dialog.resource-editor-dialog .material-dialog__content { + width: min(860px, 100%); + } + + /* Generic Material dialog headline + content helpers (emitted by MaterialDialog) */ + .material-dialog__headline { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + } + + .material-dialog__headline-text { + overflow-wrap: anywhere; + font-size: 1.02rem; + font-weight: 680; + color: var(--mb-color-on-surface); + } + + .material-dialog__close { + --md-icon-button-icon-size: 20px; + --md-icon-button-icon-color: var(--mb-color-on-surface-variant); + --md-icon-button-hover-icon-color: var(--mb-color-on-surface); + --md-icon-button-focus-icon-color: var(--mb-color-on-surface); + --md-icon-button-pressed-icon-color: var(--mb-color-primary); + } + + @media (max-width: 600px) { + .resource-editor-card--summary .resource-editor-card__header { + flex-wrap: wrap; + } + .resource-editor-card--summary .resource-editor-card__meta { + flex-wrap: wrap; + justify-content: flex-start; + } + md-dialog.resource-editor-dialog .material-dialog__content { + width: 100%; + } + } +`; diff --git a/webui/src/app/styles/responsive.ts b/webui/src/app/styles/responsive.ts new file mode 100644 index 00000000..509d4571 --- /dev/null +++ b/webui/src/app/styles/responsive.ts @@ -0,0 +1,135 @@ +export const responsiveStyles = ` @media (max-width: 760px) { + .top-app-bar { + align-items: flex-start; + flex-direction: column; + padding: 14px 16px; + } + + .top-app-bar__meta { + width: 100%; + flex-wrap: wrap; + justify-content: flex-start; + white-space: normal; + } + + .workspace { + grid-template-columns: 1fr; + align-content: start; + min-height: 0; + } + + .navigation-rail { + position: static; + top: auto; + z-index: 1; + height: auto; + flex-direction: row; + align-items: stretch; + justify-content: flex-start; + overflow-x: auto; + padding: 10px 12px; + scroll-snap-type: x proximity; + } + + .nav-item { + flex: 0 0 108px; + scroll-snap-align: start; + } + + .content-surface { + padding: 16px; + } + + .log-output { + max-height: 360px; + } + + .placeholder-panel { + min-height: 440px; + padding: 24px; + } + + .metric-grid, + .section-grid, + .usage-summary-grid, + .usage-chart-grid { + grid-template-columns: 1fr; + } + + .compact-list li { + grid-template-columns: 1fr; + gap: 4px; + } + + .resource-table { + min-width: 640px; + } + + .resource-editor-card__header { + display: grid; + } + + .resource-editor-card__meta { + min-width: 0; + justify-content: flex-start; + } + + .resource-editor-card__status { + justify-content: flex-start; + } + + .section-heading { + display: grid; + } + + .create-resource { + justify-items: stretch; + } + + .create-resource__add { + justify-content: center; + width: 100%; + } + + .form-grid, + .section-grid { + grid-template-columns: 1fr; + } + + .form-grid__compact, + .form-grid__medium, + .form-grid__wide, + .form-grid.create-resource__fields .form-field--create-track { + grid-column: 1 / -1; + max-width: none; + } + + .form-grid__reasoning-defaults { + grid-template-columns: 1fr; + } + + .form-grid.create-resource__fields .create-resource__context-window-row { + grid-template-columns: 1fr; + } + + } + + @media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } + + .resource-editor-card:hover, + .usage-metric:hover, + md-filled-button:active, + md-outlined-button:active, + .nav-item:hover .nav-item__icon md-icon { + transform: none !important; + } + } +`; diff --git a/webui/src/app/styles/sharedResources.ts b/webui/src/app/styles/sharedResources.ts new file mode 100644 index 00000000..1ed08d09 --- /dev/null +++ b/webui/src/app/styles/sharedResources.ts @@ -0,0 +1,311 @@ +export const sharedResourceStyles = ` .metric-card, + .content-panel, + .state-panel { + border-radius: var(--mb-shape-panel); + outline: 0; + background: var(--mb-color-surface-container); + } + + .metric-card { + min-height: 112px; + display: grid; + align-content: space-between; + gap: 18px; + padding: 20px; + } + + .metric-card span { + color: var(--mb-color-on-surface-variant); + font-size: 0.78rem; + font-weight: 650; + text-transform: uppercase; + } + + .metric-card strong { + overflow-wrap: anywhere; + font-size: 1.65rem; + line-height: 1.1; + font-weight: 680; + } + + .section-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr)); + gap: 16px; + } + + .content-panel, + .state-panel { + min-width: 0; + padding: 24px; + } + + .state-panel { + min-height: 280px; + display: grid; + align-content: center; + } + + .content-panel h2, + .state-panel h2 { + margin: 0 0 14px; + font-size: 1rem; + line-height: 1.3; + } + + .content-panel--subtle { + background: color-mix(in srgb, var(--mb-color-surface-container) 74%, var(--mb-color-surface)); + } + + .state-panel p:last-child { + margin: 0; + color: var(--mb-color-on-surface-variant); + line-height: 1.55; + } + + .compact-list { + display: grid; + gap: 10px; + margin: 0; + padding: 0; + list-style: none; + } + + .compact-list li { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + padding: 10px 0; + border-top: 1px solid color-mix(in srgb, var(--mb-color-outline) 24%, transparent); + } + + .compact-list li:first-child { + border-top: 0; + } + + .compact-list strong, + .compact-list span { + min-width: 0; + overflow-wrap: anywhere; + } + + .compact-list span, + .empty-state { + color: var(--mb-color-on-surface-variant); + } + + .empty-state { + margin: 0; + line-height: 1.55; + } + + .table-scroll { + overflow-x: auto; + } + + .resource-table { + width: 100%; + min-width: 720px; + border-collapse: collapse; + font-size: 0.92rem; + } + + .resource-table th, + .resource-table td { + padding: 13px 12px; + border-bottom: 1px solid color-mix(in srgb, var(--mb-color-outline) 28%, transparent); + text-align: left; + vertical-align: top; + } + + .resource-table th { + color: var(--mb-color-on-surface-variant); + font-size: 0.74rem; + font-weight: 720; + text-transform: uppercase; + } + + .resource-table td { + overflow-wrap: anywhere; + } + + .resource-table tbody tr:hover { + background: color-mix(in srgb, var(--mb-color-primary) 7%, transparent); + } + + .status-pill { + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 30px; + border-radius: var(--mb-shape-full); + padding: 0 12px; + color: var(--mb-color-on-primary-container); + background: var(--mb-color-primary-container); + font-size: 0.78rem; + font-weight: 600; + letter-spacing: 0.01em; + } + + .status-pill--muted { + color: var(--mb-color-on-surface); + background: var(--mb-color-surface-container-high); + } + + .status-pill--restartRequired, + .status-pill--critical { + --mb-status-danger-container: color-mix(in srgb, var(--mb-color-error) 16%, var(--mb-color-surface-container-highest)); + --mb-status-danger-label: color-mix(in srgb, var(--mb-color-error) 72%, var(--mb-color-on-surface)); + color: var(--mb-status-danger-label); + background: var(--mb-status-danger-container); + } + + .status-pill--needsAttention { + color: var(--mb-color-on-tertiary-container); + background: var(--mb-color-tertiary-container); + } + + .resource-card-list { + display: grid; + gap: 14px; + } + + .resource-section { + min-width: 0; + display: grid; + gap: 14px; + } + + .resource-section h2 { + margin: 0; + font-size: 1rem; + line-height: 1.3; + } + + .resource-card-list--compact { + gap: 10px; + } + + /* Summary rows: compact, evenly spaced scannable list (dialog-based editing). */ + .resource-card-list--summary { + gap: 10px; + } + + .model-resource-group, + .provider-resource-group { + display: grid; + gap: 10px; + } + + .model-provider-bindings .resource-card-list--compact { + gap: 12px; + } + + .section-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + } + + .section-heading h2 { + margin: 0; + } + + .section-heading--compact { + margin-bottom: 0; + } + + .section-heading--compact h3 { + margin: 0; + font-size: 0.9rem; + line-height: 1.25; + } + + .create-resource { + display: grid; + justify-items: end; + gap: 12px; + } + + .create-resource__add { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 40px; + padding: 0 20px; + --md-filled-button-container-color: var(--mb-color-secondary-container); + --md-filled-button-label-text-color: var(--mb-color-on-secondary-container); + --md-filled-button-hover-label-text-color: var(--mb-color-on-secondary-container); + --md-filled-button-focus-label-text-color: var(--mb-color-on-secondary-container); + --md-filled-button-pressed-label-text-color: var(--mb-color-on-secondary-container); + --md-filled-button-icon-color: var(--mb-color-on-secondary-container); + --md-filled-button-hover-icon-color: var(--mb-color-on-secondary-container); + --md-filled-button-focus-icon-color: var(--mb-color-on-secondary-container); + --md-filled-button-pressed-icon-color: var(--mb-color-on-secondary-container); + --md-filled-button-hover-state-layer-color: var(--mb-color-secondary); + } + + .create-resource__panel { + width: min(760px, 100%); + display: grid; + gap: 14px; + border-radius: var(--mb-shape-panel); + outline: 0; + padding: 18px 20px; + background: var(--mb-color-surface-container-high); + } + + .create-resource__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + } + + .create-resource__header h3 { + margin: 0; + font-size: 0.95rem; + line-height: 1.25; + } + + .form-grid.create-resource__fields { + width: 100%; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .form-grid.create-resource__fields .form-field--create-track { + grid-column: span 1; + } + + .form-grid.create-resource__fields .create-resource__context-window-row { + grid-column: 1 / -1; + display: grid; + grid-template-columns: minmax(220px, 1fr) auto; + align-items: start; + gap: 12px 18px; + } + + .create-resource__context-window-presets { + align-self: start; + min-height: 40px; + align-content: start; + } + + .icon-button { + --md-icon-button-state-layer-width: 36px; + --md-icon-button-state-layer-height: 36px; + --md-icon-button-icon-size: 20px; + --md-icon-button-icon-color: var(--mb-color-on-surface-variant); + --md-icon-button-hover-icon-color: var(--mb-color-primary); + --md-icon-button-focus-icon-color: var(--mb-color-primary); + --md-icon-button-pressed-icon-color: var(--mb-color-primary); + --md-icon-button-hover-state-layer-color: var(--mb-color-primary); + --md-icon-button-focus-state-layer-color: var(--mb-color-primary); + --md-icon-button-pressed-state-layer-color: var(--mb-color-primary); + --md-icon-button-hover-state-layer-opacity: 0.12; + --md-icon-button-focus-state-layer-opacity: 0.12; + } + +`; diff --git a/webui/src/app/styles/shellLayout.ts b/webui/src/app/styles/shellLayout.ts new file mode 100644 index 00000000..472ea513 --- /dev/null +++ b/webui/src/app/styles/shellLayout.ts @@ -0,0 +1,188 @@ +export const shellLayoutStyles = ` .workspace { + display: grid; + grid-template-columns: 96px minmax(0, 1fr); + min-height: calc(100vh - 69px); + } + + .navigation-rail { + position: sticky; + top: 69px; + align-self: start; + height: calc(100vh - 69px); + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 14px 8px; + outline: 0; + background: var(--mb-color-surface-container-low); + } + + .nav-item { + position: relative; + width: 100%; + min-height: 58px; + display: grid; + justify-items: center; + align-content: center; + gap: 5px; + padding: 6px 2px; + border-radius: var(--mb-shape-md); + color: var(--mb-color-on-surface-variant); + text-decoration: none; + -webkit-tap-highlight-color: transparent; + transition: color var(--mb-duration-medium) var(--mb-ease-standard); + } + + .nav-item__icon { + position: relative; + display: grid; + place-items: center; + width: 56px; + height: 32px; + border-radius: var(--mb-shape-full); + overflow: hidden; + isolation: isolate; + --md-ripple-hover-color: var(--mb-color-on-surface); + --md-ripple-pressed-color: var(--mb-color-primary); + --md-icon-size: 24px; + } + + .nav-item__icon md-icon { + position: relative; + z-index: 1; + transition: transform var(--mb-duration-medium) var(--mb-ease-spring); + } + + .nav-item__indicator { + position: absolute; + inset: 0; + z-index: 0; + border-radius: var(--mb-shape-full); + background: var(--mb-color-secondary-container); + } + + .nav-item__icon::after { + content: ""; + position: absolute; + inset: 0; + z-index: 0; + border-radius: inherit; + background: transparent; + transition: background var(--mb-duration-short) var(--mb-ease-standard); + } + + .nav-item:hover .nav-item__icon::after { + background: color-mix(in srgb, var(--mb-color-on-surface) 8%, transparent); + } + + .nav-item__label { + max-width: 90px; + font-size: 0.6875rem; + line-height: 1.15; + font-weight: 600; + letter-spacing: 0.01em; + text-align: center; + white-space: normal; + transition: color var(--mb-duration-medium) var(--mb-ease-standard); + } + + .nav-item:hover { + color: var(--mb-color-on-surface); + } + + .nav-item:hover .nav-item__icon md-icon { + transform: translateY(-1px) scale(1.08); + } + + .nav-item--active { + color: var(--mb-color-on-surface); + } + + .nav-item--active .nav-item__icon { + color: var(--mb-color-on-secondary-container); + } + + .nav-item--active .nav-item__label { + color: var(--mb-color-on-surface); + font-weight: 700; + } + + .nav-item:focus-visible { + outline: none; + } + + .nav-item:focus-visible .nav-item__icon { + outline: 2px solid var(--mb-color-primary); + outline-offset: 3px; + } + + .content-surface { + min-width: 0; + padding: 24px var(--mb-content-gutter); + } + + .placeholder-panel { + min-height: calc(100vh - 120px); + display: flex; + align-items: center; + border-radius: var(--mb-shape-panel); + outline: 0; + padding: 32px; + background: var(--mb-color-surface-container); + } + + .placeholder-panel > div { + max-width: 760px; + } + + .eyebrow { + margin: 0 0 10px; + color: var(--mb-color-primary); + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + } + + h1 { + margin: 0; + font-size: clamp(2rem, 4vw, 3.5rem); + line-height: 1.05; + font-weight: 650; + } + + .placeholder-panel p:last-child { + margin: 18px 0 0; + max-width: 620px; + color: var(--mb-color-on-surface-variant); + font-size: 1rem; + line-height: 1.6; + } + + .page-stack { + display: grid; + gap: 20px; + /* Center the page content within the available rail+content area and cap it at the + content measure so wide/ultrawide screens stay readable instead of stretching. */ + width: min(100%, var(--mb-content-max)); + margin-inline: auto; + } + + .page-header { + max-width: none; + } + + .page-header h1 { + font: var(--mb-type-display); + letter-spacing: var(--mb-tracking-display); + } + + .page-header p:last-child { + margin: 12px 0 0; + max-width: 68ch; + color: var(--mb-color-on-surface-variant); + font-size: 0.95rem; + line-height: 1.55; + } + +`; diff --git a/webui/src/app/styles/shellStyles.ts b/webui/src/app/styles/shellStyles.ts new file mode 100644 index 00000000..da3ccc08 --- /dev/null +++ b/webui/src/app/styles/shellStyles.ts @@ -0,0 +1,19 @@ +import { baseStyles } from "./base"; +import { shellLayoutStyles } from "./shellLayout"; +import { overviewStyles } from "./overview"; +import { sharedResourceStyles } from "./sharedResources"; +import { resourceEditorStyles } from "./resourceEditor"; +import { formStyles } from "./forms"; +import { logStyles } from "./logs"; +import { responsiveStyles } from "./responsive"; + +export const shellStyles = [ + baseStyles, + shellLayoutStyles, + overviewStyles, + sharedResourceStyles, + resourceEditorStyles, + formStyles, + logStyles, + responsiveStyles +].join(""); diff --git a/webui/src/components/AuthGate.test.tsx b/webui/src/components/AuthGate.test.tsx new file mode 100644 index 00000000..1716cc8a --- /dev/null +++ b/webui/src/components/AuthGate.test.tsx @@ -0,0 +1,191 @@ +import { act, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithConsoleProviders } from "../test/renderWithConsoleProviders"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { ApiError } from "../rpc/http"; +import { expectPanelElementToBeFlat } from "../test/panelStyleAssertions"; +import { AuthGate } from "./AuthGate"; + +describe("AuthGate", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("renders children when there is no auth error", () => { + renderWithConsoleProviders(Console content); + + expect(screen.getByText("Console content")).toBeInTheDocument(); + }); + + test("calls onSubmit with the token and remember flag", async () => { + const onSubmit = vi.fn(); + + renderWithConsoleProviders( + + Console content + + ); + + const tokenField = getMaterialTextField(document, "Token"); + const submitButton = getMaterialButton(document, "Save token"); + expect(tokenField.type).toBe("password"); + expect(submitButton.type).toBe("submit"); + + setMaterialTextFieldValue(tokenField, "secret-token"); + await submitAuthForm(submitButton); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledWith("secret-token", false)); + }); + + test("toggles token visibility through the trailing icon button", async () => { + renderWithConsoleProviders( + + Console content + + ); + + const tokenField = getMaterialTextField(document, "Token"); + // Hidden by default; the trailing toggle reveals it. + expect(tokenField.type).toBe("password"); + const showButton = getMaterialIconButton(document, "Show token"); + + await userEvent.click(showButton); + + expect(tokenField.type).toBe("text"); + expect(getMaterialIconButton(document, "Hide token")).toBeInTheDocument(); + }); + + test("forwards remember=true when the checkbox is checked", async () => { + const onSubmit = vi.fn(); + + renderWithConsoleProviders( + + Console content + + ); + + const tokenField = getMaterialTextField(document, "Token"); + const rememberCheckbox = getMaterialCheckbox(document, "Remember on this device"); + + setMaterialTextFieldValue(tokenField, "remembered-token"); + setMaterialCheckboxChecked(rememberCheckbox, true); + await submitAuthForm(getMaterialButton(document, "Save token")); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledWith("remembered-token", true)); + }); + + test("disables and relabels the submit button while pending", () => { + renderWithConsoleProviders( + + Console content + + ); + + const submitButton = getMaterialButton(document, "Verifying…"); + expect(submitButton).toBeInTheDocument(); + expect((submitButton as unknown as { disabled: boolean }).disabled).toBe(true); + }); + + test("localizes authentication controls in Chinese locale", () => { + renderWithConsoleProviders( + + Console content + , + { locale: "zh-CN" } + ); + + expect(getMaterialTextField(document, "Token")).toBeInTheDocument(); + expect(getMaterialCheckbox(document, "在此设备记住")).toBeInTheDocument(); + expect(getMaterialButton(document, "保存 Token")).toBeInTheDocument(); + }); + + test("renders the auth background panel without borders or glow", () => { + renderWithConsoleProviders( + + Console content + + ); + + const authCard = document.querySelector(".auth-card"); + expect(authCard).toBeInTheDocument(); + expectPanelElementToBeFlat(authCard!); + }); +}); + +function getMaterialTextField(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-outlined-text-field")).find( + (textField) => (textField as HTMLElement & { label: string }).label === label + ); + if (!element) { + throw new Error(`Expected a Material Web text field labelled "${label}".`); + } + return element as HTMLElement & { label: string; type: string; value: string }; +} + +function getMaterialIconButton(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-icon-button")).find( + (button) => button.getAttribute("aria-label") === label + ); + if (!element) { + throw new Error(`Expected a Material Web icon button labelled "${label}".`); + } + return element as HTMLElement; +} + +function getMaterialCheckbox(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-checkbox")).find( + (checkbox) => checkbox.getAttribute("aria-label") === label + ); + if (!element) { + throw new Error(`Expected a Material Web checkbox labelled "${label}".`); + } + return element as HTMLElement & { checked: boolean }; +} + +function getMaterialButton(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-filled-button")).find( + (button) => button.textContent?.trim() === label + ); + if (!element) { + throw new Error(`Expected a Material Web filled button labelled "${label}".`); + } + return element as HTMLElement & { type: string }; +} + +function setMaterialTextFieldValue(element: HTMLElement & { value: string }, value: string) { + act(() => { + element.value = value; + element.dispatchEvent(new Event("input", { bubbles: true })); + }); +} + +function setMaterialCheckboxChecked(element: HTMLElement & { checked: boolean }, checked: boolean) { + act(() => { + element.checked = checked; + element.dispatchEvent(new Event("change", { bubbles: true })); + }); +} + +async function submitAuthForm(button: HTMLElement) { + const form = button.closest("form"); + if (!form) { + throw new Error("Expected Material Web submit button inside AuthGate form."); + } + let clicked = false; + let submitted = false; + button.addEventListener("click", () => { + clicked = true; + }, { once: true }); + form.addEventListener("submit", () => { + submitted = true; + }, { once: true }); + await userEvent.click(button); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(clicked).toBe(true); + if (!submitted) { + await act(async () => { + form.requestSubmit(); + await Promise.resolve(); + }); + } +} diff --git a/webui/src/components/AuthGate.tsx b/webui/src/components/AuthGate.tsx new file mode 100644 index 00000000..3744b5dd --- /dev/null +++ b/webui/src/components/AuthGate.tsx @@ -0,0 +1,95 @@ +import { createElement, type FormEvent, type ReactNode, useState } from "react"; +import { motion } from "motion/react"; +import { MaterialFilledButton, MaterialIconButton } from "./MaterialButton"; +import { MaterialCheckbox } from "./MaterialCheckbox"; +import { MaterialOutlinedTextField } from "./MaterialTextField"; +import { type ApiError, isAuthError } from "../rpc/http"; +import { useI18n } from "../i18n/I18nProvider"; +import { springs, surfaceMotion } from "../theme/motion"; + +type AuthGateProps = { + children: ReactNode; + /** When this is a 401 error the login card is shown; otherwise children render. */ + error?: unknown; + /** True while a submitted token is being verified — disables + relabels submit. */ + pending?: boolean; + /** Called with the trimmed token and the remember flag on submit. */ + onSubmit?: (token: string, remember: boolean) => void | Promise; +}; + +export function AuthGate({ children, error, pending = false, onSubmit }: AuthGateProps) { + const { t } = useI18n(); + const [token, setToken] = useState(""); + const [remember, setRemember] = useState(false); + const [revealed, setRevealed] = useState(false); + + if (!isAuthError(error)) { + return <>{children}; + } + + async function submit(event: FormEvent) { + event.preventDefault(); + const value = token.trim(); + if (!value || pending) { + return; + } + await onSubmit?.(value, remember); + } + + const apiError = error as ApiError; + + return ( +
    + + +

    {t("auth.eyebrow")}

    +

    {t("auth.title")}

    +

    {apiError.message}

    + setRevealed((current) => !current)} + onMouseDown={(event) => event.preventDefault()} + slot="trailing-icon" + /> + } + /> + + + {pending ? t("auth.verifying") : t("action.saveToken")} + +
    +
    + ); +} diff --git a/webui/src/components/ErrorState.tsx b/webui/src/components/ErrorState.tsx new file mode 100644 index 00000000..4ae65fb7 --- /dev/null +++ b/webui/src/components/ErrorState.tsx @@ -0,0 +1,12 @@ +import { useI18n } from "../i18n/I18nProvider"; + +export function ErrorState({ title, message }: { title?: string; message: string }) { + const { t } = useI18n(); + return ( +
    +

    {t("common.error")}

    +

    {title ?? t("error.requestFailed")}

    +

    {message}

    +
    + ); +} diff --git a/webui/src/components/LoadingState.tsx b/webui/src/components/LoadingState.tsx new file mode 100644 index 00000000..31fbd177 --- /dev/null +++ b/webui/src/components/LoadingState.tsx @@ -0,0 +1,11 @@ +import { useI18n } from "../i18n/I18nProvider"; + +export function LoadingState({ label }: { label?: string }) { + const { t } = useI18n(); + return ( +
    +

    {t("common.loading")}

    +

    {label ?? t("common.loading")}

    +
    + ); +} diff --git a/webui/src/components/MaterialButton.tsx b/webui/src/components/MaterialButton.tsx new file mode 100644 index 00000000..4b43c12e --- /dev/null +++ b/webui/src/components/MaterialButton.tsx @@ -0,0 +1,161 @@ +import "@material/web/button/filled-button.js"; +import "@material/web/button/outlined-button.js"; +import "@material/web/icon/icon.js"; +import "@material/web/iconbutton/icon-button.js"; +import { createElement, forwardRef, type KeyboardEvent, type MouseEvent, type ReactNode } from "react"; +import type { MdOutlinedButton } from "@material/web/button/outlined-button.js"; +import type { MdIconButton } from "@material/web/iconbutton/icon-button.js"; + +type MaterialOutlinedButtonProps = { + ariaExpanded?: boolean; + ariaLabel?: string; + ariaPressed?: boolean; + children: ReactNode; + className?: string; + controls?: string; + disabled?: boolean; + id?: string; + icon?: string; + onClick: (event: MouseEvent) => void; + type?: "button" | "reset" | "submit"; +}; + +type MaterialFilledButtonProps = { + ariaLabel?: string; + ariaPressed?: boolean; + children: ReactNode; + className?: string; + disabled?: boolean; + icon?: string; + onClick?: () => void; + type?: "button" | "reset" | "submit"; +}; + +type MaterialIconButtonProps = { + ariaExpanded?: boolean; + className?: string; + controls?: string; + describedBy?: string; + disabled?: boolean; + icon: string; + label: string; + onBlur?: () => void; + onClick: (event: MouseEvent) => void; + onFocus?: () => void; + onKeyDown?: (event: KeyboardEvent) => void; + onMouseDown?: (event: MouseEvent) => void; + onMouseEnter?: () => void; + onMouseLeave?: () => void; + slot?: string; +}; + +export const MaterialOutlinedButton = forwardRef( + function MaterialOutlinedButton({ + ariaExpanded, + ariaLabel, + ariaPressed, + children, + className, + controls, + disabled = false, + id, + icon, + onClick, + type = "button" + }: MaterialOutlinedButtonProps, ref) { + return ( + + {icon ? createElement("md-icon", { slot: "icon" }, icon) : null} + {children} + + ); + } +); + +export function MaterialFilledButton({ + ariaLabel, + ariaPressed, + children, + className, + disabled = false, + icon, + onClick, + type = "button" +}: MaterialFilledButtonProps) { + return ( + + {icon ? createElement("md-icon", { slot: "icon" }, icon) : null} + {children} + + ); +} + +export const MaterialIconButton = forwardRef( + function MaterialIconButton({ + ariaExpanded, + className, + controls, + describedBy, + disabled = false, + icon, + label, + onBlur, + onClick, + onFocus, + onKeyDown, + onMouseDown, + onMouseEnter, + onMouseLeave, + slot + }: MaterialIconButtonProps, ref) { + return ( + + {createElement("md-icon", null, icon)} + + ); + } +); + +function ariaBoolean(value: boolean | undefined): "true" | "false" | undefined { + if (value === undefined) { + return undefined; + } + return value ? "true" : "false"; +} diff --git a/webui/src/components/MaterialCheckbox.tsx b/webui/src/components/MaterialCheckbox.tsx new file mode 100644 index 00000000..4ac143a0 --- /dev/null +++ b/webui/src/components/MaterialCheckbox.tsx @@ -0,0 +1,33 @@ +import "@material/web/checkbox/checkbox.js"; +import { useEffect, useRef } from "react"; +import type { MdCheckbox } from "@material/web/checkbox/checkbox.js"; + +type MaterialCheckboxProps = { + checked: boolean; + className?: string; + label: string; + onChange: (checked: boolean) => void; +}; + +export function MaterialCheckbox({ checked, className, label, onChange }: MaterialCheckboxProps) { + const checkboxRef = useRef(null); + + useEffect(() => { + const checkbox = checkboxRef.current; + if (!checkbox) { + throw new Error("MaterialCheckbox rendered before md-checkbox was registered."); + } + const handleChange = () => onChange(checkbox.checked); + checkbox.addEventListener("change", handleChange); + return () => checkbox.removeEventListener("change", handleChange); + }, [onChange]); + + useEffect(() => { + if (!checkboxRef.current) { + throw new Error("MaterialCheckbox rendered before md-checkbox was registered."); + } + checkboxRef.current.checked = checked; + }, [checked]); + + return ; +} diff --git a/webui/src/components/MaterialDialog.test.tsx b/webui/src/components/MaterialDialog.test.tsx new file mode 100644 index 00000000..6adc6bf4 --- /dev/null +++ b/webui/src/components/MaterialDialog.test.tsx @@ -0,0 +1,62 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import { MaterialDialog } from "./MaterialDialog"; + +describe("MaterialDialog", () => { + test("renders the official md-dialog host with aria-label, headline and content", () => { + const { container } = render( + undefined} ariaLabel="Edit Route primary" headline="Edit Route"> +

    route fields

    +
    + ); + + const dialog = container.querySelector("md-dialog"); + expect(dialog).toBeInTheDocument(); + expect(dialog).toHaveAttribute("aria-label", "Edit Route primary"); + + const headlineSlot = container.querySelector('[slot="headline"]'); + expect(headlineSlot).toBeInTheDocument(); + expect(screen.getByText("Edit Route")).toBeInTheDocument(); + + const contentSlot = container.querySelector('[slot="content"]'); + expect(contentSlot).toBeInTheDocument(); + expect(screen.getByText("route fields")).toBeInTheDocument(); + }); + + test("renders an official Material icon button to close the dialog", () => { + const { container } = render( + undefined} headline="Edit Route"> + body + + ); + + const closeButton = container.querySelector('md-icon-button[aria-label="Close"]'); + expect(closeButton).toBeInTheDocument(); + }); + + test("invokes onClose when the close button is clicked", () => { + const onClose = vi.fn(); + const { container } = render( + + body + + ); + + fireEvent.click(container.querySelector('md-icon-button[aria-label="Close"]')!); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + test("invokes onClose when md-dialog dispatches its close event (scrim/Escape)", () => { + const onClose = vi.fn(); + const { container } = render( + + body + + ); + + const dialog = container.querySelector("md-dialog")!; + dialog.dispatchEvent(new Event("close")); + + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/webui/src/components/MaterialDialog.tsx b/webui/src/components/MaterialDialog.tsx new file mode 100644 index 00000000..3be3dfce --- /dev/null +++ b/webui/src/components/MaterialDialog.tsx @@ -0,0 +1,89 @@ +import "@material/web/dialog/dialog.js"; +import { createElement, useEffect, useRef, type ReactNode } from "react"; +import type { MdDialog } from "@material/web/dialog/dialog.js"; +import { MaterialIconButton } from "./MaterialButton"; + +type MaterialDialogProps = { + /** Controlled open state. When true the underlying md-dialog is shown. */ + open: boolean; + /** Called when the dialog closes (scrim click, Escape, or the close button). */ + onClose: () => void; + /** Accessible name for the dialog surface. */ + ariaLabel?: string; + /** Headline text/node rendered in the headline slot. */ + headline?: ReactNode; + /** Footer actions rendered in the actions slot. */ + actions?: ReactNode; + className?: string; + children: ReactNode; +}; + +/** + * React bridge over the official Material Web `md-dialog`. + * + * The element owns its own show/close animations; this wrapper reconciles the + * controlled `open` prop with the imperative `show()`/`close()` API and forwards + * the `close` event (fired on scrim/Escape/programmatic close) back as `onClose`. + */ +export function MaterialDialog({ + open, + onClose, + ariaLabel, + headline, + actions, + className, + children +}: MaterialDialogProps) { + const ref = useRef(null); + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + + // Reconcile the controlled open flag with the imperative md-dialog API. + useEffect(() => { + const el = ref.current; + if (!el) { + return; + } + if (open && typeof el.show === "function" && !el.open) { + el.show().catch(() => { + /* show() rejects if already showing or not connected; safe to ignore */ + }); + } else if (!open && typeof el.close === "function" && el.open) { + el.close(); + } + }, [open]); + + // Forward close events (scrim / Escape / programmatic) back to React. + useEffect(() => { + const el = ref.current; + if (!el) { + return; + } + const handleClose = () => onCloseRef.current(); + el.addEventListener("close", handleClose); + return () => el.removeEventListener("close", handleClose); + }, []); + + return ( + + {headline ? ( +
    + {headline} + +
    + ) : null} +
    + {children} +
    + {actions ?
    {actions}
    : null} +
    + ); +} + +// Re-exported for callers that need to render the host element directly. +export { createElement }; diff --git a/webui/src/components/MaterialFilterChip.tsx b/webui/src/components/MaterialFilterChip.tsx new file mode 100644 index 00000000..42df5b7d --- /dev/null +++ b/webui/src/components/MaterialFilterChip.tsx @@ -0,0 +1,57 @@ +import "@material/web/chips/assist-chip.js"; +import "@material/web/chips/chip-set.js"; +import "@material/web/chips/filter-chip.js"; +import { createElement, useEffect, useRef, type ReactNode } from "react"; +import type { MdFilterChip } from "@material/web/chips/filter-chip.js"; + +type MaterialFilterChipProps = { + children: ReactNode; + onSelect: (value: Value) => void; + selected: boolean; + value: Value; +}; + +export function MaterialFilterChip({ + children, + onSelect, + selected, + value +}: MaterialFilterChipProps) { + const chipRef = useRef(null); + + useEffect(() => { + const chip = chipRef.current; + if (!chip) { + throw new Error("MaterialFilterChip rendered before md-filter-chip was registered."); + } + const handleClick = () => { + onSelect(value); + if (selected) { + window.requestAnimationFrame(() => { + if (chipRef.current) { + chipRef.current.selected = true; + } + }); + } + }; + chip.addEventListener("click", handleClick); + return () => chip.removeEventListener("click", handleClick); + }, [onSelect, selected, value]); + + useEffect(() => { + if (!chipRef.current) { + throw new Error("MaterialFilterChip rendered before md-filter-chip was registered."); + } + chipRef.current.selected = selected; + }, [selected]); + + return ( + + {children} + + ); +} + +export function MaterialAssistChip({ children }: { children: ReactNode }) { + return createElement("md-assist-chip", null, children); +} diff --git a/webui/src/components/MaterialInputChip.tsx b/webui/src/components/MaterialInputChip.tsx new file mode 100644 index 00000000..6233ecd5 --- /dev/null +++ b/webui/src/components/MaterialInputChip.tsx @@ -0,0 +1,53 @@ +import "@material/web/chips/input-chip.js"; +import { useEffect, useRef, type ReactNode } from "react"; +import type { MdInputChip } from "@material/web/chips/input-chip.js"; + +type MaterialInputChipProps = { + children: ReactNode; + className?: string; + disabled?: boolean; + label: string; + onRemove: () => void; +}; + +export function MaterialInputChip({ + children, + className, + disabled = false, + label, + onRemove +}: MaterialInputChipProps) { + const chipRef = useRef(null); + + useEffect(() => { + const chip = chipRef.current; + if (!chip) { + throw new Error("MaterialInputChip rendered before md-input-chip was registered."); + } + const handleRemove = () => onRemove(); + chip.addEventListener("remove", handleRemove); + return () => chip.removeEventListener("remove", handleRemove); + }, [onRemove]); + + useEffect(() => { + const chip = chipRef.current; + if (!chip) { + throw new Error("MaterialInputChip rendered before md-input-chip was registered."); + } + chip.disabled = disabled; + chip.removeOnly = true; + chip.setAttribute("aria-label", label); + }, [disabled, label]); + + return ( + + {children} + + ); +} diff --git a/webui/src/components/MaterialSelect.tsx b/webui/src/components/MaterialSelect.tsx new file mode 100644 index 00000000..e9622fb3 --- /dev/null +++ b/webui/src/components/MaterialSelect.tsx @@ -0,0 +1,158 @@ +import "@material/web/select/outlined-select.js"; +import "@material/web/select/select-option.js"; +import { type ReactNode, useEffect, useRef } from "react"; +import type { MdOutlinedSelect } from "@material/web/select/outlined-select.js"; +import type { MdSelectOption } from "@material/web/select/select-option.js"; + +export type MaterialSelectOption = { + label: string; + leadingIcon?: ReactNode; + value: string; +}; + +type MaterialSelectProps = { + ariaLabel?: string; + className?: string; + describedBy?: string; + disabled?: boolean; + error?: boolean; + errorText?: string; + label: string; + leadingIcon?: ReactNode; + onChange: (value: string) => void; + options: MaterialSelectOption[]; + required?: boolean; + supportingText?: string; + value: string; +}; + +export function MaterialSelect({ + ariaLabel, + className, + describedBy, + disabled = false, + error = false, + errorText, + label, + leadingIcon, + onChange, + options, + required = false, + supportingText, + value +}: MaterialSelectProps) { + const selectRef = useRef(null); + + useEffect(() => { + const selectElement = selectRef.current; + if (!selectElement) { + throw new Error("MaterialSelect rendered before md-outlined-select was registered."); + } + const handleChange = () => onChange(selectedMaterialSelectValue(selectElement)); + selectElement.addEventListener("change", handleChange); + return () => selectElement.removeEventListener("change", handleChange); + }, [onChange]); + + useEffect(() => { + const selectElement = selectRef.current; + if (!selectElement) { + throw new Error("MaterialSelect rendered before md-outlined-select was registered."); + } + selectElement.label = label; + selectElement.disabled = disabled; + selectElement.error = error; + selectElement.errorText = errorText ?? ""; + selectElement.required = required; + selectElement.supportingText = supportingText ?? ""; + selectElement.menuPositioning = "popover"; + selectElement.clampMenuWidth = true; + selectElement.value = value; + if (label) { + selectElement.setAttribute("label", label); + } else { + selectElement.removeAttribute("label"); + } + if (ariaLabel) { + selectElement.setAttribute("aria-label", ariaLabel); + } else { + selectElement.removeAttribute("aria-label"); + } + if (describedBy) { + selectElement.setAttribute("aria-describedby", describedBy); + } else { + selectElement.removeAttribute("aria-describedby"); + } + }, [ariaLabel, describedBy, disabled, error, errorText, label, required, supportingText, value, options]); + + const resolvedClassName = mergeClassNames(className, "material-select--single-line"); + + return ( + + {leadingIcon ? ( + + ) : null} + {options.map((option) => ( + + ))} + + ); +} + +function selectedMaterialSelectValue(selectElement: MdOutlinedSelect) { + if (selectElement.value) { + return selectElement.value; + } + const selectedOption = Array.from(selectElement.querySelectorAll("md-select-option")) + .find((option) => option.selected); + return selectedOption?.value || selectedOption?.getAttribute("value") || selectedOption?.getAttribute("data-value") || ""; +} + +function mergeClassNames(...classNames: Array) { + return classNames.filter(Boolean).join(" "); +} + +type MaterialSelectOptionElementProps = { + label: string; + leadingIcon?: ReactNode; + selected: boolean; + value: string; +}; + +function MaterialSelectOptionElement({ label, leadingIcon, selected, value }: MaterialSelectOptionElementProps) { + const optionRef = useRef(null); + + useEffect(() => { + const optionElement = optionRef.current; + if (!optionElement) { + throw new Error("MaterialSelectOption rendered before md-select-option was registered."); + } + optionElement.value = value; + optionElement.displayText = label; + optionElement.selected = selected; + }, [label, selected, value]); + + return ( + + {leadingIcon ? ( + + ) : null} + {label} + + ); +} diff --git a/webui/src/components/MaterialSwitch.tsx b/webui/src/components/MaterialSwitch.tsx new file mode 100644 index 00000000..8bdfb3a5 --- /dev/null +++ b/webui/src/components/MaterialSwitch.tsx @@ -0,0 +1,45 @@ +import "@material/web/switch/switch.js"; +import { useEffect, useRef } from "react"; +import type { MdSwitch } from "@material/web/switch/switch.js"; + +type MaterialSwitchProps = { + disabled?: boolean; + label: string; + onChange: (selected: boolean) => void; + selected: boolean; +}; + +export function MaterialSwitch({ disabled = false, label, onChange, selected }: MaterialSwitchProps) { + const switchRef = useRef(null); + + useEffect(() => { + const switchElement = switchRef.current; + if (!switchElement) { + throw new Error("MaterialSwitch rendered before md-switch was registered."); + } + const handleChange = () => onChange(switchElement.selected); + switchElement.addEventListener("change", handleChange); + return () => switchElement.removeEventListener("change", handleChange); + }, [onChange]); + + useEffect(() => { + if (!switchRef.current) { + throw new Error("MaterialSwitch rendered before md-switch was registered."); + } + switchRef.current.selected = selected; + }, [selected]); + + useEffect(() => { + if (!switchRef.current) { + throw new Error("MaterialSwitch rendered before md-switch was registered."); + } + switchRef.current.disabled = disabled; + }, [disabled]); + + return ( + + ); +} diff --git a/webui/src/components/MaterialTextField.test.tsx b/webui/src/components/MaterialTextField.test.tsx new file mode 100644 index 00000000..b7cf77c5 --- /dev/null +++ b/webui/src/components/MaterialTextField.test.tsx @@ -0,0 +1,88 @@ +import { render } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import { MaterialFilledTextField, MaterialOutlinedTextField } from "./MaterialTextField"; + +describe("MaterialTextField", () => { + test("marks single-line outlined text fields with the compact density class", () => { + const { container } = render( + undefined} + /> + ); + + const field = getOutlinedTextField(container, "Model"); + + expect(field).toHaveClass("external-field"); + expect(field).toHaveClass("material-text-field--single-line"); + }); + + test("serializes the Material label on the host element", () => { + const { container } = render( + undefined} + /> + ); + + expect(getOutlinedTextField(container, "Max uses")).toHaveAttribute("label", "Max uses"); + }); + + test("marks single-line filled text fields with the compact density class", () => { + const { container } = render( + undefined} + /> + ); + + const field = getFilledTextField(container, "Token"); + + expect(field).toHaveClass("auth-token-field"); + expect(field).toHaveClass("material-text-field--single-line"); + }); + + test("keeps textarea text fields out of the single-line density class", () => { + const { container } = render( + undefined} + /> + ); + + expect(getOutlinedTextField(container, "Input")).not.toHaveClass("material-text-field--single-line"); + }); +}); + +type MaterialTextFieldElement = HTMLElement & { + label: string; +}; + +function getOutlinedTextField(container: ParentNode, label: string) { + const field = Array.from(container.querySelectorAll("md-outlined-text-field")).find( + (candidate) => candidate.label === label + ); + if (!field) { + throw new Error(`Expected outlined text field labelled "${label}".`); + } + return field; +} + +function getFilledTextField(container: ParentNode, label: string) { + const field = Array.from(container.querySelectorAll("md-filled-text-field")).find( + (candidate) => candidate.label === label + ); + if (!field) { + throw new Error(`Expected filled text field labelled "${label}".`); + } + return field; +} diff --git a/webui/src/components/MaterialTextField.tsx b/webui/src/components/MaterialTextField.tsx new file mode 100644 index 00000000..ba5c4790 --- /dev/null +++ b/webui/src/components/MaterialTextField.tsx @@ -0,0 +1,252 @@ +import "@material/web/textfield/filled-text-field.js"; +import "@material/web/textfield/outlined-text-field.js"; +import { createElement, forwardRef, type ForwardedRef, type ReactNode, useCallback, useEffect, useRef } from "react"; +import type { MdFilledTextField } from "@material/web/textfield/filled-text-field.js"; +import type { MdOutlinedTextField, TextFieldType } from "@material/web/textfield/outlined-text-field.js"; + +type MaterialTextFieldProps = { + ariaDescribedBy?: string; + ariaLabel?: string; + ariaInvalid?: boolean; + autoFocus?: boolean; + autoComplete?: string; + disabled?: boolean; + error?: boolean; + errorText?: string; + className?: string; + id?: string; + label: string; + leadingIcon?: string; + leadingIconNode?: ReactNode; + onBlur?: () => void; + onInput: (value: string) => void; + inputMode?: string; + rows?: number; + spellCheck?: boolean; + step?: string; + supportingText?: string; + required?: boolean; + trailingIcon?: ReactNode; + type?: TextFieldType; + value: string; +}; + +export type MaterialTextFieldElement = MdFilledTextField | MdOutlinedTextField; + +export const MaterialFilledTextField = forwardRef( + function MaterialFilledTextField(props, ref) { + return ; + } +); + +export const MaterialOutlinedTextField = forwardRef( + function MaterialOutlinedTextField({ + ariaDescribedBy, + ariaLabel, + ariaInvalid, + autoFocus, + autoComplete, + className, + disabled, + error, + errorText, + id, + label, + leadingIcon, + leadingIconNode, + onBlur, + onInput, + inputMode, + rows, + spellCheck, + step, + supportingText, + required, + trailingIcon, + type = "text", + value + }: MaterialTextFieldProps, ref) { + return ( + + ); +}); + +type MaterialTextFieldTagName = "md-filled-text-field" | "md-outlined-text-field"; + +const MaterialTextField = forwardRef( + function MaterialTextField({ + ariaDescribedBy, + ariaLabel, + ariaInvalid = false, + autoFocus = false, + autoComplete, + className, + disabled = false, + error = false, + errorText, + id, + label, + leadingIcon, + leadingIconNode, + onBlur, + onInput, + inputMode, + rows, + spellCheck, + step, + supportingText, + required = false, + trailingIcon, + type = "text", + value, + tagName + }, forwardedRef) { + const fieldRef = useRef(null); + const setFieldRef = useCallback((field: MaterialTextFieldElement | null) => { + fieldRef.current = field; + assignRef(forwardedRef, field); + }, [forwardedRef]); + + useEffect(() => { + const field = fieldRef.current; + if (!field) { + throw new Error("MaterialTextField rendered before the Material Web text field was registered."); + } + const handleInput = () => onInput(field.value); + field.addEventListener("input", handleInput); + return () => field.removeEventListener("input", handleInput); + }, [onInput]); + + useEffect(() => { + const field = fieldRef.current; + if (!field) { + throw new Error("MaterialTextField rendered before the Material Web text field was registered."); + } + field.label = label; + field.type = type; + field.value = value; + field.autocomplete = autoComplete ?? ""; + field.disabled = disabled; + field.error = error; + field.errorText = errorText ?? ""; + field.supportingText = supportingText ?? ""; + field.required = required; + field.spellcheck = spellCheck ?? false; + field.setAttribute("spellcheck", spellCheck ?? false ? "true" : "false"); + if (label) { + field.setAttribute("label", label); + } else { + field.removeAttribute("label"); + } + field.inputMode = inputMode ?? ""; + if (ariaLabel) { + field.setAttribute("aria-label", ariaLabel); + } else { + field.removeAttribute("aria-label"); + } + if (ariaInvalid) { + field.setAttribute("aria-invalid", "true"); + } else { + field.removeAttribute("aria-invalid"); + } + if (ariaDescribedBy) { + field.setAttribute("aria-describedby", ariaDescribedBy); + } else { + field.removeAttribute("aria-describedby"); + } + if (rows !== undefined) { + field.rows = rows; + } + if (step !== undefined) { + field.step = step; + } + }, [ + ariaDescribedBy, + ariaInvalid, + ariaLabel, + autoComplete, + disabled, + error, + errorText, + inputMode, + label, + rows, + spellCheck, + step, + supportingText, + required, + type, + value + ]); + + const resolvedClassName = mergeClassNames( + className, + type !== "textarea" && rows === undefined ? "material-text-field--single-line" : undefined + ); + + return createElement( + tagName, + { + ref: setFieldRef, + autoFocus, + className: resolvedClassName, + id, + onBlur + }, + renderLeadingIcon(leadingIcon, leadingIconNode), + trailingIcon + ); + } +); + +function renderLeadingIcon(leadingIcon: string | undefined, leadingIconNode: ReactNode | undefined) { + if (leadingIconNode) { + return createElement( + "span", + { "aria-hidden": "true", className: "material-field-leading-node", slot: "leading-icon" }, + leadingIconNode + ); + } + return leadingIcon ? createElement("md-icon", { slot: "leading-icon" }, leadingIcon) : null; +} + +function mergeClassNames(...classNames: Array) { + return classNames.filter(Boolean).join(" "); +} + +function assignRef(ref: ForwardedRef, value: T | null) { + if (typeof ref === "function") { + ref(value); + return; + } + if (ref) { + ref.current = value; + } +} diff --git a/webui/src/components/ResourceTable.tsx b/webui/src/components/ResourceTable.tsx new file mode 100644 index 00000000..df8c112c --- /dev/null +++ b/webui/src/components/ResourceTable.tsx @@ -0,0 +1,73 @@ +import { + createColumnHelper, + flexRender, + getCoreRowModel, + useReactTable, + type ColumnDef +} from "@tanstack/react-table"; +import type { ReactNode } from "react"; +import { useI18n } from "../i18n/I18nProvider"; + +export type ResourceColumn = { + header: string; + accessor: (row: T) => ReactNode; +}; + +type ResourceTableProps = { + columns: ResourceColumn[]; + data: T[]; + emptyLabel?: string; +}; + +export function ResourceTable({ + columns, + data, + emptyLabel +}: ResourceTableProps) { + const { t } = useI18n(); + const columnHelper = createColumnHelper(); + const table = useReactTable({ + data, + columns: columns.map((column, index) => + columnHelper.display({ + id: `${index}-${column.header}`, + header: column.header, + cell: (context) => column.accessor(context.row.original) + }) + ) as ColumnDef[], + getCoreRowModel: getCoreRowModel() + }); + + if (data.length === 0) { + return

    {emptyLabel ?? t("empty.resources")}

    ; + } + + return ( +
    + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
    + {flexRender(header.column.columnDef.header, header.getContext())} +
    + {flexRender(cell.column.columnDef.cell, cell.getContext())} +
    +
    + ); +} diff --git a/webui/src/components/material-web.d.ts b/webui/src/components/material-web.d.ts new file mode 100644 index 00000000..8332df2b --- /dev/null +++ b/webui/src/components/material-web.d.ts @@ -0,0 +1,66 @@ +import type { MdFilledButton } from "@material/web/button/filled-button.js"; +import type { MdOutlinedButton } from "@material/web/button/outlined-button.js"; +import type { MdChipSet } from "@material/web/chips/chip-set.js"; +import type { MdFilterChip } from "@material/web/chips/filter-chip.js"; +import type { MdInputChip } from "@material/web/chips/input-chip.js"; +import type { MdCheckbox } from "@material/web/checkbox/checkbox.js"; +import type { MdDialog } from "@material/web/dialog/dialog.js"; +import type { MdIcon } from "@material/web/icon/icon.js"; +import type { MdIconButton } from "@material/web/iconbutton/icon-button.js"; +import type { MdOutlinedSelect } from "@material/web/select/outlined-select.js"; +import type { MdSelectOption } from "@material/web/select/select-option.js"; +import type { MdSwitch } from "@material/web/switch/switch.js"; +import type { MdFilledTextField } from "@material/web/textfield/filled-text-field.js"; +import type { MdOutlinedTextField } from "@material/web/textfield/outlined-text-field.js"; + +declare module "react/jsx-runtime" { + namespace JSX { + interface IntrinsicElements { + "md-chip-set": React.DetailedHTMLProps, MdChipSet>; + "md-checkbox": React.DetailedHTMLProps, MdCheckbox>; + "md-dialog": React.DetailedHTMLProps, MdDialog>; + "md-filter-chip": React.DetailedHTMLProps, MdFilterChip>; + "md-input-chip": React.DetailedHTMLProps, MdInputChip> & { + disabled?: boolean; + "remove-only"?: boolean; + selected?: boolean; + }; + "md-filled-button": React.DetailedHTMLProps, MdFilledButton> & { + disabled?: boolean; + "has-icon"?: boolean; + type?: "button" | "reset" | "submit"; + }; + "md-filled-text-field": React.DetailedHTMLProps, MdFilledTextField> & { + label?: string; + type?: "email" | "number" | "password" | "search" | "tel" | "text" | "url" | "textarea"; + }; + "md-icon": React.DetailedHTMLProps, MdIcon> & { + slot?: string; + }; + "md-icon-button": React.DetailedHTMLProps, MdIconButton> & { + disabled?: boolean; + type?: "button" | "reset" | "submit"; + }; + "md-outlined-button": React.DetailedHTMLProps, MdOutlinedButton> & { + disabled?: boolean; + "has-icon"?: boolean; + type?: "button" | "reset" | "submit"; + }; + "md-outlined-select": React.DetailedHTMLProps, MdOutlinedSelect> & { + "clamp-menu-width"?: boolean; + "menu-positioning"?: "absolute" | "fixed" | "popover"; + label?: string; + }; + "md-outlined-text-field": React.DetailedHTMLProps, MdOutlinedTextField> & { + label?: string; + type?: "email" | "number" | "password" | "search" | "tel" | "text" | "url" | "textarea"; + }; + "md-select-option": React.DetailedHTMLProps, MdSelectOption> & { + "display-text"?: string; + selected?: boolean; + value?: string; + }; + "md-switch": React.DetailedHTMLProps, MdSwitch>; + } + } +} diff --git a/webui/src/configDocs/configDescriptions.test.ts b/webui/src/configDocs/configDescriptions.test.ts new file mode 100644 index 00000000..1a6fd8ec --- /dev/null +++ b/webui/src/configDocs/configDescriptions.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "vitest"; +import { configDescriptions, requiredConfigPaths } from "./configDescriptions"; + +describe("configDescriptions", () => { + test("documents all required first-pass config paths in both languages", () => { + for (const path of requiredConfigPaths) { + const entry = configDescriptions[path]; + + expect(entry, path).toBeDefined(); + expect(entry.title["en-US"], path).toBeTruthy(); + expect(entry.title["zh-CN"], path).toBeTruthy(); + expect(entry.description["en-US"], path).toBeTruthy(); + expect(entry.description["zh-CN"], path).toBeTruthy(); + } + }); + + test("marks secret-bearing fields as sensitive", () => { + expect(configDescriptions["server.auth_token"].sensitive).toBe(true); + expect(configDescriptions["providers..api_key"].sensitive).toBe(true); + expect(configDescriptions["web_search.tavily_api_key"].sensitive).toBe(true); + expect(configDescriptions["web_search.firecrawl_api_key"].sensitive).toBe(true); + }); + + test("covers backend schema fields rendered by the resource editor", () => { + const expectedPaths = [ + "trace.enabled", + "log.level", + "log.format", + "server.max_sessions", + "server.session_ttl", + "models..display_name", + "models..description", + "models..base_instructions", + "models..supports_reasoning", + "models..default_reasoning_level", + "models..supported_reasoning_levels", + "models..supports_reasoning_summaries", + "models..default_reasoning_summary", + "models..input_modalities", + "models..supports_image_detail_original", + "models..web_search", + "models..extensions", + "providers..web_search", + "providers..extensions", + "providers..offers[].priority", + "providers..offers[].overrides", + "routes..to", + "routes..display_name", + "routes..description", + "routes..context_window", + "routes..web_search", + "routes..extensions", + "cache.ttl", + "cache.prompt_caching", + "cache.automatic_prompt_cache", + "cache.explicit_cache_breakpoints", + "cache.allow_retention_downgrade", + "cache.max_breakpoints", + "cache.min_cache_tokens", + "cache.expected_reuse", + "cache.minimum_value_score", + "cache.min_breakpoint_tokens" + ] as const; + + for (const path of expectedPaths) { + expect(configDescriptions[path], path).toBeDefined(); + } + }); +}); diff --git a/webui/src/configDocs/configDescriptions.ts b/webui/src/configDocs/configDescriptions.ts new file mode 100644 index 00000000..5fbcf23f --- /dev/null +++ b/webui/src/configDocs/configDescriptions.ts @@ -0,0 +1,696 @@ +import type { Locale } from "../i18n/messages"; + +export type ConfigDocEntry = { + path: string; + title: Record; + description: Record; + type: string; + defaultValue?: string; + sensitive?: boolean; + apply: Record; +}; + +export const requiredConfigPaths = [ + "mode", + "trace.enabled", + "log.level", + "log.format", + "server.addr", + "server.auth_token", + "server.max_sessions", + "server.session_ttl", + "persistence.active_provider", + "cache.mode", + "cache.ttl", + "cache.prompt_caching", + "cache.automatic_prompt_cache", + "cache.explicit_cache_breakpoints", + "cache.allow_retention_downgrade", + "cache.max_breakpoints", + "cache.min_cache_tokens", + "cache.expected_reuse", + "cache.minimum_value_score", + "cache.min_breakpoint_tokens", + "defaults.model", + "defaults.max_tokens", + "defaults.system_prompt", + "models..context_window", + "models..max_output_tokens", + "models..slug", + "models..display_name", + "models..description", + "models..base_instructions", + "models..supports_reasoning", + "models..default_reasoning_level", + "models..supported_reasoning_levels", + "models..supports_reasoning_summaries", + "models..default_reasoning_summary", + "models..input_modalities", + "models..supports_image_detail_original", + "models..web_search", + "models..extensions", + "providers..key", + "providers..base_url", + "providers..api_key", + "providers..protocol", + "providers..version", + "providers..user_agent", + "providers..web_search", + "providers..extensions", + "providers..offers[].model", + "providers..offers[].upstream_name", + "providers..offers[].priority", + "providers..offers[].pricing", + "providers..offers[].overrides", + "routes..alias", + "routes..to", + "routes..model", + "routes..provider", + "routes..display_name", + "routes..description", + "routes..context_window", + "routes..web_search", + "routes..extensions", + "web_search.support", + "web_search.max_uses", + "web_search.tavily_api_key", + "web_search.firecrawl_api_key", + "web_search.search_max_rounds", + "extensions..enabled", + "extensions..config", + "proxy.response", + "proxy.anthropic" +] as const; + +export type ConfigPath = (typeof requiredConfigPaths)[number]; + +export const configDescriptions: Record = { + "mode": entry( + "mode", + "运行模式", + "Run mode", + "决定 Moon Bridge 如何处理请求:在协议之间转换,或直接转发给单一上游。", + "How Moon Bridge handles requests: convert between formats, or pass straight through to one provider.", + "Transform | CaptureResponse | CaptureAnthropic", + "Transform" + ), + "trace.enabled": entry( + "trace.enabled", + "启用追踪", + "Enable tracing", + "记录每个请求的处理过程,方便排查问题。", + "Records how each request is handled, for troubleshooting.", + "boolean" + ), + "log.level": entry( + "log.level", + "日志级别", + "Log level", + "控制运行时输出的最低日志级别。", + "Controls the minimum runtime log level.", + "debug | info | warn | error", + "info" + ), + "log.format": entry( + "log.format", + "日志格式", + "Log format", + "控制运行时日志输出为文本或 JSON。", + "Controls whether runtime logs are emitted as text or JSON.", + "text | json", + "text" + ), + "server.addr": entry( + "server.addr", + "监听地址", + "Listen address", + "服务监听的地址。控制台和 API 都从这里访问。", + "Address the server listens on. The console and API are served here.", + "host:port", + "127.0.0.1:38440" + ), + "server.auth_token": entry( + "server.auth_token", + "认证 Token", + "Auth token", + "控制台与 API 的访问密码。留空则不需要登录。", + "Password for the console and API. Leave empty to disable sign-in.", + "string", + "empty", + true + ), + "server.max_sessions": entry( + "server.max_sessions", + "最大会话数", + "Max sessions", + "允许保留的最大会话数量;0 表示不限制。", + "Maximum retained session count; 0 means unlimited.", + "number", + "0" + ), + "server.session_ttl": entry( + "server.session_ttl", + "会话 TTL", + "Session TTL", + "会话状态保留时长,例如 24h。", + "How long session state is retained, for example 24h.", + "string", + "24h" + ), + "persistence.active_provider": entry( + "persistence.active_provider", + "持久化提供商", + "Persistence provider", + "设置保存的位置。启用后才能在控制台保存修改。", + "Where settings are stored. Needed to save changes from the console.", + "db_sqlite | db_d1", + "db_sqlite" + ), + "cache.mode": entry( + "cache.mode", + "缓存模式", + "Cache mode", + "提示词缓存的工作方式:关闭、显式断点、自动选择,或两者混合。", + "How prompt caching works: off, explicit breakpoints, automatic, or a mix of both.", + "off | explicit | automatic | hybrid", + "explicit" + ), + "cache.ttl": entry( + "cache.ttl", + "缓存 TTL", + "Cache TTL", + "缓存条目的保留时长,例如 1h。", + "Retention duration for cache entries, for example 1h.", + "string" + ), + "cache.prompt_caching": entry( + "cache.prompt_caching", + "启用 Prompt Cache", + "Enable prompt caching", + "允许 Moon Bridge 为支持的上游协议启用 prompt cache。", + "Allows Moon Bridge to enable prompt caching for supported upstream protocols.", + "boolean" + ), + "cache.automatic_prompt_cache": entry( + "cache.automatic_prompt_cache", + "自动 Prompt Cache", + "Automatic prompt cache", + "根据请求内容自动选择合适的缓存断点。", + "Automatically chooses suitable cache breakpoints from request content.", + "boolean" + ), + "cache.explicit_cache_breakpoints": entry( + "cache.explicit_cache_breakpoints", + "显式缓存断点", + "Explicit cache breakpoints", + "允许请求显式指定 prompt cache 断点。", + "Allows requests to explicitly specify prompt cache breakpoints.", + "boolean" + ), + "cache.allow_retention_downgrade": entry( + "cache.allow_retention_downgrade", + "允许降级保留策略", + "Allow retention downgrade", + "当上游不支持所选缓存时长时,自动改用较短的可用时长。", + "Use a shorter cache lifetime if the provider doesn't support the chosen one.", + "boolean" + ), + "cache.max_breakpoints": entry( + "cache.max_breakpoints", + "最大断点数", + "Max breakpoints", + "单次请求可插入的最大缓存断点数量。", + "Maximum number of cache breakpoints inserted for one request.", + "number" + ), + "cache.min_cache_tokens": entry( + "cache.min_cache_tokens", + "最小缓存 Token", + "Min cache tokens", + "低于该 token 数的片段不会作为缓存候选。", + "Segments below this token count are not considered cache candidates.", + "number" + ), + "cache.expected_reuse": entry( + "cache.expected_reuse", + "预期复用次数", + "Expected reuse", + "缓存内容预期被复用的次数(用于自动缓存决策)。", + "How many times a cached block is expected to be reused (for automatic caching).", + "number" + ), + "cache.minimum_value_score": entry( + "cache.minimum_value_score", + "最小价值分", + "Minimum value score", + "自动缓存生效的最低价值阈值。", + "Threshold below which automatic caching is skipped.", + "number" + ), + "cache.min_breakpoint_tokens": entry( + "cache.min_breakpoint_tokens", + "最小断点 Token", + "Min breakpoint tokens", + "两个缓存点之间至少要相隔的 token 数。", + "Minimum number of tokens between two cache points.", + "number" + ), + "defaults.model": entry( + "defaults.model", + "默认模型", + "Default model", + "请求未指定模型时使用的模型。", + "Model used when a request doesn't specify one.", + "string", + "moonbridge" + ), + "defaults.max_tokens": entry( + "defaults.max_tokens", + "默认最大 Token", + "Default max tokens", + "请求未提供 max_output_tokens 时的默认输出上限。", + "Default output limit when a request does not provide max_output_tokens.", + "number", + "65536" + ), + "defaults.system_prompt": entry( + "defaults.system_prompt", + "全局系统提示词", + "Global system prompt", + "追加到请求中的全局 system prompt,适合放置所有模型共享的行为约束。", + "Global system prompt appended to requests, useful for behavior rules shared by all models.", + "string", + "empty" + ), + "models..context_window": entry( + "models..context_window", + "上下文窗口", + "Context window", + "模型一次最多能处理的 token 数。", + "Maximum tokens the model can handle at once.", + "number" + ), + "models..max_output_tokens": entry( + "models..max_output_tokens", + "最大输出 Token", + "Max output tokens", + "模型单次响应允许的最大输出 token 数。", + "Maximum output tokens the model can emit in one response.", + "number" + ), + "models..slug": entry( + "models..slug", + "模型 Slug", + "Model slug", + "模型的稳定标识,其他设置通过它引用此模型。", + "Stable id other settings use to refer to this model.", + "string" + ), + "models..display_name": entry( + "models..display_name", + "模型显示名称", + "Model display name", + "控制台中展示的人类可读模型名称。", + "Human-readable model name shown in the console.", + "string" + ), + "models..description": entry( + "models..description", + "模型描述", + "Model description", + "描述模型用途、能力或限制,便于控制台识别。", + "Describes model purpose, capabilities, or limits for console readers.", + "string" + ), + "models..base_instructions": entry( + "models..base_instructions", + "基础指令", + "Base instructions", + "追加到该模型请求中的默认行为指令。", + "Default behavior instructions appended to requests for this model.", + "string" + ), + "models..supports_reasoning": entry( + "models..supports_reasoning", + "支持思考", + "Supports reasoning", + "标记该模型是否支持思考深度配置。", + "Marks whether this model supports reasoning configuration.", + "boolean" + ), + "models..default_reasoning_level": entry( + "models..default_reasoning_level", + "默认思考深度", + "Default reasoning level", + "请求未指定思考深度时使用的默认 level。", + "Default reasoning level used when a request does not specify one.", + "string" + ), + "models..supported_reasoning_levels": entry( + "models..supported_reasoning_levels", + "支持的思考深度", + "Supported reasoning levels", + "该模型允许选择的思考深度列表。", + "List of reasoning levels allowed for this model.", + "array" + ), + "models..supports_reasoning_summaries": entry( + "models..supports_reasoning_summaries", + "支持思考摘要", + "Supports reasoning summaries", + "标记该模型是否支持返回思考摘要。", + "Marks whether this model supports returning reasoning summaries.", + "boolean" + ), + "models..default_reasoning_summary": entry( + "models..default_reasoning_summary", + "默认思考摘要", + "Default reasoning summary", + "请求未指定摘要模式时使用的默认思考摘要设置。", + "Default reasoning summary setting used when a request does not specify one.", + "string" + ), + "models..input_modalities": entry( + "models..input_modalities", + "输入模态", + "Input modalities", + "该模型支持的输入类型,例如 text 或 image。", + "Input types supported by this model, such as text or image.", + "array" + ), + "models..supports_image_detail_original": entry( + "models..supports_image_detail_original", + "支持原始图像细节", + "Supports original image detail", + "标记视觉请求是否可向该模型发送 original 图像细节。", + "Marks whether visual requests can send original image detail to this model.", + "boolean" + ), + "models..web_search": entry( + "models..web_search", + "模型联网搜索", + "Model web search", + "覆盖该模型的联网搜索行为。", + "Overrides web search behavior for this model.", + "object" + ), + "models..extensions": entry( + "models..extensions", + "模型扩展", + "Model extensions", + "覆盖该模型启用的扩展工具和配置。", + "Overrides extension tools and config enabled for this model.", + "object" + ), + "providers..key": entry( + "providers..key", + "Provider Key", + "Provider key", + "提供商的稳定标识,路由通过它选择此提供商。", + "Stable id routes use to pick this provider.", + "string" + ), + "providers..base_url": entry( + "providers..base_url", + "上游 Base URL", + "Upstream base URL", + "上游 Provider API 地址。", + "Upstream provider API URL.", + "url" + ), + "providers..api_key": entry( + "providers..api_key", + "上游 API Key", + "Upstream API key", + "此提供商的 API 密钥。显示为掩码;输入新值即可更新。", + "API key for this provider. Shown masked; enter a new value to update it.", + "string", + undefined, + true + ), + "providers..protocol": entry( + "providers..protocol", + "上游协议", + "Upstream protocol", + "选择上游接口格式:Anthropic Messages、OpenAI Responses、Google GenAI 或 OpenAI Chat。", + "Selects the upstream API format: Anthropic Messages, OpenAI Responses, Google GenAI, or OpenAI Chat.", + "anthropic | openai-response | google-genai | openai-chat", + "anthropic" + ), + "providers..version": entry( + "providers..version", + "协议版本", + "Protocol version", + "API 版本头(主要 Anthropic 使用),部分提供商可留空。", + "API version header (mainly for Anthropic). Optional for some providers.", + "string" + ), + "providers..user_agent": entry( + "providers..user_agent", + "User Agent", + "User agent", + "发送给提供商的 User-Agent 字符串。", + "User-Agent string sent to the provider.", + "string" + ), + "providers..web_search": entry( + "providers..web_search", + "提供商联网搜索", + "Provider web search", + "覆盖该 Provider 的联网搜索行为。", + "Overrides web search behavior for this provider.", + "object" + ), + "providers..extensions": entry( + "providers..extensions", + "提供商扩展", + "Provider extensions", + "覆盖该 Provider 启用的扩展工具和配置。", + "Overrides extension tools and config enabled for this provider.", + "object" + ), + "providers..offers[].model": entry( + "providers..offers[].model", + "提供商模型", + "Provider model", + "此绑定提供的模型。", + "Which model this binding serves.", + "string" + ), + "providers..offers[].upstream_name": entry( + "providers..offers[].upstream_name", + "上游模型名", + "Upstream model name", + "发送给提供商的真实模型名。留空则使用模型标识。", + "Real model name sent to the provider. Leave empty to use the model id.", + "string" + ), + "providers..offers[].priority": entry( + "providers..offers[].priority", + "提供商优先级", + "Provider priority", + "同一模型有多个提供商时的取舍权重,数值越小越优先。", + "Tie-breaker when several providers serve the same model; lower wins.", + "number" + ), + "providers..offers[].pricing": entry( + "providers..offers[].pricing", + "计费", + "Billing", + "可选的单价信息,用于费用统计。", + "Optional prices for cost tracking.", + "number" + ), + "providers..offers[].overrides": entry( + "providers..offers[].overrides", + "提供商覆盖配置", + "Provider overrides", + "该提供商绑定专用的模型能力覆盖项。", + "Model capability overrides specific to this provider binding.", + "object" + ), + "routes..to": entry( + "routes..to", + "路由目标", + "Route target", + "此路由指向的内部目标。", + "Internal target this route points to.", + "string" + ), + "routes..model": entry( + "routes..model", + "路由模型", + "Route model", + "此别名实际指向的模型。", + "Model this alias points to.", + "string" + ), + "routes..alias": entry( + "routes..alias", + "路由别名", + "Route alias", + "客户端在请求中使用的模型名。", + "The model name clients send in requests.", + "string" + ), + "routes..provider": entry( + "routes..provider", + "路由提供商", + "Route provider", + "处理该路由的 Provider key。", + "Provider key that handles this route.", + "string" + ), + "routes..display_name": entry( + "routes..display_name", + "路由显示名称", + "Route display name", + "控制台中展示的人类可读路由名称。", + "Human-readable route name shown in the console.", + "string" + ), + "routes..description": entry( + "routes..description", + "路由描述", + "Route description", + "描述该路由的用途或模型选择策略。", + "Describes this route's purpose or model selection policy.", + "string" + ), + "routes..context_window": entry( + "routes..context_window", + "路由上下文窗口", + "Route context window", + "该路由暴露给客户端的上下文窗口上限。", + "Context window limit exposed to clients for this route.", + "number" + ), + "routes..web_search": entry( + "routes..web_search", + "路由联网搜索", + "Route web search", + "覆盖该路由的联网搜索行为。", + "Overrides web search behavior for this route.", + "object" + ), + "routes..extensions": entry( + "routes..extensions", + "路由扩展", + "Route extensions", + "覆盖该路由启用的扩展工具和配置。", + "Overrides extension tools and config enabled for this route.", + "object" + ), + "web_search.support": entry( + "web_search.support", + "网页搜索模式", + "Web search mode", + "auto 优先使用 Provider 原生搜索并回退注入;enabled 强制原生;disabled 禁用;injected 使用 Tavily/Firecrawl 工具注入。", + "auto prefers provider-native search and falls back to injection; enabled forces native; disabled turns it off; injected uses Tavily/Firecrawl tools.", + "auto | enabled | disabled | injected", + "auto" + ), + "web_search.max_uses": entry( + "web_search.max_uses", + "最大使用次数", + "Max uses", + "限制单次请求可使用的网页搜索次数。", + "Limits how many web search calls one request may use.", + "number" + ), + "web_search.tavily_api_key": entry( + "web_search.tavily_api_key", + "Tavily API Key", + "Tavily API key", + "注入式网页搜索使用的 Tavily 密钥。", + "Tavily secret used by injected web search.", + "string", + undefined, + true + ), + "web_search.firecrawl_api_key": entry( + "web_search.firecrawl_api_key", + "Firecrawl API Key", + "Firecrawl API key", + "注入式网页搜索用于抓取页面内容的 Firecrawl 密钥。", + "Firecrawl secret used by injected web search to fetch page content.", + "string", + undefined, + true + ), + "web_search.search_max_rounds": entry( + "web_search.search_max_rounds", + "最大搜索轮次", + "Search max rounds", + "单次请求最多执行的搜索轮数。", + "Maximum number of search rounds per request.", + "number", + "3" + ), + "extensions..enabled": entry( + "extensions..enabled", + "启用扩展", + "Enable extension", + "开启或关闭此扩展。", + "Turn this extension on or off.", + "boolean" + ), + "extensions..config": entry( + "extensions..config", + "扩展配置", + "Extension config", + "此扩展的设置。", + "Settings for this extension.", + "object" + ), + "proxy.response": entry( + "proxy.response", + "OpenAI Capture 代理", + "OpenAI capture proxy", + "直通(Capture)模式下转发到 OpenAI 所需的地址、密钥和默认模型。", + "Address, key, and default model used when passing requests straight through to OpenAI.", + "object" + ), + "proxy.anthropic": entry( + "proxy.anthropic", + "Anthropic Capture 代理", + "Anthropic capture proxy", + "直通(Capture)模式下转发到 Anthropic 所需的地址、密钥、版本和模型。", + "Address, key, version, and model used when passing requests straight through to Anthropic.", + "object" + ) +}; + +export function getConfigDescription(path: ConfigPath, locale: Locale) { + const entry = configDescriptions[path]; + return { + ...entry, + title: entry.title[locale], + description: entry.description[locale], + apply: entry.apply[locale] + }; +} + +function entry( + path: ConfigPath, + zhTitle: string, + enTitle: string, + zhDescription: string, + enDescription: string, + type: string, + defaultValue?: string, + sensitive = false +): ConfigDocEntry { + return { + path, + title: { "zh-CN": zhTitle, "en-US": enTitle }, + description: { "zh-CN": zhDescription, "en-US": enDescription }, + type, + defaultValue, + sensitive, + apply: { + "zh-CN": "即时保存;部分字段需重启后生效。", + "en-US": "Saved instantly; some fields need a restart to take effect." + } + }; +} diff --git a/webui/src/e2e/console.test.tsx b/webui/src/e2e/console.test.tsx new file mode 100644 index 00000000..3beb8e9a --- /dev/null +++ b/webui/src/e2e/console.test.tsx @@ -0,0 +1,360 @@ +import { act, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { AppShell } from "../app/App"; +import { DefaultsPage } from "../features/defaults/DefaultsPage"; +import { LogsPage } from "../features/logs/LogsPage"; +import { ModelsProvidersPage } from "../features/modelProviders/ModelsProvidersPage"; +import { OverviewPage } from "../features/overview/OverviewPage"; +import { SecurityPage } from "../features/security/SecurityPage"; +import { CONSOLE_THEME_STORAGE_KEY } from "../theme/ThemeProvider"; +import { configGraphFixture } from "../test/configGraphFixtures"; +import { renderWithConsoleProviders } from "../test/renderWithConsoleProviders"; +import type { ConfigGraph, FieldError, PatchResponse } from "../rpc/types"; + +type FetchCall = { + url: string; + init?: RequestInit; + body?: unknown; +}; + +describe("config graph console smoke flow", () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + localStorage.clear(); + }); + + test("primary navigation has no config file or apply entry points", () => { + renderWithConsoleProviders( + + Console content} /> + + ); + + const nav = screen.getByRole("navigation", { name: /console sections/i }); + expect(nav).toHaveTextContent("Overview"); + expect(nav).toHaveTextContent("Models & Providers"); + expect(nav).toHaveTextContent("Search & Tools"); + expect(nav).not.toHaveTextContent("Logs"); + expect(nav).not.toHaveTextContent("Config"); + expect(nav).not.toHaveTextContent("YAML"); + expect(nav).not.toHaveTextContent("Diagnostics"); + expect(screen.getByRole("main", { name: "Console route content" })).toHaveTextContent("Console content"); + expect(screen.queryByRole("button", { name: /^apply$/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /apply changes/i })).not.toBeInTheDocument(); + }); + + test("models and providers render shared resource cards", async () => { + mockFetch({ + graph: configGraphFixture() + }); + + renderWithConsoleProviders(); + + expect(await screen.findByRole("heading", { level: 2, name: "Providers (1)" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { level: 3, name: "anthropic" })).toBeInTheDocument(); + }); + + test("overview loads model usage and embedded logs", async () => { + mockFetch({ + graph: configGraphFixture(), + logs: [ + { + timestamp: "2026-06-07T00:00:00Z", + level: "INFO", + message: "server started", + raw: "time=2026-06-07T00:00:00Z level=INFO msg=server-started" + } + ], + usage: usageFixture() + }); + + renderWithConsoleProviders(); + + expect(document.documentElement.dataset.theme).toBe("dark"); + expect(localStorage.getItem(CONSOLE_THEME_STORAGE_KEY)).toBe("dark"); + expect(await screen.findByRole("heading", { name: "Usage Analytics" })).toBeInTheDocument(); + const requestsLabel = (await screen.findAllByText("Requests")).find((el) => + el.classList.contains("usage-metric__label") + ); + expect(within(requestsLabel!.closest(".usage-metric") as HTMLElement).getByText("2")).toBeInTheDocument(); + expect(screen.getByRole("img", { name: /Token split chart/ })).toBeInTheDocument(); + expect(screen.getByRole("region", { name: "Backend logs" })).toBeInTheDocument(); + expect(screen.getByText(/server started/)).toBeInTheDocument(); + expect(screen.queryByText("Transform")).not.toBeInTheDocument(); + expect(screen.queryByText("rev-1")).not.toBeInTheDocument(); + }); + + test("editing a field patches the config graph directly", async () => { + const graph = configGraphFixture(); + const { calls } = mockFetch({ + graph, + patch: { + result: "committed", + revision: "rev-2", + graph: configGraphFixture({ revision: "rev-2" }) + } + }); + + renderWithConsoleProviders(); + + await screen.findByLabelText("Defaults main"); + const modelField = getMaterialTextField(document, "Default model"); + setMaterialTextFieldValue(modelField, "gpt-4o"); + fireEvent.blur(modelField); + + await waitFor(() => { + expect(findPatch(calls)?.body).toEqual({ + baseRevision: "rev-1", + changes: [ + { + kind: "defaults", + id: "main", + field: "model", + value: "gpt-4o" + } + ] + }); + }); + }); + + test("draft rejection keeps the edited value and shows the field error", async () => { + const error = fieldError("defaults", "main", "model", "draftRejected", "Model is invalid"); + mockFetch({ + graph: configGraphFixture(), + patch: { + result: "draftRejected", + revision: "rev-1", + errors: [error] + } + }); + + renderWithConsoleProviders(); + + await screen.findByLabelText("Defaults main"); + const model = getMaterialTextField(document, "Default model"); + setMaterialTextFieldValue(model, "invalid-model"); + fireEvent.blur(model); + + expect(model.value).toBe("invalid-model"); + expect(await screen.findByRole("alert")).toHaveTextContent("Model is invalid"); + }); + + test("runtime rejection rolls back a critical field", async () => { + const error = fieldError("server", "main", "addr", "runtimeRejected", "Runtime rejected"); + mockFetch({ + graph: configGraphFixture(), + patch: { + result: "runtimeRejected", + revision: "rev-1", + errors: [error], + rollbackValue: ":38440" + } + }); + + renderWithConsoleProviders(); + + await screen.findByLabelText("Server main"); + const address = getMaterialTextField(document, "Listen address"); + setMaterialTextFieldValue(address, ":9999"); + fireEvent.blur(address); + + await waitFor(() => { + expect(address.value).toBe(":38440"); + }); + expect(await screen.findByRole("alert")).toHaveTextContent("Runtime rejected"); + }); + + test("logs page renders recent backend log lines and level filters", async () => { + mockFetch({ + graph: configGraphFixture(), + logs: [ + { + timestamp: "2026-06-07T00:00:00Z", + level: "INFO", + message: "server started", + raw: "time=2026-06-07T00:00:00Z level=INFO msg=server-started" + }, + { + timestamp: "2026-06-07T00:00:01Z", + level: "ERROR", + message: "database unavailable", + raw: "time=2026-06-07T00:00:01Z level=ERROR msg=database-unavailable" + } + ] + }); + + renderWithConsoleProviders(); + + expect(await screen.findByText(/server started/)).toBeInTheDocument(); + expect(screen.getByText(/database unavailable/)).toBeInTheDocument(); + + fireEvent.click(getMaterialFilterChip(document, "ERROR")); + + expect(screen.queryByText(/server started/)).not.toBeInTheDocument(); + expect(screen.getByText(/database unavailable/)).toBeInTheDocument(); + expect(screen.getByText("1 of 2 logs")).toBeInTheDocument(); + }); +}); + +function mockFetch({ + graph, + patch, + logs = [], + usage = emptyUsageFixture() +}: { + graph: ConfigGraph; + patch?: PatchResponse; + logs?: unknown[]; + usage?: unknown; +}) { + const calls: FetchCall[] = []; + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const method = init?.method ?? "GET"; + const body = parseBody(init?.body); + calls.push({ url, init, body }); + + if (url === "/api/v1/config/graph" && method === "GET") { + return jsonResponse(graph); + } + if (url === "/api/v1/config/graph" && method === "PATCH") { + if (!patch) { + throw new Error("Unexpected config graph patch"); + } + return jsonResponse(patch); + } + if (url.startsWith("/api/v1/logs/recent")) { + return jsonResponse(logs); + } + if (url === "/api/v1/logs/stream") { + return new Response(new ReadableStream(), { status: 200 }); + } + if (url === "/api/v1/stats/usage") { + return jsonResponse(usage); + } + throw new Error(`Unexpected fetch: ${method} ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + return { calls, fetchMock }; +} + +function jsonResponse(payload: unknown) { + return new Response(JSON.stringify(payload), { + status: 200, + headers: { "Content-Type": "application/json" } + }); +} + +function parseBody(body: BodyInit | null | undefined) { + return typeof body === "string" ? JSON.parse(body) as unknown : undefined; +} + +function findPatch(calls: FetchCall[]) { + return calls.find((call) => call.url === "/api/v1/config/graph" && call.init?.method === "PATCH"); +} + +type MaterialTextFieldElement = HTMLElement & { + label: string; + value: string; +}; + +function getMaterialTextField(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-outlined-text-field")).find( + (candidate) => materialElementLabel(candidate) === label + ); + if (!element) { + throw new Error(`Expected a Material Web outlined text field labelled "${label}".`); + } + return element; +} + +function materialElementLabel(element: HTMLElement & { label?: string }) { + const labelledBy = element.getAttribute("aria-labelledby"); + if (labelledBy) { + return labelledBy + .split(/\s+/) + .map((id) => document.getElementById(id)?.textContent?.trim() ?? "") + .filter(Boolean) + .join(" "); + } + return element.label || element.getAttribute("aria-label") || element.getAttribute("label") || ""; +} + +function setMaterialTextFieldValue(element: MaterialTextFieldElement, value: string) { + act(() => { + element.value = value; + element.dispatchEvent(new InputEvent("input", { bubbles: true, composed: true })); + }); +} + +function getMaterialFilterChip(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-filter-chip")).find( + (candidate) => candidate.textContent?.trim() === label + ); + if (!element) { + throw new Error(`Expected a Material Web filter chip labelled "${label}".`); + } + return element as HTMLElement; +} + +function fieldError( + resourceKind: FieldError["resourceKind"], + resourceId: string, + field: string, + code: string, + message: string +): FieldError { + return { resourceKind, resourceId, field, code, message }; +} + +function usageFixture() { + return { + totals: { + requests: 2, + input_tokens: 300, + output_tokens: 80, + cache_creation: 40, + cache_read: 120, + cache_hit_rate: 40, + cache_write_rate: 13.3, + cache_rw_ratio: 3, + total_cost: 0.42, + duration: "1m" + }, + by_model: [ + { + model: "claude-sonnet", + actual_model: "claude-3-5-sonnet", + requests: 2, + input_tokens: 300, + output_tokens: 80, + cache_creation: 40, + cache_read: 120, + cache_hit_rate: 40, + cost: 0.42, + avg_cost_per_mtoken: 1105.26 + } + ] + }; +} + +function emptyUsageFixture() { + return { + totals: { + requests: 0, + input_tokens: 0, + output_tokens: 0, + cache_creation: 0, + cache_read: 0, + cache_hit_rate: 0, + cache_write_rate: 0, + cache_rw_ratio: 0, + total_cost: 0, + duration: "0s" + }, + by_model: [] + }; +} diff --git a/webui/src/features/configGraph/CreateResourcePanel.tsx b/webui/src/features/configGraph/CreateResourcePanel.tsx new file mode 100644 index 00000000..404c4bba --- /dev/null +++ b/webui/src/features/configGraph/CreateResourcePanel.tsx @@ -0,0 +1,931 @@ +import { useId, useRef, useState, type FormEvent, type ReactNode } from "react"; +import { configDescriptions, type ConfigPath } from "../../configDocs/configDescriptions"; +import type { ConfigGraph } from "../../rpc/types"; +import { useI18n } from "../../i18n/I18nProvider"; +import { MaterialFilledButton, MaterialIconButton, MaterialOutlinedButton } from "../../components/MaterialButton"; +import { MaterialAssistChip, MaterialFilterChip } from "../../components/MaterialFilterChip"; +import { MaterialSelect, type MaterialSelectOption } from "../../components/MaterialSelect"; +import { MaterialOutlinedTextField } from "../../components/MaterialTextField"; +import type { MessageKey } from "../../i18n/messages"; +import { MaterialSwitch } from "../../components/MaterialSwitch"; +import { useCreateConfigResource } from "./useConfigGraph"; +import { useAnchoredTooltipPosition } from "./helpTooltipPosition"; +import { modelIconForName, modelSelectOptions, protocolIconForValue } from "./modelProviderIcons"; +import type { MdIconButton } from "@material/web/iconbutton/icon-button.js"; + +type CreatableKind = "provider" | "model" | "provider_offer" | "route" | "extension"; + +type CreateResourcePanelProps = { + availableExtensionIds?: string[]; + graph: ConfigGraph; + kind: CreatableKind; + modelId?: string; + providerId?: string; +}; + +type FormValues = { + id: string; + baseUrl: string; + apiKey: string; + protocol: string; + displayName: string; + contextWindow: string; + model: string; + provider: string; + upstreamName: string; + priority: string; + inputPrice: string; + outputPrice: string; + cacheWritePrice: string; + cacheReadPrice: string; + billingEnabled: boolean; + enabled: boolean; +}; + +const initialValues: FormValues = { + id: "", + baseUrl: "", + apiKey: "", + protocol: "openai-response", + displayName: "", + contextWindow: "128000", + model: "", + provider: "", + upstreamName: "", + priority: "1", + inputPrice: "0", + outputPrice: "0", + cacheWritePrice: "0", + cacheReadPrice: "0", + billingEnabled: false, + enabled: true +}; + +const createTextKeys: Record = { + extension: { + add: "create.extension.add", + submit: "create.extension.submit", + title: "create.extension.title" + }, + model: { + add: "create.model.add", + submit: "create.model.submit", + title: "create.model.title" + }, + provider: { + add: "create.provider.add", + submit: "create.provider.submit", + title: "create.provider.title" + }, + provider_offer: { + add: "create.offer.add", + submit: "create.offer.submit", + title: "create.offer.title" + }, + route: { + add: "create.route.add", + submit: "create.route.submit", + title: "create.route.title" + } +}; + +export function CreateResourcePanel({ + availableExtensionIds, + graph, + kind, + modelId, + providerId +}: CreateResourcePanelProps) { + const { t } = useI18n(); + const create = useCreateConfigResource(); + const extensionIds = availableExtensionIds ?? []; + const extensionCreateDisabled = kind === "extension" && extensionIds.length === 0; + const [open, setOpen] = useState(false); + const [values, setValues] = useState(() => defaultValues(kind, graph, providerId, modelId, extensionIds)); + const [error, setError] = useState(""); + + const title = t(createTextKeys[kind].title); + const addLabel = t(createTextKeys[kind].add); + const submitLabel = t(createTextKeys[kind].submit); + + function openPanel() { + setValues(defaultValues(kind, graph, providerId, modelId, extensionIds)); + setError(""); + setOpen(true); + } + + async function submit(event: FormEvent) { + event.preventDefault(); + setError(""); + const draft = createResourceDraft( + kind, + values, + { + cacheReadPrice: t("create.offer.cacheReadPrice"), + cacheWritePrice: t("create.offer.cacheWritePrice"), + contextWindow: t("create.model.contextWindow"), + inputPrice: t("create.offer.inputPrice"), + outputPrice: t("create.offer.outputPrice"), + priority: t("create.offer.priority") + }, + (field) => t("create.invalidNumber", { field }), + (field) => t("create.positiveNumber", { field }) + ); + if (!draft.ok) { + setError(draft.error); + return; + } + try { + await create.mutateAsync({ + kind, + body: { + baseRevision: graph.revision, + id: draft.id, + value: draft.value + } + }); + setOpen(false); + setValues(defaultValues(kind, graph, providerId, modelId, extensionIds)); + } catch (cause) { + setError(errorMessage(cause, t("error.requestFailed"))); + } + } + + return ( +
    + + {addLabel} + + {open ? ( +
    +
    +

    {title}

    + setOpen(false)} + /> +
    + {error ? ( +

    + {error} +

    + ) : null} +
    + +
    +
    + + {submitLabel} + + setOpen(false)}> + {t("create.cancel")} + +
    +
    + ) : null} +
    + ); +} + +function CreateFields({ + availableExtensionIds = [], + graph, + kind, + modelId, + providerId, + values, + setValues +}: { + availableExtensionIds?: string[]; + graph: ConfigGraph; + kind: CreatableKind; + modelId?: string; + providerId?: string; + values: FormValues; + setValues: (values: FormValues) => void; +}) { + const { locale, t } = useI18n(); + const models = graph.resources.filter((resource) => resource.kind === "model"); + const providers = graph.resources.filter((resource) => resource.kind === "provider"); + const fieldHelp = (docPath: ConfigPath, fallbackKey: MessageKey) => + configDescriptions[docPath]?.description[locale] ?? t(fallbackKey); + + if (kind === "provider") { + return ( + <> + .key", "create.help.providerId")} + label={t("create.provider.id")} + path="key" + value={values.id} + onChange={(id) => setValues({ ...values, id })} + /> + .base_url", "create.help.providerBaseUrl")} + label={t("create.provider.baseUrl")} + path="base_url" + value={values.baseUrl} + onChange={(baseUrl) => setValues({ ...values, baseUrl })} + /> + .api_key", "create.help.providerApiKey")} + label={t("create.provider.apiKey")} + path="api_key" + value={values.apiKey} + onChange={(apiKey) => setValues({ ...values, apiKey })} + secret + /> + .protocol", "create.help.providerProtocol")} + label={t("create.provider.protocol")} + options={protocolSelectOptions(t)} + value={values.protocol} + onChange={(protocol) => setValues({ ...values, protocol })} + /> + + ); + } + + if (kind === "model") { + return ( + <> + .slug", "create.help.modelId")} + label={t("create.model.id")} + path="slug" + value={values.id} + onChange={(id) => setValues({ ...values, id })} + /> + setValues({ ...values, displayName })} + /> + .context_window", "create.help.modelContextWindow")} + label={t("create.model.contextWindow")} + value={values.contextWindow} + onChange={(contextWindow) => setValues({ ...values, contextWindow })} + /> + + ); + } + + if (kind === "route") { + return ( + <> + .alias", "create.help.routeId")} + label={t("create.route.id")} + path="alias" + value={values.id} + onChange={(id) => setValues({ ...values, id })} + /> + .model", "create.help.routeModel")} + label={t("create.route.model")} + options={modelSelectOptions(models)} + value={values.model} + onChange={(model) => setValues({ ...values, model })} + /> + .provider", "create.help.routeProvider")} + label={t("create.route.provider")} + options={toSelectOptions(providers.map((provider) => provider.id))} + value={values.provider} + onChange={(provider) => setValues({ ...values, provider })} + /> + + ); + } + + if (kind === "provider_offer") { + return ( + <> +
    + .offers[].model", "create.help.offerModel")} + label={t("create.offer.model")} + /> + {modelId ?? values.model} +
    + .key", "create.help.offerProvider")} + label={t("create.offer.provider")} + options={toSelectOptions(providers.map((provider) => provider.id))} + value={values.provider} + onChange={(provider) => setValues({ ...values, provider })} + /> + .offers[].upstream_name", "create.help.offerUpstreamName")} label={t("create.offer.upstreamName")} path="upstream_name" value={values.upstreamName} onChange={(upstreamName) => setValues({ ...values, upstreamName })} /> + setValues({ ...values, priority })} /> + setValues({ ...values, billingEnabled })} + /> + {values.billingEnabled ? ( + <> + .offers[].pricing", "create.help.offerInputPrice")} label={t("create.offer.inputPrice")} path="input_price" value={values.inputPrice} onChange={(inputPrice) => setValues({ ...values, inputPrice })} /> + .offers[].pricing", "create.help.offerOutputPrice")} label={t("create.offer.outputPrice")} path="output_price" value={values.outputPrice} onChange={(outputPrice) => setValues({ ...values, outputPrice })} /> + .offers[].pricing", "create.help.offerCacheWritePrice")} label={t("create.offer.cacheWritePrice")} path="cache_write_price" value={values.cacheWritePrice} onChange={(cacheWritePrice) => setValues({ ...values, cacheWritePrice })} /> + .offers[].pricing", "create.help.offerCacheReadPrice")} label={t("create.offer.cacheReadPrice")} path="cache_read_price" value={values.cacheReadPrice} onChange={(cacheReadPrice) => setValues({ ...values, cacheReadPrice })} /> + + ) : null} + + ); + } + + return ( + <> + setValues({ ...values, id })} + /> + .enabled", "create.help.extensionEnabled")} + label={t("create.extension.enabled")} + value={values.enabled} + onChange={(enabled) => setValues({ ...values, enabled })} + /> + + ); +} + +function TextInput({ + helpText, + label, + leadingIconNode, + onChange, + path, + secret, + value +}: { + helpText: string; + label: string; + leadingIconNode?: ReactNode; + onChange: (value: string) => void; + path: string; + secret?: boolean; + value: string; +}) { + const id = useStableCreateId(label); + const help = useCreateFieldHelp(label); + return ( +
    +
    + + +
    +
    + ); +} + +function SelectInput({ + helpText, + label, + onChange, + options, + value +}: { + helpText: string; + label: string; + onChange: (value: string) => void; + options: MaterialSelectOption[]; + value: string; +}) { + const help = useCreateFieldHelp(label); + return ( +
    +
    + {help.button(undefined, "mb-field__select-help")} +
    +
    + option.value === value)?.leadingIcon} + onChange={onChange} + options={options} + value={value} + /> +
    + +
    + ); +} + +function ChipOptionGroup({ + helpText, + label, + onChange, + optionLabel = (option) => option, + options, + value +}: { + helpText: string; + label: string; + onChange: (value: string) => void; + optionLabel?: (option: string) => string; + options: string[]; + value: string; +}) { + return ( +
    + + + {options.map((option) => ( + + {optionLabel(option)} + + ))} + +
    + ); +} + +function ContextWindowInput({ + helpText, + label, + onChange, + value +}: { + helpText: string; + label: string; + onChange: (value: string) => void; + value: string; +}) { + const { t } = useI18n(); + const id = useStableCreateId(label); + const help = useCreateFieldHelp(label); + const presets = [ + [t("create.contextWindowPreset.128k"), "128000"], + [t("create.contextWindowPreset.400k"), "400000"], + [t("create.contextWindowPreset.1m"), "1000000"] + ] as const; + + return ( +
    +
    + + +
    + + {presets.map(([presetLabel, presetValue]) => ( + + {presetLabel} + + ))} + +
    + ); +} + +function SwitchInput({ + helpText, + label, + onChange, + value +}: { + helpText: string; + label: string; + onChange: (value: boolean) => void; + value: boolean; +}) { + return ( +
    +
    + + +
    +
    + ); +} + +function CreateFieldLabel({ helpText, label }: { helpText: string; label: string }) { + return ( + + {label} + + + ); +} + +function fieldLeadingIcon(path: string, secret = false): string | undefined { + if (secret) { + return "key"; + } + const normalizedPath = path.toLowerCase(); + if (normalizedPath.includes("url") || normalizedPath.includes("endpoint") || normalizedPath.includes("addr")) { + return "link"; + } + if (normalizedPath.includes("model")) { + return "smart_toy"; + } + if (normalizedPath.includes("agent")) { + return "badge"; + } + if (normalizedPath.includes("price") || normalizedPath.includes("priority") || normalizedPath.includes("window")) { + return "tag"; + } + return undefined; +} + +function useCreateFieldHelp(label: string) { + const { t } = useI18n(); + const [open, setOpen] = useState(false); + const openedByHover = useRef(false); + const anchorRef = useRef(null); + const helpId = `${useStableCreateId(label)}-help`; + + return { + anchorRef, + button: (slot?: string, className = "schema-field__help") => ( + setOpen(false)} + onClick={(event) => { + event.stopPropagation(); + if (openedByHover.current) { + openedByHover.current = false; + setOpen(true); + return; + } + setOpen((current) => !current); + }} + onFocus={() => setOpen(true)} + onKeyDown={(event) => { + if (event.key === "Escape") { + setOpen(false); + } + }} + onMouseDown={(event) => event.preventDefault()} + onMouseEnter={() => { + openedByHover.current = true; + setOpen(true); + }} + onMouseLeave={() => { + openedByHover.current = false; + setOpen(false); + }} + ref={anchorRef} + slot={slot} + /> + ), + helpId, + open + }; +} + +function CreateFieldHelpButton({ helpText, label }: { helpText: string; label: string }) { + const help = useCreateFieldHelp(label); + return ( + + {help.button()} + + + ); +} + +function CreateFieldHelpTooltip({ + anchorRef, + helpId, + helpText, + open +}: { + anchorRef: React.RefObject; + helpId: string; + helpText: string; + open: boolean; +}) { + const position = useAnchoredTooltipPosition(anchorRef, open); + if (!open) { + return null; + } + return ( + + {helpText} + + ); +} + +function tooltipPositionStyle(position: { left: number; maxWidth: number; top: number } | undefined) { + if (!position) { + return undefined; + } + return { + left: `${position.left}px`, + maxWidth: `${position.maxWidth}px`, + position: "fixed" as const, + top: `${position.top}px` + }; +} + +function useStableCreateId(label: string) { + const id = useId(); + return `create-resource-${id}-${label}`.replace(/[^a-zA-Z0-9_-]/g, "-"); +} + +function toSelectOptions(options: string[]): MaterialSelectOption[] { + return options.map((option) => ({ label: option, value: option })); +} + +function protocolSelectOptions(t: (key: MessageKey) => string): MaterialSelectOption[] { + return ["openai-response", "openai-chat", "anthropic", "google-genai"].map((option) => ({ + label: protocolOptionLabel(option, t), + leadingIcon: protocolIconForValue(option), + value: option + })); +} + +function defaultValues( + kind: CreatableKind, + graph: ConfigGraph, + providerId?: string, + modelId?: string, + availableExtensionIds: string[] = [] +): FormValues { + const firstModel = graph.resources.find((resource) => resource.kind === "model")?.id ?? ""; + const firstProvider = graph.resources.find((resource) => resource.kind === "provider")?.id ?? ""; + return { + ...initialValues, + id: kind === "extension" ? availableExtensionIds[0] ?? "" : initialValues.id, + model: kind === "provider_offer" ? modelId ?? firstModel : kind === "route" ? firstModel : "", + provider: kind === "route" || kind === "provider_offer" ? providerId ?? firstProvider : "" + }; +} + +function createResourceId(kind: CreatableKind, values: FormValues) { + if (kind === "provider_offer") { + return `${values.provider}/${values.model}`; + } + return values.id; +} + +type ResourceDraft = + | { + ok: true; + id: string; + value: Record; + } + | { + ok: false; + error: string; + }; + +function createResourceDraft( + kind: CreatableKind, + values: FormValues, + fieldLabels: NumberFieldLabels, + invalidNumberMessage: (field: string) => string, + positiveNumberMessage: (field: string) => string +): ResourceDraft { + const value = createValue(kind, values, fieldLabels, invalidNumberMessage, positiveNumberMessage); + if (!value.ok) { + return value; + } + return { + ok: true, + id: createResourceId(kind, values), + value: value.value + }; +} + +function createValue( + kind: CreatableKind, + values: FormValues, + fieldLabels: NumberFieldLabels, + invalidNumberMessage: (field: string) => string, + positiveNumberMessage: (field: string) => string +): { ok: true; value: Record } | { ok: false; error: string } { + if (kind === "provider") { + return { + ok: true, + value: { + base_url: values.baseUrl, + api_key: values.apiKey, + protocol: values.protocol + } + }; + } + if (kind === "model") { + const contextWindow = positiveNumericValue( + values.contextWindow, + fieldLabels.contextWindow, + invalidNumberMessage, + positiveNumberMessage + ); + if (!contextWindow.ok) { + return contextWindow; + } + return { + ok: true, + value: { + display_name: values.displayName, + context_window: contextWindow.value + } + }; + } + if (kind === "route") { + return { + ok: true, + value: { + model: values.model, + provider: values.provider + } + }; + } + if (kind === "provider_offer") { + const priority = numericValue(values.priority, fieldLabels.priority, invalidNumberMessage); + if (!priority.ok) { + return priority; + } + const offerValue: Record = { + model: values.model, + upstream_name: values.upstreamName, + priority: priority.value + }; + if (!values.billingEnabled) { + return { + ok: true, + value: offerValue + }; + } + const inputPrice = numericValue(values.inputPrice, fieldLabels.inputPrice, invalidNumberMessage); + const outputPrice = numericValue(values.outputPrice, fieldLabels.outputPrice, invalidNumberMessage); + const cacheWritePrice = numericValue(values.cacheWritePrice, fieldLabels.cacheWritePrice, invalidNumberMessage); + const cacheReadPrice = numericValue(values.cacheReadPrice, fieldLabels.cacheReadPrice, invalidNumberMessage); + if (!inputPrice.ok) { + return inputPrice; + } + if (!outputPrice.ok) { + return outputPrice; + } + if (!cacheWritePrice.ok) { + return cacheWritePrice; + } + if (!cacheReadPrice.ok) { + return cacheReadPrice; + } + return { + ok: true, + value: { + ...offerValue, + pricing: { + input_price: inputPrice.value, + output_price: outputPrice.value, + cache_write_price: cacheWritePrice.value, + cache_read_price: cacheReadPrice.value + } + } + }; + } + return { + ok: true, + value: { + enabled: values.enabled + } + }; +} + +type NumberFieldLabels = { + cacheReadPrice: string; + cacheWritePrice: string; + contextWindow: string; + inputPrice: string; + outputPrice: string; + priority: string; +}; + +function numericValue( + value: string, + field: string, + invalidNumberMessage: (field: string) => string +): { ok: true; value: number } | { ok: false; error: string } { + if (value.trim() === "") { + return { ok: true, value: 0 }; + } + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return { ok: false, error: invalidNumberMessage(field) }; + } + return { ok: true, value: parsed }; +} + +function positiveNumericValue( + value: string, + field: string, + invalidNumberMessage: (field: string) => string, + positiveNumberMessage: (field: string) => string +): { ok: true; value: number } | { ok: false; error: string } { + const parsed = numericValue(value, field, invalidNumberMessage); + if (!parsed.ok) { + return parsed; + } + if (parsed.value <= 0) { + return { ok: false, error: positiveNumberMessage(field) }; + } + return parsed; +} + +function errorMessage(cause: unknown, fallback: string) { + const rawErrors = rawErrorsFrom(cause); + if (rawErrors.length > 0 && typeof rawErrors[0]?.message === "string") { + return rawErrors[0].message; + } + if (cause instanceof Error) { + return cause.message; + } + return fallback; +} + +function rawErrorsFrom(cause: unknown): Array<{ message?: unknown }> { + if (!cause || typeof cause !== "object") { + return []; + } + const raw = "raw" in cause ? (cause as { raw?: unknown }).raw : undefined; + if (!raw || typeof raw !== "object" || !("errors" in raw)) { + return []; + } + const errors = (raw as { errors?: unknown }).errors; + return Array.isArray(errors) ? errors : []; +} + +export type { CreatableKind }; + +function protocolOptionLabel(option: string, t: (key: MessageKey) => string) { + const labels: Record = { + anthropic: "provider.protocol.anthropic", + "openai-response": "provider.protocol.openaiResponses", + "openai-chat": "provider.protocol.openaiChat", + "google-genai": "provider.protocol.googleGenai" + }; + const key = labels[option]; + return key ? t(key) : option; +} diff --git a/webui/src/features/configGraph/FieldStatus.test.tsx b/webui/src/features/configGraph/FieldStatus.test.tsx new file mode 100644 index 00000000..db556f18 --- /dev/null +++ b/webui/src/features/configGraph/FieldStatus.test.tsx @@ -0,0 +1,30 @@ +import { screen } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import { renderWithConsoleProviders } from "../../test/renderWithConsoleProviders"; +import { FieldStatus } from "./FieldStatus"; + +describe("FieldStatus", () => { + test("renders compact save state labels", () => { + const { unmount } = renderWithConsoleProviders(); + + expect(screen.getByText("Saving")).toBeInTheDocument(); + + unmount(); + renderWithConsoleProviders(); + + expect(screen.getByText("invalid value")).toBeInTheDocument(); + }); + + test("exposes status metadata for icon-ready styling", () => { + renderWithConsoleProviders(); + + expect(screen.getByRole("status")).toHaveAttribute("data-status", "dirty"); + expect(screen.getByRole("status").querySelector(".field-status__dot")).toBeInTheDocument(); + }); + + test("localizes save state labels in Chinese locale", () => { + renderWithConsoleProviders(, { locale: "zh-CN" }); + + expect(screen.getByText("保存中")).toBeInTheDocument(); + }); +}); diff --git a/webui/src/features/configGraph/FieldStatus.tsx b/webui/src/features/configGraph/FieldStatus.tsx new file mode 100644 index 00000000..9e19ee52 --- /dev/null +++ b/webui/src/features/configGraph/FieldStatus.tsx @@ -0,0 +1,33 @@ +import { useI18n } from "../../i18n/I18nProvider"; +import type { MessageKey } from "../../i18n/messages"; +import type { AutosaveFieldStatus } from "./useAutosaveField"; + +const statusLabelKeys: Record = { + idle: "saveStatus.idle", + dirty: "saveStatus.dirty", + saving: "saveStatus.saving", + saved: "saveStatus.saved", + error: "saveStatus.error" +}; + +export function FieldStatus({ + status, + message +}: { + status: AutosaveFieldStatus; + message?: string; +}) { + const { t } = useI18n(); + const label = status === "error" && message ? message : t(statusLabelKeys[status]); + return ( + + + ); +} diff --git a/webui/src/features/configGraph/GraphResourceField.tsx b/webui/src/features/configGraph/GraphResourceField.tsx new file mode 100644 index 00000000..6e151de4 --- /dev/null +++ b/webui/src/features/configGraph/GraphResourceField.tsx @@ -0,0 +1,92 @@ +import { useCallback } from "react"; +import type { ConfigResource, FieldSchema } from "../../rpc/types"; +import { useI18n } from "../../i18n/I18nProvider"; +import { SchemaField, type SchemaFieldProps } from "./SchemaField"; +import { configDocPathForResource } from "./configDocPath"; +import { useAutosaveField, type SaveFieldRequest } from "./useAutosaveField"; +import { useReportFieldStatus } from "./editorStatus"; +import { useConfigGraph, useGraphFieldSaver } from "./useConfigGraph"; +import { modelSelectOptions, providerSelectOptions, resourceFieldModelIcon } from "./modelProviderIcons"; + +export function GraphResourceField({ + resource, + field, + objectDisplay, + revision, + modelDisplayNames = {} +}: { + modelDisplayNames?: Record; + resource: ConfigResource; + field: FieldSchema; + objectDisplay?: "collapsible" | "expandedFixed"; + revision: string; +}) { + const { t } = useI18n(); + const graph = useConfigGraph(); + const saveGraphField = useGraphFieldSaver(); + const save = useCallback( + (request: SaveFieldRequest) => saveGraphField(request), + [saveGraphField] + ); + const autosave = useAutosaveField({ + resourceKind: resource.kind, + resourceId: resource.id, + field: field.path, + committedValue: resource.value[field.path], + revision, + save, + configUpdateFailedMessage: (result) => t("field.configUpdateFailed", { result }), + requestFailedMessage: t("error.requestFailed") + }); + useReportFieldStatus(`${resource.kind}:${resource.id}:${field.path}`, autosave.status); + const draftResource = { + ...resource, + value: { + ...resource.value, + [field.path]: autosave.value + } + }; + + const routeSelect = resource.kind === "route" && (field.path === "model" || field.path === "provider") + ? routeSelectProps(field.path, autosave.value, graph.data?.resources ?? [], t) + : undefined; + + return ( + + ); +} + +/** Build select options + an invalid/missing warning for a route's model or provider field. */ +function routeSelectProps( + path: "model" | "provider", + value: unknown, + resources: ConfigResource[], + t: ReturnType["t"] +): Pick { + const options = path === "model" ? modelSelectOptions(resources) : providerSelectOptions(resources); + const text = typeof value === "string" ? value.trim() : ""; + const known = options.some((option) => option.value === text); + let warning: string | undefined; + if (!text) { + warning = path === "model" ? t("route.warning.modelMissing") : t("route.warning.providerMissing"); + } else if (!known) { + warning = path === "model" + ? t("route.warning.modelUnknown", { value: text }) + : t("route.warning.providerUnknown", { value: text }); + } + return { options, warning }; +} diff --git a/webui/src/features/configGraph/ResourceEditorCard.test.tsx b/webui/src/features/configGraph/ResourceEditorCard.test.tsx new file mode 100644 index 00000000..1b6d18ed --- /dev/null +++ b/webui/src/features/configGraph/ResourceEditorCard.test.tsx @@ -0,0 +1,1534 @@ +import { act, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { MemoryRouter } from "react-router-dom"; +import { AppShell } from "../../app/App"; +import { renderWithConsoleProviders } from "../../test/renderWithConsoleProviders"; +import { configGraphFixture, field, resource } from "../../test/configGraphFixtures"; +import { + expectPanelElementToBeFlat, + expectPanelRuleToAvoidEdges, + expectPanelStateRuleToStayFlat +} from "../../test/panelStyleAssertions"; +import * as configGraph from "../../rpc/configGraph"; +import { ResourceEditorCard } from "./ResourceEditorCard"; + +describe("ResourceEditorCard", () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + test("renders resource identity, status metadata, and editable fields", () => { + vi.spyOn(configGraph, "patchConfigGraph").mockResolvedValue({ + result: "committed", + revision: "rev-2" + }); + const provider = resource("provider", "anthropic", "Anthropic", { + base_url: "https://api.anthropic.com", + api_key: "******", + protocol: "anthropic" + }, [ + field("base_url", "Base URL"), + field("api_key", "API Key", "string", "secret", undefined, true), + field("protocol", "Protocol", "string", "select", ["anthropic", "openai-response"]) + ]); + + renderWithConsoleProviders( + + ); + + expect(screen.getByRole("heading", { name: "anthropic" })).toBeInTheDocument(); + expect(document.querySelector(".resource-kind-icon")).toBeInTheDocument(); + expect(within(screen.getByLabelText("anthropic status")).getByText("Saved")).toBeInTheDocument(); + expect(getMaterialTextField(document, "Upstream base URL")).toBeInTheDocument(); + expect(getMaterialTextField(document, "Upstream API key")).toHaveProperty("type", "password"); + }); + + test("clears a saved provider API key draft after autosave commits with a masked graph value", async () => { + const provider = resource("provider", "anthropic", "Anthropic", { + base_url: "https://api.anthropic.com", + api_key: "******", + protocol: "anthropic" + }, [ + field("base_url", "Base URL"), + field("api_key", "API Key", "string", "secret", undefined, true), + field("protocol", "Protocol", "string", "select", ["anthropic", "openai-response"]) + ]); + const patch = vi.spyOn(configGraph, "patchConfigGraph").mockResolvedValue({ + result: "committed", + revision: "rev-2", + graph: configGraphFixture({ + revision: "rev-2", + resources: [provider] + }) + }); + + renderWithConsoleProviders( + + ); + + const apiKeyField = getMaterialTextField(document, "Upstream API key"); + expect(apiKeyField.value).toBe(""); + + setMaterialTextFieldValue(apiKeyField, "sk-live-draft"); + + expect(apiKeyField.value).toBe("sk-live-draft"); + + fireEvent.blur(apiKeyField); + + await waitFor(() => + expect(patch).toHaveBeenCalledWith({ + baseRevision: "rev-1", + changes: [ + { + kind: "provider", + id: "anthropic", + field: "api_key", + value: "sk-live-draft" + } + ] + }) + ); + await waitFor(() => expect(apiKeyField.value).toBe("")); + }); + + test("keeps editor background panels tonal without borders, glow, or hover lift", () => { + vi.spyOn(configGraph, "patchConfigGraph").mockResolvedValue({ + result: "committed", + revision: "rev-2" + }); + const model = resource("model", "claude-sonnet", "Claude Sonnet", { + display_name: "Claude Sonnet", + context_window: 200000, + supports_reasoning: true, + default_reasoning_level: "medium", + default_reasoning_summary: "auto", + supported_reasoning_levels: [ + { effort: "low", description: "Fast responses" }, + { effort: "medium", description: "Balanced" } + ], + web_search: { support: "auto" }, + extensions: {} + }, [ + field("display_name", "Display Name"), + field("context_window", "Context Window", "number", "number"), + field("supports_reasoning", "Supports Reasoning", "boolean", "switch"), + field("default_reasoning_level", "Default Reasoning Level"), + field("supported_reasoning_levels", "Supported Reasoning Levels", "array", "array"), + field("default_reasoning_summary", "Default Reasoning Summary"), + field("web_search", "Web Search", "object", "object"), + field("extensions", "Extensions", "object", "object") + ]); + + const { container } = renderWithConsoleProviders( + + } /> + + ); + + const editorCard = container.querySelector(".resource-editor-card"); + const fieldGroups = container.querySelectorAll(".resource-field-group"); + const advancedPanels = container.querySelectorAll(".resource-field-group--advanced"); + const identityPanel = screen.getByRole("group", { name: "Identity" }); + const basicPanel = screen.getByRole("group", { name: "Basic" }); + expect(editorCard).toBeInTheDocument(); + expect(fieldGroups.length).toBeGreaterThan(0); + expect(advancedPanels.length).toBeGreaterThan(0); + const displayNameField = getMaterialTextField(identityPanel, "Model display name"); + expect(identityPanel).toContainElement(displayNameField); + expectLobeLeadingIcon(displayNameField); + expect(within(identityPanel).queryByText(/fields?$/)).not.toBeInTheDocument(); + expect(basicPanel).toContainElement(getMaterialTextField(basicPanel, "Context window")); + expectPanelElementToBeFlat(editorCard!); + for (const panel of fieldGroups) { + expectPanelElementToBeFlat(panel); + } + expectPanelRuleToAvoidEdges(".resource-editor-card"); + expectPanelStateRuleToStayFlat(".resource-editor-card:hover"); + expectPanelStateRuleToStayFlat(".resource-editor-card:focus-within"); + expectPanelRuleToAvoidEdges(".resource-field-group"); + expectPanelRuleToAvoidEdges(".resource-field-group--advanced"); + expectFieldGroupHeadersToBeTitleOnly(fieldGroups); + }); + + test("lets single-line switch banks fill the available row", () => { + vi.spyOn(configGraph, "patchConfigGraph").mockResolvedValue({ + result: "committed", + revision: "rev-2" + }); + const cache = resource("cache", "main", "Cache", { + prompt_caching: true, + automatic_prompt_cache: true, + explicit_cache_breakpoints: true, + allow_retention_downgrade: false + }, [ + field("prompt_caching", "Prompt Caching", "boolean", "switch"), + field("automatic_prompt_cache", "Automatic Prompt Cache", "boolean", "switch"), + field("explicit_cache_breakpoints", "Explicit Cache Breakpoints", "boolean", "switch"), + field("allow_retention_downgrade", "Allow Retention Downgrade", "boolean", "switch") + ]); + + const { container } = renderWithConsoleProviders( + + } /> + + ); + + const switchBank = container.querySelector(".switch-bank"); + expect(switchBank).toBeInTheDocument(); + expect(switchBank?.querySelectorAll("md-switch")).toHaveLength(4); + expect(getSwitchBankGridTemplateRule()).toContain("auto-fit"); + expect(getSwitchBankGridTemplateRule()).not.toContain("auto-fill"); + }); + + test("surfaces restart and critical runtime metadata", () => { + vi.spyOn(configGraph, "patchConfigGraph").mockResolvedValue({ + result: "restartRequired", + revision: "rev-2" + }); + const server = resource("server", "main", "Server", { + addr: "127.0.0.1:38440" + }, [ + field("addr", "Address") + ], { + hotReloadable: false, + runtimeImpact: "critical", + status: "restartRequired" + }); + + renderWithConsoleProviders( + + ); + + expect(screen.getByRole("heading", { name: "main" })).toBeInTheDocument(); + expect(screen.getByText("Restart required")).toBeInTheDocument(); + expect(screen.getByText("Critical")).toBeInTheDocument(); + }); + + test("renders status metadata pills with uniform icon structure and spacing", () => { + vi.spyOn(configGraph, "patchConfigGraph").mockResolvedValue({ + result: "restartRequired", + revision: "rev-2" + }); + const server = resource("server", "main", "Server", { + addr: "127.0.0.1:38440" + }, [ + field("addr", "Address") + ], { + hotReloadable: false, + runtimeImpact: "critical", + status: "restartRequired" + }); + + const { container } = renderWithConsoleProviders( + + } /> + + ); + + const metadataPills = Array.from( + container.querySelectorAll(".resource-editor-card__facts .resource-meta-pill") + ); + const facts = container.querySelector(".resource-editor-card__facts"); + const statusGroup = container.querySelector(".resource-editor-card__status-group"); + expect(metadataPills).toHaveLength(4); + expect(metadataPills.map((pill) => pill.textContent?.trim())).toEqual([ + "restart_altRestart required", + "priority_highCritical", + "list_alt1 field", + "restart_altRestart on change" + ]); + expect(getComputedStyle(statusGroup!).columnGap).toBe(getComputedStyle(facts!).columnGap); + for (const pill of metadataPills) { + expect(pill.querySelectorAll(".material-symbol[aria-hidden=\"true\"]")).toHaveLength(1); + expect(getComputedStyle(pill).padding).toBe("0px 12px"); + } + }); + + test("uses low-emphasis error color for critical metadata pills in dark theme", () => { + vi.spyOn(configGraph, "patchConfigGraph").mockResolvedValue({ + result: "restartRequired", + revision: "rev-2" + }); + const server = resource("server", "main", "Server", { + addr: "127.0.0.1:38440" + }, [ + field("addr", "Address") + ], { + hotReloadable: false, + runtimeImpact: "critical", + status: "restartRequired" + }); + + const { container } = renderWithConsoleProviders( + + } /> + + ); + + const criticalPill = container.querySelector(".status-pill--critical"); + expect(getComputedStyle(criticalPill!).getPropertyValue("--mb-status-danger-container").trim()) + .toBe("color-mix(in srgb, var(--mb-color-error) 16%, var(--mb-color-surface-container-highest))"); + expect(getComputedStyle(criticalPill!).getPropertyValue("--mb-status-danger-label").trim()) + .toBe("color-mix(in srgb, var(--mb-color-error) 72%, var(--mb-color-on-surface))"); + expect(getComputedStyle(criticalPill!).getPropertyValue("--mb-status-danger-container").trim()) + .not.toBe("var(--mb-color-error-container)"); + }); + + test("scopes metadata pill geometry to resource editor facts", () => { + vi.spyOn(configGraph, "patchConfigGraph").mockResolvedValue({ + result: "restartRequired", + revision: "rev-2" + }); + const server = resource("server", "main", "Server", { + addr: "127.0.0.1:38440" + }, [ + field("addr", "Address") + ], { + hotReloadable: false, + runtimeImpact: "critical", + status: "restartRequired" + }); + + const { container } = renderWithConsoleProviders( + + + + + Outside + + + + )} + /> + + ); + + const outsidePill = screen.getByTestId("outside-meta-pill"); + const outsideStyle = getComputedStyle(outsidePill); + expect(outsideStyle.minHeight).not.toBe("30px"); + expect(outsideStyle.paddingLeft).not.toBe("12px"); + expect(outsideStyle.gap).not.toBe("6px"); + expect(container.querySelectorAll(".resource-editor-card__facts .resource-meta-pill")).toHaveLength(4); + }); + + test("localizes resource metadata in Chinese locale", () => { + vi.spyOn(configGraph, "patchConfigGraph").mockResolvedValue({ + result: "committed", + revision: "rev-2" + }); + const server = resource("server", "main", "Server", { + addr: "127.0.0.1:38440" + }, [ + field("addr", "Address") + ], { + hotReloadable: false, + runtimeImpact: "critical", + status: "restartRequired" + }); + + renderWithConsoleProviders( + , + { locale: "zh-CN" } + ); + + expect(screen.getByText("需要重启")).toBeInTheDocument(); + expect(screen.getByText("关键运行时")).toBeInTheDocument(); + expect(screen.getAllByText("1 个字段").length).toBeGreaterThan(0); + expect(screen.getAllByText("变更后重启").length).toBeGreaterThan(0); + expect(within(screen.getByLabelText("main 状态")).getByText("需要重启")).toBeInTheDocument(); + }); + + test("renders provider pricing and model overrides as structured panels", async () => { + const patch = vi.spyOn(configGraph, "patchConfigGraph").mockResolvedValue({ + result: "committed", + revision: "rev-2" + }); + const offer = resource("provider_offer", "anthropic/claude-sonnet", "Provider", { + model: "claude-sonnet", + upstream_name: "claude-3-5-sonnet", + priority: 1, + pricing: { + input_price: 3, + output_price: 15 + }, + overrides: { + context_window: 200000, + supports_reasoning: true, + input_modalities: ["text"] + } + }, [ + field("model", "Model"), + field("upstream_name", "Upstream Name"), + field("priority", "Priority", "number", "number"), + field("pricing", "Pricing", "object", "object"), + field("overrides", "Overrides", "object", "object") + ]); + + renderWithConsoleProviders( + + ); + + const identityGroup = screen.getByRole("group", { name: "Identity" }); + const standardGroup = screen.getByRole("group", { name: "Basic" }); + const billingGroup = screen.getByRole("group", { name: "Billing" }); + + expect(getMaterialTextField(identityGroup, "Provider model")).toBeInTheDocument(); + expect(getMaterialTextField(identityGroup, "Upstream model name")).toBeInTheDocument(); + expect(getMaterialTextField(standardGroup, "Provider priority")).toBeInTheDocument(); + const overridesEditor = getProviderOverrideEditor(standardGroup); + expect(overridesEditor).toHaveTextContent("Provider overrides"); + expect(getMaterialTextField(overridesEditor, "Override context window")).toHaveValue("200000"); + expect(getMaterialSelectOptions(getMaterialSelect(overridesEditor, "Override supports reasoning")) + .find((option) => option.value === "true")?.selected).toBe(true); + expect(getEditableList(overridesEditor, "Override input modalities")).toBeInTheDocument(); + expect(getEditableListItems(overridesEditor, "Override input modalities")).toEqual(["text"]); + expect(queryMaterialTextField(standardGroup, "Provider overrides JSON")).not.toBeInTheDocument(); + expect(within(overridesEditor).queryByText("Readonly structured summary")).not.toBeInTheDocument(); + expect(() => getStructuredObject(standardGroup, "Pricing")).toThrow("Missing structured object editor: Pricing"); + expect(queryMaterialTextField(standardGroup, "Pricing JSON")).not.toBeInTheDocument(); + expect(getMaterialSwitch(billingGroup, "Billing").selected).toBe(true); + expect(getMaterialTextField(billingGroup, "Input price")).toHaveAttribute("spellcheck", "false"); + expect(getMaterialTextField(billingGroup, "Output price")).toHaveAttribute("spellcheck", "false"); + expect(getMaterialTextField(billingGroup, "Cache write price")).toHaveAttribute("spellcheck", "false"); + expect(getMaterialTextField(billingGroup, "Cache read price")).toHaveAttribute("spellcheck", "false"); + expect(screen.queryByRole("group", { name: "Advanced JSON" })).not.toBeInTheDocument(); + + setMaterialTextFieldValue(getMaterialTextField(overridesEditor, "Override max output tokens"), "4096"); + fireEvent.blur(getMaterialTextField(overridesEditor, "Override max output tokens")); + + await waitFor(() => + expect(patch).toHaveBeenCalledWith({ + baseRevision: "rev-1", + changes: [ + { + kind: "provider_offer", + id: "anthropic/claude-sonnet", + field: "overrides", + value: { + context_window: 200000, + supports_reasoning: true, + input_modalities: ["text"], + max_output_tokens: 4096 + } + } + ] + }) + ); + + setMaterialSelectValue(getMaterialSelect(overridesEditor, "Override supports reasoning"), "false"); + + await waitFor(() => + expect(patch).toHaveBeenCalledWith({ + baseRevision: "rev-2", + changes: [ + { + kind: "provider_offer", + id: "anthropic/claude-sonnet", + field: "overrides", + value: { + context_window: 200000, + supports_reasoning: false, + input_modalities: ["text"], + max_output_tokens: 4096 + } + } + ] + }) + ); + + setMaterialSwitchSelected(getMaterialSwitch(billingGroup, "Billing"), false); + + await waitFor(() => + expect(patch).toHaveBeenCalledWith({ + baseRevision: "rev-1", + changes: [ + { + kind: "provider_offer", + id: "anthropic/claude-sonnet", + field: "pricing", + value: null + } + ] + }) + ); + expect(patch).not.toHaveBeenCalledWith({ + baseRevision: "rev-1", + changes: [ + { + kind: "provider_offer", + id: "anthropic/claude-sonnet", + field: "pricing", + value: {} + } + ] + }); + + await waitFor(() => expect(queryMaterialTextField(billingGroup, "Input price")).not.toBeInTheDocument()); + setMaterialSwitchSelected(getMaterialSwitch(billingGroup, "Billing"), true); + expect(getMaterialTextField(billingGroup, "Input price")).toBeInTheDocument(); + setMaterialTextFieldValue(getMaterialTextField(billingGroup, "Cache read price"), "0.3"); + fireEvent.blur(getMaterialTextField(billingGroup, "Cache read price")); + + await waitFor(() => + expect(patch).toHaveBeenLastCalledWith({ + baseRevision: "rev-2", + changes: [ + { + kind: "provider_offer", + id: "anthropic/claude-sonnet", + field: "pricing", + value: { + input_price: 0, + output_price: 0, + cache_write_price: 0, + cache_read_price: 0.3 + } + } + ] + }) + ); + }); + + test("keeps plain long text fields in basic without a raw JSON editor group", () => { + vi.spyOn(configGraph, "patchConfigGraph").mockResolvedValue({ + result: "committed", + revision: "rev-2" + }); + const model = resource("model", "claude-sonnet", "Claude Sonnet", { + display_name: "Claude Sonnet", + description: "Balanced model" + }, [ + field("display_name", "Display Name"), + field("description", "Description", "string", "textarea") + ]); + + renderWithConsoleProviders( + + ); + + const settingsGroup = screen.getByRole("group", { name: "Basic" }); + expect(getMaterialTextField(settingsGroup, "Model description")).toBeInTheDocument(); + expect(screen.queryByRole("group", { name: "Advanced JSON" })).not.toBeInTheDocument(); + }); + + test("uses a compact four-column identity layout for route target fields", () => { + vi.spyOn(configGraph, "patchConfigGraph").mockResolvedValue({ + result: "committed", + revision: "rev-2" + }); + const route = resource("route", "primary", "Primary Route", { + to: "primary", + model: "moonbridge-default", + provider: "anthropic", + display_name: "Primary Route" + }, [ + field("to", "Route Target"), + field("model", "Model"), + field("provider", "Provider"), + field("display_name", "Display Name") + ]); + + renderWithConsoleProviders( + + ); + + const identityGroup = screen.getByRole("group", { name: "Identity" }); + expect(identityGroup).toHaveClass("resource-field-group--route-identity"); + const identityGrid = identityGroup.querySelector(".form-grid"); + expect(identityGrid).toHaveClass("form-grid--route-identity"); + const targetField = getMaterialTextField(identityGroup, "Route target"); + const modelField = getMaterialTextField(identityGroup, "Route model"); + const providerField = getMaterialTextField(identityGroup, "Route provider"); + const displayNameField = getMaterialTextField(identityGroup, "Route display name"); + expect([ + targetField, + modelField, + providerField, + displayNameField + ].map((fieldElement) => fieldElement.closest(".form-grid"))).toEqual([ + identityGrid, + identityGrid, + identityGrid, + identityGrid + ]); + expectLobeLeadingIcon(targetField); + expectLobeLeadingIcon(modelField); + expectLobeLeadingIcon(displayNameField); + }); + + test("infers route target and display-name icons from their own visible text first", () => { + vi.spyOn(configGraph, "patchConfigGraph").mockResolvedValue({ + result: "committed", + revision: "rev-2" + }); + const route = resource("route", "primary", "Primary Route", { + to: "claude-sonnet", + model: "moonbridge-default", + provider: "local", + display_name: "Gemini Flash" + }, [ + field("to", "Route Target"), + field("model", "Model"), + field("provider", "Provider"), + field("display_name", "Display Name") + ]); + + renderWithConsoleProviders( + + ); + + const identityGroup = screen.getByRole("group", { name: "Identity" }); + expectLobeLeadingIcon(getMaterialTextField(identityGroup, "Route target"), "Claude"); + expectLobeLeadingIcon(getMaterialTextField(identityGroup, "Route model"), "OpenAI"); + expectLobeLeadingIcon(getMaterialTextField(identityGroup, "Route display name"), "Gemini"); + }); + + test("updates model display-name icon from the draft field value", () => { + vi.spyOn(configGraph, "patchConfigGraph").mockResolvedValue({ + result: "committed", + revision: "rev-2" + }); + const model = resource("model", "claude-sonnet", "Claude Sonnet", { + display_name: "Claude Sonnet" + }, [ + field("display_name", "Display Name") + ]); + + renderWithConsoleProviders( + + ); + + const displayNameField = getMaterialTextField(document, "Model display name"); + expectLobeLeadingIcon(displayNameField, "Claude"); + setMaterialTextFieldValue(displayNameField, "GPT-4o"); + expectLobeLeadingIcon(displayNameField, "OpenAI"); + }); + + test("groups model settings into basic, multimodal, advanced features, and reasoning panels", () => { + vi.spyOn(configGraph, "patchConfigGraph").mockResolvedValue({ + result: "committed", + revision: "rev-2" + }); + const model = resource("model", "claude-sonnet", "Claude Sonnet", { + display_name: "Claude Sonnet", + context_window: 200000, + max_output_tokens: 8192, + supports_reasoning: true, + default_reasoning_level: "medium", + default_reasoning_summary: "auto", + description: "Balanced model", + base_instructions: "Stay concise.", + supported_reasoning_levels: [ + { effort: "low", description: "Fast responses with lighter reasoning" }, + { effort: "medium", description: "Balances speed and reasoning depth" }, + { effort: "high", description: "Greater reasoning depth" } + ], + input_modalities: ["text", "image"], + supports_image_detail_original: true, + web_search: { support: "auto", max_uses: 4 }, + extensions: { + visual: { enabled: true } + } + }, [ + field("display_name", "Display Name"), + field("context_window", "Context Window", "number", "number"), + field("max_output_tokens", "Max Output Tokens", "number", "number"), + field("supports_reasoning", "Supports Reasoning", "boolean", "switch"), + field("default_reasoning_level", "Default Reasoning Level"), + field("supported_reasoning_levels", "Supported Reasoning Levels", "array", "array"), + field("default_reasoning_summary", "Default Reasoning Summary"), + field("description", "Description", "string", "textarea"), + field("base_instructions", "Base Instructions", "string", "textarea"), + field("input_modalities", "Input Modalities", "array", "array"), + field("supports_image_detail_original", "Supports Image Detail Original", "boolean", "switch"), + field("web_search", "Web Search", "object", "object"), + field("extensions", "Extensions", "object", "object") + ]); + + renderWithConsoleProviders( + + ); + + const reasoningPanel = screen.getByRole("group", { name: "Reasoning" }); + const basicPanel = screen.getByRole("group", { name: "Basic" }); + const multimodalPanel = screen.getByRole("group", { name: "Multimodal" }); + const advancedPanel = screen.getByRole("group", { name: "Advanced Features" }); + expect(basicPanel).toContainElement(getMaterialTextField(document, "Context window")); + expect(basicPanel).toContainElement(getMaterialTextField(document, "Max output tokens")); + expect(basicPanel).toContainElement(getMaterialTextField(document, "Model description")); + expect(basicPanel).toContainElement(getMaterialTextField(document, "Base instructions")); + expect(Array.from(document.querySelectorAll(".resource-field-group")).map((group) => group.getAttribute("aria-label"))) + .toEqual(["Identity", "Basic", "Reasoning", "Multimodal", "Advanced Features"]); + expect(multimodalPanel).toHaveClass("resource-field-group--multimodal"); + expect(getMaterialIconButton(multimodalPanel, "Toggle Multimodal")).toHaveAttribute("aria-expanded", "false"); + expect(getMaterialIconButton(advancedPanel, "Toggle Advanced Features")).toHaveAttribute("aria-expanded", "false"); + expect(queryEditableList(multimodalPanel, "Input modalities")).not.toBeInTheDocument(); + expect(queryMaterialSelect(advancedPanel, "Model web search mode")).not.toBeInTheDocument(); + + fireEvent.click(getMaterialIconButton(multimodalPanel, "Toggle Multimodal")); + fireEvent.click(getMaterialIconButton(advancedPanel, "Toggle Advanced Features")); + + expect(getMaterialIconButton(multimodalPanel, "Toggle Multimodal")).toHaveAttribute("aria-expanded", "true"); + expect(getMaterialIconButton(advancedPanel, "Toggle Advanced Features")).toHaveAttribute("aria-expanded", "true"); + expect(getEditableList(multimodalPanel, "Input modalities")).toBeInTheDocument(); + expect(getMaterialSwitch(multimodalPanel, "Supports original image detail")).toBeInTheDocument(); + expect(advancedPanel).toHaveClass("resource-field-group--advanced"); + expect(getMaterialSelect(advancedPanel, "Model web search mode")).toBeInTheDocument(); + expect(getMaterialTextField(advancedPanel, "Model web search max uses")).toBeInTheDocument(); + expect(getExtensionFeatureRow(advancedPanel, "visual")).toBeInTheDocument(); + expect(getMaterialSwitch(advancedPanel, "Enable visual extension")).toBeInTheDocument(); + expect(queryMaterialTextField(document, "Model web search JSON")).not.toBeInTheDocument(); + expect(queryMaterialTextField(document, "Model extensions JSON")).not.toBeInTheDocument(); + expect(reasoningPanel).toHaveClass("resource-field-group--reasoning"); + const reasoningSwitch = getMaterialSwitch(reasoningPanel, "Supports reasoning"); + expect(reasoningSwitch.selected).toBe(true); + expect(within(reasoningPanel).queryByText("4 fields")).not.toBeInTheDocument(); + const defaultLevelCell = getMaterialSelect(reasoningPanel, "Default reasoning level").closest(".form-grid__compact"); + const defaultSummaryCell = getMaterialTextField(document, "Default reasoning summary").closest(".form-grid__compact"); + expect(defaultLevelCell).toBeInTheDocument(); + expect(defaultSummaryCell).toBeInTheDocument(); + expect(defaultLevelCell?.parentElement).toBe(defaultSummaryCell?.parentElement); + expect(defaultLevelCell?.nextElementSibling).toBe(defaultSummaryCell); + const reasoningDefaultsRow = getMaterialSelect(document, "Default reasoning level") + .closest(".form-grid__reasoning-defaults"); + expect(reasoningDefaultsRow).toBeInTheDocument(); + expect(reasoningDefaultsRow).toHaveClass("form-grid__wide"); + expect(reasoningDefaultsRow).toContainElement(getMaterialTextField(document, "Default reasoning summary")); + expect(Array.from(reasoningDefaultsRow!.children).map((child) => child.className)).toEqual([ + "form-grid__compact", + "form-grid__compact" + ]); + expect(getMaterialSelectOptions(getMaterialSelect(document, "Default reasoning level")).map((option) => option.value)) + .toEqual(["low", "medium", "high"]); + expect(queryMaterialTextField(document, "Default reasoning level")).not.toBeInTheDocument(); + expect(getMaterialTextField(document, "Model display name").closest(".form-grid__medium")).toBeInTheDocument(); + expect(getMaterialTextField(document, "Context window").closest(".form-grid__compact")).toBeInTheDocument(); + expect(getMaterialTextField(document, "Model description").closest(".form-grid__wide")).toBeInTheDocument(); + expect(getEditableList(reasoningPanel, "Supported reasoning levels")).toBeInTheDocument(); + expect(getEditableList(multimodalPanel, "Input modalities")).toBeInTheDocument(); + expect(getEditableListItems(document, "Supported reasoning levels")).toEqual(["low", "medium", "high"]); + expect(getEditableListItems(document, "Input modalities")).toEqual(["text", "image"]); + expect(getEditableList(document, "Supported reasoning levels").querySelector("md-chip-set")).toBeInTheDocument(); + expect(getEditableList(document, "Supported reasoning levels").querySelectorAll("md-input-chip")).toHaveLength(3); + expect(getMaterialTextField(getEditableList(document, "Supported reasoning levels"), "Add Supported reasoning levels")) + .toHaveAttribute("spellcheck", "false"); + expect(getMaterialButton(getEditableList(document, "Supported reasoning levels"), "Add Supported reasoning levels item", "filled")) + .toBeInTheDocument(); + expect(queryMaterialTextField(document, "Supported reasoning levels JSON")).not.toBeInTheDocument(); + expect(queryMaterialTextField(document, "Input modalities JSON")).not.toBeInTheDocument(); + expect(queryMaterialOutlinedButton(document, /Supported reasoning levels.*3 items/)).not.toBeInTheDocument(); + expect(queryMaterialOutlinedButton(document, /Input modalities.*2 items/)).not.toBeInTheDocument(); + expect(queryMaterialOutlinedButton(document, /Model web search.*1 key/)).not.toBeInTheDocument(); + expect(queryMaterialOutlinedButton(document, /Model extensions.*1 key/)).not.toBeInTheDocument(); + }); + + test("autosaves model web search through structured Material controls while preserving existing keys", async () => { + const patch = vi.spyOn(configGraph, "patchConfigGraph").mockResolvedValue({ + result: "committed", + revision: "rev-2" + }); + const model = resource("model", "claude-sonnet", "Claude Sonnet", { + web_search: { + support: "auto", + max_uses: 4, + search_max_rounds: 2, + tavily_api_key: "******", + custom_flag: "keep" + } + }, [ + field("web_search", "Web Search", "object", "object") + ]); + + renderWithConsoleProviders( + + ); + + const advancedPanel = screen.getByRole("group", { name: "Advanced Features" }); + fireEvent.click(getMaterialIconButton(advancedPanel, "Toggle Advanced Features")); + const supportSelect = getMaterialSelect(advancedPanel, "Model web search mode"); + expect(getMaterialSelectOptions(supportSelect).map((option) => option.value)).toEqual([ + "auto", + "enabled", + "disabled", + "injected" + ]); + expect(getMaterialTextField(advancedPanel, "Model web search max uses")).toHaveAttribute("spellcheck", "false"); + expect(getMaterialTextField(advancedPanel, "Model web search Tavily API key")).toHaveProperty("type", "password"); + expect(getMaterialTextField(advancedPanel, "Model web search Firecrawl API key")).toHaveProperty("type", "password"); + expectWebSearchFieldHelp(advancedPanel, "Model web search max uses", "Limits how many web search calls one request may use."); + expectWebSearchFieldHelp(advancedPanel, "Model web search search max rounds", "Maximum number of search rounds per request."); + expectWebSearchFieldHelp(advancedPanel, "Model web search Tavily API key", "Tavily secret used by injected web search."); + expectWebSearchFieldHelp(advancedPanel, "Model web search Firecrawl API key", "Firecrawl secret used by injected web search to fetch page content."); + expect(queryMaterialTextField(advancedPanel, "Model web search JSON")).not.toBeInTheDocument(); + + fireEvent.blur(getMaterialTextField(advancedPanel, "Model web search Tavily API key")); + expect(patch).not.toHaveBeenCalled(); + + setMaterialSelectValueBySelectedOption(supportSelect, "disabled"); + + await waitFor(() => + expect(patch).toHaveBeenCalledWith({ + baseRevision: "rev-1", + changes: [ + { + kind: "model", + id: "claude-sonnet", + field: "web_search", + value: { + support: "disabled", + max_uses: 4, + search_max_rounds: 2, + tavily_api_key: "******", + custom_flag: "keep" + } + } + ] + }) + ); + }); + + test("autosaves provider and route web search through structured Material controls", async () => { + const patch = vi.spyOn(configGraph, "patchConfigGraph").mockResolvedValue({ + result: "committed", + revision: "rev-2" + }); + const provider = resource("provider", "anthropic", "Anthropic", { + web_search: { + support: "injected", + tavily_api_key: "******", + firecrawl_api_key: "******", + search_max_rounds: 2 + } + }, [ + field("web_search", "Web Search", "object", "object") + ]); + const { unmount } = renderWithConsoleProviders( + + ); + + setMaterialTextFieldValue(getMaterialTextField(document, "Provider web search Tavily API key"), "tv-new"); + fireEvent.blur(getMaterialTextField(document, "Provider web search Tavily API key")); + + await waitFor(() => + expect(patch).toHaveBeenCalledWith({ + baseRevision: "rev-1", + changes: [ + { + kind: "provider", + id: "anthropic", + field: "web_search", + value: { + support: "injected", + tavily_api_key: "tv-new", + firecrawl_api_key: "******", + search_max_rounds: 2 + } + } + ] + }) + ); + + unmount(); + patch.mockClear(); + const route = resource("route", "primary", "Primary", { + web_search: { + support: "auto", + max_uses: 3 + } + }, [ + field("web_search", "Web Search", "object", "object") + ]); + renderWithConsoleProviders( + + ); + + setMaterialSelectValueBySelectedOption(getMaterialSelect(document, "Route web search mode"), "enabled"); + + await waitFor(() => + expect(patch).toHaveBeenCalledWith({ + baseRevision: "rev-1", + changes: [ + { + kind: "route", + id: "primary", + field: "web_search", + value: { + support: "enabled", + max_uses: 3 + } + } + ] + }) + ); + }); + + test("autosaves model extensions through structured Material controls while preserving extension config", async () => { + const patch = vi.spyOn(configGraph, "patchConfigGraph").mockResolvedValue({ + result: "committed", + revision: "rev-2" + }); + const model = resource("model", "claude-sonnet", "Claude Sonnet", { + extensions: { + visual: { + enabled: true, + config: { provider: "openai", model: "gpt-4.1" }, + custom_flag: "keep" + } + } + }, [ + field("extensions", "Extensions", "object", "object") + ]); + + renderWithConsoleProviders( + + ); + + const advancedPanel = screen.getByRole("group", { name: "Advanced Features" }); + fireEvent.click(getMaterialIconButton(advancedPanel, "Toggle Advanced Features")); + expect(getExtensionFeatureRow(advancedPanel, "visual")).toBeInTheDocument(); + expect(queryMaterialTextField(advancedPanel, "Model extensions JSON")).not.toBeInTheDocument(); + expect(getMaterialTextField(advancedPanel, "visual provider")).toHaveAttribute("spellcheck", "false"); + expect(getMaterialTextField(advancedPanel, "visual model")).toHaveAttribute("spellcheck", "false"); + + setMaterialSwitchSelected(getMaterialSwitch(advancedPanel, "Enable visual extension"), false); + + await waitFor(() => + expect(patch).toHaveBeenCalledWith({ + baseRevision: "rev-1", + changes: [ + { + kind: "model", + id: "claude-sonnet", + field: "extensions", + value: { + visual: { + enabled: false, + config: { provider: "openai", model: "gpt-4.1" }, + custom_flag: "keep" + } + } + } + ] + }) + ); + + patch.mockClear(); + setMaterialTextFieldValue(getMaterialTextField(advancedPanel, "visual model"), "gpt-4.2"); + fireEvent.blur(getMaterialTextField(advancedPanel, "visual model")); + + await waitFor(() => + expect(patch).toHaveBeenCalledWith({ + baseRevision: "rev-2", + changes: [ + { + kind: "model", + id: "claude-sonnet", + field: "extensions", + value: { + visual: { + enabled: false, + config: { provider: "openai", model: "gpt-4.2" }, + custom_flag: "keep" + } + } + } + ] + }) + ); + }); + + test("keeps structured web search help tooltip ids scoped per resource", () => { + vi.spyOn(configGraph, "patchConfigGraph").mockResolvedValue({ + result: "committed", + revision: "rev-2" + }); + const provider = resource("provider", "anthropic", "Anthropic", { + web_search: { support: "auto" } + }, [ + field("web_search", "Web Search", "object", "object") + ]); + const model = resource("model", "claude-sonnet", "Claude Sonnet", { + web_search: { support: "auto" } + }, [ + field("web_search", "Web Search", "object", "object") + ]); + + renderWithConsoleProviders( + <> + + + + ); + + const providerHelp = getMaterialIconButton(document, "Help for Provider web search max uses"); + fireEvent.click(getMaterialIconButton(screen.getAllByRole("group", { name: "Advanced Features" })[1], "Toggle Advanced Features")); + const modelHelp = getMaterialIconButton(document, "Help for Model web search max uses"); + fireEvent.focus(providerHelp); + fireEvent.focus(modelHelp); + + const describedIds = [ + providerHelp.getAttribute("aria-describedby"), + modelHelp.getAttribute("aria-describedby") + ]; + expect(describedIds.every(Boolean)).toBe(true); + expect(new Set(describedIds).size).toBe(2); + }); + + test("hides model reasoning options when the model does not support reasoning", async () => { + const patch = vi.spyOn(configGraph, "patchConfigGraph").mockResolvedValue({ + result: "committed", + revision: "rev-2" + }); + const model = resource("model", "plain-model", "Plain Model", { + supports_reasoning: false, + default_reasoning_level: "medium", + default_reasoning_summary: "auto", + supported_reasoning_levels: [ + { effort: "low", description: "Fast responses" }, + { effort: "medium", description: "Balanced" } + ] + }, [ + field("supports_reasoning", "Supports Reasoning", "boolean", "switch"), + field("default_reasoning_level", "Default Reasoning Level"), + field("supported_reasoning_levels", "Supported Reasoning Levels", "array", "array"), + field("supports_reasoning_summaries", "Supports Reasoning Summaries", "boolean", "switch"), + field("default_reasoning_summary", "Default Reasoning Summary") + ]); + + renderWithConsoleProviders( + + ); + + const reasoningPanel = screen.getByRole("group", { name: "Reasoning" }); + expect(reasoningPanel).toHaveClass("resource-field-group--reasoning"); + const reasoningSwitch = getMaterialSwitch(reasoningPanel, "Supports reasoning"); + expect(reasoningSwitch.selected).toBe(false); + expect(queryMaterialTextField(reasoningPanel, "Default reasoning summary")).not.toBeInTheDocument(); + expect(queryMaterialTextField(reasoningPanel, "Supported reasoning levels JSON")).not.toBeInTheDocument(); + expect(queryMaterialTextField(reasoningPanel, "Default reasoning level")).not.toBeInTheDocument(); + expect(reasoningPanel.querySelector(".editable-list-field")).not.toBeInTheDocument(); + expect(reasoningPanel.querySelectorAll("md-switch")).toHaveLength(1); + + setMaterialSwitchSelected(reasoningSwitch, true); + + await waitFor(() => + expect(patch).toHaveBeenCalledWith({ + baseRevision: "rev-1", + changes: [ + { + kind: "model", + id: "plain-model", + field: "supports_reasoning", + value: true + } + ] + }) + ); + }); + + test("skips model reasoning controls when the support switch is absent from schema", () => { + vi.spyOn(configGraph, "patchConfigGraph").mockResolvedValue({ + result: "committed", + revision: "rev-2" + }); + const model = resource("model", "legacy-model", "Legacy Model", { + default_reasoning_level: "medium", + default_reasoning_summary: "auto", + supported_reasoning_levels: [ + { effort: "low", description: "Fast responses" }, + { effort: "medium", description: "Balanced" } + ], + display_name: "Legacy Model" + }, [ + field("default_reasoning_level", "Default Reasoning Level"), + field("supported_reasoning_levels", "Supported Reasoning Levels", "array", "array"), + field("default_reasoning_summary", "Default Reasoning Summary"), + field("display_name", "Display Name") + ]); + + renderWithConsoleProviders( + + ); + + expect(screen.getByRole("heading", { name: "legacy-model" })).toBeInTheDocument(); + expect(screen.queryByRole("group", { name: "Reasoning" })).not.toBeInTheDocument(); + expect(document.querySelector("md-switch[aria-label=\"Supports reasoning\"]")).not.toBeInTheDocument(); + expect(queryMaterialTextField(document, "Default reasoning summary")).not.toBeInTheDocument(); + expect(queryMaterialTextField(document, "Default reasoning level")).not.toBeInTheDocument(); + expect(document.querySelector(".editable-list-field[aria-label=\"Supported reasoning levels\"]")).not.toBeInTheDocument(); + expect(getMaterialTextField(document, "Model display name")).toBeInTheDocument(); + }); + + test("autosaves editable model array lists through graph patches", async () => { + const patch = vi.spyOn(configGraph, "patchConfigGraph").mockResolvedValue({ + result: "committed", + revision: "rev-2" + }); + const model = resource("model", "claude-sonnet", "Claude Sonnet", { + supports_reasoning: true, + supported_reasoning_levels: [ + { effort: "low", description: "Fast responses with lighter reasoning" }, + { effort: "medium", description: "Balances speed and reasoning depth" } + ], + extensions: {} + }, [ + field("supports_reasoning", "Supports Reasoning", "boolean", "switch"), + field("supported_reasoning_levels", "Supported Reasoning Levels", "array", "array"), + field("extensions", "Extensions", "object", "object") + ]); + + renderWithConsoleProviders( + + ); + + setMaterialTextFieldValue(getMaterialTextField(document, "Add Supported reasoning levels"), "high"); + await userEvent.click(getMaterialButton(document, "Add Supported reasoning levels item", "filled")); + + expect(patch).toHaveBeenCalledWith({ + baseRevision: "rev-1", + changes: [ + { + kind: "model", + id: "claude-sonnet", + field: "supported_reasoning_levels", + value: [ + { effort: "low", description: "Fast responses with lighter reasoning" }, + { effort: "medium", description: "Balances speed and reasoning depth" }, + { effort: "high" } + ] + } + ] + }); + + await waitFor(() => + expect(getMaterialInputChip(document, "Remove high from Supported reasoning levels")).toBeInTheDocument() + ); + act(() => { + getMaterialInputChip(document, "Remove high from Supported reasoning levels") + .dispatchEvent(new Event("remove")); + }); + + await waitFor(() => expect(patch).toHaveBeenCalledTimes(2)); + expect(patch).toHaveBeenLastCalledWith({ + baseRevision: "rev-2", + changes: [ + { + kind: "model", + id: "claude-sonnet", + field: "supported_reasoning_levels", + value: [ + { effort: "low", description: "Fast responses with lighter reasoning" }, + { effort: "medium", description: "Balances speed and reasoning depth" } + ] + } + ] + }); + }); + + test("selects the default reasoning level from supported reasoning levels", async () => { + const patch = vi.spyOn(configGraph, "patchConfigGraph").mockResolvedValue({ + result: "committed", + revision: "rev-2" + }); + const model = resource("model", "claude-sonnet", "Claude Sonnet", { + supports_reasoning: true, + default_reasoning_level: "medium", + default_reasoning_summary: "auto", + supported_reasoning_levels: [ + { effort: "low", description: "Fast responses with lighter reasoning" }, + { effort: "medium", description: "Balances speed and reasoning depth" }, + { effort: "high", description: "Greater reasoning depth" } + ] + }, [ + field("supports_reasoning", "Supports Reasoning", "boolean", "switch"), + field("default_reasoning_level", "Default Reasoning Level"), + field("supported_reasoning_levels", "Supported Reasoning Levels", "array", "array"), + field("default_reasoning_summary", "Default Reasoning Summary") + ]); + + renderWithConsoleProviders( + + ); + + const defaultLevel = getMaterialSelect(document, "Default reasoning level"); + await waitFor(() => expect(defaultLevel.value).toBe("medium")); + expect(getMaterialSelectOptions(defaultLevel).map((option) => option.value)).toEqual([ + "low", + "medium", + "high" + ]); + expect(queryMaterialTextField(document, "Default reasoning level")).not.toBeInTheDocument(); + + setMaterialTextFieldValue(getMaterialTextField(document, "Add Supported reasoning levels"), "xhigh"); + await userEvent.click(getMaterialButton(document, "Add Supported reasoning levels item", "filled")); + await waitFor(() => + expect(getMaterialSelectOptions(defaultLevel).map((option) => option.value)).toEqual([ + "low", + "medium", + "high", + "xhigh" + ]) + ); + await waitFor(() => { + expect(defaultLevel.options?.map((option) => option.value)).toContain("xhigh"); + }); + + setMaterialSelectValue(defaultLevel, "xhigh"); + + await waitFor(() => + expect(patch).toHaveBeenLastCalledWith({ + baseRevision: "rev-1", + changes: [ + { + kind: "model", + id: "claude-sonnet", + field: "default_reasoning_level", + value: "xhigh" + } + ] + }) + ); + }); + + test("uses Material Web buttons for delete confirmation state changes", async () => { + vi.spyOn(configGraph, "patchConfigGraph").mockResolvedValue({ + result: "committed", + revision: "rev-2" + }); + const remove = vi.spyOn(configGraph, "deleteConfigResource").mockResolvedValue({ + result: "committed", + revision: "rev-2" + }); + const provider = resource("provider", "anthropic", "Anthropic", { + base_url: "https://api.anthropic.com" + }, [ + field("base_url", "Base URL") + ]); + + renderWithConsoleProviders( + + ); + + const deleteButton = getMaterialButton(document, "Delete Provider anthropic", "filled"); + expect(deleteButton).toHaveTextContent("Delete"); + + await userEvent.click(deleteButton); + + expect(screen.getByText("Delete anthropic? This takes effect after saving.")) + .toBeInTheDocument(); + expect(getMaterialButton(document, "Confirm delete anthropic", "filled")).toBeInTheDocument(); + const cancelButton = getMaterialButton(document, "Cancel", "outlined"); + + await userEvent.click(cancelButton); + + expect(screen.queryByText("Delete anthropic? This takes effect after saving.")) + .not.toBeInTheDocument(); + expect(remove).not.toHaveBeenCalled(); + + await userEvent.click(getMaterialButton(document, "Delete Provider anthropic", "filled")); + await userEvent.click(getMaterialButton(document, "Confirm delete anthropic", "filled")); + + await waitFor(() => expect(remove).toHaveBeenCalledWith("provider", "anthropic", "rev-1")); + }); + + test("keeps delete button icon colors aligned with error-container labels", async () => { + vi.spyOn(configGraph, "patchConfigGraph").mockResolvedValue({ + result: "committed", + revision: "rev-2" + }); + const provider = resource("provider", "anthropic", "Anthropic", { + base_url: "https://api.anthropic.com" + }, [ + field("base_url", "Base URL") + ]); + + const { container } = renderWithConsoleProviders( + + } /> + + ); + + const deleteButton = getMaterialButton(container, "Delete Provider anthropic", "filled"); + expectMaterialFilledButtonContentColors(deleteButton, "var(--mb-color-on-error-container)"); + + await userEvent.click(deleteButton); + + const confirmButton = getMaterialButton(container, "Confirm delete anthropic", "filled"); + expectMaterialFilledButtonContentColors(confirmButton, "var(--mb-color-on-error)"); + }); +}); + +function getMaterialTextField(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-outlined-text-field")).find( + (textField) => materialElementLabel(textField as HTMLElement & { label?: string }) === label + ); + if (!element) { + throw new Error(`Missing md-outlined-text-field: ${label}`); + } + return element as HTMLElement & { label: string; type: string; value: string }; +} + +function expectLobeLeadingIcon(fieldElement: HTMLElement, title?: string) { + const leadingIcon = fieldElement.querySelector("[slot='leading-icon']"); + expect(leadingIcon).toBeInTheDocument(); + expect(leadingIcon?.querySelector("svg")).toBeInTheDocument(); + if (title) { + expect(leadingIcon?.querySelector("title")).toHaveTextContent(title); + } +} + +function expectFieldGroupHeadersToBeTitleOnly(fieldGroups: NodeListOf) { + for (const group of Array.from(fieldGroups)) { + const header = group.querySelector(".resource-field-group__header"); + expect(header).toBeInTheDocument(); + expect(header?.querySelector("h4")).toBeInTheDocument(); + expect(header?.querySelector("h4")?.textContent?.trim()).not.toBe(""); + const directSpanChildren = Array.from(header?.children ?? []).filter((child) => + child.tagName.toLowerCase() === "span" && !child.classList.contains("resource-field-group__switch") + ); + expect(directSpanChildren).toHaveLength(0); + } +} + +function getSwitchBankGridTemplateRule() { + for (const styleElement of Array.from(document.querySelectorAll("style"))) { + const css = styleElement.textContent ?? ""; + const match = css.match(/\.switch-bank\s*\{[^}]*grid-template-columns\s*:\s*([^;]+);/); + if (match) { + return match[1].trim(); + } + } + throw new Error("Missing .switch-bank grid-template-columns rule."); +} + +function expectWebSearchFieldHelp(container: ParentNode, fieldLabel: string, bodyText: string) { + const textField = getMaterialTextField(container, fieldLabel); + const helpButton = getMaterialIconButton(textField, `Help for ${fieldLabel}`); + expect(helpButton).toHaveAttribute("slot", "trailing-icon"); + + fireEvent.focus(helpButton); + + const tooltip = screen.getByText(bodyText).closest("[role='tooltip']"); + expect(tooltip).toBeInTheDocument(); + expect(tooltip).toHaveTextContent(fieldLabel); +} + +function queryMaterialTextField(container: ParentNode, label: string) { + return Array.from(container.querySelectorAll("md-outlined-text-field")).find( + (textField) => materialElementLabel(textField as HTMLElement & { label?: string }) === label + ) ?? null; +} + +function getEditableList(container: ParentNode, label: string) { + const element = queryEditableList(container, label); + if (!element) { + throw new Error(`Missing editable list: ${label}`); + } + return element; +} + +function queryEditableList(container: ParentNode, label: string) { + return Array.from(container.querySelectorAll(".editable-list-field")).find( + (candidate) => candidate.getAttribute("aria-label") === label + ) ?? null; +} + +function getEditableListItems(container: ParentNode, label: string) { + return Array.from(getEditableList(container, label).querySelectorAll("md-input-chip")) + .map((item) => item.textContent?.trim() ?? ""); +} + +function getExtensionFeatureRow(container: ParentNode, name: string) { + const element = Array.from(container.querySelectorAll(".extension-feature-row")).find( + (candidate) => candidate.getAttribute("data-extension-name") === name + ); + if (!element) { + throw new Error(`Missing extension feature row: ${name}`); + } + return element; +} + +function getMaterialInputChip(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-input-chip")).find( + (candidate) => candidate.getAttribute("aria-label") === label + ); + if (!element) { + throw new Error(`Missing md-input-chip: ${label}`); + } + return element; +} + +function getMaterialSelect(container: ParentNode, label: string) { + const element = queryMaterialSelect(container, label); + if (!element) { + throw new Error(`Missing md-outlined-select: ${label}`); + } + return element as HTMLElement & { + options?: Array<{ value: string }>; + select: (value: string) => void; + value: string; + }; +} + +function queryMaterialSelect(container: ParentNode, label: string) { + return (Array.from(container.querySelectorAll("md-outlined-select")).find( + (selectElement) => materialElementLabel(selectElement as HTMLElement & { label?: string }) === label + ) ?? null) as HTMLElement & { + options?: Array<{ value: string }>; + select: (value: string) => void; + value: string; + } | null; +} + +type MaterialSelectOptionElement = HTMLElement & { + displayText: string; + selected: boolean; + value: string; +}; + +function getMaterialSelectOptions(select: ParentNode) { + const options = Array.from(select.querySelectorAll("md-select-option")); + if (options.length === 0) { + throw new Error("Expected Material Web select options to be rendered."); + } + return options; +} + +function getMaterialSwitch(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-switch")).find( + (switchElement) => switchElement.getAttribute("aria-label") === label + ); + if (!element) { + throw new Error(`Missing md-switch: ${label}`); + } + return element as HTMLElement & { selected: boolean }; +} + +function materialElementLabel(element: HTMLElement & { label?: string }) { + return element.label || element.getAttribute("aria-label") || element.getAttribute("label") || ""; +} + +function getMaterialButton( + container: ParentNode, + label: string | RegExp, + variant: "filled" | "outlined" +) { + const tagName = variant === "filled" ? "md-filled-button" : "md-outlined-button"; + const element = Array.from(container.querySelectorAll(tagName)).find( + (button) => { + const accessibleLabel = button.getAttribute("aria-label") ?? button.textContent ?? ""; + return typeof label === "string" ? accessibleLabel.trim() === label : label.test(accessibleLabel); + } + ); + if (!element) { + throw new Error(`Missing ${tagName} button: ${label}`); + } + expect(element.tagName.toLowerCase()).toBe(tagName); + return element; +} + +function getMaterialIconButton(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-icon-button")).find( + (candidate) => candidate.getAttribute("aria-label") === label + ); + if (!element) { + throw new Error(`Missing md-icon-button: ${label}`); + } + return element as HTMLElement; +} + +function queryMaterialOutlinedButton(container: ParentNode, label: string | RegExp) { + return Array.from(container.querySelectorAll("md-outlined-button")).find( + (button) => { + const accessibleLabel = button.getAttribute("aria-label") ?? button.textContent ?? ""; + return typeof label === "string" ? accessibleLabel.trim() === label : label.test(accessibleLabel); + } + ) ?? null; +} + +function getStructuredObject(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll(".schema-structured-object")).find( + (summary) => summary.getAttribute("aria-label")?.startsWith(`${label},`) + ); + if (!element) { + throw new Error(`Missing structured object editor: ${label}`); + } + return element as HTMLElement; +} + +function getProviderOverrideEditor(container: ParentNode) { + const element = container.querySelector(".provider-overrides-editor"); + if (!element) { + throw new Error("Missing provider overrides editor."); + } + return element; +} + +function expectMaterialFilledButtonContentColors(button: Element, colorToken: string) { + expect(button.tagName.toLowerCase()).toBe("md-filled-button"); + for (const property of [ + "--md-filled-button-label-text-color", + "--md-filled-button-hover-label-text-color", + "--md-filled-button-focus-label-text-color", + "--md-filled-button-pressed-label-text-color", + "--md-filled-button-icon-color", + "--md-filled-button-hover-icon-color", + "--md-filled-button-focus-icon-color", + "--md-filled-button-pressed-icon-color" + ]) { + expect(getComputedStyle(button).getPropertyValue(property).trim()).toBe(colorToken); + } +} + +function setMaterialTextFieldValue( + element: HTMLElement & { value: string }, + value: string +) { + act(() => { + element.value = value; + element.dispatchEvent(new InputEvent("input", { bubbles: true, composed: true })); + }); +} + +function setMaterialSelectValue(element: HTMLElement & { select: (value: string) => void; value: string }, value: string) { + act(() => { + element.select(value); + element.value = value; + element.dispatchEvent(new Event("change", { bubbles: true })); + }); +} + +function setMaterialSelectValueBySelectedOption( + element: HTMLElement & { value: string }, + value: string +) { + act(() => { + for (const option of Array.from(element.querySelectorAll("md-select-option"))) { + const optionValue = option.value || option.getAttribute("value") || option.displayText; + option.selected = optionValue === value; + } + element.value = ""; + element.dispatchEvent(new Event("change", { bubbles: true })); + }); +} + +function setMaterialSwitchSelected(element: HTMLElement & { selected: boolean }, selected: boolean) { + act(() => { + element.selected = selected; + element.dispatchEvent(new Event("change", { bubbles: true })); + }); +} diff --git a/webui/src/features/configGraph/ResourceEditorCard.tsx b/webui/src/features/configGraph/ResourceEditorCard.tsx new file mode 100644 index 00000000..f25282fa --- /dev/null +++ b/webui/src/features/configGraph/ResourceEditorCard.tsx @@ -0,0 +1,2882 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from "react"; +import { motion } from "motion/react"; +import type { ConfigResource, FieldError, FieldSchema, ResourceKind, ResourceStatus, RuntimeImpact } from "../../rpc/types"; +import { configDescriptions, type ConfigPath } from "../../configDocs/configDescriptions"; +import { useI18n } from "../../i18n/I18nProvider"; +import type { MessageKey } from "../../i18n/messages"; +import { springs } from "../../theme/motion"; +import { MaterialFilledButton, MaterialIconButton, MaterialOutlinedButton } from "../../components/MaterialButton"; +import { MaterialInputChip } from "../../components/MaterialInputChip"; +import { MaterialSelect, type MaterialSelectOption } from "../../components/MaterialSelect"; +import { MaterialSwitch } from "../../components/MaterialSwitch"; +import { MaterialOutlinedTextField } from "../../components/MaterialTextField"; +import { EditorStatusProvider, useReportFieldStatus, type FieldStatusReporter } from "./editorStatus"; +import { GraphResourceField } from "./GraphResourceField"; +import { modelIconForName } from "./modelProviderIcons"; +import { type TooltipPosition, useAnchoredTooltipPosition } from "./helpTooltipPosition"; +import { useAutosaveField, type AutosaveFieldStatus, type SaveFieldRequest } from "./useAutosaveField"; +import { useDeleteConfigResource, useGraphFieldSaver } from "./useConfigGraph"; +import { configDocPathForResource } from "./configDocPath"; +import type { MdIconButton } from "@material/web/iconbutton/icon-button.js"; + +const statusLabelKeys: Record = { + saved: "resource.status.saved", + needsAttention: "resource.status.needsAttention", + restartRequired: "resource.status.restartRequired" +}; + +const impactLabelKeys: Record = { + normal: "resource.impact.normal", + critical: "resource.impact.critical" +}; + +const statusIcons: Record = { + saved: "check_circle", + needsAttention: "report", + restartRequired: "restart_alt" +}; + +const impactIcons: Record = { + normal: "info", + critical: "priority_high" +}; + +const deletableKinds = new Set([ + "extension", + "model", + "provider", + "provider_offer", + "route" +]); + +export function ResourceEditorCard({ + ariaLabel, + children, + embedded = false, + modelDisplayNames = {}, + onOpenEditor, + resource, + revision, + title, + variant = "full" +}: { + ariaLabel?: string; + children?: ReactNode; + embedded?: boolean; + modelDisplayNames?: Record; + onOpenEditor?: () => void; + resource: ConfigResource; + revision: string; + title?: string; + variant?: "full" | "summary"; +}) { + const { t } = useI18n(); + const deleteResource = useDeleteConfigResource(); + const [confirmingDelete, setConfirmingDelete] = useState(false); + const [deleteError, setDeleteError] = useState(""); + const [fieldStatuses, setFieldStatuses] = useState>({}); + const reportFieldStatus = useCallback((id, status) => { + setFieldStatuses((current) => (current[id] === status ? current : { ...current, [id]: status })); + }, []); + const liveStatus = useMemo(() => deriveLiveStatus(fieldStatuses), [fieldStatuses]); + const fieldCount = resource.schema.fields.length; + const reloadText = resource.hotReloadable + ? t("resource.reload.hot") + : t("resource.reload.restart"); + const label = ariaLabel ?? resource.id; + const fieldGroups = groupFields(resource.kind, resource.schema.fields); + const resourceTitle = title ?? resource.label; + const canDelete = deletableKinds.has(resource.kind); + const summary = variant === "summary"; + + async function confirmDelete() { + setDeleteError(""); + try { + await deleteResource.mutateAsync({ + kind: resource.kind, + id: resource.id, + baseRevision: revision + }); + } catch (cause) { + setDeleteError(errorMessage(cause, t("error.requestFailed"))); + } + } + + const cardClassName = [ + "resource-editor-card", + summary ? "resource-editor-card--summary" : "", + embedded ? "resource-editor-card--embedded" : "" + ].filter(Boolean).join(" "); + + // In summary rows we hide the operational markers (live save state, runtime impact, + // hot-reload) and only surface a status pill when something actually needs attention, + // so the list stays scannable and the freed room shows key field details instead. + const showStatusInSummary = resource.status !== "saved"; + const statusGroup = (!summary || showStatusInSummary) ? ( + + + + {t(statusLabelKeys[resource.status])} + + {!summary && resource.runtimeImpact === "critical" ? ( + + + {t(impactLabelKeys[resource.runtimeImpact])} + + ) : null} + {!summary && liveStatus ? ( + + + {t(liveStatusKeys[liveStatus])} + + ) : null} + + ) : null; + + return ( + +
    +
    +
    + + {summary ? summaryIdentityBadge(resource) : null} +

    {resource.id}

    +
    +
    + {statusGroup} + {summary + ? summaryValueFacts(resource, t).map((fact) => ( + + + {fact.text} + + )) + : ( + + + {t(fieldCount === 1 ? "resource.fieldCount.one" : "resource.fieldCount.many", { count: fieldCount })} + + )} + {!summary ? ( + + + {reloadText} + + ) : null} +
    +
    +
    + {summary && onOpenEditor ? ( + onOpenEditor()} + > + {t("resource.editShort")} + + ) : null} + {canDelete ? ( + { + setConfirmingDelete(true); + setDeleteError(""); + }} + > + {t("resource.deleteShort")} + + ) : null} +
    +
    + + {confirmingDelete ? ( + +

    {t("resource.deletePrompt", { id: resource.id })}

    + {deleteError ? ( +

    + {deleteError} +

    + ) : null} +
    + + {t("resource.confirmDeleteShort")} + + { + setConfirmingDelete(false); + setDeleteError(""); + }} + > + {t("resource.cancelDelete")} + +
    +
    + ) : null} + + {summary ? null : ( + + + {children} + + + )} +
    + ); +} + +/** Brand/icon badge shown next to a resource id in summary rows (e.g. model/route). */ +function summaryIdentityBadge(resource: ConfigResource) { + const candidate = + resource.kind === "route" + ? stringValue(resource.value.model) || resource.id + : resource.kind === "model" + ? stringValue(resource.value.display_name) || resource.id + : resource.id; + return modelIconForName(candidate) ?? null; +} + +type SummaryFact = { key: string; icon: string; text: string }; + +/** + * A few value-derived facts shown in summary rows so users can tell entries apart + * without opening the editor. Kept defensive: missing values simply yield no pill. + */ +function summaryValueFacts(resource: ConfigResource, t: ReturnType["t"]): SummaryFact[] { + const value = resource.value ?? {}; + const facts: SummaryFact[] = []; + const push = (key: string, icon: string, text: string) => { + if (text) facts.push({ key, icon, text }); + }; + + if (resource.kind === "provider") { + const protocol = stringValue(value.protocol); + push("protocol", "swap_horiz", protocolLabel(protocol, t)); + push("host", "link", hostFromUrl(stringValue(value.base_url))); + if (stringValue(value.api_key)) { + push("key", "vpn_key", t("resource.fact.keySet")); + } + const version = stringValue(value.version); + if (version) { + push("version", "history", version); + } + } else if (resource.kind === "model") { + const displayName = stringValue(value.display_name); + if (displayName && displayName !== resource.id) { + push("displayName", "label", displayName); + } + const ctx = numberValue(value.context_window); + if (typeof ctx === "number") { + push("context", "memory", formatContextWindow(ctx)); + } + const maxOutput = numberValue(value.max_output_tokens); + if (typeof maxOutput === "number") { + push("maxout", "output", formatContextWindow(maxOutput)); + } + } else if (resource.kind === "route") { + const model = stringValue(value.model); + const provider = stringValue(value.provider); + if (model) { + push("model", "smart_toy", model); + } + if (provider) { + push("provider", "cloud", provider); + } + const ctx = numberValue(value.context_window); + if (typeof ctx === "number") { + push("context", "memory", formatContextWindow(ctx)); + } + } else if (resource.kind === "provider_offer") { + const priority = numberValue(value.priority); + if (typeof priority === "number") { + push("priority", "format_list_numbered", `#${priority}`); + } + const upstream = stringValue(value.upstream_name); + if (upstream) { + push("upstream", "arrow_forward", upstream); + } + } + + return facts; +} + +function protocolLabel(protocol: string, t: ReturnType["t"]): string { + switch (protocol) { + case "anthropic": + return t("provider.protocol.anthropic"); + case "google-genai": + case "googleGenai": + return t("provider.protocol.googleGenai"); + case "openai-chat": + case "openaiChat": + return t("provider.protocol.openaiChat"); + case "openai-response": + case "openaiResponses": + return t("provider.protocol.openaiResponses"); + default: + return protocol; + } +} + +function hostFromUrl(url: string): string { + if (!url) { + return ""; + } + try { + return new URL(url).host; + } catch { + return url.replace(/^https?:\/\//, "").split("/")[0]; + } +} + +function formatContextWindow(tokens: number): string { + if (tokens >= 1000) { + return `${Math.round(tokens / 1000)}k ctx`; + } + return `${tokens} ctx`; +} + +function stringValue(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function numberValue(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +type FieldGroup = { + key: "identity" | "basic" | "billing" | "multimodal" | "reasoning" | "advancedFeatures"; + labelKey: MessageKey; + fields: FieldSchema[]; +}; + +function ResourceFieldGroups({ + alwaysExpanded, + children, + fieldGroups, + modelDisplayNames, + resource, + revision +}: { + alwaysExpanded?: boolean; + children?: ReactNode; + fieldGroups: FieldGroup[]; + modelDisplayNames: Record; + resource: ConfigResource; + revision: string; +}) { + const hasReasoningSupportField = resource.kind === "model" && + resource.schema.fields.some((field) => field.path === "supports_reasoning"); + const reasoningLevelsField = hasReasoningSupportField + ? resource.schema.fields.find((field) => field.path === "supported_reasoning_levels") + : undefined; + const modelReasoningLevels = useModelReasoningLevels(resource, revision, reasoningLevelsField); + + return ( +
    + {fieldGroups.map((group) => ( + + ))} + {children} +
    + ); +} + +function ResourceFieldGroup({ + alwaysExpanded, + group, + modelDisplayNames, + modelReasoningLevels, + resource, + revision +}: { + alwaysExpanded?: boolean; + group: FieldGroup; + modelDisplayNames: Record; + modelReasoningLevels: ModelReasoningLevelsState | undefined; + resource: ConfigResource; + revision: string; +}) { + const { t } = useI18n(); + const collapsible = !alwaysExpanded && isCollapsibleResourceFieldGroup(resource.kind, group); + const [open, setOpen] = useState(!collapsible); + if (group.key === "reasoning") { + return ( + + ); + } + if (group.key === "billing") { + return ( + + ); + } + const toggleFields = group.fields.filter(isToggleField); + const inputFields = group.fields.filter((field) => !isToggleField(field)); + const bodyId = `${resource.kind}-${resource.id}-${group.key}-fields`.replace(/[^a-zA-Z0-9_-]/g, "-"); + const label = t(group.labelKey); + + return ( +
    +
    +

    + + {label} +

    + {collapsible ? ( + setOpen((current) => !current)} + /> + ) : null} +
    + {open ? ( +
    + {inputFields.length ? ( +
    + {renderInputFields(resource, revision, group, inputFields, modelDisplayNames, modelReasoningLevels)} +
    + ) : null} + {toggleFields.length ? ( +
    + {toggleFields.map((field) => ( + + ))} +
    + ) : null} +
    + ) : null} +
    + ); +} + +function ReasoningFieldGroup({ + group, + modelReasoningLevels, + resource, + revision +}: { + group: FieldGroup; + modelReasoningLevels: ModelReasoningLevelsState | undefined; + resource: ConfigResource; + revision: string; +}) { + const { t } = useI18n(); + const reasoningSupportField = group.key === "reasoning" + ? group.fields.find((field) => field.path === "supports_reasoning") + : undefined; + const reasoningSupport = useModelReasoningSupport(resource, revision, reasoningSupportField); + const supportsReasoning = requiredModelReasoningSupport(reasoningSupport).value; + const visibleFields = group.fields.filter((field) => field.path !== "supports_reasoning"); + const toggleFields = visibleFields.filter(isToggleField); + const inputFields = visibleFields.filter((field) => !isToggleField(field)); + + return ( +
    +
    +

    + + {t(group.labelKey)} +

    + {reasoningSupportField ? ( + + ) : null} +
    + {supportsReasoning && inputFields.length ? ( +
    + {renderInputFields(resource, revision, group, inputFields, {}, modelReasoningLevels)} +
    + ) : null} + {supportsReasoning && toggleFields.length ? ( +
    + {toggleFields.map((field) => ( + + ))} +
    + ) : null} +
    + ); +} + +const advancedFeaturePaths = new Set(["web_search", "extensions"]); +const modelMultimodalPaths = new Set([ + "input_modalities", + "supports_image_detail_original" +]); +const modelReasoningPaths = new Set([ + "supports_reasoning", + "default_reasoning_level", + "supported_reasoning_levels", + "supports_reasoning_summaries", + "default_reasoning_summary" +]); +const modelReasoningDefaultsPaths = [ + "default_reasoning_level", + "default_reasoning_summary" +] as const; +const modelEditableListPaths = new Set(["input_modalities"]); +const structuredFeatureKinds = new Set(["model", "provider", "route"]); +const providerOfferBillingPaths = new Set(["pricing"]); + +const kindIcons: Record = { + provider: "dns", + offer: "smart_toy", + model: "smart_toy", + route: "alt_route", + defaults: "tune", + server: "lan", + cache: "database", + persistence: "save", + store: "database", + proxy: "swap_horiz", + plugin: "extension", + extension: "extension" +}; + +function kindIcon(kind: string): string { + return kindIcons[kind] ?? "tune"; +} + +function statusIcon(status: ResourceStatus): string { + return statusIcons[status]; +} + +function impactIcon(impact: RuntimeImpact): string { + return impactIcons[impact]; +} + +function isToggleField(field: FieldSchema): boolean { + return field.type === "boolean" || field.control === "switch"; +} + +function deriveLiveStatus( + statuses: Record +): "saving" | "error" | "dirty" | null { + const values = Object.values(statuses); + if (values.includes("saving")) { + return "saving"; + } + if (values.includes("error")) { + return "error"; + } + if (values.includes("dirty")) { + return "dirty"; + } + return null; +} + +const liveStatusKeys: Record<"saving" | "error" | "dirty", MessageKey> = { + saving: "editor.liveSaving", + error: "editor.liveError", + dirty: "editor.liveUnsaved" +}; + +function liveStatusIcon(status: "saving" | "error" | "dirty") { + if (status === "saving") { + return "progress_activity"; + } + if (status === "error") { + return "error"; + } + return "edit"; +} + +function groupFields(kind: ResourceKind, fields: FieldSchema[]): FieldGroup[] { + const canRenderModelReasoning = kind === "model" && + fields.some((field) => field.path === "supports_reasoning"); + const groups: Record = { + identity: { key: "identity", labelKey: "resource.group.identity", fields: [] }, + basic: { key: "basic", labelKey: "resource.group.basic", fields: [] }, + billing: { key: "billing", labelKey: "resource.group.billing", fields: [] }, + multimodal: { key: "multimodal", labelKey: "resource.group.multimodal", fields: [] }, + reasoning: { key: "reasoning", labelKey: "resource.group.reasoning", fields: [] }, + advancedFeatures: { key: "advancedFeatures", labelKey: "resource.group.advancedFeatures", fields: [] } + }; + + const order: FieldGroup["key"][] = kind === "model" + ? ["identity", "basic", "reasoning", "multimodal", "advancedFeatures", "billing"] + : ["identity", "basic", "billing", "multimodal", "advancedFeatures", "reasoning"]; + + for (const field of fields) { + if (isIdentityField(field)) { + groups.identity.fields.push(field); + } else if (isProviderOfferBillingField(kind, field)) { + groups.billing.fields.push(field); + } else if (isModelMultimodalField(kind, field)) { + groups.multimodal.fields.push(field); + } else if (isModelReasoningField(kind, field) && canRenderModelReasoning) { + groups.reasoning.fields.push(field); + } else if (isAdvancedFeatureField(kind, field)) { + groups.advancedFeatures.fields.push(field); + } else if (isModelReasoningField(kind, field)) { + continue; + } else { + groups.basic.fields.push(field); + } + } + + if (kind === "model") { + groups.basic.fields = orderModelBasicFields(groups.basic.fields); + groups.reasoning.fields = orderModelReasoningFields(groups.reasoning.fields); + } + + return order.map((key) => groups[key]).filter((group) => group.fields.length > 0); +} + +function isIdentityField(field: FieldSchema) { + return [ + "addr", + "base_url", + "display_name", + "model", + "mode", + "provider", + "protocol", + "to", + "upstream_name" + ].includes(field.path); +} + +function isAdvancedFeatureField(kind: ResourceKind, field: FieldSchema) { + return structuredFeatureKinds.has(kind) && advancedFeaturePaths.has(field.path); +} + +function isModelReasoningField(kind: ResourceKind, field: FieldSchema) { + return kind === "model" && modelReasoningPaths.has(field.path); +} + +function isModelMultimodalField(kind: ResourceKind, field: FieldSchema) { + return kind === "model" && modelMultimodalPaths.has(field.path); +} + +function isProviderOfferBillingField(kind: ResourceKind, field: FieldSchema) { + return kind === "provider_offer" && providerOfferBillingPaths.has(field.path); +} + +function isProviderOfferOverridesField(kind: ResourceKind, field: FieldSchema) { + return kind === "provider_offer" && field.path === "overrides"; +} + +function fieldGroupClass(kind: ResourceKind, group: FieldGroup, collapsed = false) { + const base = `resource-field-group resource-field-group--${group.key}`; + const classes = [base]; + if (group.key === "advancedFeatures" || group.key === "billing" || group.key === "multimodal" || group.key === "reasoning") { + classes.push("resource-field-group--advanced"); + } + if (group.key === "multimodal") { + classes.push("resource-field-group--multimodal"); + } + if (group.key === "reasoning") { + classes.push("resource-field-group--reasoning"); + } + if (kind === "route" && group.key === "identity") { + classes.push("resource-field-group--route-identity"); + } + if (collapsed) { + classes.push("resource-field-group--collapsed"); + } + return classes.join(" "); +} + +function isCollapsibleResourceFieldGroup(kind: ResourceKind, group: FieldGroup) { + return kind === "model" && (group.key === "multimodal" || group.key === "advancedFeatures"); +} + +function fieldGridContainerClass(kind: ResourceKind, group: FieldGroup) { + if (kind === "route" && group.key === "identity") { + return "form-grid form-grid--route-identity"; + } + return "form-grid"; +} + +function fieldGroupIcon(group: FieldGroup) { + if (group.key === "identity") { + return "badge"; + } + if (group.key === "advancedFeatures") { + return "extension"; + } + if (group.key === "multimodal") { + return "image"; + } + if (group.key === "reasoning") { + return "psychology"; + } + if (group.key === "billing") { + return "payments"; + } + return "tune"; +} + +function renderInputFields( + resource: ConfigResource, + revision: string, + group: FieldGroup, + fields: FieldSchema[], + modelDisplayNames: Record, + modelReasoningLevels: ModelReasoningLevelsState | undefined +) { + const rendered: ReactNode[] = []; + let index = 0; + while (index < fields.length) { + if (isModelReasoningDefaultsGroup(resource.kind, fields, index)) { + const pair = fields.slice(index, index + modelReasoningDefaultsPaths.length); + rendered.push( +
    + {pair.map((field) => renderInputField(resource, revision, group, field, modelDisplayNames, modelReasoningLevels))} +
    + ); + index += pair.length; + continue; + } + rendered.push(renderInputField(resource, revision, group, fields[index], modelDisplayNames, modelReasoningLevels)); + index += 1; + } + return rendered; +} + +function renderInputField( + resource: ConfigResource, + revision: string, + group: FieldGroup, + field: FieldSchema, + modelDisplayNames: Record, + modelReasoningLevels: ModelReasoningLevelsState | undefined +) { + if (isModelReasoningLevelsField(resource.kind, field)) { + return ( +
    + +
    + ); + } + if (isModelEditableListField(resource.kind, field)) { + return ( +
    + +
    + ); + } + if (isProviderOfferOverridesField(resource.kind, field)) { + return ( +
    + +
    + ); + } + if (isDefaultReasoningLevelField(resource.kind, field)) { + return ( +
    + +
    + ); + } + if (isStructuredWebSearchField(resource.kind, field)) { + return ( +
    + +
    + ); + } + if (isStructuredExtensionsField(resource.kind, field)) { + return ( +
    + +
    + ); + } + return ( +
    + +
    + ); +} + +function isModelEditableListField(kind: ResourceKind, field: FieldSchema) { + return kind === "model" && modelEditableListPaths.has(field.path); +} + +function isModelReasoningLevelsField(kind: ResourceKind, field: FieldSchema) { + return kind === "model" && field.path === "supported_reasoning_levels"; +} + +function isDefaultReasoningLevelField(kind: ResourceKind, field: FieldSchema) { + return kind === "model" && field.path === "default_reasoning_level"; +} + +function isStructuredWebSearchField(kind: ResourceKind, field: FieldSchema) { + return structuredFeatureKinds.has(kind) && field.path === "web_search"; +} + +function isStructuredExtensionsField(kind: ResourceKind, field: FieldSchema) { + return structuredFeatureKinds.has(kind) && field.path === "extensions"; +} + +function isModelReasoningDefaultsGroup(kind: ResourceKind, fields: FieldSchema[], index: number) { + return ( + kind === "model" && + modelReasoningDefaultsPaths.every((path, offset) => fields[index + offset]?.path === path) + ); +} + +function fieldObjectDisplay(kind: ResourceKind, field: FieldSchema, group: FieldGroup) { + if (group.key === "advancedFeatures") { + return "expandedFixed"; + } + return undefined; +} + +function ReasoningSupportSwitch({ + autosave, + field, + resource +}: { + autosave: BooleanFieldState; + field: FieldSchema; + resource: ConfigResource; +}) { + const { locale } = useI18n(); + const docPath = configDocPathForResource(resource, field); + const label = docPath ? configDescriptions[docPath].title[locale] : field.label; + + return ( + + + + ); +} + +const providerOfferPriceKeys = [ + "input_price", + "output_price", + "cache_write_price", + "cache_read_price" +] as const; + +type ProviderOfferPriceKey = typeof providerOfferPriceKeys[number]; + +function ProviderOfferBillingGroup({ + group, + resource, + revision +}: { + group: FieldGroup; + resource: ConfigResource; + revision: string; +}) { + const { t } = useI18n(); + const pricingField = group.fields.find((field) => field.path === "pricing"); + if (!pricingField) { + throw new Error("Provider offer billing group requires a pricing field."); + } + const autosave = useObjectFieldState(resource, revision, pricingField); + const [enabled, setEnabled] = useState(() => hasProviderOfferPricing(autosave.value)); + const manuallyEnabled = useRef(false); + + useEffect(() => { + if (hasProviderOfferPricing(autosave.value)) { + setEnabled(true); + return; + } + if (!manuallyEnabled.current && autosave.status !== "dirty") { + setEnabled(false); + } + }, [autosave.status, autosave.value]); + + function setBillingEnabled(nextEnabled: boolean) { + manuallyEnabled.current = nextEnabled; + setEnabled(nextEnabled); + if (!nextEnabled) { + autosave.commitSerializedValue({}, null); + } + } + + return ( +
    +
    +

    + + {t(group.labelKey)} +

    + + + +
    + {enabled ? ( +
    +
    + {providerOfferPriceKeys.map((fieldKey) => ( + + ))} +
    + {autosave.error ? ( +

    + {autosave.error.message} +

    + ) : null} +
    + ) : null} +
    + ); +} + +function ProviderOfferPriceField({ + autosave, + fieldKey, + label +}: { + autosave: ObjectFieldState; + fieldKey: ProviderOfferPriceKey; + label: string; +}) { + const { t } = useI18n(); + const committedDraft = providerOfferPriceDraft(autosave.value[fieldKey]); + const [draft, setDraft] = useState(committedDraft); + const [localError, setLocalError] = useState(""); + + useEffect(() => { + setDraft(committedDraft); + setLocalError(""); + }, [committedDraft]); + + function commit() { + const trimmed = draft.trim(); + if (trimmed === committedDraft) { + setLocalError(""); + return; + } + const parsed = Number(trimmed); + if (!Number.isFinite(parsed)) { + setLocalError(t("field.invalidNumber")); + return; + } + setLocalError(""); + autosave.commitValue({ + ...providerOfferPricingValue(autosave.value), + [fieldKey]: parsed + }); + } + + return ( +
    +
    + { + setDraft(next); + if (localError && next.trim() === committedDraft) { + setLocalError(""); + } + }} + /> +
    + {localError ? ( +

    + {localError} +

    + ) : null} +
    + ); +} + +function providerOfferPriceLabel(key: ProviderOfferPriceKey, t: ReturnType["t"]) { + const labels: Record = { + input_price: "create.offer.inputPrice", + output_price: "create.offer.outputPrice", + cache_write_price: "create.offer.cacheWritePrice", + cache_read_price: "create.offer.cacheReadPrice" + }; + return t(labels[key]); +} + +function hasProviderOfferPricing(value: Record) { + return providerOfferPriceKeys.some((key) => value[key] !== undefined); +} + +function providerOfferPricingValue(value: Record): Record { + return { + input_price: providerOfferPriceNumber(value.input_price), + output_price: providerOfferPriceNumber(value.output_price), + cache_write_price: providerOfferPriceNumber(value.cache_write_price), + cache_read_price: providerOfferPriceNumber(value.cache_read_price) + }; +} + +function providerOfferPriceDraft(value: unknown) { + return String(providerOfferPriceNumber(value)); +} + +function providerOfferPriceNumber(value: unknown) { + if (value === undefined || value === null || value === "") { + return 0; + } + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error("Provider offer price requires a finite number."); + } + return value; +} + +const providerOverrideNumberKeys = [ + "context_window", + "max_output_tokens" +] as const; + +const providerOverrideTextKeys = [ + "display_name", + "default_reasoning_level", + "default_reasoning_summary" +] as const; + +const providerOverrideLongTextKeys = [ + "description", + "base_instructions" +] as const; + +const providerOverrideBooleanKeys = [ + "supports_reasoning", + "supports_reasoning_summaries", + "supports_image_detail_original" +] as const; + +type ProviderOverrideNumberKey = typeof providerOverrideNumberKeys[number]; +type ProviderOverrideTextKey = typeof providerOverrideTextKeys[number]; +type ProviderOverrideLongTextKey = typeof providerOverrideLongTextKeys[number]; +type ProviderOverrideBooleanKey = typeof providerOverrideBooleanKeys[number]; + +function ProviderOfferOverridesField({ + field, + resource, + revision +}: { + field: FieldSchema; + resource: ConfigResource; + revision: string; +}) { + const { t } = useI18n(); + const autosave = useObjectFieldState(resource, revision, field); + + function commitKey(key: string, value: unknown) { + const next = value === undefined ? omitObjectKey(autosave.value, key) : { ...autosave.value, [key]: value }; + commitProviderOverrides(autosave, next); + } + + const inputModalities = stringArrayValue(autosave.value.input_modalities); + const reasoningLevels = toReasoningLevelPresets(autosave.value.supported_reasoning_levels); + + return ( +
    +
    + {t("field.providerOverrides.title")} +
    +
    + {providerOverrideNumberKeys.map((key) => ( + commitKey(key, value)} + /> + ))} + {providerOverrideTextKeys.map((key) => ( + commitKey(key, value)} + /> + ))} + {providerOverrideBooleanKeys.map((key) => ( + commitKey(key, value)} + /> + ))} +
    +
    + {providerOverrideLongTextKeys.map((key) => ( + commitKey(key, value)} + /> + ))} +
    + commitKey("input_modalities", items.length ? items : undefined), + error: autosave.error, + label: localizedStaticLabel(t("field.providerOverrides.inputModalities")), + status: autosave.status, + value: inputModalities + }} + field={field} + valueFromDraft={stringListItemLabel} + valueFromInput={(input) => input} + /> + commitKey("supported_reasoning_levels", items.length ? items : undefined), + error: autosave.error, + label: localizedStaticLabel(t("field.providerOverrides.supportedReasoningLevels")), + status: autosave.status, + value: reasoningLevels + }} + field={field} + valueFromDraft={reasoningLevelEffort} + valueFromInput={newReasoningLevel} + /> + {autosave.error ? ( +

    + {autosave.error.message} +

    + ) : null} +
    + ); +} + +function ProviderOverrideNumberField({ + disabled, + label, + onCommit, + value +}: { + disabled: boolean; + label: string; + onCommit: (value: number | undefined) => void; + value: unknown; +}) { + const { t } = useI18n(); + const committedDraft = objectNumberDraft(value); + const [draft, setDraft] = useState(committedDraft); + const [localError, setLocalError] = useState(""); + + useEffect(() => { + setDraft(committedDraft); + setLocalError(""); + }, [committedDraft]); + + function commit() { + const trimmed = draft.trim(); + if (trimmed === committedDraft) { + setLocalError(""); + return; + } + if (trimmed === "") { + setLocalError(""); + onCommit(undefined); + return; + } + const parsed = Number(trimmed); + if (!Number.isFinite(parsed)) { + setLocalError(t("field.invalidNumber")); + return; + } + setLocalError(""); + onCommit(parsed); + } + + return ( +
    +
    + { + setDraft(next); + if (localError && next.trim() === committedDraft) { + setLocalError(""); + } + }} + /> +
    + {localError ? ( +

    + {localError} +

    + ) : null} +
    + ); +} + +function ProviderOverrideTextField({ + disabled, + label, + multiline = false, + onCommit, + value +}: { + disabled: boolean; + label: string; + multiline?: boolean; + onCommit: (value: string | undefined) => void; + value: unknown; +}) { + const committedDraft = objectStringDraft(value, false); + const [draft, setDraft] = useState(committedDraft); + + useEffect(() => { + setDraft(committedDraft); + }, [committedDraft]); + + function commit() { + if (draft === committedDraft) { + return; + } + onCommit(draft.trim() === "" ? undefined : draft); + } + + return ( +
    +
    + +
    +
    + ); +} + +function ProviderOverrideBooleanField({ + disabled, + label, + onCommit, + value +}: { + disabled: boolean; + label: string; + onCommit: (value: boolean | undefined) => void; + value: unknown; +}) { + const { t } = useI18n(); + const selected = typeof value === "boolean" ? String(value) : "inherit"; + return ( +
    +
    + { + if (next === "inherit") { + onCommit(undefined); + return; + } + onCommit(next === "true"); + }} + /> +
    +
    + ); +} + +function providerOverrideLabel( + key: ProviderOverrideNumberKey | ProviderOverrideTextKey | ProviderOverrideLongTextKey | ProviderOverrideBooleanKey, + t: ReturnType["t"] +) { + const labels: Record< + ProviderOverrideNumberKey | ProviderOverrideTextKey | ProviderOverrideLongTextKey | ProviderOverrideBooleanKey, + MessageKey + > = { + base_instructions: "field.providerOverrides.baseInstructions", + context_window: "field.providerOverrides.contextWindow", + default_reasoning_level: "field.providerOverrides.defaultReasoningLevel", + default_reasoning_summary: "field.providerOverrides.defaultReasoningSummary", + description: "field.providerOverrides.description", + display_name: "field.providerOverrides.displayName", + max_output_tokens: "field.providerOverrides.maxOutputTokens", + supports_image_detail_original: "field.providerOverrides.supportsImageDetailOriginal", + supports_reasoning: "field.providerOverrides.supportsReasoning", + supports_reasoning_summaries: "field.providerOverrides.supportsReasoningSummaries" + }; + return t(labels[key]); +} + +function commitProviderOverrides(autosave: ObjectFieldState, next: Record) { + const cleaned = cleanProviderOverrides(next); + autosave.commitSerializedValue(cleaned, Object.keys(cleaned).length ? cleaned : null); +} + +function cleanProviderOverrides(value: Record) { + const next: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if (entry === undefined || entry === null || entry === "") { + continue; + } + if (Array.isArray(entry) && entry.length === 0) { + continue; + } + if (typeof entry === "object" && !Array.isArray(entry) && Object.keys(entry).length === 0) { + continue; + } + next[key] = entry; + } + return next; +} + +function stringArrayValue(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return value.filter((item): item is string => typeof item === "string"); +} + +function localizedStaticLabel(label: string): LocalizedLabel { + return { + "en-US": label, + "zh-CN": label + }; +} + +type WebSearchStructuredFieldKey = "max_uses" | "search_max_rounds" | "tavily_api_key" | "firecrawl_api_key"; + +const webSearchConfigPaths: Record = { + max_uses: "web_search.max_uses", + search_max_rounds: "web_search.search_max_rounds", + tavily_api_key: "web_search.tavily_api_key", + firecrawl_api_key: "web_search.firecrawl_api_key" +}; + +function WebSearchFeatureField({ + field, + resource, + revision +}: { + field: FieldSchema; + resource: ConfigResource; + revision: string; +}) { + const { locale, t } = useI18n(); + const autosave = useObjectFieldState(resource, revision, field); + const config = autosave.value; + const docPath = configDocPathForResource(resource, field); + const title = docPath ? configDescriptions[docPath].title[locale] : field.label; + const supportLabel = t("field.webSearch.support", { label: title }); + const helpScope = `${resource.kind}-${resource.id}-${field.path}`; + const maxUsesLabel = t("field.webSearch.maxUses", { label: title }); + const tavilyAPIKeyLabel = t("field.webSearch.tavilyAPIKey", { label: title }); + const firecrawlAPIKeyLabel = t("field.webSearch.firecrawlAPIKey", { label: title }); + const searchMaxRoundsLabel = t("field.webSearch.searchMaxRounds", { label: title }); + + function commitKey(key: string, value: unknown) { + autosave.commitValue(value === undefined ? omitObjectKey(config, key) : { ...config, [key]: value }); + } + + return ( +
    +
    +
    +
    + commitKey("support", value)} + /> +
    +
    + + + + +
    + {autosave.error ? ( +

    + {autosave.error.message} +

    + ) : null} +
    + ); +} + +type StructuredFeatureFieldHelp = { + button: ReactNode; + tooltip: ReactNode; +}; + +function useStructuredFeatureFieldHelp( + fieldKey: WebSearchStructuredFieldKey, + helpScope: string, + label: string +): StructuredFeatureFieldHelp { + const i18n = useI18n(); + const anchorRef = useRef(null); + const openedByHover = useRef(false); + const [open, setOpen] = useState(false); + const docPath = webSearchConfigPaths[fieldKey]; + const helpId = `structured-help-${helpScope}-${docPath}`.replace(/[^a-zA-Z0-9_-]/g, "-"); + const position = useAnchoredTooltipPosition(anchorRef, open); + const helpParts = structuredFeatureHelpParts(docPath, label, i18n); + + return { + button: ( + setOpen(false)} + onClick={() => { + if (openedByHover.current) { + openedByHover.current = false; + setOpen(true); + return; + } + setOpen((current) => !current); + }} + onFocus={() => setOpen(true)} + onKeyDown={(event: KeyboardEvent) => { + if (event.key === "Escape") { + setOpen(false); + } + }} + onMouseDown={(event) => event.preventDefault()} + onMouseEnter={() => { + openedByHover.current = true; + setOpen(true); + }} + onMouseLeave={() => { + openedByHover.current = false; + setOpen(false); + }} + ref={anchorRef} + slot="trailing-icon" + /> + ), + tooltip: open ? ( + + ) : null + }; +} + +function StructuredFeatureTooltip({ + helpId, + helpParts, + position +}: { + helpId: string; + helpParts: StructuredFeatureHelpParts; + position: TooltipPosition | undefined; +}) { + return ( + + {helpParts.subhead} + {helpParts.body} + + {helpParts.metas.map((meta, index) => ( + + {meta.label ? `${meta.label}: ${meta.value}` : meta.value} + + ))} + + + ); +} + +type StructuredFeatureHelpParts = { + subhead: string; + body: string; + metas: { label?: string; value: string }[]; +}; + +function structuredFeatureHelpParts( + docPath: ConfigPath, + label: string, + { locale, t }: ReturnType +): StructuredFeatureHelpParts { + const entry = configDescriptions[docPath]; + const metas: { label?: string; value: string }[] = [ + { label: t("configDoc.type"), value: localizedConfigMetaValue(entry.type, t) } + ]; + if (entry.defaultValue) { + metas.push({ label: t("configDoc.default"), value: localizedConfigMetaValue(String(entry.defaultValue), t) }); + } + if (entry.sensitive) { + metas.push({ value: t("configDoc.sensitive") }); + } + return { + subhead: label, + body: entry.description[locale], + metas + }; +} + +function localizedConfigMetaValue(value: string, t: ReturnType["t"]) { + const normalized = value.trim().toLowerCase(); + const localized: Partial> = { + array: "configDoc.type.array", + boolean: "configDoc.type.boolean", + empty: "configDoc.default.empty", + "host:port": "configDoc.type.hostPort", + number: "configDoc.type.number", + object: "configDoc.type.object", + string: "configDoc.type.string", + url: "configDoc.type.url" + }; + return localized[normalized] ? t(localized[normalized]) : value; +} + +function tooltipPositionStyle(position: TooltipPosition | undefined): CSSProperties | undefined { + if (!position) { + return undefined; + } + return { + left: `${position.left}px`, + maxWidth: `${position.maxWidth}px`, + position: "fixed", + top: `${position.top}px` + }; +} + +function SecretObjectField({ + autosave, + fieldKey, + helpScope, + label +}: { + autosave: ObjectFieldState; + fieldKey: WebSearchStructuredFieldKey; + helpScope: string; + label: string; +}) { + const help = useStructuredFeatureFieldHelp(fieldKey, helpScope, label); + const [draft, setDraft] = useState(() => objectStringDraft(autosave.value[fieldKey], true)); + const committedDraft = objectStringDraft(autosave.value[fieldKey], true); + + useEffect(() => { + setDraft(committedDraft); + }, [committedDraft]); + + const commit = useCallback(() => { + if (draft === committedDraft) { + return; + } + autosave.commitValue({ ...autosave.value, [fieldKey]: draft }); + }, [autosave, committedDraft, draft, fieldKey]); + + return ( +
    +
    + +
    + {help.tooltip} +
    + ); +} + +function IntegerObjectField({ + autosave, + fieldKey, + helpScope, + label +}: { + autosave: ObjectFieldState; + fieldKey: WebSearchStructuredFieldKey; + helpScope: string; + label: string; +}) { + const { t } = useI18n(); + const help = useStructuredFeatureFieldHelp(fieldKey, helpScope, label); + const [draft, setDraft] = useState(() => objectIntegerDraft(autosave.value[fieldKey])); + const [localError, setLocalError] = useState(""); + const committedDraft = objectIntegerDraft(autosave.value[fieldKey]); + + useEffect(() => { + setDraft(committedDraft); + setLocalError(""); + }, [committedDraft]); + + const commit = useCallback(() => { + const trimmed = draft.trim(); + if (trimmed === "") { + setLocalError(""); + autosave.commitValue(omitObjectKey(autosave.value, fieldKey)); + return; + } + if (!/^\d+$/.test(trimmed)) { + setLocalError(t("field.invalidNumber")); + return; + } + const parsed = Number.parseInt(trimmed, 10); + setLocalError(""); + autosave.commitValue({ ...autosave.value, [fieldKey]: parsed }); + }, [autosave, draft, fieldKey, t]); + + return ( +
    +
    + { + setDraft(value); + if (localError && value.trim() === committedDraft) { + setLocalError(""); + } + }} + /> +
    + {help.tooltip} + {localError ? ( +

    + {localError} +

    + ) : null} +
    + ); +} + +function ExtensionsFeatureField({ + field, + resource, + revision +}: { + field: FieldSchema; + resource: ConfigResource; + revision: string; +}) { + const { locale, t } = useI18n(); + const [draft, setDraft] = useState(""); + const autosave = useObjectFieldState(resource, revision, field); + const docPath = configDocPathForResource(resource, field); + const title = docPath ? configDescriptions[docPath].title[locale] : field.label; + const extensions = extensionEntries(autosave.value); + const extensionNames = extensions.map((entry) => entry.name); + const trimmedDraft = draft.trim(); + const duplicateDraft = extensionNames.includes(trimmedDraft); + const addDisabled = !trimmedDraft || duplicateDraft || autosave.status === "saving"; + + function addExtension() { + if (addDisabled) { + return; + } + setDraft(""); + autosave.commitValue({ + ...autosave.value, + [trimmedDraft]: { enabled: true } + }); + } + + function removeExtension(name: string) { + autosave.commitValue(omitObjectKey(autosave.value, name)); + } + + function setExtensionEnabled(name: string, enabled: boolean) { + autosave.commitValue({ + ...autosave.value, + [name]: { + ...extensionObjectValue(autosave.value[name]), + enabled + } + }); + } + + function setExtensionConfigValue(name: string, key: string, value: unknown) { + const extension = extensionObjectValue(autosave.value[name]); + const config = objectRecordOrEmpty(extension.config); + autosave.commitValue({ + ...autosave.value, + [name]: { + ...extension, + config: value === undefined + ? omitObjectKey(config, key) + : { ...config, [key]: value } + } + }); + } + + return ( +
    +
    + {extensions.map((entry) => ( +
    + removeExtension(entry.name)} + > + {entry.name} + + + setExtensionEnabled(entry.name, enabled)} + /> + + setExtensionConfigValue(entry.name, key, value)} + /> +
    + ))} +
    +
    + undefined} + /> + + {t("field.editableList.add")} + +
    + {autosave.error ? ( +

    + {autosave.error.message} +

    + ) : null} +
    + ); +} + +function ExtensionConfigFields({ + disabled, + entry, + onCommit +}: { + disabled: boolean; + entry: ExtensionEntry; + onCommit: (key: string, value: unknown) => void; +}) { + const fields = extensionConfigFields(entry); + if (fields.length === 0) { + return null; + } + return ( +
    + {fields.map((field) => ( + onCommit(field.key, value)} + /> + ))} +
    + ); +} + +function ExtensionConfigField({ + disabled, + field, + onCommit, + value +}: { + disabled: boolean; + field: ExtensionConfigFieldDef; + onCommit: (value: unknown) => void; + value: unknown; +}) { + if (field.type === "boolean") { + return ( +
    +
    + + {field.label} + + +
    +
    + ); + } + if (field.type === "number") { + return ( + + ); + } + return ( + + ); +} + +function ExtensionConfigTextField({ + disabled, + field, + onCommit, + value +}: { + disabled: boolean; + field: ExtensionConfigFieldDef; + onCommit: (value: unknown) => void; + value: unknown; +}) { + const [draft, setDraft] = useState(() => objectStringDraft(value, false)); + const committedDraft = objectStringDraft(value, false); + + useEffect(() => { + setDraft(committedDraft); + }, [committedDraft]); + + return ( +
    +
    + onCommit(draft.trim() === "" ? undefined : draft)} + onInput={setDraft} + /> +
    +
    + ); +} + +function ExtensionConfigNumberField({ + disabled, + field, + onCommit, + value +}: { + disabled: boolean; + field: ExtensionConfigFieldDef; + onCommit: (value: unknown) => void; + value: unknown; +}) { + const { t } = useI18n(); + const [draft, setDraft] = useState(() => objectNumberDraft(value)); + const [localError, setLocalError] = useState(""); + const committedDraft = objectNumberDraft(value); + + useEffect(() => { + setDraft(committedDraft); + setLocalError(""); + }, [committedDraft]); + + function commit() { + const trimmed = draft.trim(); + if (trimmed === "") { + setLocalError(""); + onCommit(undefined); + return; + } + const parsed = Number(trimmed); + if (!Number.isFinite(parsed)) { + setLocalError(t("field.invalidNumber")); + return; + } + setLocalError(""); + onCommit(parsed); + } + + return ( +
    +
    + { + setDraft(next); + if (localError && next.trim() === committedDraft) { + setLocalError(""); + } + }} + /> +
    + {localError ? ( +

    + {localError} +

    + ) : null} +
    + ); +} + +function GenericEditableListResourceField({ + field, + resource, + revision +}: { + field: FieldSchema; + resource: ConfigResource; + revision: string; +}) { + const autosave = useGenericEditableListState(resource, revision, field); + return ( + input} + /> + ); +} + +function EditableListResourceField({ + autosave, + field, + valueFromDraft, + valueFromInput +}: { + autosave: EditableListState; + field: FieldSchema; + valueFromDraft: (item: T) => string; + valueFromInput: (input: string) => T; +}) { + const { locale, t } = useI18n(); + const [draft, setDraft] = useState(""); + const label = autosave.label[locale]; + const items = autosave.value; + const trimmedDraft = draft.trim(); + const itemLabels = useMemo( + () => items.map((item) => valueFromDraft(item)), + [items, valueFromDraft] + ); + const duplicateDraft = trimmedDraft ? itemLabels.includes(trimmedDraft) : false; + const addDisabled = !trimmedDraft || duplicateDraft || autosave.status === "saving"; + + function commitItems(nextItems: T[]) { + autosave.commitValue(nextItems); + } + + function addDraft() { + if (!trimmedDraft || duplicateDraft) { + return; + } + setDraft(""); + commitItems([...items, valueFromInput(trimmedDraft)]); + } + + function removeItem(removed: T) { + const removedLabel = valueFromDraft(removed); + commitItems(items.filter((item) => valueFromDraft(item) !== removedLabel)); + } + + return ( +
    +
    + {label} +
    + + {items.map((item) => { + const itemLabel = valueFromDraft(item); + return ( + removeItem(item)} + > + {itemLabel} + + ); + })} + +
    + undefined} + /> + + {t("field.editableList.add")} + +
    + {autosave.error ? ( +

    + {autosave.error.message} +

    + ) : null} +
    + ); +} + +type ReasoningLevelPreset = { + effort: string; + description?: string; +}; + +type LocalizedLabel = Record<"en-US" | "zh-CN", string>; + +type BooleanFieldState = { + commitValue: (value: boolean) => void; + status: AutosaveFieldStatus; + value: boolean; +}; + +type ObjectFieldState = { + commitValue: (value: Record) => void; + commitSerializedValue: (value: Record, serialized: Record | null) => void; + error?: FieldError; + status: AutosaveFieldStatus; + value: Record; +}; + +type EditableListState = { + commitValue: (value: T[]) => void; + error?: FieldError; + label: LocalizedLabel; + status: AutosaveFieldStatus; + value: T[]; +}; + +type ModelReasoningLevelsState = EditableListState; + +const webSearchSupportOptions: MaterialSelectOption[] = [ + { value: "auto", label: "auto" }, + { value: "enabled", label: "enabled" }, + { value: "disabled", label: "disabled" }, + { value: "injected", label: "injected" } +]; + +function useObjectFieldState( + resource: ConfigResource, + revision: string, + field: FieldSchema +): ObjectFieldState { + const { t } = useI18n(); + const value = resource.value[field.path]; + const committedObjectKey = JSON.stringify(objectRecord(value)); + const committedObject = useMemo( + () => JSON.parse(committedObjectKey) as Record, + [committedObjectKey] + ); + const saveGraphField = useGraphFieldSaver | null>(); + const save = useCallback( + (request: SaveFieldRequest | null>) => saveGraphField(request), + [saveGraphField] + ); + const autosave = useAutosaveField, Record | null>({ + resourceKind: resource.kind, + resourceId: resource.id, + field: field.path, + committedValue: committedObject, + revision, + save, + configUpdateFailedMessage: (result) => t("field.configUpdateFailed", { result }), + requestFailedMessage: t("error.requestFailed") + }); + useReportFieldStatus(`${resource.kind}:${resource.id}:${field.path}`, autosave.status); + return { + commitValue: autosave.commitValue, + commitSerializedValue: autosave.commitSerializedValue, + error: autosave.error, + status: autosave.status, + value: autosave.value + }; +} + +function useModelReasoningSupport( + resource: ConfigResource, + revision: string, + field: FieldSchema | undefined +): BooleanFieldState | undefined { + const { t } = useI18n(); + const selected = field ? resource.value[field.path] === true : false; + const saveGraphField = useGraphFieldSaver(); + const save = useCallback( + (request: SaveFieldRequest) => saveGraphField(request), + [saveGraphField] + ); + const autosave = useAutosaveField({ + resourceKind: resource.kind, + resourceId: resource.id, + field: "supports_reasoning", + committedValue: selected, + revision, + save, + disabled: !field, + configUpdateFailedMessage: (result) => t("field.configUpdateFailed", { result }), + requestFailedMessage: t("error.requestFailed") + }); + useReportFieldStatus(`${resource.kind}:${resource.id}:supports_reasoning`, autosave.status); + if (!field) { + return undefined; + } + return { + commitValue: autosave.commitValue, + status: autosave.status, + value: autosave.value + }; +} + +function requiredModelReasoningSupport(state: BooleanFieldState | undefined) { + if (!state) { + throw new Error("Model reasoning support field is required to render model reasoning controls."); + } + return state; +} + +function useGenericEditableListState( + resource: ConfigResource, + revision: string, + field: FieldSchema +): EditableListState { + const { t } = useI18n(); + const value = resource.value[field.path]; + const committedItemsKey = Array.isArray(value) + ? JSON.stringify(value.map((item) => String(item))) + : "[]"; + const committedItems = useMemo( + () => JSON.parse(committedItemsKey) as string[], + [committedItemsKey] + ); + const saveGraphField = useGraphFieldSaver(); + const save = useCallback( + (request: SaveFieldRequest) => saveGraphField(request), + [saveGraphField] + ); + const autosave = useAutosaveField({ + resourceKind: resource.kind, + resourceId: resource.id, + field: field.path, + committedValue: committedItems, + revision, + save, + configUpdateFailedMessage: (result) => t("field.configUpdateFailed", { result }), + requestFailedMessage: t("error.requestFailed") + }); + useReportFieldStatus(`${resource.kind}:${resource.id}:${field.path}`, autosave.status); + const docPath = configDocPathForResource(resource, field); + const label = docPath ? configDescriptions[docPath].title : localizedFallback(field.label); + return { + commitValue: autosave.commitValue, + error: autosave.error, + label, + status: autosave.status, + value: autosave.value + }; +} + +function useModelReasoningLevels( + resource: ConfigResource, + revision: string, + field: FieldSchema | undefined +): ModelReasoningLevelsState | undefined { + const { t } = useI18n(); + const value = field ? resource.value[field.path] : undefined; + const committedItemsKey = JSON.stringify(toReasoningLevelPresets(value)); + const committedItems = useMemo( + () => JSON.parse(committedItemsKey) as ReasoningLevelPreset[], + [committedItemsKey] + ); + const saveGraphField = useGraphFieldSaver(); + const save = useCallback( + (request: SaveFieldRequest) => saveGraphField(request), + [saveGraphField] + ); + const autosave = useAutosaveField({ + resourceKind: resource.kind, + resourceId: resource.id, + field: "supported_reasoning_levels", + committedValue: committedItems, + revision, + save, + disabled: !field, + configUpdateFailedMessage: (result) => t("field.configUpdateFailed", { result }), + requestFailedMessage: t("error.requestFailed") + }); + useReportFieldStatus(`${resource.kind}:${resource.id}:supported_reasoning_levels`, autosave.status); + if (!field) { + return undefined; + } + const docPath = configDocPathForResource(resource, field); + return { + commitValue: autosave.commitValue, + error: autosave.error, + label: docPath ? configDescriptions[docPath].title : localizedFallback(field.label), + status: autosave.status, + value: autosave.value + }; +} + +function requiredModelReasoningLevels(state: ModelReasoningLevelsState | undefined) { + if (!state) { + throw new Error("Model reasoning levels field is required to render model reasoning controls."); + } + return state; +} + +function DefaultReasoningLevelField({ + field, + levels, + resource, + revision +}: { + field: FieldSchema; + levels: ReasoningLevelPreset[]; + resource: ConfigResource; + revision: string; +}) { + const { locale, t } = useI18n(); + const value = resource.value[field.path]; + const selected = typeof value === "string" ? value : ""; + const saveGraphField = useGraphFieldSaver(); + const save = useCallback( + (request: SaveFieldRequest) => saveGraphField(request), + [saveGraphField] + ); + const autosave = useAutosaveField({ + resourceKind: resource.kind, + resourceId: resource.id, + field: field.path, + committedValue: selected, + revision, + save, + configUpdateFailedMessage: (result) => t("field.configUpdateFailed", { result }), + requestFailedMessage: t("error.requestFailed") + }); + useReportFieldStatus(`${resource.kind}:${resource.id}:${field.path}`, autosave.status); + const docPath = configDocPathForResource(resource, field); + const label = docPath ? configDescriptions[docPath].title[locale] : field.label; + const options = useMemo( + () => reasoningLevelOptions(levels, autosave.value), + [autosave.value, levels] + ); + + return ( +
    +
    + +
    + {autosave.error ? ( +

    + {autosave.error.message} +

    + ) : null} +
    + ); +} + +function toReasoningLevelPresets(value: unknown): ReasoningLevelPreset[] { + if (!Array.isArray(value)) { + return []; + } + return value + .map((item) => { + if (typeof item === "string") { + return { effort: item }; + } + if (!item || typeof item !== "object") { + return undefined; + } + const effort = "effort" in item ? (item as { effort?: unknown }).effort : undefined; + if (typeof effort !== "string" || effort.trim() === "") { + return undefined; + } + const description = "description" in item ? (item as { description?: unknown }).description : undefined; + return typeof description === "string" && description.trim() + ? { effort, description } + : { effort }; + }) + .filter((item): item is ReasoningLevelPreset => item !== undefined); +} + +function reasoningLevelOptions(levels: ReasoningLevelPreset[], selected: string): MaterialSelectOption[] { + const options = levels.map((level) => ({ + value: level.effort, + label: level.effort + })); + if (selected && !options.some((option) => option.value === selected)) { + return [{ value: selected, label: selected }, ...options]; + } + return options; +} + +function reasoningLevelEffort(item: unknown) { + if (!item || typeof item !== "object" || !("effort" in item) || typeof item.effort !== "string") { + throw new Error("Reasoning level preset requires a string effort."); + } + return item.effort; +} + +function newReasoningLevel(effort: string): ReasoningLevelPreset { + return { effort }; +} + +function objectRecord(value: unknown): Record { + if (value === undefined || value === null) { + return {}; + } + if (typeof value !== "object" || Array.isArray(value)) { + throw new Error("Structured feature field requires an object value."); + } + return value as Record; +} + +function omitObjectKey(source: Record, key: string): Record { + const next = { ...source }; + delete next[key]; + return next; +} + +function webSearchSupportValue(value: unknown) { + if (typeof value !== "string" || value.trim() === "") { + return "auto"; + } + if (!webSearchSupportOptions.some((option) => option.value === value)) { + throw new Error(`Unknown web search support mode: ${value}`); + } + return value; +} + +function objectIntegerDraft(value: unknown) { + if (value === undefined || value === null || value === "") { + return ""; + } + if (typeof value !== "number" || !Number.isInteger(value)) { + throw new Error("Structured integer field requires an integer value."); + } + return String(value); +} + +type ExtensionEntry = { + config: Record; + enabled: boolean; + name: string; +}; + +type ExtensionConfigFieldDef = { + key: string; + label: string; + type: "string" | "number" | "boolean"; +}; + +const knownExtensionConfigFields: Record = { + deepseek_v4: [ + { key: "reinforce_instructions", label: "deepseek_v4 reinforce instructions", type: "boolean" }, + { key: "reinforce_prompt", label: "deepseek_v4 reinforce prompt", type: "string" } + ], + db_d1: [ + { key: "binding", label: "db_d1 binding", type: "string" } + ], + db_sqlite: [ + { key: "path", label: "db_sqlite path", type: "string" }, + { key: "wal", label: "db_sqlite WAL", type: "boolean" }, + { key: "busy_timeout_ms", label: "db_sqlite busy timeout ms", type: "number" }, + { key: "max_open_conns", label: "db_sqlite max open conns", type: "number" } + ], + kimi_workaround: [ + { key: "max_tool_rounds", label: "kimi_workaround max tool rounds", type: "number" }, + { key: "convergence_margin", label: "kimi_workaround convergence margin", type: "number" } + ], + metrics: [ + { key: "default_limit", label: "metrics default limit", type: "number" }, + { key: "max_limit", label: "metrics max limit", type: "number" } + ], + visual: [ + { key: "provider", label: "visual provider", type: "string" }, + { key: "model", label: "visual model", type: "string" }, + { key: "max_rounds", label: "visual max rounds", type: "number" }, + { key: "max_tokens", label: "visual max tokens", type: "number" } + ] +}; + +function extensionEntries(value: Record): ExtensionEntry[] { + return Object.keys(value) + .sort((left, right) => left.localeCompare(right)) + .map((name) => { + const objectValue = extensionObjectValue(value[name]); + return { + name, + config: objectRecordOrEmpty(objectValue.config), + enabled: extensionEnabledValue(value[name]) + }; + }); +} + +function extensionConfigFields(entry: ExtensionEntry) { + const knownFields = knownExtensionConfigFields[entry.name] ?? []; + const knownKeys = new Set(knownFields.map((field) => field.key)); + const extraFields = Object.keys(entry.config) + .filter((key) => !knownKeys.has(key)) + .sort((left, right) => left.localeCompare(right)) + .flatMap((key) => { + const fieldType = extensionConfigFieldType(entry.config[key]); + return fieldType ? [{ key, label: `${entry.name} ${key}`, type: fieldType }] : []; + }); + return knownFields.concat(extraFields); +} + +function extensionConfigFieldType(value: unknown): ExtensionConfigFieldDef["type"] | undefined { + if (value === undefined || value === null) { + return "string"; + } + if (typeof value === "boolean") { + return "boolean"; + } + if (typeof value === "number") { + return "number"; + } + if (typeof value === "string") { + return "string"; + } + return undefined; +} + +function extensionEnabledValue(value: unknown) { + const objectValue = extensionObjectValue(value); + if (!("enabled" in objectValue)) { + return true; + } + if (typeof objectValue.enabled !== "boolean") { + throw new Error("Extension enabled value must be boolean when present."); + } + return objectValue.enabled; +} + +function extensionObjectValue(value: unknown): Record { + if (value === undefined || value === null) { + return {}; + } + if (typeof value !== "object" || Array.isArray(value)) { + throw new Error("Extension entry must be an object."); + } + return value as Record; +} + +function objectRecordOrEmpty(value: unknown): Record { + if (value === undefined || value === null) { + return {}; + } + return objectRecord(value); +} + +function objectStringDraft(value: unknown, secret: boolean) { + if (secret && value === "******") { + return ""; + } + if (value === undefined || value === null) { + return ""; + } + if (typeof value !== "string") { + throw new Error("Structured string field requires a string value."); + } + return value; +} + +function objectNumberDraft(value: unknown) { + if (value === undefined || value === null || value === "") { + return ""; + } + if (typeof value !== "number") { + throw new Error("Structured number field requires a number value."); + } + return String(value); +} + +function stringListItemLabel(item: unknown) { + if (typeof item !== "string") { + throw new Error("Editable string list item must be a string."); + } + return item; +} + +function localizedFallback(label: string): LocalizedLabel { + return { + "en-US": label, + "zh-CN": label + }; +} + +function orderModelBasicFields(fields: FieldSchema[]) { + return [ + "context_window", + "max_output_tokens", + "description", + "base_instructions" + ].flatMap((path) => fields.filter((field) => field.path === path)) + .concat(fields.filter((field) => !modelBasicPreferredOrder.has(field.path))); +} + +const modelBasicPreferredOrder = new Set([ + "context_window", + "max_output_tokens", + "description", + "base_instructions" +]); + +function orderModelReasoningFields(fields: FieldSchema[]) { + return [ + "supports_reasoning", + "default_reasoning_level", + "default_reasoning_summary", + "supported_reasoning_levels", + "supports_reasoning_summaries" + ].flatMap((path) => fields.filter((field) => field.path === path)) + .concat(fields.filter((field) => !modelReasoningPaths.has(field.path))); +} + +function isWideField(field: FieldSchema) { + return ( + field.control === "textarea" || + field.control === "object" || + field.control === "array" || + field.type === "object" || + field.type === "array" + ); +} + +function fieldGridClass(field: FieldSchema) { + if (isWideField(field)) { + return "form-grid__wide"; + } + if ( + field.type === "number" || + field.type === "boolean" || + field.control === "number" || + field.control === "switch" || + compactFieldPaths.has(field.path) + ) { + return "form-grid__compact"; + } + return "form-grid__medium"; +} + +const compactFieldPaths = new Set([ + "active_provider", + "addr", + "default_reasoning_level", + "default_reasoning_summary", + "format", + "level", + "max_sessions", + "mode", + "priority", + "search_max_rounds", + "session_ttl", + "support", + "ttl", + "version" +]); + +function errorMessage(cause: unknown, fallback: string) { + const rawErrors = rawErrorsFrom(cause); + if (rawErrors.length > 0 && typeof rawErrors[0]?.message === "string") { + return rawErrors[0].message; + } + if (cause instanceof Error) { + return cause.message; + } + return fallback; +} + +function rawErrorsFrom(cause: unknown): Array<{ message?: unknown }> { + if (!cause || typeof cause !== "object") { + return []; + } + const raw = "raw" in cause ? (cause as { raw?: unknown }).raw : undefined; + if (!raw || typeof raw !== "object" || !("errors" in raw)) { + return []; + } + const errors = (raw as { errors?: unknown }).errors; + return Array.isArray(errors) ? errors : []; +} diff --git a/webui/src/features/configGraph/ResourceEditorDialog.tsx b/webui/src/features/configGraph/ResourceEditorDialog.tsx new file mode 100644 index 00000000..fcccb48e --- /dev/null +++ b/webui/src/features/configGraph/ResourceEditorDialog.tsx @@ -0,0 +1,52 @@ +import { type ReactNode } from "react"; +import { MaterialDialog } from "../../components/MaterialDialog"; +import { useI18n } from "../../i18n/I18nProvider"; +import type { ConfigResource } from "../../rpc/types"; +import { ResourceEditorCard } from "./ResourceEditorCard"; + +/** + * Modal editor for a single config resource. Wraps the full (embedded) + * ResourceEditorCard inside an official md-dialog so a long list of models, + * providers or routes stays scannable: the page shows compact summary rows and + * the full field surface only mounts when one entry is opened. + */ +export function ResourceEditorDialog({ + onClose, + open, + resource, + revision, + modelDisplayNames, + title, + children +}: { + onClose: () => void; + open: boolean; + resource: ConfigResource; + revision: string; + modelDisplayNames?: Record; + title?: string; + children?: ReactNode; +}) { + const { t } = useI18n(); + const resourceTitle = title ?? resource.label; + + return ( + + + {children} + + + ); +} diff --git a/webui/src/features/configGraph/SchemaField.test.tsx b/webui/src/features/configGraph/SchemaField.test.tsx new file mode 100644 index 00000000..a4e8b5f7 --- /dev/null +++ b/webui/src/features/configGraph/SchemaField.test.tsx @@ -0,0 +1,738 @@ +import { act, fireEvent, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, test, vi } from "vitest"; +import { MemoryRouter } from "react-router-dom"; +import { useState } from "react"; +import { AppShell } from "../../app/App"; +import type { FieldSchema } from "../../rpc/types"; +import { renderWithConsoleProviders } from "../../test/renderWithConsoleProviders"; +import { SchemaField } from "./SchemaField"; + +describe("SchemaField", () => { + test("renders enum fields with the Material Web select", async () => { + const field: FieldSchema = { + path: "protocol", + type: "string", + label: "Protocol", + control: "select", + enum: ["anthropic", "openai-response", "openai-chat", "google-genai"], + hotReloadable: true + }; + const onChange = vi.fn(); + renderWithConsoleProviders( + + ); + + expect(document.querySelector(".schema-field select")).not.toBeInTheDocument(); + const materialSelect = await findMaterialSelect(document, "Upstream protocol"); + expect(document.querySelector(".select-menu")).not.toBeInTheDocument(); + expect(materialSelect.value).toBe("anthropic"); + const options = getMaterialSelectOptions(materialSelect); + expect(options.map((option) => option.value)).toEqual([ + "anthropic", + "openai-response", + "openai-chat", + "google-genai" + ]); + expect(options.map((option) => option.displayText)).toEqual([ + "Anthropic", + "OpenAI Responses", + "OpenAI Chat", + "Gemini" + ]); + for (const option of options) { + const optionIcon = option.querySelector("[slot='start']"); + expect(optionIcon).toBeInTheDocument(); + expect(optionIcon?.querySelector("svg")).toBeInTheDocument(); + } + expect(options[0].selected).toBe(true); + expect(materialSelect.label).toBe("Upstream protocol"); + expect(materialSelect.supportingText).toBe(""); + expect(materialSelect.closest(".mb-field__select-shell")).not.toBeInTheDocument(); + expect(getOptionalMaterialIconButton(document, "Help for Upstream protocol")).not.toBeInTheDocument(); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + + setMaterialSelectValue(materialSelect, "openai-response"); + + expect(onChange).toHaveBeenCalledWith("openai-response"); + }); + + test("shows field help from config docs on demand", async () => { + const field: FieldSchema = { + path: "base_url", + type: "string", + label: "Base URL", + hotReloadable: true + }; + + renderWithConsoleProviders( + undefined} + docPath="providers..base_url" + /> + ); + + const helpButton = getMaterialIconButton(document, "Help for Upstream base URL"); + expect(helpButton.tagName.toLowerCase()).toBe("md-icon-button"); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + + await userEvent.click(helpButton); + + expect(screen.getByRole("tooltip")).toHaveTextContent("Upstream provider API URL"); + expect(helpButton).toHaveAttribute("aria-describedby"); + }); + + test("localizes fallback field help metadata in Chinese locale", async () => { + const field: FieldSchema = { + path: "custom_limit", + type: "number", + label: "Custom limit", + required: true, + hotReloadable: false + }; + + renderWithConsoleProviders( + undefined} + />, + { locale: "zh-CN" } + ); + + await userEvent.click(getMaterialIconButton(document, "Custom limit 帮助")); + + const tooltip = screen.getByRole("tooltip"); + expect(tooltip).toHaveTextContent("类型: number"); + expect(tooltip).toHaveTextContent("必填"); + expect(tooltip).toHaveTextContent("可能需要重启"); + }); + + test("localizes provider protocol option labels in Chinese locale", async () => { + const field: FieldSchema = { + path: "protocol", + type: "string", + label: "Protocol", + control: "select", + enum: ["anthropic", "openai-response", "openai-chat", "google-genai"], + hotReloadable: true + }; + + renderWithConsoleProviders( + undefined} + docPath="providers..protocol" + />, + { locale: "zh-CN" } + ); + + const materialSelect = await findMaterialSelect(document, "上游协议"); + expect(getMaterialSelectOptions(materialSelect).map((option) => option.displayText)).toEqual([ + "Anthropic", + "OpenAI Responses", + "OpenAI Chat", + "Gemini" + ]); + }); + + test("keeps trailing field help tooltip inside the viewport", async () => { + const field: FieldSchema = { + path: "config", + type: "string", + label: "Config", + control: "text", + hotReloadable: true + }; + Object.defineProperty(window, "innerWidth", { configurable: true, value: 360 }); + + renderWithConsoleProviders( + undefined} + docPath="extensions..config" + /> + ); + + const helpButton = getMaterialIconButton(document, "Help for Extension config"); + vi.spyOn(helpButton, "getBoundingClientRect").mockReturnValue({ + x: 4, + y: 64, + width: 20, + height: 20, + top: 64, + right: 24, + bottom: 84, + left: 4, + toJSON: () => ({}) + } as DOMRect); + + await userEvent.click(helpButton); + + const tooltip = screen.getByRole("tooltip"); + expect(tooltip).toHaveStyle({ + left: "12px", + maxWidth: "336px", + position: "fixed", + top: "92px" + }); + }); + + test("uses balanced spacing inside rich field help tooltips", async () => { + const field: FieldSchema = { + path: "api_key", + type: "string", + label: "API Key", + hotReloadable: true + }; + + renderWithConsoleProviders( + + undefined} + docPath="providers..api_key" + /> + )} + /> + + ); + + await userEvent.click(getMaterialIconButton(document, "Help for Upstream API key")); + + const tooltip = screen.getByRole("tooltip"); + expect(getComputedStyle(tooltip).paddingTop).toBe("16px"); + expect(getComputedStyle(tooltip).paddingRight).toBe("16px"); + expect(getComputedStyle(tooltip).paddingBottom).toBe("16px"); + expect(getComputedStyle(tooltip).paddingLeft).toBe("16px"); + expect(getComputedStyle(tooltip.querySelector(".rich-tooltip__metas")!)).toHaveProperty("marginTop", "0px"); + }); + + test("renders secret fields without exposing the value", () => { + const field: FieldSchema = { + path: "api_key", + type: "string", + label: "API key", + secret: true, + hotReloadable: true + }; + + renderWithConsoleProviders( undefined} />); + + const fieldElement = getMaterialTextField(document, "API key"); + expect(document.querySelector(".mb-field__control input")).not.toBeInTheDocument(); + expect(fieldElement.type).toBe("password"); + expect(fieldElement.value).toBe(""); + }); + + test("reveals a secret field value through the trailing visibility toggle", async () => { + const field: FieldSchema = { + path: "api_key", + type: "string", + label: "API key", + secret: true, + hotReloadable: true + }; + + renderWithConsoleProviders( undefined} />); + + const fieldElement = getMaterialTextField(document, "API key"); + expect(fieldElement.type).toBe("password"); + + await userEvent.click(getMaterialIconButton(document, "Show token")); + + expect(fieldElement.type).toBe("text"); + }); + + test("keeps a newly entered secret draft visible after the controlled parent rerenders", () => { + const field: FieldSchema = { + path: "api_key", + type: "string", + label: "API key", + secret: true, + hotReloadable: true + }; + const onParentChange = vi.fn(); + + function ControlledSecretField() { + const [value, setValue] = useState("sk-saved"); + return ( + { + setValue(next); + onParentChange(next); + }} + /> + ); + } + + renderWithConsoleProviders(); + + const fieldElement = getMaterialTextField(document, "API key"); + expect(fieldElement.type).toBe("password"); + expect(fieldElement.value).toBe(""); + + setMaterialTextFieldValue(fieldElement, "sk-live-draft"); + + expect(onParentChange).toHaveBeenCalledWith("sk-live-draft"); + expect(fieldElement.value).toBe("sk-live-draft"); + }); + + test("renders text fields with Material label and icon slots instead of an outer outlined field", () => { + const field: FieldSchema = { + path: "base_url", + type: "string", + label: "Base URL", + hotReloadable: true + }; + + renderWithConsoleProviders( + undefined} + docPath="providers..base_url" + /> + ); + + const fieldElement = getMaterialTextField(document, "Upstream base URL"); + + expect(fieldElement.label).toBe("Upstream base URL"); + expect(fieldElement).toHaveClass("material-text-field--single-line"); + expect(fieldElement.getAttribute("spellcheck")).toBe("false"); + expect(fieldElement.closest(".mb-field")?.querySelector(".mb-field__label")).not.toBeInTheDocument(); + expect(fieldElement.querySelector("[slot='leading-icon']")).toHaveTextContent("link"); + const trailing = fieldElement.querySelector("[slot='trailing-icon']"); + expect(trailing?.tagName.toLowerCase()).toBe("md-icon-button"); + expect(trailing).toHaveAttribute("aria-label", "Help for Upstream base URL"); + }); + + test("keeps Material selects aligned with single-line text field density", async () => { + const selectField: FieldSchema = { + path: "protocol", + type: "string", + label: "Protocol", + control: "select", + enum: ["anthropic", "openai-response"], + hotReloadable: true + }; + const textField: FieldSchema = { + path: "base_url", + type: "string", + label: "Base URL", + hotReloadable: true + }; + + renderWithConsoleProviders( + + + undefined} + docPath="providers..protocol" + /> + undefined} + docPath="providers..base_url" + /> + + )} + /> + + ); + + const materialSelect = await findMaterialSelect(document, "Upstream protocol"); + const materialTextField = getMaterialTextField(document, "Upstream base URL"); + const selectStyle = getComputedStyle(materialSelect); + const textFieldStyle = getComputedStyle(materialTextField); + + expect(selectStyle.getPropertyValue("--md-outlined-field-top-space").trim()).toBe( + textFieldStyle.getPropertyValue("--md-outlined-text-field-top-space").trim() + ); + expect(selectStyle.getPropertyValue("--md-outlined-field-bottom-space").trim()).toBe( + textFieldStyle.getPropertyValue("--md-outlined-text-field-bottom-space").trim() + ); + expect(selectStyle.getPropertyValue("--md-outlined-select-text-field-input-text-line-height").trim()).toBe( + textFieldStyle.getPropertyValue("--md-outlined-text-field-input-text-line-height").trim() + ); + }); + + test("keeps select fields on the official Material select surface", async () => { + const field: FieldSchema = { + path: "protocol", + type: "string", + label: "Protocol", + control: "select", + enum: ["anthropic", "openai-response"], + hotReloadable: true + }; + + renderWithConsoleProviders( + + undefined} + docPath="providers..protocol" + /> + )} + /> + + ); + + const materialSelect = await findMaterialSelect(document, "Upstream protocol"); + + expect(materialSelect.label).toBe("Upstream protocol"); + expect(materialSelect.closest(".mb-field__select-shell")).not.toBeInTheDocument(); + expect(getOptionalMaterialIconButton(document, "Help for Upstream protocol")).not.toBeInTheDocument(); + expect(Array.from(materialSelect.children).map((child) => [ + child.tagName.toLowerCase(), + child.getAttribute("slot") + ])).toEqual([ + ["span", "leading-icon"], + ["md-select-option", null], + ["md-select-option", null] + ]); + expect(Array.from(materialSelect.querySelectorAll("md-select-option")).map((child) => child.tagName.toLowerCase())).toEqual([ + "md-select-option", + "md-select-option" + ]); + }); + + test("guides secret replacement without exposing the committed value", () => { + const field: FieldSchema = { + path: "api_key", + type: "string", + label: "API key", + secret: true, + hotReloadable: true + }; + + renderWithConsoleProviders( undefined} />); + + expect(getMaterialTextField(document, "API key").supportingText).toBe("Enter a new value to replace the saved secret."); + expect(screen.queryByDisplayValue("sk-secret")).not.toBeInTheDocument(); + }); + + test("coerces numeric input before emitting changes", async () => { + const field: FieldSchema = { + path: "max_tokens", + type: "number", + label: "Max tokens", + hotReloadable: true + }; + const onChange = vi.fn(); + renderWithConsoleProviders(); + + const input = getMaterialTextField(document, "Max tokens"); + expect(document.querySelector(".mb-field__control input")).not.toBeInTheDocument(); + expect(input.type).toBe("text"); + + setMaterialTextFieldValue(input, "2048"); + + expect(onChange).toHaveBeenLastCalledWith(2048); + }); + + test("rejects invalid numeric input without emitting autosave changes", async () => { + const field: FieldSchema = { + path: "max_tokens", + type: "number", + label: "Max tokens", + hotReloadable: true + }; + const onChange = vi.fn(); + renderWithConsoleProviders(); + + const input = getMaterialTextField(document, "Max tokens"); + onChange.mockClear(); + setMaterialTextFieldValue(input, "abc"); + + expect(input.getAttribute("aria-invalid")).toBe("true"); + expect(screen.getByRole("alert")).toHaveTextContent("Invalid number"); + expect(onChange).not.toHaveBeenCalled(); + }); + + test("edits object scalar fields through structured Material controls without raw JSON editing", async () => { + const field: FieldSchema = { + path: "pricing", + type: "object", + label: "Pricing", + control: "object", + hotReloadable: true + }; + const onChange = vi.fn(); + const onCommitValue = vi.fn(); + renderWithConsoleProviders( + + ); + + expect(screen.queryByLabelText("Pricing JSON")).not.toBeInTheDocument(); + expect(document.querySelector(".schema-json-editor")).not.toBeInTheDocument(); + expect(document.querySelector(".schema-structured-summary")).toHaveTextContent("nested"); + expect(document.querySelector(".schema-structured-summary")).toHaveTextContent("1 key"); + expect(document.querySelector(".schema-structured-object")).toHaveAttribute("aria-label", "Pricing, structured editor"); + expect(getMaterialTextField(document, "input_price")).toHaveAttribute("spellcheck", "false"); + expect(getMaterialTextField(document, "note")).toHaveAttribute("spellcheck", "false"); + expect(getMaterialSwitch(document, "cache_enabled").selected).toBe(true); + + setMaterialTextFieldValue(getMaterialTextField(document, "input_price"), "4.5"); + fireEvent.blur(getMaterialTextField(document, "input_price")); + + expect(onCommitValue).toHaveBeenCalledWith({ + input_price: 4.5, + cache_enabled: true, + note: "default", + nested: { keep: true } + }); + expect(onChange).not.toHaveBeenCalled(); + }); + + test("renders fixed object fields as structured editors without raw JSON text areas", () => { + const field: FieldSchema = { + path: "web_search", + type: "object", + label: "Web Search", + control: "object", + hotReloadable: true + }; + const onChange = vi.fn(); + renderWithConsoleProviders( + + ); + + expect(screen.queryByRole("button", { name: /Web Search.*1 key/ })).not.toBeInTheDocument(); + expect(document.querySelector(".schema-field__topline")).not.toBeInTheDocument(); + expect(queryMaterialTextField(document, "Web Search JSON")).not.toBeInTheDocument(); + expect(document.querySelector(".schema-json-editor")).not.toBeInTheDocument(); + expect(getMaterialTextField(document, "support")).toBeInTheDocument(); + expect(document.querySelector(".schema-structured-object")).toHaveTextContent("Web Search"); + expect(document.querySelector(".schema-structured-object")).not.toHaveTextContent("Structured editor"); + expect(onChange).not.toHaveBeenCalled(); + }); + + test("localizes structured object editors in Chinese locale", () => { + const field: FieldSchema = { + path: "pricing", + type: "object", + label: "Pricing", + control: "object", + hotReloadable: true + }; + + renderWithConsoleProviders( + undefined} />, + { locale: "zh-CN" } + ); + + const editor = document.querySelector(".schema-structured-object"); + expect(editor).toHaveAttribute("aria-label", "Pricing,结构化编辑器"); + expect(editor).not.toHaveTextContent("结构化编辑器"); + expect(getMaterialTextField(document, "input_price")).toBeInTheDocument(); + expect(queryMaterialTextField(document, "Pricing JSON")).not.toBeInTheDocument(); + }); + + test("toggles boolean fields with the Material Web switch", async () => { + const field: FieldSchema = { + path: "enabled", + type: "boolean", + label: "Enabled", + control: "switch", + hotReloadable: true + }; + const onChange = vi.fn(); + + renderWithConsoleProviders(); + + const materialSwitch = getMaterialSwitch(document, "Enabled"); + expect(document.querySelector(".schema-switch")).not.toBeInTheDocument(); + expect(document.querySelector(".schema-field input[type='checkbox']")).not.toBeInTheDocument(); + expect(materialSwitch.selected).toBe(false); + + setMaterialSwitchSelected(materialSwitch, true); + + expect(onChange).toHaveBeenCalledWith(true); + }); + + test("marks textarea and object controls as wide layout fields", () => { + const field: FieldSchema = { + path: "system_prompt", + type: "string", + label: "System prompt", + control: "textarea", + hotReloadable: true + }; + + const { unmount } = renderWithConsoleProviders( + undefined} /> + ); + + expect(getMaterialTextField(document, "System prompt").closest(".mb-field")).toHaveClass("mb-field--wide"); + expect(getMaterialTextField(document, "System prompt")).not.toHaveClass("material-text-field--single-line"); + + unmount(); + renderWithConsoleProviders( + undefined} + /> + ); + + expect(document.querySelector(".schema-structured-object")?.closest(".schema-field")).toHaveClass("schema-field--wide"); + }); +}); + +function getMaterialSwitch(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-switch")).find( + (switchElement) => switchElement.getAttribute("aria-label") === label + ); + if (!element) { + throw new Error(`Expected a Material Web switch labelled "${label}".`); + } + return element as HTMLElement & { selected: boolean }; +} + +function getMaterialSelect(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-outlined-select")).find( + (selectElement) => materialElementLabel(selectElement as HTMLElement & { label?: string }) === label + ); + if (!element) { + throw new Error(`Expected a Material Web select labelled "${label}".`); + } + return element as HTMLElement & { supportingText: string; value: string }; +} + +type MaterialSelectOptionElement = HTMLElement & { + displayText: string; + selected: boolean; + value: string; +}; + +function getMaterialSelectOptions(select: ParentNode) { + const options = Array.from(select.querySelectorAll("md-select-option")); + if (options.length === 0) { + throw new Error("Expected Material Web select options to be rendered."); + } + return options; +} + +function getMaterialTextField(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-outlined-text-field")).find( + (textField) => materialElementLabel(textField as HTMLElement & { label?: string }) === label + ); + if (!element) { + throw new Error(`Expected a Material Web text field labelled "${label}".`); + } + return element as HTMLElement & { label: string; supportingText: string; type: string; value: string }; +} + +function queryMaterialTextField(container: ParentNode, label: string) { + return Array.from(container.querySelectorAll("md-outlined-text-field")).find( + (textField) => materialElementLabel(textField as HTMLElement & { label?: string }) === label + ) ?? null; +} + +function materialElementLabel(element: HTMLElement & { label?: string }) { + const labelledBy = element.getAttribute("aria-labelledby"); + if (labelledBy) { + return labelledBy + .split(/\s+/) + .map((id) => document.getElementById(id)?.textContent?.trim() ?? "") + .filter(Boolean) + .join(" "); + } + return element.label || element.getAttribute("aria-label") || element.getAttribute("label") || ""; +} + +function getMaterialIconButton(container: ParentNode, label: string) { + const element = getOptionalMaterialIconButton(container, label); + if (!element) { + throw new Error(`Expected a Material Web icon button labelled "${label}".`); + } + return element as HTMLElement; +} + +function getOptionalMaterialIconButton(container: ParentNode, label: string) { + return Array.from(container.querySelectorAll("md-icon-button")).find( + (button) => button.getAttribute("aria-label") === label + ) ?? null; +} + +function getMaterialButton(container: ParentNode, label: RegExp) { + const element = Array.from(container.querySelectorAll("md-outlined-button")).find( + (button) => label.test(button.getAttribute("aria-label") ?? button.textContent ?? "") + ); + if (!element) { + throw new Error(`Expected a Material Web outlined button labelled "${label}".`); + } + return element as HTMLElement; +} + +async function findMaterialSelect(container: ParentNode, label: string) { + const element = getMaterialSelect(container, label) as HTMLElement & { + label: string; + select: (value: string) => void; + supportingText: string; + updateComplete?: Promise; + value: string; + }; + await element.updateComplete; + return element; +} + +function setMaterialTextFieldValue(element: HTMLElement & { value: string }, value: string) { + act(() => { + element.value = value; + element.dispatchEvent(new InputEvent("input", { bubbles: true, composed: true })); + }); +} + +function setMaterialSelectValue(element: HTMLElement & { select: (value: string) => void; value: string }, value: string) { + act(() => { + element.select(value); + element.dispatchEvent(new Event("change", { bubbles: true })); + }); +} + +function setMaterialSwitchSelected(element: HTMLElement & { selected: boolean }, selected: boolean) { + act(() => { + element.selected = selected; + element.dispatchEvent(new Event("change", { bubbles: true })); + }); +} diff --git a/webui/src/features/configGraph/SchemaField.tsx b/webui/src/features/configGraph/SchemaField.tsx new file mode 100644 index 00000000..168a8c02 --- /dev/null +++ b/webui/src/features/configGraph/SchemaField.tsx @@ -0,0 +1,1145 @@ +import { + type CSSProperties, + type KeyboardEvent, + type RefObject, + type ReactNode, + useEffect, + useMemo, + useRef, + useState +} from "react"; +import { configDescriptions, type ConfigPath } from "../../configDocs/configDescriptions"; +import type { FieldSchema } from "../../rpc/types"; +import { useI18n } from "../../i18n/I18nProvider"; +import type { MessageKey } from "../../i18n/messages"; +import { MaterialIconButton } from "../../components/MaterialButton"; +import { MaterialOutlinedTextField, type MaterialTextFieldElement } from "../../components/MaterialTextField"; +import { MaterialSwitch } from "../../components/MaterialSwitch"; +import { SelectMenu, type SelectMenuOption } from "./SelectMenu"; +import type { MdIconButton } from "@material/web/iconbutton/icon-button.js"; +import { type TooltipPosition, useAnchoredTooltipPosition } from "./helpTooltipPosition"; +import { protocolIconForValue } from "./modelProviderIcons"; + +export type SchemaFieldProps = { + field: FieldSchema; + value: unknown; + onChange: (value: unknown) => void; + onCommit?: () => void; + onCommitValue?: (value: unknown) => void; + clearSecretDraft?: boolean; + disabled?: boolean; + idPrefix?: string; + docPath?: ConfigPath; + error?: string; + /** Explicit select options (e.g. route model/provider picked from existing resources). + * When provided, the field renders as a select regardless of enum/control. */ + options?: SelectMenuOption[]; + /** Soft warning shown beneath the field (e.g. a route references a missing model). */ + warning?: string; + leadingIconNode?: ReactNode; + objectDisplay?: "collapsible" | "expandedFixed"; +}; + +export function SchemaField({ + field, + value, + onChange, + onCommit, + onCommitValue, + clearSecretDraft = false, + disabled = false, + idPrefix, + docPath, + error, + options, + warning, + leadingIconNode, + objectDisplay = "collapsible" +}: SchemaFieldProps) { + const { locale, t } = useI18n(); + const id = useMemo(() => { + const prefix = idPrefix ? `${idPrefix}-` : ""; + return `schema-field-${prefix}${field.path}`.replace(/[^a-zA-Z0-9_-]/g, "-"); + }, [field.path, idPrefix]); + const [text, setText] = useState(displayValue(field, value)); + const [parseError, setParseError] = useState(""); + const [helpOpen, setHelpOpen] = useState(false); + const [revealed, setRevealed] = useState(false); + const emittedSecretDraftRef = useRef(undefined); + const trailingHelpAnchorRef = useRef(null); + const displayLabel = fieldLabel(field, docPath, locale); + + useEffect(() => { + if (isSecretField(field)) { + if (typeof value === "string" && value === emittedSecretDraftRef.current) { + setText(value); + } else { + emittedSecretDraftRef.current = undefined; + setText(""); + } + setParseError(""); + return; + } + setText(displayValue(field, value)); + setParseError(""); + }, [field, value]); + + useEffect(() => { + if (!clearSecretDraft || !isSecretField(field)) { + return; + } + emittedSecretDraftRef.current = undefined; + setText(""); + setParseError(""); + }, [clearSecretDraft, field]); + + const wide = isWideField(field); + const errorId = `${id}-error`; + const warnId = `${id}-warning`; + const helpId = `${id}-help`; + const helpParts = fieldHelpParts(field, displayLabel, docPath, locale, { + default: t("configDoc.default"), + defaultEmpty: t("configDoc.default.empty"), + optional: t("configDoc.optional"), + required: t("configDoc.required"), + restartMayBeRequired: t("configDoc.restartMayBeRequired"), + savedRealtime: t("configDoc.savedRealtime"), + sensitive: t("configDoc.sensitive"), + type: t("configDoc.type"), + typeArray: t("configDoc.type.array"), + typeBoolean: t("configDoc.type.boolean"), + typeHostPort: t("configDoc.type.hostPort"), + typeNumber: t("configDoc.type.number"), + typeObject: t("configDoc.type.object"), + typeString: t("configDoc.type.string"), + typeUrl: t("configDoc.type.url") + }); + const fieldError = error || parseError; + const fieldDescribedBy = [ + helpOpen ? helpId : undefined, + fieldError ? errorId : undefined + ].filter(Boolean).join(" ") || undefined; + const commitOnBlur = onCommit ? () => onCommit() : undefined; + const commit = onCommitValue ?? onChange; + + if (field.control === "select" || (field.enum?.length ?? 0) > 0 || (options?.length ?? 0) > 0) { + const selected = typeof value === "string" ? value : ""; + const useProtocolIcons = isProviderProtocolField(field, docPath); + const resolvedOptions: SelectMenuOption[] = options && options.length > 0 + ? options + : (field.enum ?? []).map((option) => ({ + value: option, + label: optionLabel(option, t), + leadingIcon: useProtocolIcons ? protocolIconForValue(option) : undefined + })); + const selectedOption = resolvedOptions.find((option) => option.value === selected); + return ( +
    +
    + commit(next)} + disabled={disabled} + ariaLabel={displayLabel} + describedBy={fieldError ? errorId : warning ? warnId : undefined} + error={Boolean(fieldError)} + errorText={fieldError} + leadingIcon={useProtocolIcons ? protocolIconForValue(selected) : selectedOption?.leadingIcon} + required={field.required} + /> +
    + + {warning && !fieldError ? ( +

    + {warning} +

    + ) : null} +
    + ); + } + + if (field.type === "boolean" || field.control === "switch") { + return ( +
    +
    + + + {displayLabel} + {field.required ? : null} + + + + commit(selected)} + /> +
    + {fieldError ? ( + + ) : null} +
    + ); + } + + if (field.control === "textarea") { + return ( +
    +
    + + )} + type="textarea" + value={text} + onInput={(next) => updateTextValue(next, onChange, field)} + onBlur={commitOnBlur} + /> + +
    + +
    + ); + } + + if (field.type === "object" || field.type === "array" || field.control === "object" || field.control === "array") { + const summary = structuredSummary(value, field, displayLabel, t); + if (isObjectFieldValue(value)) { + return ( +
    + {objectDisplay === "expandedFixed" ? null : ( + + )} + + ) : null} + /> + {objectDisplay === "expandedFixed" ? ( + + ) : null} + +
    + ); + } + return ( +
    + {objectDisplay === "expandedFixed" ? null : ( + + )} +
    +
    + {summary.title} + {t("field.structuredReadonly")} + {objectDisplay === "expandedFixed" ? ( + + ) : null} +
    + {summary.rows.length ? ( +
    + {summary.rows.map((row) => ( +
    +
    {row.key}
    +
    {row.value}
    +
    + ))} +
    + ) : ( +

    {t("field.structuredEmpty")}

    + )} +
    + {objectDisplay === "expandedFixed" ? ( + + ) : null} + +
    + ); + } + + return ( +
    +
    + updateTextValue(next, onChange, field)} + onBlur={commitOnBlur} + /> + +
    + +
    + ); + + function updateTextValue(next: string, emit: (value: unknown) => void, schema: FieldSchema) { + setText(next); + if (isSecretField(schema)) { + emittedSecretDraftRef.current = next; + } + if (schema.type === "number" || schema.control === "number") { + if (next === "") { + setParseError(""); + emit(undefined); + return; + } + const parsed = Number(next); + if (!Number.isFinite(parsed)) { + setParseError(t("field.invalidNumber")); + return; + } + setParseError(""); + emit(parsed); + return; + } + setParseError(""); + emit(next); + } +} + +type FieldHelpParts = { + subhead: string; + body: string; + metas: { label?: string; value: string }[]; +}; + +function fieldLeadingIcon(field: FieldSchema): string | undefined { + if (field.secret || field.control === "secret") { + return "key"; + } + const path = field.path.toLowerCase(); + if (path.includes("url") || path.includes("endpoint") || path.includes("addr")) { + return "link"; + } + if (path.includes("model")) { + return "smart_toy"; + } + if (path.includes("agent")) { + return "badge"; + } + if (field.type === "number" || field.control === "number") { + return "tag"; + } + return undefined; +} + +function isProviderProtocolField(field: FieldSchema, docPath: ConfigPath | undefined) { + return field.path === "protocol" && docPath === "providers..protocol"; +} + +function fieldLabel(field: FieldSchema, docPath: ConfigPath | undefined, locale: "en-US" | "zh-CN") { + const entry = docPath ? configDescriptions[docPath] : undefined; + return entry?.title[locale] ?? field.label; +} + +function FieldTopline({ + field, + helpId, + helpOpen, + helpParts, + id, + label, + labelForControl = true, + labelId, + setHelpOpen +}: { + field: FieldSchema; + helpId: string; + helpOpen: boolean; + helpParts: FieldHelpParts; + id: string; + label: string; + labelForControl?: boolean; + labelId?: string; + setHelpOpen: (open: boolean | ((current: boolean) => boolean)) => void; +}) { + const labelContent = ( + <> + {label} + {field.required ? : null} + + ); + + return ( +
    + + {labelForControl ? ( + + ) : ( + + {labelContent} + + )} + + +
    + ); +} + +function StructuredObjectEditor({ + describedBy, + disabled, + helpButton, + id, + label, + objectDisplay, + onCommit, + summary, + value +}: { + describedBy?: string; + disabled: boolean; + helpButton: ReactNode; + id: string; + label: string; + objectDisplay: "collapsible" | "expandedFixed"; + onCommit: (value: unknown) => void; + summary: StructuredSummary; + value: Record; +}) { + const { t } = useI18n(); + const editableEntries = Object.entries(value).filter(([, entryValue]) => isStructuredEditableScalar(entryValue)); + const summaryEntries = Object.entries(value).filter(([, entryValue]) => !isStructuredEditableScalar(entryValue)); + return ( +
    +
    + {label} + {helpButton} +
    + {editableEntries.length ? ( +
    + {editableEntries.map(([key, entryValue]) => ( + onCommit({ ...value, [key]: nextValue })} + /> + ))} +
    + ) : null} + {summaryEntries.length || editableEntries.length === 0 ? ( + ({ + key, + value: structuredScalar(entryValue, undefined, t) + }))) + }} + /> + ) : null} +
    + ); +} + +function StructuredObjectEntry({ + disabled, + label, + onCommit, + value +}: { + disabled: boolean; + label: string; + onCommit: (value: unknown) => void; + value: unknown; +}) { + if (typeof value === "boolean") { + return ( +
    +
    + + {label} + + +
    +
    + ); + } + if (typeof value === "number") { + return ( + + ); + } + return ( + + ); +} + +function StructuredObjectTextField({ + disabled, + label, + onCommit, + value +}: { + disabled: boolean; + label: string; + onCommit: (value: unknown) => void; + value: string; +}) { + const [draft, setDraft] = useState(value); + + useEffect(() => { + setDraft(value); + }, [value]); + + return ( +
    +
    + { + if (draft !== value) { + onCommit(draft); + } + }} + onInput={setDraft} + /> +
    +
    + ); +} + +function StructuredObjectNumberField({ + disabled, + label, + onCommit, + value +}: { + disabled: boolean; + label: string; + onCommit: (value: unknown) => void; + value: number; +}) { + const { t } = useI18n(); + const [draft, setDraft] = useState(String(value)); + const [localError, setLocalError] = useState(""); + + useEffect(() => { + setDraft(String(value)); + setLocalError(""); + }, [value]); + + function commit() { + const trimmed = draft.trim(); + if (trimmed === String(value)) { + setLocalError(""); + return; + } + const parsed = Number(trimmed); + if (!Number.isFinite(parsed)) { + setLocalError(t("field.invalidNumber")); + return; + } + setLocalError(""); + onCommit(parsed); + } + + return ( +
    +
    + { + setDraft(next); + if (localError && next.trim() === String(value)) { + setLocalError(""); + } + }} + /> +
    + {localError ? ( +

    + {localError} +

    + ) : null} +
    + ); +} + +function StructuredObjectSummary({ + objectDisplay, + summary +}: { + objectDisplay: "collapsible" | "expandedFixed"; + summary: StructuredSummary; +}) { + const { t } = useI18n(); + return ( +
    +
    + {summary.title} + {t(objectDisplay === "expandedFixed" ? "field.structuredReadonly" : "field.structuredSummary")} +
    + {summary.rows.length ? ( +
    + {summary.rows.map((row) => ( +
    +
    {row.key}
    +
    {row.value}
    +
    + ))} +
    + ) : ( +

    {t("field.structuredEmpty")}

    + )} +
    + ); +} + +function FieldHelpButton({ + field, + helpId, + helpOpen, + helpParts, + label, + setHelpOpen +}: { + field: FieldSchema; + helpId: string; + helpOpen: boolean; + helpParts: FieldHelpParts; + label: string; + setHelpOpen: (open: boolean | ((current: boolean) => boolean)) => void; +}) { + const anchorRef = useRef(null); + return ( + + + + + ); +} + +function renderFieldTrailing({ + field, + revealed, + setRevealed, + revealLabel, + hideLabel, + displayLabel, + helpId, + helpOpen, + setHelpOpen, + trailingHelpAnchorRef +}: { + field: FieldSchema; + revealed: boolean; + setRevealed: (next: boolean | ((current: boolean) => boolean)) => void; + revealLabel: string; + hideLabel: string; + displayLabel: string; + helpId: string; + helpOpen: boolean; + setHelpOpen: (open: boolean | ((current: boolean) => boolean)) => void; + trailingHelpAnchorRef?: RefObject; +}) { + // Material Web's text-field trailing slot holds exactly one icon (fixed-width + // container + absolutely-positioned slotted content). Secret fields use that + // slot for the visibility toggle; the help affordance is dropped for them + // (they still show their supporting text). + if (isSecretField(field)) { + return ( + setRevealed((current) => !current)} + onMouseDown={(event) => event.preventDefault()} + slot="trailing-icon" + /> + ); + } + return ( + + ); +} + +function FieldHelpIconButton({ + anchorRef, + field, + helpId, + helpOpen, + label, + setHelpOpen, + slot +}: { + anchorRef?: RefObject; + field: FieldSchema; + helpId: string; + helpOpen: boolean; + label: string; + setHelpOpen: (open: boolean | ((current: boolean) => boolean)) => void; + slot?: string; +}) { + const { t } = useI18n(); + const openedByHover = useRef(false); + + return ( + setHelpOpen(false)} + onClick={() => { + if (openedByHover.current) { + openedByHover.current = false; + setHelpOpen(true); + return; + } + setHelpOpen((open) => !open); + }} + onFocus={() => setHelpOpen(true)} + onKeyDown={(event: KeyboardEvent) => { + if (event.key === "Escape") { + setHelpOpen(false); + } + }} + onMouseDown={(event) => event.preventDefault()} + onMouseEnter={() => { + openedByHover.current = true; + setHelpOpen(true); + }} + onMouseLeave={() => { + openedByHover.current = false; + setHelpOpen(false); + }} + ref={anchorRef} + slot={slot} + /> + ); +} + +function FieldHelpTooltip({ + anchorRef, + helpId, + helpOpen, + helpParts +}: { + anchorRef: RefObject; + helpId: string; + helpOpen: boolean; + helpParts: FieldHelpParts; +}) { + const position = useAnchoredTooltipPosition(anchorRef, helpOpen); + const style = tooltipPositionStyle(position); + return helpOpen ? ( + + {helpParts.subhead ? {helpParts.subhead} : null} + {helpParts.body ? {helpParts.body} : null} + {helpParts.metas.length ? ( + + {helpParts.metas.map((meta, index) => ( + + {meta.label ? `${meta.label}: ${meta.value}` : meta.value} + + ))} + + ) : null} + + ) : null; +} + +function tooltipPositionStyle(position: TooltipPosition | undefined): CSSProperties | undefined { + if (!position) { + return undefined; + } + return { + left: `${position.left}px`, + maxWidth: `${position.maxWidth}px`, + position: "fixed", + top: `${position.top}px` + }; +} + +function FieldA11yMessages({ + errorId, + error +}: { + errorId: string; + error: string; +}) { + return ( + <> + {error ? ( + + ) : null} + + ); +} + +function fieldSupportingText(field: FieldSchema, secretReplacementHint: string) { + if (field.secret) { + return secretReplacementHint; + } + return ""; +} + +function schemaFieldClass(wide: boolean) { + return wide ? "schema-field schema-field--wide" : "schema-field"; +} + +function isWideField(field: FieldSchema) { + return ( + field.control === "textarea" || + field.control === "object" || + field.control === "array" || + field.type === "object" || + field.type === "array" + ); +} + +function displayValue(field: FieldSchema, value: unknown) { + if (isSecretField(field)) { + return ""; + } + if (value === undefined || value === null) { + return ""; + } + if (field.type === "object" || field.type === "array" || field.control === "object" || field.control === "array") { + return JSON.stringify(value, null, 2); + } + return String(value); +} + +function inputType(field: FieldSchema) { + if (isSecretField(field)) { + return "password"; + } + if (field.type === "number" || field.control === "number") { + return "text"; + } + return "text"; +} + +function isSecretField(field: FieldSchema) { + return field.secret || field.control === "secret"; +} + +function fieldHelpParts( + field: FieldSchema, + label: string, + docPath: ConfigPath | undefined, + locale: "en-US" | "zh-CN", + labels: FieldHelpLabels +): FieldHelpParts { + const entry = docPath ? configDescriptions[docPath] : undefined; + const metas: { label?: string; value: string }[] = []; + if (entry) { + metas.push({ label: labels.type, value: localizedConfigMetaValue(entry.type, labels) }); + if (entry.defaultValue) { + metas.push({ label: labels.default, value: localizedConfigMetaValue(String(entry.defaultValue), labels) }); + } + if (entry.sensitive || field.secret) { + metas.push({ value: labels.sensitive }); + } + return { subhead: label, body: entry.description[locale], metas }; + } + metas.push({ label: labels.type, value: field.type }); + metas.push({ value: field.required ? labels.required : labels.optional }); + if (field.secret) { + metas.push({ value: labels.sensitive }); + } + metas.push({ value: field.hotReloadable ? labels.savedRealtime : labels.restartMayBeRequired }); + return { subhead: label, body: "", metas }; +} + +type FieldHelpLabels = { + default: string; + defaultEmpty: string; + optional: string; + required: string; + restartMayBeRequired: string; + savedRealtime: string; + sensitive: string; + type: string; + typeArray: string; + typeBoolean: string; + typeHostPort: string; + typeNumber: string; + typeObject: string; + typeString: string; + typeUrl: string; +}; + +function localizedConfigMetaValue(value: string, labels: FieldHelpLabels) { + const normalized = value.trim().toLowerCase(); + const localized: Record = { + array: labels.typeArray, + boolean: labels.typeBoolean, + empty: labels.defaultEmpty, + "host:port": labels.typeHostPort, + number: labels.typeNumber, + object: labels.typeObject, + string: labels.typeString, + url: labels.typeUrl + }; + return localized[normalized] ?? value; +} + +function optionLabel(option: string, t: (key: MessageKey) => string) { + const labels: Record = { + anthropic: "provider.protocol.anthropic", + "openai-response": "provider.protocol.openaiResponses", + "openai-chat": "provider.protocol.openaiChat", + "google-genai": "provider.protocol.googleGenai" + }; + const key = labels[option]; + return key ? t(key) : option; +} + +type StructuredSummary = { + rows: Array<{ key: string; value: string }>; + title: string; +}; + +function structuredSummary( + value: unknown, + field: FieldSchema, + label: string, + t: (key: MessageKey, values?: Record) => string +): StructuredSummary { + if (Array.isArray(value)) { + return { + title: label, + rows: value.slice(0, 6).map((item, index) => ({ + key: String(index + 1), + value: structuredScalar(item, field, t) + })) + }; + } + if (value && typeof value === "object") { + const entries = Object.entries(value as Record); + return { + title: label, + rows: entries.slice(0, 6).map(([key, item]) => ({ + key, + value: structuredScalar(item, field, t) + })) + }; + } + return { + title: label, + rows: [] + }; +} + +function structuredScalar( + value: unknown, + field: FieldSchema | undefined, + t: (key: MessageKey, values?: Record) => string +) { + if (value === undefined || value === null || value === "") { + return t("field.structuredEmptyValue"); + } + if (Array.isArray(value)) { + return t(summaryKey("field.summary.items", value.length), { count: value.length }); + } + if (value && typeof value === "object") { + const count = Object.keys(value).length; + return t(summaryKey("field.summary.keys", count), { count }); + } + if (field?.secret) { + return "******"; + } + return String(value); +} + +function summaryKey(prefix: "field.summary.items" | "field.summary.keys", count: number): MessageKey { + return `${prefix}.${count === 1 ? "one" : "many"}` as MessageKey; +} + +function isObjectFieldValue(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function isStructuredEditableScalar(value: unknown) { + return value === undefined || value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean"; +} diff --git a/webui/src/features/configGraph/SelectMenu.tsx b/webui/src/features/configGraph/SelectMenu.tsx new file mode 100644 index 00000000..dc1513b8 --- /dev/null +++ b/webui/src/features/configGraph/SelectMenu.tsx @@ -0,0 +1,58 @@ +import { MaterialSelect } from "../../components/MaterialSelect"; +import type { ReactNode } from "react"; + +export type SelectMenuOption = { + value: string; + label: string; + leadingIcon?: ReactNode; +}; + +export function SelectMenu({ + options, + value, + onChange, + disabled = false, + describedBy, + error, + errorText, + ariaLabel, + leadingIcon, + placeholder, + required, + supportingText +}: { + options: SelectMenuOption[]; + value: string; + onChange: (value: string) => void; + disabled?: boolean; + id?: string; + describedBy?: string; + error?: boolean; + errorText?: string; + ariaLabel?: string; + leadingIcon?: ReactNode; + placeholder?: string; + required?: boolean; + supportingText?: string; +}) { + return ( + ({ + value: option.value, + label: option.label, + leadingIcon: option.leadingIcon + }))} + required={required} + supportingText={supportingText} + value={value} + onChange={onChange} + /> + ); +} diff --git a/webui/src/features/configGraph/configDocPath.ts b/webui/src/features/configGraph/configDocPath.ts new file mode 100644 index 00000000..bedfa642 --- /dev/null +++ b/webui/src/features/configGraph/configDocPath.ts @@ -0,0 +1,64 @@ +import type { ConfigPath } from "../../configDocs/configDescriptions"; +import type { ConfigResource, FieldSchema } from "../../rpc/types"; + +export function configDocPathForResource( + resource: Pick, + field: Pick +): ConfigPath | undefined { + switch (resource.kind) { + case "mode": + return field.path === "mode" ? "mode" : undefined; + case "trace": + return topLevelPath("trace", field.path); + case "log": + return topLevelPath("log", field.path); + case "server": + return topLevelPath("server", field.path); + case "defaults": + return topLevelPath("defaults", field.path); + case "web_search": + return topLevelPath("web_search", field.path); + case "cache": + return topLevelPath("cache", field.path); + case "persistence": + return topLevelPath("persistence", field.path); + case "proxy": + return topLevelPath("proxy", field.path); + case "provider": + return providerPath(field.path); + case "provider_offer": + return providerOfferPath(field.path); + case "model": + return modelPath(field.path); + case "route": + return routePath(field.path); + case "extension": + return extensionPath(field.path); + default: + return undefined; + } +} + +function topLevelPath(prefix: string, fieldPath: string) { + return `${prefix}.${fieldPath}` as ConfigPath; +} + +function providerPath(fieldPath: string) { + return `providers..${fieldPath}` as ConfigPath; +} + +function providerOfferPath(fieldPath: string) { + return `providers..offers[].${fieldPath}` as ConfigPath; +} + +function modelPath(fieldPath: string) { + return `models..${fieldPath}` as ConfigPath; +} + +function routePath(fieldPath: string) { + return `routes..${fieldPath}` as ConfigPath; +} + +function extensionPath(fieldPath: string) { + return `extensions..${fieldPath}` as ConfigPath; +} diff --git a/webui/src/features/configGraph/editorStatus.tsx b/webui/src/features/configGraph/editorStatus.tsx new file mode 100644 index 00000000..8e8e53f5 --- /dev/null +++ b/webui/src/features/configGraph/editorStatus.tsx @@ -0,0 +1,32 @@ +import { createContext, useContext, useEffect, type ReactNode } from "react"; +import type { AutosaveFieldStatus } from "./useAutosaveField"; + +export type FieldStatusReporter = (id: string, status: AutosaveFieldStatus) => void; + +const EditorStatusContext = createContext(null); + +export function EditorStatusProvider({ + report, + children +}: { + report: FieldStatusReporter; + children: ReactNode; +}) { + return {children}; +} + +/** + * Reports a single field's live autosave status up to the nearest editor card so + * the card can show one consolidated indicator instead of one per field. + */ +export function useReportFieldStatus(id: string, status: AutosaveFieldStatus) { + const report = useContext(EditorStatusContext); + useEffect(() => { + report?.(id, status); + }, [report, id, status]); + useEffect(() => { + return () => { + report?.(id, "idle"); + }; + }, [report, id]); +} diff --git a/webui/src/features/configGraph/helpTooltipPosition.ts b/webui/src/features/configGraph/helpTooltipPosition.ts new file mode 100644 index 00000000..b0ab693b --- /dev/null +++ b/webui/src/features/configGraph/helpTooltipPosition.ts @@ -0,0 +1,61 @@ +import { type RefObject, useEffect, useState } from "react"; + +export type TooltipPosition = { + left: number; + maxWidth: number; + top: number; +}; + +const tooltipViewportPadding = 12; +const tooltipAnchorGap = 8; +const tooltipPreferredWidth = 320; + +export function useAnchoredTooltipPosition( + anchorRef: RefObject, + open: boolean +): TooltipPosition | undefined { + const [position, setPosition] = useState(); + + useEffect(() => { + if (!open) { + setPosition(undefined); + return undefined; + } + + function updatePosition() { + const anchor = anchorRef.current; + if (!anchor) { + setPosition(undefined); + return; + } + setPosition(calculateTooltipPosition(anchor.getBoundingClientRect())); + } + + updatePosition(); + window.addEventListener("resize", updatePosition); + window.addEventListener("scroll", updatePosition, true); + return () => { + window.removeEventListener("resize", updatePosition); + window.removeEventListener("scroll", updatePosition, true); + }; + }, [anchorRef, open]); + + return position; +} + +function calculateTooltipPosition(anchorRect: DOMRect): TooltipPosition { + const viewportWidth = Math.max(window.innerWidth || tooltipPreferredWidth, tooltipViewportPadding * 2); + const maxWidth = Math.max(0, viewportWidth - tooltipViewportPadding * 2); + const tooltipWidth = Math.min(tooltipPreferredWidth, maxWidth); + const preferredLeft = anchorRect.right - tooltipWidth; + const maxLeft = viewportWidth - tooltipViewportPadding - tooltipWidth; + return { + left: Math.round(clamp(preferredLeft, tooltipViewportPadding, maxLeft)), + maxWidth: Math.round(maxWidth), + top: Math.round(anchorRect.bottom + tooltipAnchorGap) + }; +} + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), Math.max(min, max)); +} diff --git a/webui/src/features/configGraph/modelProviderIcons.tsx b/webui/src/features/configGraph/modelProviderIcons.tsx new file mode 100644 index 00000000..3d439eec --- /dev/null +++ b/webui/src/features/configGraph/modelProviderIcons.tsx @@ -0,0 +1,151 @@ +import Anthropic from "@lobehub/icons/es/Anthropic/components/Mono"; +import Baichuan from "@lobehub/icons/es/Baichuan/components/Mono"; +import Claude from "@lobehub/icons/es/Claude/components/Mono"; +import Cohere from "@lobehub/icons/es/Cohere/components/Mono"; +import DeepSeek from "@lobehub/icons/es/DeepSeek/components/Mono"; +import Doubao from "@lobehub/icons/es/Doubao/components/Mono"; +import Gemini from "@lobehub/icons/es/Gemini/components/Mono"; +import Gemma from "@lobehub/icons/es/Gemma/components/Mono"; +import Google from "@lobehub/icons/es/Google/components/Mono"; +import Grok from "@lobehub/icons/es/Grok/components/Mono"; +import Kimi from "@lobehub/icons/es/Kimi/components/Mono"; +import Meta from "@lobehub/icons/es/Meta/components/Mono"; +import Minimax from "@lobehub/icons/es/Minimax/components/Mono"; +import Mistral from "@lobehub/icons/es/Mistral/components/Mono"; +import Moonshot from "@lobehub/icons/es/Moonshot/components/Mono"; +import OpenAI from "@lobehub/icons/es/OpenAI/components/Mono"; +import Perplexity from "@lobehub/icons/es/Perplexity/components/Mono"; +import Qwen from "@lobehub/icons/es/Qwen/components/Mono"; +import XAI from "@lobehub/icons/es/XAI/components/Mono"; +import Yi from "@lobehub/icons/es/Yi/components/Mono"; +import Zhipu from "@lobehub/icons/es/Zhipu/components/Mono"; +import type { ReactNode } from "react"; +import type { ConfigResource, FieldSchema } from "../../rpc/types"; + +type IconComponent = (props: { className?: string; size?: number | string }) => ReactNode; + +const iconSize = 18; + +const modelMatchers: Array<{ icon: IconComponent; patterns: RegExp[] }> = [ + { icon: Claude, patterns: [/\bclaude\b/i, /\banthropic\b/i] }, + { icon: OpenAI, patterns: [/\b(gpt|openai|o[1345]|chatgpt)\b/i] }, + { icon: Gemini, patterns: [/\b(gemini|google-genai)\b/i] }, + { icon: Gemma, patterns: [/\bgemma\b/i] }, + { icon: DeepSeek, patterns: [/\bdeepseek\b/i] }, + { icon: Qwen, patterns: [/\b(qwen|qwq|qvq|tongyi)\b/i] }, + { icon: Mistral, patterns: [/\b(mistral|mixtral|codestral)\b/i] }, + { icon: Meta, patterns: [/\b(llama|meta)\b/i] }, + { icon: Grok, patterns: [/\b(grok|xai)\b/i] }, + { icon: XAI, patterns: [/\bx-ai\b/i] }, + { icon: Kimi, patterns: [/\b(kimi|moonshot)\b/i] }, + { icon: Moonshot, patterns: [/\bmoonshot\b/i] }, + { icon: Doubao, patterns: [/\b(doubao|volcengine|bytedance)\b/i] }, + { icon: Baichuan, patterns: [/\bbaichuan\b/i] }, + { icon: Yi, patterns: [/\byi\b/i, /\b01-ai\b/i] }, + { icon: Zhipu, patterns: [/\b(zhipu|glm|chatglm)\b/i] }, + { icon: Minimax, patterns: [/\bminimax\b/i] }, + { icon: Perplexity, patterns: [/\b(perplexity|sonar)\b/i] }, + { icon: Cohere, patterns: [/\b(cohere|command-r)\b/i] } +]; + +const protocolIcons: Record = { + anthropic: Anthropic, + "google-genai": Gemini, + "openai-chat": OpenAI, + "openai-response": OpenAI +}; + +export function protocolIconForValue(protocol: string) { + const Icon = protocolIcons[protocol]; + return Icon ? : undefined; +} + +export function modelIconForName(name: string | undefined) { + if (!name) { + return undefined; + } + const normalized = name.trim(); + if (!normalized) { + return undefined; + } + const match = modelMatchers.find((candidate) => candidate.patterns.some((pattern) => pattern.test(normalized))); + const Icon = match?.icon; + return Icon ? : undefined; +} + +function modelIconForCandidateNames(...names: Array) { + for (const name of names) { + const icon = modelIconForName(name); + if (icon) { + return icon; + } + } + return undefined; +} + +export function resourceFieldModelIcon( + resource: ConfigResource, + field: FieldSchema, + modelDisplayNames: Record = {} +) { + const value = resource.value[field.path]; + if (resource.kind === "model" && field.path === "display_name") { + return modelIconForName(stringValue(value) || resource.id || resource.label); + } + if (resource.kind === "route" && routeModelIconFields.has(field.path)) { + const modelId = stringValue(resource.value.model); + const fieldValue = stringValue(value); + if (field.path === "model") { + return modelIconForCandidateNames(modelDisplayNames[fieldValue], modelDisplayNames[modelId], fieldValue, modelId); + } + return modelIconForCandidateNames(fieldValue, modelDisplayNames[modelId], modelId, resource.id, resource.label); + } + if (resource.kind === "defaults" && field.path === "model") { + const modelId = stringValue(value); + return modelIconForCandidateNames(modelDisplayNames[modelId], modelId); + } + return undefined; +} + +export function modelSelectOptions(resources: ConfigResource[]) { + return resources + .filter((resource) => resource.kind === "model") + .map((resource) => ({ + label: resource.id, + leadingIcon: modelIconForName(modelDisplayName(resource)), + value: resource.id + })); +} + +export function providerSelectOptions(resources: ConfigResource[]) { + return resources + .filter((resource) => resource.kind === "provider") + .map((resource) => ({ + label: resource.id, + leadingIcon: protocolIconForValue(stringValue(resource.value.protocol)), + value: resource.id + })); +} + +export function modelDisplayNamesById(resources: ConfigResource[]) { + const modelNames = Object.fromEntries(resources + .filter((resource) => resource.kind === "model") + .map((resource) => [resource.id, modelDisplayName(resource)])); + const routeNames = resources + .filter((resource) => resource.kind === "route") + .map((resource) => { + const routeModel = stringValue(resource.value.model); + return [resource.id, modelNames[routeModel] || routeModel || modelDisplayName(resource)] as const; + }); + return Object.fromEntries([...Object.entries(modelNames), ...routeNames]); +} + +const routeModelIconFields = new Set(["model", "to", "display_name"]); + +function stringValue(value: unknown) { + return typeof value === "string" ? value : ""; +} + +function modelDisplayName(resource: ConfigResource) { + return stringValue(resource.value.display_name) || resource.label || resource.id; +} diff --git a/webui/src/features/configGraph/useAutosaveField.test.tsx b/webui/src/features/configGraph/useAutosaveField.test.tsx new file mode 100644 index 00000000..c5c057d6 --- /dev/null +++ b/webui/src/features/configGraph/useAutosaveField.test.tsx @@ -0,0 +1,314 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import type { PatchResponse } from "../../rpc/types"; +import { useAutosaveField, type SaveField } from "./useAutosaveField"; + +const committed = (revision = "rev-2"): PatchResponse => ({ + result: "committed", + revision +}); + +describe("useAutosaveField", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("marks the field dirty as soon as local value changes", () => { + const save: SaveField = vi.fn(); + const { result } = renderHook(() => + useAutosaveField({ + resourceKind: "defaults", + resourceId: "main", + field: "model", + committedValue: "claude-3-5-sonnet", + revision: "rev-1", + save, + configUpdateFailedMessage, + requestFailedMessage + }) + ); + + act(() => result.current.setValue("claude-3-7-sonnet")); + + expect(result.current.value).toBe("claude-3-7-sonnet"); + expect(result.current.status).toBe("dirty"); + expect(save).not.toHaveBeenCalled(); + }); + + test("persists the dirty value on commit and clears the dirty state", async () => { + const save: SaveField = vi.fn().mockResolvedValue(committed()); + const { result } = renderHook(() => + useAutosaveField({ + resourceKind: "defaults", + resourceId: "main", + field: "model", + committedValue: "old-model", + revision: "rev-1", + save, + configUpdateFailedMessage, + requestFailedMessage + }) + ); + + act(() => result.current.setValue("new-model")); + expect(save).not.toHaveBeenCalled(); + + act(() => result.current.commit()); + expect(save).toHaveBeenCalledWith({ + baseRevision: "rev-1", + change: { + kind: "defaults", + id: "main", + field: "model", + value: "new-model" + } + }); + await waitFor(() => expect(result.current.status).toBe("saved")); + expect(result.current.error).toBeUndefined(); + }); + + test("keeps a newer dirty value when an earlier save commits", async () => { + const firstSave = deferred(); + const save: SaveField = vi.fn() + .mockReturnValueOnce(firstSave.promise) + .mockResolvedValue(committed("rev-3")); + const { result } = renderHook(() => + useAutosaveField({ + resourceKind: "provider", + resourceId: "anthropic", + field: "api_key", + committedValue: "masked", + revision: "rev-1", + save, + configUpdateFailedMessage, + requestFailedMessage + }) + ); + + act(() => result.current.setValue("secret-a")); + act(() => result.current.commit()); + + expect(result.current.status).toBe("saving"); + expect(save).toHaveBeenCalledWith({ + baseRevision: "rev-1", + change: { + kind: "provider", + id: "anthropic", + field: "api_key", + value: "secret-a" + } + }); + + act(() => result.current.setValue("secret-b")); + + expect(result.current.value).toBe("secret-b"); + expect(result.current.status).toBe("dirty"); + + await act(async () => { + firstSave.resolve(committed("rev-2")); + await firstSave.promise; + }); + + expect(result.current.value).toBe("secret-b"); + expect(result.current.status).toBe("dirty"); + + act(() => result.current.commit()); + + expect(save).toHaveBeenCalledTimes(2); + expect(save).toHaveBeenLastCalledWith({ + baseRevision: "rev-2", + change: { + kind: "provider", + id: "anthropic", + field: "api_key", + value: "secret-b" + } + }); + await waitFor(() => expect(result.current.status).toBe("saved")); + }); + + test("commit is a no-op when the field is not dirty", () => { + const save: SaveField = vi.fn().mockResolvedValue(committed()); + const { result } = renderHook(() => + useAutosaveField({ + resourceKind: "defaults", + resourceId: "main", + field: "model", + committedValue: "old-model", + revision: "rev-1", + save, + configUpdateFailedMessage, + requestFailedMessage + }) + ); + + act(() => result.current.commit()); + + expect(save).not.toHaveBeenCalled(); + }); + + test("commitValue persists immediately for discrete controls", async () => { + const save: SaveField = vi.fn().mockResolvedValue(committed()); + const { result } = renderHook(() => + useAutosaveField({ + resourceKind: "provider", + resourceId: "anthropic", + field: "enabled", + committedValue: false, + revision: "rev-1", + save, + configUpdateFailedMessage, + requestFailedMessage + }) + ); + + act(() => result.current.commitValue(true)); + + expect(save).toHaveBeenCalledWith({ + baseRevision: "rev-1", + change: { + kind: "provider", + id: "anthropic", + field: "enabled", + value: true + } + }); + await waitFor(() => expect(result.current.status).toBe("saved")); + }); + + test("keeps draft value and field error after draft rejection", async () => { + const save: SaveField = vi.fn().mockResolvedValue({ + result: "draftRejected", + revision: "rev-1", + errors: [ + { + resourceKind: "defaults", + resourceId: "main", + field: "max_tokens", + code: "invalidValue", + message: "must be positive" + } + ] + } satisfies PatchResponse); + const { result } = renderHook(() => + useAutosaveField({ + resourceKind: "defaults", + resourceId: "main", + field: "max_tokens", + committedValue: 1024, + revision: "rev-1", + save, + configUpdateFailedMessage, + requestFailedMessage + }) + ); + + act(() => result.current.setValue(-1)); + act(() => result.current.commit()); + + await waitFor(() => expect(result.current.status).toBe("error")); + expect(result.current.value).toBe(-1); + expect(result.current.error?.message).toBe("must be positive"); + }); + + test("uses the localized generic message when rejected patches do not include field errors", async () => { + const save: SaveField = vi.fn().mockResolvedValue({ + result: "validationRejected", + revision: "rev-1" + } satisfies PatchResponse); + const { result } = renderHook(() => + useAutosaveField({ + resourceKind: "defaults", + resourceId: "main", + field: "max_tokens", + committedValue: 1024, + revision: "rev-1", + save, + configUpdateFailedMessage, + requestFailedMessage + }) + ); + + act(() => result.current.setValue(-1)); + act(() => result.current.commit()); + + await waitFor(() => expect(result.current.status).toBe("error")); + expect(result.current.error?.message).toBe("Config update validationRejected failed."); + }); + + test("rolls back to the server value after runtime rejection", async () => { + const save: SaveField = vi.fn().mockResolvedValue({ + result: "runtimeRejected", + revision: "rev-2", + rollbackValue: "old-address", + errors: [ + { + resourceKind: "server", + resourceId: "main", + field: "addr", + code: "runtimeReloadRejected", + message: "address already in use" + } + ] + } satisfies PatchResponse); + const { result } = renderHook(() => + useAutosaveField({ + resourceKind: "server", + resourceId: "main", + field: "addr", + committedValue: "old-address", + revision: "rev-1", + save, + configUpdateFailedMessage, + requestFailedMessage + }) + ); + + act(() => result.current.setValue("bad-address")); + act(() => result.current.commit()); + + await waitFor(() => expect(result.current.status).toBe("error")); + expect(result.current.value).toBe("old-address"); + expect(result.current.error?.message).toBe("address already in use"); + }); + + test("does not discard an in-progress edit when the committed value refreshes", () => { + const save: SaveField = vi.fn(); + const { result, rerender } = renderHook( + ({ committedValue, revision }) => + useAutosaveField({ + resourceKind: "defaults", + resourceId: "main", + field: "model", + committedValue, + revision, + save, + configUpdateFailedMessage, + requestFailedMessage + }), + { initialProps: { committedValue: "original", revision: "rev-1" } } + ); + + act(() => result.current.setValue("user-typing")); + expect(result.current.status).toBe("dirty"); + + // Another field commits, refreshing the graph with a new revision. + rerender({ committedValue: "original", revision: "rev-2" }); + + expect(result.current.value).toBe("user-typing"); + expect(result.current.status).toBe("dirty"); + }); +}); + +const requestFailedMessage = "Request failed"; +const configUpdateFailedMessage = (result: PatchResponse["result"]) => `Config update ${result} failed.`; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (cause: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, reject, resolve }; +} diff --git a/webui/src/features/configGraph/useAutosaveField.ts b/webui/src/features/configGraph/useAutosaveField.ts new file mode 100644 index 00000000..3e56a877 --- /dev/null +++ b/webui/src/features/configGraph/useAutosaveField.ts @@ -0,0 +1,242 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { FieldError, PatchOp, PatchResponse, ResourceKind } from "../../rpc/types"; + +export type AutosaveFieldStatus = "idle" | "dirty" | "saving" | "saved" | "error"; + +export type SaveFieldRequest = { + baseRevision: string; + change: PatchOp & { value: T }; +}; + +export type SaveField = (request: SaveFieldRequest) => Promise; + +export type UseAutosaveFieldOptions = { + resourceKind: ResourceKind; + resourceId: string; + field: string; + committedValue: T; + revision: string; + save: SaveField; + disabled?: boolean; + configUpdateFailedMessage: (result: PatchResponse["result"]) => string; + requestFailedMessage: string; +}; + +export type AutosaveFieldState = { + value: T; + status: AutosaveFieldStatus; + error?: FieldError; + setValue: (value: T) => void; + commit: () => void; + commitValue: (value: T) => void; + commitSerializedValue: (localValue: T, saveValue: SaveValue) => void; + reset: () => void; +}; + +export function useAutosaveField({ + resourceKind, + resourceId, + field, + committedValue, + revision, + save, + disabled = false, + configUpdateFailedMessage, + requestFailedMessage +}: UseAutosaveFieldOptions): AutosaveFieldState { + const [value, setValueState] = useState(committedValue); + const [status, setStatus] = useState("idle"); + const [error, setError] = useState(); + const saveSeq = useRef(0); + const committedRef = useRef(committedValue); + const valueRef = useRef(value); + const statusRef = useRef(status); + const revisionRef = useRef(revision); + + valueRef.current = value; + statusRef.current = status; + + useEffect(() => { + revisionRef.current = revision; + }, [revision]); + + // Adopt values committed elsewhere (e.g. another field saved and refreshed + // the graph) without discarding an edit the user is in the middle of typing. + useEffect(() => { + if (valuesEqual(committedValue, committedRef.current)) { + return; + } + committedRef.current = committedValue; + if (statusRef.current === "dirty" || statusRef.current === "saving") { + return; + } + setValueState(committedValue); + setError(undefined); + setStatus("idle"); + }, [committedValue]); + + const runSave = useCallback( + (pendingValue: T, saveValue: SaveValue) => { + if (disabled) { + return; + } + const sequence = ++saveSeq.current; + setStatus("saving"); + setError(undefined); + + save({ + baseRevision: revisionRef.current, + change: { + kind: resourceKind, + id: resourceId, + field, + value: saveValue + } + }) + .then((response) => { + if (sequence !== saveSeq.current) { + return; + } + applySaveResponse(response, pendingValue); + }) + .catch((cause: unknown) => { + if (sequence !== saveSeq.current) { + return; + } + setError({ + resourceKind, + resourceId, + field, + code: "requestFailed", + message: cause instanceof Error ? cause.message : requestFailedMessage + }); + setStatus("error"); + }); + }, + [disabled, field, requestFailedMessage, resourceId, resourceKind, save] + ); + + const setValue = useCallback((next: T) => { + setValueState(next); + setError(undefined); + setStatus(valuesEqual(next, committedRef.current) ? "idle" : "dirty"); + }, []); + + // Commit the current value (used when an input loses focus). + const commit = useCallback(() => { + if (statusRef.current !== "dirty") { + return; + } + runSave(valueRef.current, valueRef.current as unknown as SaveValue); + }, [runSave]); + + // Set and immediately persist (used by discrete controls like switches/menus). + const commitValue = useCallback( + (next: T) => { + setValueState(next); + if (valuesEqual(next, committedRef.current)) { + setError(undefined); + setStatus("idle"); + return; + } + runSave(next, next as unknown as SaveValue); + }, + [runSave] + ); + + // Persist a different wire value while keeping the local UI value typed. + const commitSerializedValue = useCallback( + (next: T, serialized: SaveValue) => { + setValueState(next); + if (valuesEqual(next, committedRef.current)) { + setError(undefined); + setStatus("idle"); + return; + } + runSave(next, serialized); + }, + [runSave] + ); + + const reset = useCallback(() => { + setValueState(committedRef.current); + setError(undefined); + setStatus("idle"); + }, []); + + return { value, status, error, setValue, commit, commitValue, commitSerializedValue, reset }; + + function applySaveResponse(response: PatchResponse, pendingValue: T) { + const fieldError = findFieldError(response.errors, resourceKind, resourceId, field); + switch (response.result) { + case "committed": + case "restartRequired": + revisionRef.current = response.revision; + committedRef.current = pendingValue; + setError(undefined); + if (!valuesEqual(valueRef.current, pendingValue)) { + setStatus("dirty"); + return; + } + setStatus("saved"); + return; + case "draftRejected": + case "validationRejected": + case "revisionConflict": + setError(fieldError ?? genericPatchError(response, resourceKind, resourceId, field, configUpdateFailedMessage)); + setStatus("error"); + return; + case "runtimeRejected": { + const rollback = response.rollbackValue === undefined + ? committedRef.current + : response.rollbackValue as T; + setValueState(rollback); + setError(fieldError ?? genericPatchError(response, resourceKind, resourceId, field, configUpdateFailedMessage)); + setStatus("error"); + return; + } + default: + setError(genericPatchError(response, resourceKind, resourceId, field, configUpdateFailedMessage)); + setStatus("error"); + } + } +} + +function findFieldError( + errors: FieldError[] | undefined, + resourceKind: ResourceKind, + resourceId: string, + field: string +) { + return errors?.find((error) => + (error.resourceKind === resourceKind || error.resourceKind === "") && + (error.resourceId === resourceId || error.resourceId === "") && + (!error.field || error.field === field) + ) ?? errors?.[0]; +} + +function genericPatchError( + response: PatchResponse, + resourceKind: ResourceKind, + resourceId: string, + field: string, + message: (result: PatchResponse["result"]) => string +): FieldError { + return { + resourceKind, + resourceId, + field, + code: response.result, + message: message(response.result) + }; +} + +function valuesEqual(left: unknown, right: unknown) { + if (Object.is(left, right)) { + return true; + } + if (typeof left !== "object" || left === null || typeof right !== "object" || right === null) { + return false; + } + return JSON.stringify(left) === JSON.stringify(right); +} diff --git a/webui/src/features/configGraph/useConfigGraph.test.ts b/webui/src/features/configGraph/useConfigGraph.test.ts new file mode 100644 index 00000000..92aeb173 --- /dev/null +++ b/webui/src/features/configGraph/useConfigGraph.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "vitest"; +import { patchRequestForField } from "./useConfigGraph"; + +describe("config graph hooks", () => { + test("converts field save requests into graph patch requests", () => { + const request = patchRequestForField({ + baseRevision: "rev-1", + change: { + kind: "defaults", + id: "main", + field: "model", + value: "claude-sonnet" + } + }); + + expect(request).toEqual({ + baseRevision: "rev-1", + changes: [ + { + kind: "defaults", + id: "main", + field: "model", + value: "claude-sonnet" + } + ] + }); + }); +}); diff --git a/webui/src/features/configGraph/useConfigGraph.ts b/webui/src/features/configGraph/useConfigGraph.ts new file mode 100644 index 00000000..1128c7a6 --- /dev/null +++ b/webui/src/features/configGraph/useConfigGraph.ts @@ -0,0 +1,89 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + createConfigResource, + deleteConfigResource, + getConfigGraph, + patchConfigGraph, + validateConfigGraph +} from "../../rpc/configGraph"; +import { queryKeys } from "../../rpc/queryKeys"; +import type { + CreateConfigResourceRequest, + PatchRequest, + PatchResponse, + ResourceKind +} from "../../rpc/types"; +import type { SaveFieldRequest } from "./useAutosaveField"; + +export function useConfigGraph() { + return useQuery({ + queryKey: queryKeys.configGraph, + queryFn: getConfigGraph + }); +} + +export function usePatchConfigGraph() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (request: PatchRequest) => patchConfigGraph(request), + onSuccess: (response) => updateGraphCache(queryClient, response) + }); +} + +export function useValidateConfigGraph() { + return useMutation({ + mutationFn: (request: PatchRequest) => validateConfigGraph(request) + }); +} + +export function useCreateConfigResource() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ kind, body }: { kind: ResourceKind; body: CreateConfigResourceRequest }) => + createConfigResource(kind, body), + onSuccess: (response) => updateGraphCache(queryClient, response) + }); +} + +export function useDeleteConfigResource() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + kind, + id, + baseRevision + }: { + kind: ResourceKind; + id: string; + baseRevision: string; + }) => deleteConfigResource(kind, id, baseRevision), + onSuccess: (response) => updateGraphCache(queryClient, response) + }); +} + +export function useGraphFieldSaver() { + const patch = usePatchConfigGraph(); + return (request: SaveFieldRequest) => + patch.mutateAsync({ + baseRevision: request.baseRevision, + changes: [request.change] + }); +} + +function updateGraphCache( + queryClient: ReturnType, + response: PatchResponse +) { + if (response.graph) { + queryClient.setQueryData(queryKeys.configGraph, response.graph); + return; + } + queryClient.invalidateQueries({ queryKey: queryKeys.configGraph }); +} + +export function patchRequestForField(request: SaveFieldRequest): PatchRequest { + return { + baseRevision: request.baseRevision, + changes: [request.change] + }; +} diff --git a/webui/src/features/defaults/DefaultsPage.test.tsx b/webui/src/features/defaults/DefaultsPage.test.tsx new file mode 100644 index 00000000..b574c0bd --- /dev/null +++ b/webui/src/features/defaults/DefaultsPage.test.tsx @@ -0,0 +1,180 @@ +import { act, fireEvent, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { renderWithConsoleProviders } from "../../test/renderWithConsoleProviders"; +import * as configGraph from "../../rpc/configGraph"; +import { configGraphFixture } from "../../test/configGraphFixtures"; +import { DefaultsPage } from "./DefaultsPage"; + +describe("DefaultsPage", () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + test("renders defaults, trace, and log resources", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + + renderWithConsoleProviders(); + + expect(await screen.findByRole("heading", { level: 2, name: "Defaults" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { level: 2, name: "Trace" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { level: 2, name: "Log" })).toBeInTheDocument(); + expect(within(screen.getByLabelText("Defaults main status")).getByText("Saved")).toBeInTheDocument(); + expect(within(screen.getByLabelText("Trace main status")).getByText("Saved")).toBeInTheDocument(); + expect(within(screen.getByLabelText("Log main status")).getByText("Saved")).toBeInTheDocument(); + expect(screen.getAllByText("Hot reload").length).toBeGreaterThan(0); + const defaultModelField = getMaterialTextField(document, "Default model"); + expect(defaultModelField.value).toBe("claude-sonnet"); + expectLobeLeadingIcon(defaultModelField); + expect(getMaterialSelect(document, "Log level").value).toBe("info"); + }); + + test("resolves default model route aliases to their underlying model icon", async () => { + const graph = configGraphFixture(); + const defaults = graph.resources.find((resource) => resource.kind === "defaults"); + if (!defaults) { + throw new Error("Fixture is missing defaults resource."); + } + defaults.value = { ...defaults.value, model: "primary" }; + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(graph); + + renderWithConsoleProviders(); + + const defaultModelField = await findMaterialTextField(document, "Default model"); + expect(defaultModelField.value).toBe("primary"); + expectLobeLeadingIcon(defaultModelField, "Claude"); + }); + + test("autosaves defaults through graph patches", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + const patch = vi.spyOn(configGraph, "patchConfigGraph").mockResolvedValue({ + result: "committed", + revision: "rev-2" + }); + + renderWithConsoleProviders(); + + const defaultsPanel = (await screen.findByRole("heading", { level: 2, name: "Defaults" })) + .closest("section")!; + vi.useFakeTimers(); + const modelField = getMaterialTextField(defaultsPanel, "Default model"); + setMaterialTextFieldValue(modelField, "gpt-4o"); + fireEvent.blur(modelField); + + await advanceAutosave(); + + expect(patch).toHaveBeenCalledWith({ + baseRevision: "rev-1", + changes: [ + { + kind: "defaults", + id: "main", + field: "model", + value: "gpt-4o" + } + ] + }); + }); + + test("does not expose delete actions for singleton default resources", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + + renderWithConsoleProviders(); + + expect(await screen.findByRole("heading", { level: 2, name: "Defaults" })).toBeInTheDocument(); + expect(queryMaterialFilledButton(document, "Delete Defaults main")).not.toBeInTheDocument(); + expect(queryMaterialFilledButton(document, "Delete Trace main")).not.toBeInTheDocument(); + expect(queryMaterialFilledButton(document, "Delete Log main")).not.toBeInTheDocument(); + }); + + test("localizes singleton resource titles and field labels in Chinese locale", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + + renderWithConsoleProviders(, { locale: "zh-CN" }); + + expect(await screen.findByRole("heading", { level: 2, name: "默认值" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { level: 2, name: "追踪" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { level: 2, name: "日志" })).toBeInTheDocument(); + expect(within(screen.getByLabelText("默认值 main 状态")).getByText("已保存")).toBeInTheDocument(); + expect(getMaterialTextField(document, "默认模型")).toBeInTheDocument(); + expect(getMaterialTextField(document, "全局系统提示词")).toBeInTheDocument(); + expect(getMaterialSelect(document, "日志级别")).toBeInTheDocument(); + }); +}); + +type MaterialTextFieldElement = HTMLElement & { + label: string; + value: string; +}; + +type MaterialSelectElement = HTMLElement & { + label: string; + value: string; +}; + +async function findMaterialTextField(container: ParentNode, label: string) { + await screen.findByRole("heading", { level: 2, name: "Defaults" }); + return getMaterialTextField(container, label); +} + +function getMaterialTextField(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-outlined-text-field")).find( + (candidate) => materialElementLabel(candidate) === label + ); + if (!element) { + throw new Error(`Expected a Material Web outlined text field labelled "${label}".`); + } + return element; +} + +function expectLobeLeadingIcon(fieldElement: HTMLElement, title?: string) { + const leadingIcon = fieldElement.querySelector("[slot='leading-icon']"); + expect(leadingIcon).toBeInTheDocument(); + expect(leadingIcon?.querySelector("svg")).toBeInTheDocument(); + if (title) { + expect(leadingIcon?.querySelector("title")).toHaveTextContent(title); + } +} + +function getMaterialSelect(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-outlined-select")).find( + (candidate) => materialElementLabel(candidate) === label + ); + if (!element) { + throw new Error(`Expected a Material Web select labelled "${label}".`); + } + return element; +} + +function materialElementLabel(element: HTMLElement & { label?: string }) { + const labelledBy = element.getAttribute("aria-labelledby"); + if (labelledBy) { + return labelledBy + .split(/\s+/) + .map((id) => document.getElementById(id)?.textContent?.trim() ?? "") + .filter(Boolean) + .join(" "); + } + return element.label || element.getAttribute("aria-label") || element.getAttribute("label") || ""; +} + +function setMaterialTextFieldValue(element: MaterialTextFieldElement, value: string) { + act(() => { + element.value = value; + element.dispatchEvent(new InputEvent("input", { bubbles: true, composed: true })); + }); +} + +function queryMaterialFilledButton(container: ParentNode, label: string) { + return Array.from(container.querySelectorAll("md-filled-button")).find((candidate) => { + const accessibleLabel = candidate.getAttribute("aria-label") ?? candidate.textContent ?? ""; + return accessibleLabel.includes(label); + }) ?? null; +} + +async function advanceAutosave() { + await act(async () => { + await vi.advanceTimersByTimeAsync(450); + await Promise.resolve(); + }); +} diff --git a/webui/src/features/defaults/DefaultsPage.tsx b/webui/src/features/defaults/DefaultsPage.tsx new file mode 100644 index 00000000..9f51446f --- /dev/null +++ b/webui/src/features/defaults/DefaultsPage.tsx @@ -0,0 +1,75 @@ +import { LoadingState } from "../../components/LoadingState"; +import { useI18n } from "../../i18n/I18nProvider"; +import type { MessageKey } from "../../i18n/messages"; +import type { ConfigResource } from "../../rpc/types"; +import { modelDisplayNamesById } from "../configGraph/modelProviderIcons"; +import { ResourceEditorCard } from "../configGraph/ResourceEditorCard"; +import { useConfigGraph } from "../configGraph/useConfigGraph"; +import { PageHeader, QueryErrorState } from "../shared"; + +const defaultResourceOrder = ["defaults", "trace", "log"] as const; +const defaultResourceTitleKeys: Record<(typeof defaultResourceOrder)[number], MessageKey> = { + defaults: "resource.kind.defaults", + trace: "resource.kind.trace", + log: "resource.kind.log" +}; + +export function DefaultsPage() { + const { t } = useI18n(); + const graph = useConfigGraph(); + + if (graph.error) { + return ; + } + if (graph.isLoading || !graph.data) { + return ; + } + + const resources = defaultResourceOrder + .map((kind) => graph.data.resources.find((resource) => resource.kind === kind)) + .filter((resource): resource is ConfigResource => Boolean(resource)); + const modelDisplayNames = modelDisplayNamesById(graph.data.resources); + + return ( +
    + + {t("config.description")} + + + {resources.map((resource) => ( + + ))} +
    + ); +} + +function DefaultResourceSection({ + resource, + revision, + modelDisplayNames +}: { + modelDisplayNames: Record; + resource: ConfigResource; + revision: string; +}) { + const { t } = useI18n(); + const titleKey = defaultResourceTitleKeys[resource.kind as (typeof defaultResourceOrder)[number]]; + const title = titleKey ? t(titleKey) : resource.label; + return ( +
    +

    {title}

    + +
    + ); +} diff --git a/webui/src/features/logs/LogPanel.tsx b/webui/src/features/logs/LogPanel.tsx new file mode 100644 index 00000000..d0753bd4 --- /dev/null +++ b/webui/src/features/logs/LogPanel.tsx @@ -0,0 +1,272 @@ +import { useEffect, useMemo, useState } from "react"; +import { motion } from "motion/react"; +import { MaterialIconButton, MaterialOutlinedButton } from "../../components/MaterialButton"; +import { MaterialFilterChip } from "../../components/MaterialFilterChip"; +import { MaterialOutlinedTextField } from "../../components/MaterialTextField"; +import { useI18n } from "../../i18n/I18nProvider"; +import { createLogStream, getRecentLogs } from "../../rpc/logs"; +import type { LogEntry } from "../../rpc/types"; +import { springs } from "../../theme/motion"; + +const logLevels = ["ALL", "ERROR", "WARN", "INFO", "DEBUG"] as const; +type LogLevelFilter = (typeof logLevels)[number]; + +export function LogPanel({ labelledBy, embedded }: { labelledBy?: string; embedded?: boolean }) { + const { t } = useI18n(); + const [entries, setEntries] = useState([]); + const [filter, setFilter] = useState(""); + const [levelFilter, setLevelFilter] = useState("ALL"); + const [follow, setFollow] = useState(true); + const [streamError, setStreamError] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(); + + useEffect(() => { + let cancelled = false; + getRecentLogs({ limit: 200 }) + .then((recent) => { + if (!cancelled) { + setEntries(recent); + setLoading(false); + } + }) + .catch((cause: unknown) => { + if (!cancelled) { + setError(cause); + setLoading(false); + } + }); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (!follow) { + return undefined; + } + setStreamError(false); + const abort = new AbortController(); + void consumeStream(abort.signal, (entry) => { + setEntries((current) => [...current, entry]); + }).catch((cause: unknown) => { + if (!abort.signal.aborted) { + setStreamError(true); + console.error("log stream failed", cause); + } + }); + return () => abort.abort(); + }, [follow]); + + const visibleEntries = useMemo(() => { + const needle = filter.trim().toLowerCase(); + return entries.filter((entry) => { + if (levelFilter !== "ALL" && normalizeLevel(entry.level) !== levelFilter.toLowerCase()) { + return false; + } + if (!needle) { + return true; + } + return logLine(entry).toLowerCase().includes(needle); + }); + }, [entries, filter, levelFilter]); + + return ( +
    +
    + {labelledBy ?
    + +
    +
    + + setFollow(true)}> + {t("logs.follow")} + + setFollow(false)}> + {t("logs.pause")} + + +
    +

    + {t("logs.visibleCount", { visible: visibleEntries.length, total: entries.length })} +

    +
    + + + {logLevels.map((level) => ( + + {level === "ALL" ? t("logs.levelAll") : level} + + ))} + + + {streamError ? ( +

    + {t("logs.streamDisconnected")} +

    + ) : null} + + {error ? ( +

    + {error instanceof Error ? error.message : t("error.unknownRequest")} +

    + ) : null} + +
    + + setFilter("")} + /> +
    + +
    + {loading ? ( +

    + {t("common.loading")} +

    + ) : visibleEntries.length === 0 ? ( +

    + {entries.length === 0 ? t("logs.empty") : t("logs.emptyFiltered")} +

    + ) : ( + visibleEntries.map((entry, index) => ( + + )) + )} +
    +
    + ); +} + +function LogRow({ entry, index }: { entry: LogEntry; index: number }) { + const { t } = useI18n(); + const level = normalizeLevel(entry.level); + return ( + + + +

    {entry.message || logLine(entry)}

    +
    + ); +} + +/** Compact HH:MM:SS view of an ISO timestamp for dense log rows. */ +function compactLogTime(timestamp: string): string { + const time = timestamp.split("T")[1]; + return time ? time.replace(/\.\d{3,}.*$/, "").replace(/Z$/i, "") : timestamp; +} + +async function consumeStream(signal: AbortSignal, append: (entry: LogEntry) => void) { + const response = await createLogStream({ signal }); + const reader = response.body?.getReader(); + if (!reader) { + throw new Error("log stream response body is empty"); + } + + const decoder = new TextDecoder(); + let buffer = ""; + for (;;) { + const { done, value } = await reader.read(); + if (done) { + break; + } + buffer += decoder.decode(value, { stream: true }); + const events = buffer.split("\n\n"); + buffer = events.pop() ?? ""; + for (const event of events) { + const entry = parseSSEEvent(event); + if (entry) { + append(entry); + } + } + } + buffer += decoder.decode(); + const entry = parseSSEEvent(buffer); + if (entry) { + append(entry); + } +} + +function parseSSEEvent(event: string): LogEntry | undefined { + const data = event + .split("\n") + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).trimStart()) + .join("\n"); + if (!data) { + return undefined; + } + return JSON.parse(data) as LogEntry; +} + +function logLine(entry: LogEntry) { + return entry.raw || `${entry.timestamp} ${entry.level} ${entry.message}`; +} + +function normalizeLevel(level: string) { + return level.trim().toLowerCase(); +} + +function copyLogs(entries: LogEntry[]) { + const text = entries.map((entry) => logLine(entry)).join("\n"); + if (!navigator.clipboard) { + console.error("clipboard API unavailable"); + return; + } + void navigator.clipboard.writeText(text).catch((cause: unknown) => { + console.error("copy logs failed", cause); + }); +} + +function downloadLogs(entries: LogEntry[]) { + const blob = new Blob([entries.map((entry) => logLine(entry)).join("\n")], { + type: "text/plain" + }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = "moonbridge-logs.txt"; + anchor.click(); + URL.revokeObjectURL(url); +} diff --git a/webui/src/features/logs/LogsPage.test.tsx b/webui/src/features/logs/LogsPage.test.tsx new file mode 100644 index 00000000..064e0c52 --- /dev/null +++ b/webui/src/features/logs/LogsPage.test.tsx @@ -0,0 +1,311 @@ +import { act, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { renderWithConsoleProviders } from "../../test/renderWithConsoleProviders"; +import * as logs from "../../rpc/logs"; +import type { LogEntry } from "../../rpc/types"; +import { LogsPage } from "./LogsPage"; + +describe("LogsPage", () => { + afterEach(() => { + vi.restoreAllMocks(); + restoreNavigatorClipboard(); + restoreURLMethods(); + }); + + test("renders recent raw logs, filters visible rows, and exposes log actions", async () => { + vi.spyOn(logs, "getRecentLogs").mockResolvedValue(logEntries()); + vi.spyOn(logs, "createLogStream").mockResolvedValue( + new Response(new ReadableStream()) + ); + + renderWithConsoleProviders(); + + expect(await screen.findByText(/server started/)).toBeInTheDocument(); + expect(screen.getByText(/database unavailable/)).toBeInTheDocument(); + expect(screen.getByText("3 of 3 logs")).toBeInTheDocument(); + expect(getMaterialFilterChip(document.body, "Pause")).toBeInTheDocument(); + expect(getMaterialButton(document.body, "Copy")).toBeInTheDocument(); + expect(getMaterialButton(document.body, "Download")).toBeInTheDocument(); + expect(getMaterialTextField(document.body, "Search logs")).toHaveClass("material-text-field--single-line"); + const topActions = document.querySelector(".logs-panel__actions"); + const toolbarActions = document.querySelector(".logs-toolbar__actions"); + expect(topActions).toBeInTheDocument(); + expect(toolbarActions).toBeInTheDocument(); + expect(getMaterialButton(topActions!, "Copy")).toBeInTheDocument(); + expect(getMaterialButton(topActions!, "Download")).toBeInTheDocument(); + expect(toolbarActions!.querySelectorAll("md-outlined-button")).toHaveLength(0); + + setMaterialTextFieldValue(getMaterialTextField(document.body, "Search logs"), "database"); + + expect(screen.queryByText(/server started/)).not.toBeInTheDocument(); + expect(screen.getByText(/database unavailable/)).toBeInTheDocument(); + expect(screen.getByText("1 of 3 logs")).toBeInTheDocument(); + + fireEvent.click(getMaterialFilterChip(document.body, "Pause")); + expect(getMaterialFilterChip(document.body, "Follow")).toBeInTheDocument(); + }); + + test("filters by level and copies only visible raw lines", async () => { + vi.spyOn(logs, "getRecentLogs").mockResolvedValue(logEntries()); + vi.spyOn(logs, "createLogStream").mockResolvedValue( + new Response(new ReadableStream()) + ); + const writeText = installClipboard(); + + renderWithConsoleProviders(); + + expect(await screen.findByText(/server started/)).toBeInTheDocument(); + + fireEvent.click(getMaterialFilterChip(document.body, "ERROR")); + expect(screen.queryByText(/server started/)).not.toBeInTheDocument(); + expect(screen.getByText(/database unavailable/)).toBeInTheDocument(); + expect(screen.getByText("1 of 3 logs")).toBeInTheDocument(); + + fireEvent.click(getMaterialButton(document.body, "Copy")); + + await waitFor(() => { + expect(writeText).toHaveBeenCalledWith("time=2026-06-07T00:00:01Z level=ERROR msg=database-unavailable"); + }); + }); + + test("uses a segmented follow control with clear pressed state", async () => { + vi.spyOn(logs, "getRecentLogs").mockResolvedValue(logEntries()); + vi.spyOn(logs, "createLogStream").mockResolvedValue( + new Response(new ReadableStream()) + ); + + renderWithConsoleProviders(); + + expect(await screen.findByText(/server started/)).toBeInTheDocument(); + + const followMode = screen.getByRole("group", { name: "Live follow mode" }); + expect(getMaterialFilterChip(followMode, "Follow")).toHaveProperty("selected", true); + expect(getMaterialFilterChip(followMode, "Pause")).toHaveProperty("selected", false); + + fireEvent.click(getMaterialFilterChip(followMode, "Pause")); + + expect(getMaterialFilterChip(followMode, "Follow")).toHaveProperty("selected", false); + expect(getMaterialFilterChip(followMode, "Pause")).toHaveProperty("selected", true); + }); + + test("localizes log row labels in Chinese locale", async () => { + vi.spyOn(logs, "getRecentLogs").mockResolvedValue(logEntries()); + vi.spyOn(logs, "createLogStream").mockResolvedValue( + new Response(new ReadableStream()) + ); + + renderWithConsoleProviders(, { locale: "zh-CN" }); + + expect(await screen.findByLabelText("日志 1")).toHaveTextContent("server started"); + expect(screen.getByLabelText("日志 2")).toHaveTextContent("database unavailable"); + }); + + test("shows empty feedback and disables log actions when filters hide every row", async () => { + vi.spyOn(logs, "getRecentLogs").mockResolvedValue(logEntries()); + vi.spyOn(logs, "createLogStream").mockResolvedValue( + new Response(new ReadableStream()) + ); + + renderWithConsoleProviders(); + + expect(await screen.findByText(/server started/)).toBeInTheDocument(); + + setMaterialTextFieldValue(getMaterialTextField(document.body, "Search logs"), "no matching backend event"); + + expect(screen.getByText("No logs match the current filters.")).toBeInTheDocument(); + expect(getMaterialButton(document.body, "Copy")).toHaveProperty("disabled", true); + expect(getMaterialButton(document.body, "Download")).toHaveProperty("disabled", true); + expect(screen.getByText("0 of 3 logs")).toBeInTheDocument(); + }); + + test("shows a calm empty state when no recent logs are available", async () => { + vi.spyOn(logs, "getRecentLogs").mockResolvedValue([]); + vi.spyOn(logs, "createLogStream").mockResolvedValue( + new Response(new ReadableStream()) + ); + + renderWithConsoleProviders(); + + expect(await screen.findByText("No log entries yet.")).toBeInTheDocument(); + expect(screen.getByText("0 of 0 logs")).toBeInTheDocument(); + }); + + test("downloads only visible raw lines", async () => { + vi.spyOn(logs, "getRecentLogs").mockResolvedValue(logEntries()); + vi.spyOn(logs, "createLogStream").mockResolvedValue( + new Response(new ReadableStream()) + ); + const { getBlob } = installURLMethods(); + + renderWithConsoleProviders(); + + expect(await screen.findByText(/server started/)).toBeInTheDocument(); + + fireEvent.click(getMaterialFilterChip(document.body, "WARN")); + fireEvent.click(getMaterialButton(document.body, "Download")); + + await expect(readBlobText(getBlob())).resolves.toBe("time=2026-06-07T00:00:02Z level=WARN msg=slow-request"); + }); + + test("shows a non-blocking status when log streaming fails", async () => { + vi.spyOn(logs, "getRecentLogs").mockResolvedValue(logEntries()); + vi.spyOn(logs, "createLogStream").mockRejectedValue(new Error("stream unavailable")); + vi.spyOn(console, "error").mockImplementation(() => undefined); + + renderWithConsoleProviders(); + + expect(await screen.findByText(/server started/)).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByRole("status")).toHaveTextContent("Live stream disconnected"); + }); + }); + + test("appends stream events without rewriting raw text", async () => { + vi.spyOn(logs, "getRecentLogs").mockResolvedValue([]); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode( + 'data: {"timestamp":"2026-06-07T00:00:02Z","level":"INFO","message":"streamed","raw":"raw streamed line"}\n\n' + ) + ); + controller.close(); + } + }); + vi.spyOn(logs, "createLogStream").mockResolvedValue(new Response(stream)); + + renderWithConsoleProviders(); + + await waitFor(() => { + expect(screen.getByText("streamed")).toBeInTheDocument(); + }); + }); +}); + +function getMaterialFilterChip(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-filter-chip")).find( + (chip) => chip.textContent?.trim() === label + ); + if (!element) { + throw new Error(`Expected a Material Web filter chip labelled "${label}".`); + } + return element as HTMLElement & { selected: boolean }; +} + +function getMaterialButton(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-outlined-button")).find( + (button) => button.textContent?.includes(label) + ); + if (!element) { + throw new Error(`Expected a Material Web outlined button labelled "${label}".`); + } + return element as HTMLElement & { disabled: boolean }; +} + +function getMaterialTextField(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-outlined-text-field")).find( + (textField) => (textField as HTMLElement & { label?: string }).label === label + ); + if (!element) { + throw new Error(`Expected a Material Web outlined text field labelled "${label}".`); + } + return element as HTMLElement & { value: string }; +} + +function setMaterialTextFieldValue(element: HTMLElement & { value: string }, value: string) { + act(() => { + element.value = value; + element.dispatchEvent(new InputEvent("input", { bubbles: true, composed: true })); + }); +} + +function logEntries(): LogEntry[] { + return [ + { + timestamp: "2026-06-07T00:00:00Z", + level: "INFO", + message: "server started", + raw: "time=2026-06-07T00:00:00Z level=INFO msg=server-started" + }, + { + timestamp: "2026-06-07T00:00:01Z", + level: "ERROR", + message: "database unavailable", + raw: "time=2026-06-07T00:00:01Z level=ERROR msg=database-unavailable" + }, + { + timestamp: "2026-06-07T00:00:02Z", + level: "WARN", + message: "slow request", + raw: "time=2026-06-07T00:00:02Z level=WARN msg=slow-request" + } + ]; +} + +const clipboardDescriptor = Object.getOwnPropertyDescriptor(Navigator.prototype, "clipboard"); +const createObjectURLDescriptor = Object.getOwnPropertyDescriptor(URL, "createObjectURL"); +const revokeObjectURLDescriptor = Object.getOwnPropertyDescriptor(URL, "revokeObjectURL"); + +function installClipboard() { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText } + }); + return writeText; +} + +function restoreNavigatorClipboard() { + if (clipboardDescriptor) { + Object.defineProperty(Navigator.prototype, "clipboard", clipboardDescriptor); + } else { + Reflect.deleteProperty(navigator, "clipboard"); + } +} + +function installURLMethods() { + let blob: Blob | undefined; + const createObjectURL = vi.fn((nextBlob: Blob) => { + blob = nextBlob; + return "blob:moonbridge-logs"; + }); + const revokeObjectURL = vi.fn(); + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined); + Object.defineProperty(URL, "createObjectURL", { + configurable: true, + value: createObjectURL + }); + Object.defineProperty(URL, "revokeObjectURL", { + configurable: true, + value: revokeObjectURL + }); + return { + createObjectURL, + revokeObjectURL, + getBlob() { + if (!blob) { + throw new Error("download did not create a blob URL"); + } + return blob; + } + }; +} + +function restoreURLMethods() { + if (createObjectURLDescriptor) { + Object.defineProperty(URL, "createObjectURL", createObjectURLDescriptor); + } + if (revokeObjectURLDescriptor) { + Object.defineProperty(URL, "revokeObjectURL", revokeObjectURLDescriptor); + } +} + +function readBlobText(blob: Blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.addEventListener("load", () => resolve(String(reader.result ?? ""))); + reader.addEventListener("error", () => reject(reader.error ?? new Error("failed to read blob"))); + reader.readAsText(blob); + }); +} diff --git a/webui/src/features/logs/LogsPage.tsx b/webui/src/features/logs/LogsPage.tsx new file mode 100644 index 00000000..c6bc3391 --- /dev/null +++ b/webui/src/features/logs/LogsPage.tsx @@ -0,0 +1,15 @@ +import { PageHeader } from "../shared"; +import { useI18n } from "../../i18n/I18nProvider"; +import { LogPanel } from "./LogPanel"; + +export function LogsPage() { + const { t } = useI18n(); + return ( +
    + + {t("logs.description")} + + +
    + ); +} diff --git a/webui/src/features/modelProviders/ModelsProvidersPage.test.tsx b/webui/src/features/modelProviders/ModelsProvidersPage.test.tsx new file mode 100644 index 00000000..f7b0e13b --- /dev/null +++ b/webui/src/features/modelProviders/ModelsProvidersPage.test.tsx @@ -0,0 +1,1008 @@ +import { act, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { MemoryRouter } from "react-router-dom"; +import { AppShell } from "../../app/App"; +import { renderWithConsoleProviders } from "../../test/renderWithConsoleProviders"; +import { expectPanelElementToBeFlat, expectPanelRuleToAvoidEdges } from "../../test/panelStyleAssertions"; +import * as configGraph from "../../rpc/configGraph"; +import { configGraphFixture } from "../../test/configGraphFixtures"; +import { ModelsProvidersPage } from "./ModelsProvidersPage"; + +describe("ModelsProvidersPage", () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + test("lists providers as summary rows and keeps provider bindings inside the model editor", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + + renderWithConsoleProviders(); + + // Providers render as compact top-level summary rows, with no provider-offers disclosure on the row. + const providerPanel = await screen.findByLabelText("Provider anthropic"); + expect(providerPanel.querySelector(".provider-offers__toggle")).not.toBeInTheDocument(); + expect(within(providerPanel).queryByRole("heading", { name: /Provider Offers/ })).not.toBeInTheDocument(); + expect(within(providerPanel).queryByText("anthropic/claude-sonnet")).not.toBeInTheDocument(); + + // A model's provider bindings live behind the model editor dialog. + const dialog = await openModelEditor(); + const supplyPanel = within(dialog).getByRole("region", { name: "Providers (1)" }); + + expect(supplyPanel).toHaveClass("resource-field-group"); + expect(supplyPanel).toHaveClass("resource-field-group--advanced"); + expect(within(supplyPanel).getByRole("heading", { name: "Providers (1)" })).toBeInTheDocument(); + // Bindings are always expanded inside the dialog (no collapse toggle). + expect(within(supplyPanel).queryByLabelText("Toggle Providers")).not.toBeInTheDocument(); + expect(within(supplyPanel).getByText("anthropic/claude-sonnet")).toBeInTheDocument(); + expect(supplyPanel.querySelector(".resource-field-group__header")).toContainElement( + getMaterialButton(supplyPanel, "Add Provider", "filled") + ); + }); + + test("places Providers above Models and omits enabled toggles", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + vi.spyOn(configGraph, "patchConfigGraph").mockResolvedValue({ + result: "committed", + revision: "rev-2" + }); + + renderWithConsoleProviders(); + + const providers = await screen.findByRole("heading", { level: 2, name: "Providers (1)" }); + const models = screen.getByRole("heading", { name: "Models (1)" }); + + expect(providers.compareDocumentPosition(models) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(screen.queryByLabelText(/^enabled$/i)).not.toBeInTheDocument(); + }); + + test("renders resource sections without outer tonal panels and keeps page header title-only", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + + const { container } = renderWithConsoleProviders( + + } /> + + ); + + expect(await screen.findByRole("heading", { level: 1, name: "Models & Providers" })).toBeInTheDocument(); + const pageHeader = container.querySelector(".page-header"); + expect(pageHeader).toBeInTheDocument(); + expect(within(pageHeader as HTMLElement).queryByText("Upstream")).not.toBeInTheDocument(); + expect(within(pageHeader as HTMLElement).queryByText("Manage provider endpoints and model definitions in one realtime editor.")) + .not.toBeInTheDocument(); + + const providerSection = screen.getByRole("heading", { level: 2, name: "Providers (1)" }).closest("section"); + const modelSection = screen.getByRole("heading", { level: 2, name: "Models (1)" }).closest("section"); + expect(providerSection).toHaveClass("resource-section"); + expect(modelSection).toHaveClass("resource-section"); + expect(providerSection).not.toHaveClass("content-panel"); + expect(modelSection).not.toHaveClass("content-panel"); + expect(getComputedStyle(providerSection!).backgroundColor).toBe("rgba(0, 0, 0, 0)"); + expect(getComputedStyle(modelSection!).backgroundColor).toBe("rgba(0, 0, 0, 0)"); + expect(providerSection?.querySelector(".resource-editor-card")).toBeInTheDocument(); + }); + + test("renders provider advanced feature structured controls without JSON editors or summary toggles", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + vi.spyOn(configGraph, "patchConfigGraph").mockResolvedValue({ + result: "committed", + revision: "rev-2" + }); + + renderWithConsoleProviders(); + + await screen.findByLabelText("Provider anthropic"); + // The full provider field surface (including advanced features) lives in the editor dialog. + await openProviderEditor(); + const advancedFeatures = screen.getByRole("group", { name: "Advanced Features" }); + + expect(getMaterialSelect(advancedFeatures, "Provider web search mode")).toBeInTheDocument(); + expect(getMaterialTextField(advancedFeatures, "Provider web search max uses")).toHaveAttribute("spellcheck", "false"); + expect(getMaterialTextField(advancedFeatures, "Provider web search search max rounds")).toHaveAttribute("spellcheck", "false"); + expect(queryMaterialTextField(advancedFeatures, "Provider web search JSON")).not.toBeInTheDocument(); + expect(queryMaterialTextField(advancedFeatures, "Provider extensions JSON")).not.toBeInTheDocument(); + expect(queryMaterialOutlinedButton(advancedFeatures, /Provider web search.*1 key/)).not.toBeInTheDocument(); + expect(queryMaterialOutlinedButton(advancedFeatures, /Provider extensions.*0 keys/)).not.toBeInTheDocument(); + expect(queryMaterialTextField(advancedFeatures, "Provider web search JSON editor")).not.toBeInTheDocument(); + expect(queryMaterialTextField(advancedFeatures, "Provider extensions JSON editor")).not.toBeInTheDocument(); + }); + + test("localizes section headings and resource metadata in Chinese locale", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + vi.spyOn(configGraph, "patchConfigGraph").mockResolvedValue({ + result: "committed", + revision: "rev-2" + }); + + renderWithConsoleProviders(, { locale: "zh-CN" }); + + expect(await screen.findByRole("heading", { level: 2, name: "提供商 (1)" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "模型 (1)" })).toBeInTheDocument(); + + await openProviderEditor("编辑提供商 anthropic"); + // Provider status ("已保存") is shown inside the editor dialog, not in the summary row. + expect(screen.getByText("已保存")).toBeInTheDocument(); + expect(getMaterialTextField(document, "上游 Base URL")).toBeInTheDocument(); + expect(screen.queryByLabelText("Base URL")).not.toBeInTheDocument(); + }); + + test("localizes create model help and validation in Chinese locale", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + const create = vi.spyOn(configGraph, "createConfigResource").mockResolvedValue({ + result: "committed", + revision: "rev-2", + graph: configGraphFixture({ revision: "rev-2" }) + }); + + renderWithConsoleProviders(, { locale: "zh-CN" }); + + await waitForMaterialButton(document, "添加模型"); + await userEvent.click(getMaterialButton(document, "添加模型", "filled")); + const form = screen.getByRole("form", { name: "创建模型" }); + await userEvent.click(getMaterialIconButton(form, "显示名称 帮助")); + + expect(within(form).getByRole("tooltip")).toHaveTextContent("控制台中显示的名称。"); + + setMaterialTextFieldValue(getMaterialTextField(form, "上下文窗口"), "0"); + setMaterialTextFieldValue(getMaterialTextField(form, "模型 ID"), "zero-window"); + await submitMaterialForm(form, "创建模型"); + + expect(await within(form).findByRole("alert")).toHaveTextContent("上下文窗口 必须大于 0。"); + expect(create).not.toHaveBeenCalled(); + }); + + test("localizes create provider protocol and context presets in Chinese locale", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + + renderWithConsoleProviders(, { locale: "zh-CN" }); + + await waitForMaterialButton(document, "添加提供商"); + await userEvent.click(getMaterialButton(document, "添加提供商", "filled")); + const providerForm = screen.getByRole("form", { name: "创建提供商" }); + const protocolSelect = getMaterialSelect(providerForm, "协议"); + expect(protocolSelect.value).toBe("openai-response"); + expect(protocolSelect.querySelector("[slot='leading-icon'] svg")).toBeInTheDocument(); + expect(getMaterialSelectOptions(protocolSelect).map((option) => option.displayText)).toEqual([ + "OpenAI Responses", + "OpenAI Chat", + "Anthropic", + "Gemini" + ]); + for (const option of getMaterialSelectOptions(protocolSelect)) { + expect(option.querySelector("[slot='start'] svg")).toBeInTheDocument(); + } + + await userEvent.click(getMaterialButton(providerForm, "取消", "outlined")); + await waitForMaterialButton(document, "添加模型"); + await userEvent.click(getMaterialButton(document, "添加模型", "filled")); + const modelForm = screen.getByRole("form", { name: "创建模型" }); + expect(getMaterialFilterChip(modelForm, "128K")).toBeInTheDocument(); + expect(getMaterialFilterChip(modelForm, "400K")).toBeInTheDocument(); + expect(getMaterialFilterChip(modelForm, "100 万")).toBeInTheDocument(); + }); + + test("autosaves provider fields and provider binding priority through graph patches", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + const patch = vi.spyOn(configGraph, "patchConfigGraph").mockResolvedValue({ + result: "committed", + revision: "rev-2" + }); + + renderWithConsoleProviders(); + + await screen.findByRole("heading", { level: 3, name: "anthropic" }); + await openProviderEditor(); + vi.useFakeTimers(); + const baseUrlField = getMaterialTextField(document, "Upstream base URL"); + setMaterialTextFieldValue(baseUrlField, "https://api.anthropic.test"); + fireEvent.blur(baseUrlField); + + await advanceAutosave(); + + expect(patch).toHaveBeenCalledWith({ + baseRevision: "rev-1", + changes: [ + { + kind: "provider", + id: "anthropic", + field: "base_url", + value: "https://api.anthropic.test" + } + ] + }); + + vi.useRealTimers(); + await closeResourceEditor(); + const dialog = await openModelEditor(); + const offerPanel = within(dialog) + .getByText("anthropic/claude-sonnet") + .closest("section")!; + vi.useFakeTimers(); + const priorityField = getMaterialTextField(offerPanel, "Provider priority"); + setMaterialTextFieldValue(priorityField, "5"); + fireEvent.blur(priorityField); + + await advanceAutosave(); + + expect(patch).toHaveBeenLastCalledWith({ + baseRevision: "rev-1", + changes: [ + { + kind: "provider_offer", + id: "anthropic/claude-sonnet", + field: "priority", + value: 5 + } + ] + }); + }); + + test("creates a provider with default OpenAI Responses protocol", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + const create = vi.spyOn(configGraph, "createConfigResource").mockResolvedValue({ + result: "committed", + revision: "rev-2", + graph: configGraphFixture({ revision: "rev-2" }) + }); + + renderWithConsoleProviders(); + + await waitForMaterialButton(document, "Add Provider"); + await userEvent.click(getMaterialButton(document, "Add Provider", "filled")); + const form = screen.getByRole("form", { name: "Create Provider" }); + const providerIdField = getMaterialTextField(form, "Provider ID"); + const baseUrlField = getMaterialTextField(form, "Base URL"); + const apiKeyField = getMaterialTextField(form, "API key"); + expect(apiKeyField.type).toBe("password"); + expect(getMaterialButton(form, "Create Provider", "filled")).toHaveProperty("type", "submit"); + expect(form.querySelectorAll("input")).toHaveLength(0); + + setMaterialTextFieldValue(providerIdField, "openai"); + setMaterialTextFieldValue(baseUrlField, "https://api.openai.com/v1"); + setMaterialTextFieldValue(apiKeyField, "sk-test"); + await submitMaterialForm(form, "Create Provider"); + + await waitFor(() => expect(create).toHaveBeenCalledWith("provider", { + baseRevision: "rev-1", + id: "openai", + value: { + base_url: "https://api.openai.com/v1", + api_key: "sk-test", + protocol: "openai-response" + } + })); + }); + + test("keeps add resource button icon colors aligned with secondary-container labels", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + + const { container } = renderWithConsoleProviders( + + } /> + + ); + + await waitFor(() => expect(getMaterialButton(container, "Add Provider", "filled")).toBeInTheDocument()); + const addProviderButton = getMaterialButton(container, "Add Provider", "filled"); + expectMaterialFilledButtonContentColors(addProviderButton, "var(--mb-color-on-secondary-container)"); + }); + + test("lets users choose provider protocol and read create field help", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + const create = vi.spyOn(configGraph, "createConfigResource").mockResolvedValue({ + result: "committed", + revision: "rev-2", + graph: configGraphFixture({ revision: "rev-2" }) + }); + + renderWithConsoleProviders(); + + await waitForMaterialButton(document, "Add Provider"); + await userEvent.click(getMaterialButton(document, "Add Provider", "filled")); + const form = screen.getByRole("form", { name: "Create Provider" }); + setMaterialTextFieldValue(getMaterialTextField(form, "Provider ID"), "gemini"); + setMaterialTextFieldValue(getMaterialTextField(form, "Base URL"), "https://generativelanguage.googleapis.com"); + setMaterialTextFieldValue(getMaterialTextField(form, "API key"), "gemini-key"); + const protocolSelect = getMaterialSelect(form, "Protocol"); + expect(protocolSelect.querySelector("[slot='trailing-icon']")).not.toBeInTheDocument(); + const protocolHelp = getMaterialIconButton(form, "Help for Protocol"); + expect(protocolHelp).toHaveClass("mb-field__select-help"); + expect(protocolHelp.closest(".mb-field__select-actions")).toBeInTheDocument(); + expect(getComputedStyle(protocolHelp).position).not.toBe("absolute"); + expect(protocolSelect).not.toContainElement(protocolHelp); + await userEvent.click(protocolHelp); + expect(within(form).getByRole("tooltip")).toHaveTextContent( + "Selects the upstream API format: Anthropic Messages, OpenAI Responses, Google GenAI, or OpenAI Chat." + ); + expect(protocolSelect.open).toBe(false); + expect(protocolSelect.querySelector("[slot='leading-icon'] title")).toHaveTextContent("OpenAI"); + setMaterialSelectValue(protocolSelect, "google-genai"); + await waitFor(() => expect(protocolSelect.querySelector("[slot='leading-icon'] title")).toHaveTextContent("Gemini")); + expect(getMaterialSelectOptions(protocolSelect).find((option) => option.value === "google-genai") + ?.querySelector("[slot='start'] title")).toHaveTextContent("Gemini"); + await submitMaterialForm(form, "Create Provider"); + + await waitFor(() => expect(create).toHaveBeenCalledWith("provider", { + baseRevision: "rev-1", + id: "gemini", + value: { + base_url: "https://generativelanguage.googleapis.com", + api_key: "gemini-key", + protocol: "google-genai" + } + })); + }); + + test("uses wider create panel field tracks than dense resource editors", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + vi.spyOn(configGraph, "createConfigResource").mockResolvedValue({ + result: "committed", + revision: "rev-2", + graph: configGraphFixture({ revision: "rev-2" }) + }); + + renderWithConsoleProviders(); + + await waitForMaterialButton(document, "Add Provider"); + await userEvent.click(getMaterialButton(document, "Add Provider", "filled")); + const form = screen.getByRole("form", { name: "Create Provider" }); + + expect(getMaterialTextField(form, "Provider ID").closest(".form-field--create-track")).toBeInTheDocument(); + expect(getMaterialTextField(form, "Base URL").closest(".form-field--create-track")).toBeInTheDocument(); + expect(getMaterialSelect(form, "Protocol").closest(".form-field--create-track")).toBeInTheDocument(); + }); + + test("keeps create background panels tonal without borders or glow", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + + renderWithConsoleProviders( + + } /> + + ); + + await waitForMaterialButton(document, "Add Provider"); + await userEvent.click(getMaterialButton(document, "Add Provider", "filled")); + const form = screen.getByRole("form", { name: "Create Provider" }); + + expectPanelElementToBeFlat(form); + expectPanelRuleToAvoidEdges(".create-resource__panel"); + }); + + test("renders create text fields with official Material labels and trailing help slots", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + + renderWithConsoleProviders(); + + await waitForMaterialButton(document, "Add Provider"); + await userEvent.click(getMaterialButton(document, "Add Provider", "filled")); + const providerForm = screen.getByRole("form", { name: "Create Provider" }); + const providerIdField = getMaterialTextField(providerForm, "Provider ID"); + expect(providerIdField.label).toBe("Provider ID"); + expect(providerIdField).not.toHaveAttribute("aria-labelledby"); + expect(providerIdField).toHaveAttribute("spellcheck", "false"); + expect(providerIdField.closest(".form-field--create-track")?.querySelector(".schema-field__label")).not.toBeInTheDocument(); + expect(getMaterialTrailingIconButton(providerIdField, "Help for Provider ID")).toBeInTheDocument(); + + await userEvent.click(getMaterialButton(providerForm, "Cancel", "outlined")); + await waitForMaterialButton(document, "Add Model"); + await userEvent.click(getMaterialButton(document, "Add Model", "filled")); + const modelForm = screen.getByRole("form", { name: "Create Model" }); + const displayNameField = getMaterialTextField(modelForm, "Display name"); + setMaterialTextFieldValue(displayNameField, "GPT-4o"); + expectLobeLeadingIcon(displayNameField); + const contextWindowField = getMaterialTextField(modelForm, "Context window"); + const contextWindowRow = contextWindowField.closest(".create-resource__context-window-row"); + expect(contextWindowField.label).toBe("Context window"); + expect(contextWindowField).toHaveAttribute("spellcheck", "false"); + expect(contextWindowField.closest(".form-field--create-track")?.querySelector(".schema-field__label")).not.toBeInTheDocument(); + expect(getMaterialTrailingIconButton(contextWindowField, "Help for Context window")).toBeInTheDocument(); + expect(contextWindowRow).toHaveClass("form-field--create-track"); + expect(contextWindowRow).toHaveClass("form-grid__wide"); + expect(Array.from(contextWindowRow!.children).map((child) => child.className)).toEqual([ + "mb-field__control", + "material-chip-group create-resource__context-window-presets" + ]); + expect(contextWindowRow!.children[0]).toContainElement(contextWindowField); + expect(contextWindowRow!.children[1]).toContainElement(getMaterialFilterChip(modelForm, "128k")); + }); + + test("keeps create subpanel controls aligned with resource editor field styling", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + + renderWithConsoleProviders(); + + await waitForMaterialButton(document, "Add Provider"); + await userEvent.click(getMaterialButton(document, "Add Provider", "filled")); + const providerForm = screen.getByRole("form", { name: "Create Provider" }); + const baseUrlField = getMaterialTextField(providerForm, "Base URL"); + const apiKeyField = getMaterialTextField(providerForm, "API key"); + const protocolSelect = getMaterialSelect(providerForm, "Protocol"); + + expect(getMaterialLeadingIcon(baseUrlField, "link")).toBeInTheDocument(); + expect(getMaterialLeadingIcon(apiKeyField, "key")).toBeInTheDocument(); + expect(protocolSelect.closest(".form-field--create-track")).toBeInTheDocument(); + expect(protocolSelect.querySelector("[slot='leading-icon'] svg")).toBeInTheDocument(); + + await userEvent.click(getMaterialButton(providerForm, "Cancel", "outlined")); + const supplyPanel = await openModelProviderBindings(); + await userEvent.click(getMaterialButton(supplyPanel, "Add Provider", "filled")); + const offerForm = within(supplyPanel).getByRole("form", { name: "Create Provider" }); + expect(offerForm.querySelector(".material-static-chip")).not.toBeInTheDocument(); + expect(getMaterialAssistChip(offerForm, "claude-sonnet").closest(".schema-field")).toBeInTheDocument(); + expect(getMaterialSelect(offerForm, "Provider").value).toBe("anthropic"); + }); + + test("creates a model with a 128k default context window", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + const create = vi.spyOn(configGraph, "createConfigResource").mockResolvedValue({ + result: "committed", + revision: "rev-2", + graph: configGraphFixture({ revision: "rev-2" }) + }); + + renderWithConsoleProviders(); + + await waitForMaterialButton(document, "Add Model"); + await userEvent.click(getMaterialButton(document, "Add Model", "filled")); + const form = screen.getByRole("form", { name: "Create Model" }); + setMaterialTextFieldValue(getMaterialTextField(form, "Model ID"), "gpt-4o"); + setMaterialTextFieldValue(getMaterialTextField(form, "Display name"), "GPT-4o"); + await submitMaterialForm(form, "Create Model"); + + await waitFor(() => expect(create).toHaveBeenCalledWith("model", { + baseRevision: "rev-1", + id: "gpt-4o", + value: { + display_name: "GPT-4o", + context_window: 128000 + } + })); + }); + + test("lets users edit model context window through presets or custom input", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + const create = vi.spyOn(configGraph, "createConfigResource").mockResolvedValue({ + result: "committed", + revision: "rev-2", + graph: configGraphFixture({ revision: "rev-2" }) + }); + + renderWithConsoleProviders(); + + await waitForMaterialButton(document, "Add Model"); + await userEvent.click(getMaterialButton(document, "Add Model", "filled")); + const form = screen.getByRole("form", { name: "Create Model" }); + await userEvent.click(getMaterialIconButton(form, "Help for Context window")); + expect(within(form).getByRole("tooltip")).toHaveTextContent("Maximum tokens the model can handle"); + const presetChip = getMaterialFilterChip(form, "400k"); + await userEvent.click(presetChip); + expect(getMaterialTextField(form, "Context window").value).toBe("400000"); + + setMaterialTextFieldValue(getMaterialTextField(form, "Context window"), "640000"); + setMaterialTextFieldValue(getMaterialTextField(form, "Model ID"), "gpt-large"); + setMaterialTextFieldValue(getMaterialTextField(form, "Display name"), "GPT Large"); + await submitMaterialForm(form, "Create Model"); + + await waitFor(() => expect(create).toHaveBeenCalledWith("model", { + baseRevision: "rev-1", + id: "gpt-large", + value: { + display_name: "GPT Large", + context_window: 640000 + } + })); + }); + + test("rejects non-positive custom model context windows", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + const create = vi.spyOn(configGraph, "createConfigResource").mockResolvedValue({ + result: "committed", + revision: "rev-2", + graph: configGraphFixture({ revision: "rev-2" }) + }); + + renderWithConsoleProviders(); + + await waitForMaterialButton(document, "Add Model"); + await userEvent.click(getMaterialButton(document, "Add Model", "filled")); + const form = screen.getByRole("form", { name: "Create Model" }); + setMaterialTextFieldValue(getMaterialTextField(form, "Context window"), "0"); + setMaterialTextFieldValue(getMaterialTextField(form, "Model ID"), "zero-window"); + await submitMaterialForm(form, "Create Model"); + + expect(await within(form).findByRole("alert")).toHaveTextContent( + "Context window must be greater than zero." + ); + expect(create).not.toHaveBeenCalled(); + expect(getMaterialTextField(form, "Context window").value).toBe("0"); + }); + + test("creates provider binding from the selected model without billing by default", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + const create = vi.spyOn(configGraph, "createConfigResource").mockResolvedValue({ + result: "committed", + revision: "rev-2", + graph: configGraphFixture({ revision: "rev-2" }) + }); + + renderWithConsoleProviders(); + + const supplyPanel = await openModelProviderBindings(); + await userEvent.click(getMaterialButton(supplyPanel, "Add Provider", "filled")); + const form = within(supplyPanel).getByRole("form", { name: "Create Provider" }); + expect(getMaterialAssistChip(form, "claude-sonnet")).toBeInTheDocument(); + expect(getMaterialSelect(form, "Provider").value).toBe("anthropic"); + expect(queryMaterialTextField(form, "Input price")).not.toBeInTheDocument(); + expect(getMaterialSwitch(form, "Billing").selected).toBe(false); + setMaterialTextFieldValue(getMaterialTextField(form, "Upstream name"), "claude-3-5-sonnet-latest"); + await submitMaterialForm(form, "Create Provider"); + + await waitFor(() => expect(create).toHaveBeenCalledWith("provider_offer", { + baseRevision: "rev-1", + id: "anthropic/claude-sonnet", + value: { + model: "claude-sonnet", + upstream_name: "claude-3-5-sonnet-latest", + priority: 1 + } + })); + }); + + test("creates provider billing only when the optional billing switch is enabled", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + const create = vi.spyOn(configGraph, "createConfigResource").mockResolvedValue({ + result: "committed", + revision: "rev-2", + graph: configGraphFixture({ revision: "rev-2" }) + }); + + renderWithConsoleProviders(); + + const supplyPanel = await openModelProviderBindings(); + await userEvent.click(getMaterialButton(supplyPanel, "Add Provider", "filled")); + const form = within(supplyPanel).getByRole("form", { name: "Create Provider" }); + setMaterialSwitchSelected(getMaterialSwitch(form, "Billing"), true); + expect(getMaterialTextField(form, "Input price")).toHaveAttribute("spellcheck", "false"); + expect(getMaterialTextField(form, "Output price")).toHaveAttribute("spellcheck", "false"); + expect(getMaterialTextField(form, "Cache write price")).toHaveAttribute("spellcheck", "false"); + expect(getMaterialTextField(form, "Cache read price")).toHaveAttribute("spellcheck", "false"); + setMaterialTextFieldValue(getMaterialTextField(form, "Upstream name"), "claude-3-5-sonnet-latest"); + setMaterialTextFieldValue(getMaterialTextField(form, "Input price"), "3"); + setMaterialTextFieldValue(getMaterialTextField(form, "Output price"), "15"); + setMaterialTextFieldValue(getMaterialTextField(form, "Cache write price"), "3.75"); + setMaterialTextFieldValue(getMaterialTextField(form, "Cache read price"), "0.3"); + await submitMaterialForm(form, "Create Provider"); + + await waitFor(() => expect(create).toHaveBeenCalledWith("provider_offer", { + baseRevision: "rev-1", + id: "anthropic/claude-sonnet", + value: { + model: "claude-sonnet", + upstream_name: "claude-3-5-sonnet-latest", + priority: 1, + pricing: { + input_price: 3, + output_price: 15, + cache_write_price: 3.75, + cache_read_price: 0.3 + } + } + })); + }); + + test("rejects invalid provider numbers without submitting the create request", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + const create = vi.spyOn(configGraph, "createConfigResource").mockResolvedValue({ + result: "committed", + revision: "rev-2", + graph: configGraphFixture({ revision: "rev-2" }) + }); + + renderWithConsoleProviders(); + + const supplyPanel = await openModelProviderBindings(); + await userEvent.click(getMaterialButton(supplyPanel, "Add Provider", "filled")); + const form = within(supplyPanel).getByRole("form", { name: "Create Provider" }); + setMaterialTextFieldValue(getMaterialTextField(form, "Priority"), "fast"); + setMaterialTextFieldValue(getMaterialTextField(form, "Upstream name"), "claude-3-5-sonnet-latest"); + await submitMaterialForm(form, "Create Provider"); + + expect(screen.getByRole("alert")).toHaveTextContent("Priority must be a valid number."); + expect(getMaterialTextField(form, "Priority").value).toBe("fast"); + expect(create).not.toHaveBeenCalled(); + }); + + test("keeps create dialog input values when backend rejects duplicate ids", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + vi.spyOn(configGraph, "createConfigResource").mockRejectedValue( + Object.assign(new Error("Request failed"), { + raw: { + errors: [ + { + message: 'provider "anthropic" already exists' + } + ] + } + }) + ); + + renderWithConsoleProviders(); + + await waitForMaterialButton(document, "Add Provider"); + await userEvent.click(getMaterialButton(document, "Add Provider", "filled")); + const form = screen.getByRole("form", { name: "Create Provider" }); + setMaterialTextFieldValue(getMaterialTextField(form, "Provider ID"), "anthropic"); + setMaterialTextFieldValue(getMaterialTextField(form, "Base URL"), "https://api.anthropic.com"); + setMaterialTextFieldValue(getMaterialTextField(form, "API key"), "sk-ant"); + await submitMaterialForm(form, "Create Provider"); + + expect(await screen.findByRole("alert")).toHaveTextContent('provider "anthropic" already exists'); + expect(getMaterialTextField(form, "Provider ID").value).toBe("anthropic"); + expect(getMaterialTextField(form, "Base URL").value).toBe("https://api.anthropic.com"); + }); + + test("deletes provider resources only after inline confirmation", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + const graphAfterDelete = configGraphFixture({ + revision: "rev-2", + resources: configGraphFixture().resources.filter((resource) => resource.id !== "anthropic") + }); + const remove = vi.spyOn(configGraph, "deleteConfigResource").mockResolvedValue({ + result: "committed", + revision: "rev-2", + graph: graphAfterDelete + }); + + renderWithConsoleProviders(); + + const providerPanel = await screen.findByLabelText("Provider anthropic"); + await userEvent.click(getMaterialButton(providerPanel, "Delete Provider anthropic", "filled")); + + expect(remove).not.toHaveBeenCalled(); + await userEvent.click(getMaterialButton(providerPanel, "Confirm delete anthropic", "filled")); + + expect(remove).toHaveBeenCalledWith("provider", "anthropic", "rev-1"); + expect(screen.queryByLabelText("Provider anthropic")).not.toBeInTheDocument(); + }); + + test("deletes provider bindings and keeps slash identifiers intact", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + const graphAfterDelete = configGraphFixture({ + revision: "rev-2", + resources: configGraphFixture().resources.filter((resource) => resource.id !== "anthropic/claude-sonnet") + }); + const remove = vi.spyOn(configGraph, "deleteConfigResource").mockResolvedValue({ + result: "committed", + revision: "rev-2", + graph: graphAfterDelete + }); + + renderWithConsoleProviders(); + + const dialog = await openModelEditor(); + const offerPanel = within(dialog) + .getByText("anthropic/claude-sonnet") + .closest("section")!; + await userEvent.click(getMaterialButton(offerPanel, "Delete Provider anthropic/claude-sonnet", "filled")); + await userEvent.click(getMaterialButton(offerPanel, "Confirm delete anthropic/claude-sonnet", "filled")); + + expect(remove).toHaveBeenCalledWith("provider_offer", "anthropic/claude-sonnet", "rev-1"); + expect(screen.queryByText("anthropic/claude-sonnet")).not.toBeInTheDocument(); + }); + + test("surfaces delete errors without removing the model card", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + vi.spyOn(configGraph, "deleteConfigResource").mockRejectedValue( + Object.assign(new Error("Request failed"), { + raw: { + errors: [ + { + message: 'model "claude-sonnet" is still referenced' + } + ] + } + }) + ); + + renderWithConsoleProviders(); + + const modelPanel = (await screen.findByRole("heading", { level: 3, name: "claude-sonnet" })) + .closest("section")!; + await userEvent.click(getMaterialButton(modelPanel, "Delete Model claude-sonnet", "filled")); + await userEvent.click(getMaterialButton(modelPanel, "Confirm delete claude-sonnet", "filled")); + + expect(await within(modelPanel).findByRole("alert")).toHaveTextContent( + 'model "claude-sonnet" is still referenced' + ); + expect(within(modelPanel).getByRole("heading", { level: 3, name: "claude-sonnet" })).toBeInTheDocument(); + }); +}); + +async function advanceAutosave() { + await act(async () => { + await vi.advanceTimersByTimeAsync(450); + await Promise.resolve(); + }); +} + +function getOutlinedButton(container: ParentNode, label: string): HTMLElement { + const element = Array.from(container.querySelectorAll("md-outlined-button")).find( + (candidate) => (candidate.getAttribute("aria-label") ?? candidate.textContent ?? "").includes(label) + ); + if (!element) { + throw new Error(`Expected a Material Web outlined button labelled "${label}".`); + } + return element as HTMLElement; +} + +function resourceDialog(): HTMLElement { + const dialog = document.querySelector("md-dialog.resource-editor-dialog"); + if (!dialog) { + throw new Error("Expected the resource editor dialog to be open."); + } + return dialog as HTMLElement; +} + +async function openProviderEditor(label = "Edit Provider anthropic") { + const button = await waitFor(() => getOutlinedButton(document, label)); + await userEvent.click(button); + await waitFor(() => expect(resourceDialog()).toBeInTheDocument()); + return resourceDialog(); +} + +async function openModelEditor(label = "Edit Model claude-sonnet") { + const button = await waitFor(() => getOutlinedButton(document, label)); + await userEvent.click(button); + await waitFor(() => expect(resourceDialog()).toBeInTheDocument()); + return resourceDialog(); +} + +async function openModelProviderBindings() { + // Provider bindings are always expanded inside the model editor dialog now. + const dialog = await openModelEditor(); + return within(dialog).getByRole("region", { name: "Providers (1)" }); +} + +async function closeResourceEditor() { + const closeButton = resourceDialog().querySelector('md-icon-button[aria-label="Close"]'); + if (closeButton) { + await userEvent.click(closeButton as HTMLElement); + } + await waitFor(() => expect(document.querySelector("md-dialog.resource-editor-dialog")).not.toBeInTheDocument()); +} + + +type MaterialTextFieldElement = HTMLElement & { + label: string; + type: string; + value: string; +}; + +type MaterialSelectElement = HTMLElement & { + label: string; + open: boolean; + value: string; +}; + +type MaterialSelectOptionElement = HTMLElement & { + displayText: string; + value: string; +}; + +function getMaterialTextField(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-outlined-text-field")).find( + (candidate) => materialElementLabel(candidate) === label + ); + if (!element) { + throw new Error(`Expected a Material Web outlined text field labelled "${label}".`); + } + return element; +} + +function queryMaterialTextField(container: ParentNode, label: string) { + return Array.from(container.querySelectorAll("md-outlined-text-field")).find( + (candidate) => materialElementLabel(candidate) === label + ) ?? null; +} + +function getMaterialSelect(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-outlined-select")).find( + (candidate) => materialElementLabel(candidate) === label + ); + if (!element) { + throw new Error(`Expected a Material Web select labelled "${label}".`); + } + return element; +} + +function getMaterialSelectOptions(select: ParentNode) { + const options = Array.from(select.querySelectorAll("md-select-option")); + if (options.length === 0) { + throw new Error("Expected Material Web select options to be rendered."); + } + return options; +} + +function getMaterialSwitch(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-switch")).find( + (candidate) => candidate.getAttribute("aria-label") === label + ); + if (!element) { + throw new Error(`Expected a Material Web switch labelled "${label}".`); + } + return element as HTMLElement & { selected: boolean }; +} + +function materialElementLabel(element: HTMLElement & { label?: string }) { + const labelledBy = element.getAttribute("aria-labelledby"); + if (labelledBy) { + return labelledBy + .split(/\s+/) + .map((id) => document.getElementById(id)?.textContent?.trim() ?? "") + .filter(Boolean) + .join(" "); + } + return element.label || element.getAttribute("aria-label") || element.getAttribute("label") || ""; +} + +function getMaterialButton(container: ParentNode, label: string, variant: "filled" | "outlined" = "outlined") { + const tagName = variant === "filled" ? "md-filled-button" : "md-outlined-button"; + const element = Array.from(container.querySelectorAll(tagName)).find( + (candidate) => { + const accessibleLabel = candidate.getAttribute("aria-label") ?? candidate.textContent ?? ""; + return accessibleLabel.includes(label); + } + ); + if (!element) { + throw new Error(`Expected a Material Web ${variant} button labelled "${label}".`); + } + return element as HTMLElement & { type: string }; +} + +function queryMaterialOutlinedButton(container: ParentNode, label: RegExp) { + return Array.from(container.querySelectorAll("md-outlined-button")).find( + (candidate) => label.test(candidate.getAttribute("aria-label") ?? candidate.textContent ?? "") + ) ?? null; +} + +function expectMaterialFilledButtonContentColors(button: HTMLElement, colorToken: string) { + expect(button.tagName.toLowerCase()).toBe("md-filled-button"); + for (const property of [ + "--md-filled-button-label-text-color", + "--md-filled-button-hover-label-text-color", + "--md-filled-button-focus-label-text-color", + "--md-filled-button-pressed-label-text-color", + "--md-filled-button-icon-color", + "--md-filled-button-hover-icon-color", + "--md-filled-button-focus-icon-color", + "--md-filled-button-pressed-icon-color" + ]) { + expect(getComputedStyle(button).getPropertyValue(property).trim()).toBe(colorToken); + } +} + +async function waitForMaterialButton(container: ParentNode, label: string, variant: "filled" | "outlined" = "filled") { + await waitFor(() => expect(getMaterialButton(container, label, variant)).toBeInTheDocument()); +} + +function getMaterialIconButton(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-icon-button")).find( + (candidate) => candidate.getAttribute("aria-label") === label + ); + if (!element) { + throw new Error(`Expected a Material Web icon button labelled "${label}".`); + } + return element as HTMLElement; +} + +function getMaterialTrailingIconButton(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-icon-button")).find( + (candidate) => candidate.getAttribute("slot") === "trailing-icon" && candidate.getAttribute("aria-label") === label + ); + if (!element) { + throw new Error(`Expected a Material Web trailing icon button labelled "${label}".`); + } + return element as HTMLElement; +} + +function getMaterialLeadingIcon(container: ParentNode, icon: string) { + const element = Array.from(container.querySelectorAll("md-icon")).find( + (candidate) => candidate.getAttribute("slot") === "leading-icon" && candidate.textContent?.trim() === icon + ); + if (!element) { + throw new Error(`Expected a Material Web leading icon "${icon}".`); + } + return element as HTMLElement; +} + +function expectLobeLeadingIcon(fieldElement: HTMLElement) { + const leadingIcon = fieldElement.querySelector("[slot='leading-icon']"); + expect(leadingIcon).toBeInTheDocument(); + expect(leadingIcon?.querySelector("svg")).toBeInTheDocument(); +} + +function getMaterialFilterChip(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-filter-chip")).find( + (candidate) => candidate.textContent?.trim() === label + ); + if (!element) { + throw new Error(`Expected a Material Web filter chip labelled "${label}".`); + } + return element as HTMLElement & { selected: boolean }; +} + +function getMaterialAssistChip(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-assist-chip")).find( + (candidate) => candidate.textContent?.trim() === label + ); + if (!element) { + throw new Error(`Expected a Material Web assist chip labelled "${label}".`); + } + return element as HTMLElement; +} + +function getMaterialChipSet(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-chip-set")).find( + (candidate) => candidate.getAttribute("aria-label") === label + ); + if (!element) { + throw new Error(`Expected a Material Web chip set labelled "${label}".`); + } + return element as HTMLElement; +} + +function setMaterialTextFieldValue(element: MaterialTextFieldElement, value: string) { + act(() => { + element.value = value; + element.dispatchEvent(new InputEvent("input", { bubbles: true, composed: true })); + }); +} + +function setMaterialSelectValue(element: MaterialSelectElement, value: string) { + act(() => { + let selectedValue = value; + Object.defineProperty(element, "value", { + configurable: true, + get: () => selectedValue, + set: (next: string) => { + selectedValue = next; + } + }); + element.dispatchEvent(new Event("change", { bubbles: true, composed: true })); + }); +} + +function setMaterialSwitchSelected(element: HTMLElement & { selected: boolean }, selected: boolean) { + act(() => { + element.selected = selected; + element.dispatchEvent(new Event("change", { bubbles: true, composed: true })); + }); +} + +async function submitMaterialForm(container: ParentNode, submitLabel: string) { + const button = getMaterialButton(container, submitLabel, "filled"); + const form = button.closest("form"); + if (!form) { + throw new Error("Expected Material Web submit button inside a form."); + } + let clicked = false; + let submitted = false; + button.addEventListener("click", () => { + clicked = true; + }, { once: true }); + form.addEventListener("submit", () => { + submitted = true; + }, { once: true }); + await userEvent.click(button); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(clicked).toBe(true); + if (!submitted) { + await act(async () => { + form.requestSubmit(); + await Promise.resolve(); + }); + } +} diff --git a/webui/src/features/modelProviders/ModelsProvidersPage.tsx b/webui/src/features/modelProviders/ModelsProvidersPage.tsx new file mode 100644 index 00000000..15ad114e --- /dev/null +++ b/webui/src/features/modelProviders/ModelsProvidersPage.tsx @@ -0,0 +1,208 @@ +import { useMemo, useState } from "react"; +import { LoadingState } from "../../components/LoadingState"; +import { useI18n } from "../../i18n/I18nProvider"; +import type { ConfigGraph, ConfigResource, ResourceKind } from "../../rpc/types"; +import { CreateResourcePanel } from "../configGraph/CreateResourcePanel"; +import { ResourceEditorDialog } from "../configGraph/ResourceEditorDialog"; +import { ResourceEditorCard } from "../configGraph/ResourceEditorCard"; +import { useConfigGraph } from "../configGraph/useConfigGraph"; +import { modelDisplayNamesById } from "../configGraph/modelProviderIcons"; +import { PageHeader, QueryErrorState } from "../shared"; + +export function ModelsProvidersPage() { + const { t } = useI18n(); + const graph = useConfigGraph(); + const [editing, setEditing] = useState<{ kind: ResourceKind; id: string } | null>(null); + + const modelDisplayNames = useMemo( + () => (graph.data ? modelDisplayNamesById(graph.data.resources) : {}), + [graph.data] + ); + + if (graph.error) { + return ; + } + if (graph.isLoading || !graph.data) { + return ; + } + + const resources = graph.data.resources; + const providers = resourcesByKind(resources, "provider"); + const offers = resourcesByKind(resources, "provider_offer"); + const models = resourcesByKind(resources, "model"); + const offersByModel = groupOffersByModel(offers); + const unmatchedOffers = offers.filter((offer) => !models.some((model) => model.id === modelIdForOffer(offer))); + + const editingResource = editing + ? resources.find((resource) => resource.kind === editing.kind && resource.id === editing.id) + : undefined; + + return ( +
    + + {t("modelsProviders.description")} + + +
    +
    +

    {t("modelsProviders.providers", { count: providers.length })}

    + +
    +
    + {providers.map((provider) => ( + setEditing({ kind: "provider", id: provider.id })} + resource={provider} + revision={graph.data.revision} + title={t("resource.kind.provider")} + variant="summary" + /> + ))} +
    +
    + + {unmatchedOffers.length > 0 ? ( +
    +

    {t("modelsProviders.unmatchedSupplies", { count: unmatchedOffers.length })}

    +
    + {unmatchedOffers.map((offer) => ( + setEditing({ kind: "provider_offer", id: offer.id })} + resource={offer} + revision={graph.data.revision} + title={t("resource.kind.offer")} + variant="summary" + /> + ))} +
    +
    + ) : null} + +
    +
    +

    {t("modelsProviders.models", { count: models.length })}

    + +
    +
    + {models.map((model) => ( + setEditing({ kind: "model", id: model.id })} + resource={model} + revision={graph.data.revision} + title={t("resource.kind.model")} + variant="summary" + /> + ))} +
    +
    + + {editingResource ? ( + setEditing(null)} + modelDisplayNames={modelDisplayNames} + resource={editingResource} + revision={graph.data.revision} + title={titleForResource(editingResource.kind, t)} + > + {editingResource.kind === "model" ? ( + + ) : null} + + ) : null} +
    + ); +} + +function titleForResource(kind: ResourceKind, t: ReturnType["t"]): string { + switch (kind) { + case "model": + return t("resource.kind.model"); + case "provider": + return t("resource.kind.provider"); + case "provider_offer": + return t("resource.kind.offer"); + default: + return ""; + } +} + +function resourcesByKind(resources: ConfigResource[], kind: ResourceKind) { + return resources.filter((resource) => resource.kind === kind); +} +function groupOffersByModel(offers: ConfigResource[]) { + const groups = new Map(); + for (const offer of offers) { + const modelId = modelIdForOffer(offer); + const group = groups.get(modelId) ?? []; + group.push(offer); + groups.set(modelId, group); + } + return groups; +} + +function modelIdForOffer(offer: ConfigResource) { + const valueModel = offer.value.model; + if (typeof valueModel === "string" && valueModel.trim()) { + return valueModel; + } + const slash = offer.id.indexOf("/"); + return slash >= 0 ? offer.id.slice(slash + 1) : ""; +} + +function ModelProviderBindings({ + graph, + modelDisplayNames, + modelId, + offers +}: { + graph: ConfigGraph; + modelDisplayNames: Record; + modelId: string; + offers: ConfigResource[]; +}) { + const { t } = useI18n(); + const headingId = `model-${modelId}-providers-heading`; + const bodyId = `model-${modelId}-providers-body`.replace(/[^a-zA-Z0-9_-]/g, "-"); + const offersLabel = t("modelsProviders.modelProviders", { count: offers.length }); + return ( +
    +
    +

    + + {offersLabel} +

    +
    + +
    +
    +
    + {offers.map((offer) => ( + + ))} +
    +
    + ); +} diff --git a/webui/src/features/overview/OverviewPage.test.tsx b/webui/src/features/overview/OverviewPage.test.tsx new file mode 100644 index 00000000..72d3fd65 --- /dev/null +++ b/webui/src/features/overview/OverviewPage.test.tsx @@ -0,0 +1,448 @@ +import { act, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { renderWithConsoleProviders } from "../../test/renderWithConsoleProviders"; +import { ApiError } from "../../rpc/http"; +import * as configGraph from "../../rpc/configGraph"; +import * as logs from "../../rpc/logs"; +import * as management from "../../rpc/management"; +import type { LogEntry, UsageStats } from "../../rpc/types"; +import { AppShell } from "../../app/App"; +import { configGraphFixture } from "../../test/configGraphFixtures"; +import { + expectPanelElementToBeFlat, + expectPanelRuleToAvoidEdges, + expectPanelStateRuleToStayFlat +} from "../../test/panelStyleAssertions"; +import { OverviewPage } from "./OverviewPage"; +import { MemoryRouter } from "react-router-dom"; + +function metricCard(label: string): HTMLElement { + const labelEl = screen + .getAllByText(label) + .find((el) => el.classList.contains("usage-metric__label")); + if (!labelEl) { + throw new Error(`usage metric not found for label: ${label}`); + } + return labelEl.closest(".usage-metric") as HTMLElement; +} + +function usageDurationPill(): HTMLElement { + const pill = document.querySelector(".usage-heading-controls .status-pill"); + if (!pill) { + throw new Error("usage duration pill not found"); + } + return pill as HTMLElement; +} + +describe("OverviewPage", () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + restoreNavigatorClipboard(); + restoreURLMethods(); + }); + + test("renders a usage dashboard with model charts and bottom logs instead of runtime status panels", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue( + configGraphFixture({ + runtime: { + status: "runtimeRejected", + errors: [ + { + resourceKind: "provider", + resourceId: "anthropic", + field: "base_url", + code: "runtimeReloadRejected", + message: "upstream rejected reload" + } + ] + }, + validation: { + valid: false, + errors: [ + { + resourceKind: "route", + resourceId: "primary", + field: "model", + code: "missingModel", + message: "route model missing" + } + ] + } + }) + ); + vi.spyOn(management, "getUsageStats").mockResolvedValue(usageStats()); + vi.spyOn(logs, "getRecentLogs").mockResolvedValue(logEntries()); + vi.spyOn(logs, "createLogStream").mockResolvedValue(new Response(new ReadableStream())); + + renderWithConsoleProviders( + + } /> + + ); + + expect(await screen.findByRole("heading", { name: "Usage Analytics" })).toBeInTheDocument(); + await screen.findAllByText("Requests"); + expect(within(metricCard("Requests")).getByText("2")).toBeInTheDocument(); + expect(within(metricCard("Input tokens")).getByText("300")).toBeInTheDocument(); + expect(within(metricCard("Output tokens")).getByText("80")).toBeInTheDocument(); + expect(within(metricCard("Cache hit")).getByText("40%")).toBeInTheDocument(); + expect(within(metricCard("Total cost")).getByText("¥0.42")).toBeInTheDocument(); + expect(screen.getByRole("img", { name: /Token split chart.*Input tokens: 300.*Output tokens: 80/ })).toBeInTheDocument(); + expect(screen.getByRole("img", { name: /Cache split chart.*Cache write: 40.*Cache read: 120/ })).toBeInTheDocument(); + expect(screen.getByRole("img", { name: /Cost by model chart.*claude-sonnet: 0.42/ })).toBeInTheDocument(); + expect(getMaterialFilterChip(document.body, "This session")).toHaveProperty("selected", true); + expect(getMaterialFilterChip(document.body, "24h")).toHaveProperty("selected", false); + + fireEvent.click(getMaterialFilterChip(document.body, "24h")); + + expect(getMaterialFilterChip(document.body, "This session")).toHaveProperty("selected", false); + expect(getMaterialFilterChip(document.body, "24h")).toHaveProperty("selected", true); + await waitFor(() => { + expect(management.getUsageStats).toHaveBeenCalledWith("24h"); + }); + + const modelRow = await screen.findByRole("row", { name: /claude-sonnet/i }); + expect(modelRow).toHaveTextContent("claude-3-5-sonnet"); + expect(modelRow).toHaveTextContent("¥0.42"); + expect(modelRow).toHaveTextContent("¥1,105.26/M"); + + expect(screen.getByRole("region", { name: "Backend logs" })).toBeInTheDocument(); + expect(screen.getByText(/server started/)).toBeInTheDocument(); + expect(screen.queryByText("runtimeRejected")).not.toBeInTheDocument(); + expect(screen.queryByText("upstream rejected reload")).not.toBeInTheDocument(); + expect(screen.queryByText("Validation")).not.toBeInTheDocument(); + }); + + test("keeps usage background panels tonal without borders, glow, or hover lift", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + vi.spyOn(management, "getUsageStats").mockResolvedValue(usageStats()); + vi.spyOn(logs, "getRecentLogs").mockResolvedValue(logEntries()); + vi.spyOn(logs, "createLogStream").mockResolvedValue(new Response(new ReadableStream())); + + renderWithConsoleProviders( + + } /> + + ); + + await screen.findAllByText("Requests"); + + const panels = [ + document.querySelector(".usage-dashboard"), + document.querySelector(".overview-logs"), + ...Array.from(document.querySelectorAll(".usage-metric")), + ...Array.from(document.querySelectorAll(".usage-chart")) + ]; + for (const panel of panels) { + expect(panel).toBeInTheDocument(); + expectPanelElementToBeFlat(panel!); + } + expectPanelRuleToAvoidEdges(".usage-metric"); + expectPanelStateRuleToStayFlat(".usage-metric:hover"); + expectPanelRuleToAvoidEdges(".usage-chart"); + expectPanelStateRuleToStayFlat(".usage-chart:focus-visible"); + }); + + test("localizes usage units and chart accessibility text in Chinese locale", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + vi.spyOn(management, "getUsageStats").mockResolvedValue(usageStats()); + vi.spyOn(logs, "getRecentLogs").mockResolvedValue(logEntries()); + vi.spyOn(logs, "createLogStream").mockResolvedValue(new Response(new ReadableStream())); + + renderWithConsoleProviders(, { locale: "zh-CN" }); + + await screen.findByRole("heading", { name: "用量分析" }); + await screen.findAllByText("请求"); + expect(screen.getByRole("img", { name: /Token 拆分图表。输入 Token:300;输出 Token:80/ })).toBeInTheDocument(); + expect(screen.getByRole("img", { name: /缓存拆分图表。缓存写入:40;缓存读取:120/ })).toBeInTheDocument(); + const modelRow = await screen.findByRole("row", { name: /claude-sonnet/i }); + expect(modelRow).toHaveTextContent("¥1,105.26/百万"); + }); + + test("keeps the current usage dashboard visible while a newly selected range is loading", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + vi.spyOn(logs, "getRecentLogs").mockResolvedValue(logEntries()); + vi.spyOn(logs, "createLogStream").mockResolvedValue(new Response(new ReadableStream())); + const usageRequest = vi.spyOn(management, "getUsageStats").mockImplementation((range = "session") => { + if (range === "session") { + return Promise.resolve(usageStats()); + } + return new Promise(() => undefined); + }); + + renderWithConsoleProviders(); + + await screen.findAllByText("Requests"); + expect(within(metricCard("Requests")).getByText("2")).toBeInTheDocument(); + + fireEvent.click(getMaterialFilterChip(document.body, "24h")); + + expect(getMaterialFilterChip(document.body, "24h")).toHaveProperty("selected", true); + await waitFor(() => { + expect(usageRequest).toHaveBeenCalledWith("24h"); + }); + expect(screen.queryByRole("heading", { name: "Loading" })).not.toBeInTheDocument(); + expect(within(metricCard("Requests")).getByText("2")).toBeInTheDocument(); + expect(screen.getByRole("table", { name: "Model usage table" })).toBeInTheDocument(); + }); + + test("updates the active session usage duration every second without refetching", async () => { + vi.useFakeTimers(); + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + vi.spyOn(management, "getUsageStats").mockResolvedValue(usageStats()); + vi.spyOn(logs, "getRecentLogs").mockResolvedValue(logEntries()); + vi.spyOn(logs, "createLogStream").mockResolvedValue(new Response(new ReadableStream())); + + renderWithConsoleProviders(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + await Promise.resolve(); + }); + expect(screen.getAllByText("Requests").length).toBeGreaterThan(0); + expect(usageDurationPill()).toHaveTextContent("1m"); + + act(() => { + vi.advanceTimersByTime(2000); + }); + + expect(usageDurationPill()).toHaveTextContent("1m 2s"); + expect(management.getUsageStats).toHaveBeenCalledTimes(1); + }); + + test("does not increment fixed usage range durations", async () => { + vi.useFakeTimers(); + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + vi.spyOn(management, "getUsageStats").mockImplementation((range = "session") => { + if (range === "24h") { + return Promise.resolve(usageStats({ duration: "24h" })); + } + return Promise.resolve(usageStats()); + }); + vi.spyOn(logs, "getRecentLogs").mockResolvedValue(logEntries()); + vi.spyOn(logs, "createLogStream").mockResolvedValue(new Response(new ReadableStream())); + + renderWithConsoleProviders(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + fireEvent.click(getMaterialFilterChip(document.body, "24h")); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + expect(usageDurationPill()).toHaveTextContent("24h"); + + act(() => { + vi.advanceTimersByTime(2000); + }); + + expect(usageDurationPill()).toHaveTextContent("24h"); + }); + + test("does not increment placeholder duration while returning to the active session range", async () => { + vi.useFakeTimers(); + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + vi.spyOn(management, "getUsageStats").mockImplementation((range = "session") => { + if (range === "24h") { + return Promise.resolve(usageStats({ duration: "24h" })); + } + return new Promise(() => undefined); + }); + vi.spyOn(logs, "getRecentLogs").mockResolvedValue(logEntries()); + vi.spyOn(logs, "createLogStream").mockResolvedValue(new Response(new ReadableStream())); + + renderWithConsoleProviders(); + + fireEvent.click(getMaterialFilterChip(document.body, "24h")); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(usageDurationPill()).toHaveTextContent("24h"); + + fireEvent.click(getMaterialFilterChip(document.body, "This session")); + await act(async () => { + vi.advanceTimersByTime(2000); + }); + + expect(usageDurationPill()).toHaveTextContent("24h"); + }); + + test("keeps the embedded log panel searchable and clearable", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + vi.spyOn(management, "getUsageStats").mockResolvedValue(usageStats()); + vi.spyOn(logs, "getRecentLogs").mockResolvedValue(logEntries()); + vi.spyOn(logs, "createLogStream").mockResolvedValue(new Response(new ReadableStream())); + + renderWithConsoleProviders(); + + expect(await screen.findByText(/server started/)).toBeInTheDocument(); + + const searchField = getMaterialTextField(document.body, "Search logs"); + setMaterialTextFieldValue(searchField, "database"); + + expect(screen.queryByText(/server started/)).not.toBeInTheDocument(); + expect(screen.getByText(/database unavailable/)).toBeInTheDocument(); + + fireEvent.click(getMaterialIconButton(document.body, "Clear log search")); + + expect(screen.getByText(/server started/)).toBeInTheDocument(); + expect(screen.getByText(/database unavailable/)).toBeInTheDocument(); + }); + + test("shows usage empty state while keeping logs available", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + vi.spyOn(management, "getUsageStats").mockResolvedValue({ + totals: { + requests: 0, + input_tokens: 0, + output_tokens: 0, + cache_creation: 0, + cache_read: 0, + cache_hit_rate: 0, + cache_write_rate: 0, + cache_rw_ratio: 0, + total_cost: 0, + duration: "0s" + }, + by_model: [] + }); + vi.spyOn(logs, "getRecentLogs").mockResolvedValue(logEntries()); + vi.spyOn(logs, "createLogStream").mockResolvedValue(new Response(new ReadableStream())); + + renderWithConsoleProviders(); + + expect(await screen.findByText("No usage has been recorded yet.")).toBeInTheDocument(); + expect(screen.getByRole("img", { name: /Token split chart/ })).toBeInTheDocument(); + expect(screen.getByRole("img", { name: /Cache split chart/ })).toBeInTheDocument(); + expect(screen.getByRole("img", { name: /Cost by model chart/ })).toBeInTheDocument(); + expect(screen.getByRole("table", { name: "Model usage table" })).toBeInTheDocument(); + expect(screen.getByText(/server started/)).toBeInTheDocument(); + }); + + test("keeps usage dashboard and logs visible when graph API store is unavailable", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockRejectedValue( + new ApiError(503, "store_unavailable", "配置存储不可用") + ); + vi.spyOn(management, "getUsageStats").mockResolvedValue(usageStats()); + vi.spyOn(logs, "getRecentLogs").mockResolvedValue([]); + vi.spyOn(logs, "createLogStream").mockResolvedValue(new Response(new ReadableStream())); + + renderWithConsoleProviders(); + + expect(await screen.findByRole("heading", { name: "Usage Analytics" })).toBeInTheDocument(); + await screen.findAllByText("Requests"); + expect(within(metricCard("Requests")).getByText("2")).toBeInTheDocument(); + expect(screen.getByRole("region", { name: "Backend logs" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Configuration graph unavailable" })).toBeInTheDocument(); + }); +}); + +function getMaterialFilterChip(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-filter-chip")).find( + (chip) => chip.textContent?.trim() === label + ); + if (!element) { + throw new Error(`Expected a Material Web filter chip labelled "${label}".`); + } + return element as HTMLElement & { selected: boolean }; +} + +function getMaterialTextField(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-outlined-text-field")).find( + (textField) => (textField as HTMLElement & { label?: string }).label === label + ); + if (!element) { + throw new Error(`Expected a Material Web outlined text field labelled "${label}".`); + } + return element as HTMLElement & { value: string }; +} + +function getMaterialIconButton(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-icon-button")).find( + (iconButton) => iconButton.getAttribute("aria-label") === label + ); + if (!element) { + throw new Error(`Expected a Material Web icon button labelled "${label}".`); + } + return element as HTMLElement; +} + +function setMaterialTextFieldValue(element: HTMLElement & { value: string }, value: string) { + act(() => { + element.value = value; + element.dispatchEvent(new InputEvent("input", { bubbles: true, composed: true })); + }); +} + +function usageStats(overrides: Partial = {}): UsageStats { + return { + totals: { + requests: 2, + input_tokens: 300, + output_tokens: 80, + cache_creation: 40, + cache_read: 120, + cache_hit_rate: 40, + cache_write_rate: 13.3, + cache_rw_ratio: 3, + total_cost: 0.42, + duration: "1m", + ...overrides + }, + by_model: [ + { + model: "claude-sonnet", + actual_model: "claude-3-5-sonnet", + requests: 2, + input_tokens: 300, + output_tokens: 80, + cache_creation: 40, + cache_read: 120, + cache_hit_rate: 40, + cost: 0.42, + avg_cost_per_mtoken: 1105.26 + } + ] + }; +} + +function logEntries(): LogEntry[] { + return [ + { + timestamp: "2026-06-07T00:00:00Z", + level: "INFO", + message: "server started", + raw: "time=2026-06-07T00:00:00Z level=INFO msg=server-started" + }, + { + timestamp: "2026-06-07T00:00:01Z", + level: "ERROR", + message: "database unavailable", + raw: "time=2026-06-07T00:00:01Z level=ERROR msg=database-unavailable" + } + ]; +} + +const clipboardDescriptor = Object.getOwnPropertyDescriptor(Navigator.prototype, "clipboard"); +const createObjectURLDescriptor = Object.getOwnPropertyDescriptor(URL, "createObjectURL"); +const revokeObjectURLDescriptor = Object.getOwnPropertyDescriptor(URL, "revokeObjectURL"); + +function restoreNavigatorClipboard() { + if (clipboardDescriptor) { + Object.defineProperty(Navigator.prototype, "clipboard", clipboardDescriptor); + } else { + Reflect.deleteProperty(navigator, "clipboard"); + } +} + +function restoreURLMethods() { + if (createObjectURLDescriptor) { + Object.defineProperty(URL, "createObjectURL", createObjectURLDescriptor); + } + if (revokeObjectURLDescriptor) { + Object.defineProperty(URL, "revokeObjectURL", revokeObjectURLDescriptor); + } +} diff --git a/webui/src/features/overview/OverviewPage.tsx b/webui/src/features/overview/OverviewPage.tsx new file mode 100644 index 00000000..b4d6265e --- /dev/null +++ b/webui/src/features/overview/OverviewPage.tsx @@ -0,0 +1,393 @@ +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { motion } from "motion/react"; +import { LoadingState } from "../../components/LoadingState"; +import { MaterialFilterChip } from "../../components/MaterialFilterChip"; +import { MaterialSwitch } from "../../components/MaterialSwitch"; +import { useI18n } from "../../i18n/I18nProvider"; +import type { MessageKey } from "../../i18n/messages"; +import { getUsageStats, type UsageRange } from "../../rpc/management"; +import { queryKeys } from "../../rpc/queryKeys"; +import type { UsageStats, UsageStatsModelRow } from "../../rpc/types"; +import { listContainer, listItem } from "../../theme/motion"; +import { useConfigGraph } from "../configGraph/useConfigGraph"; +import { LogPanel } from "../logs/LogPanel"; +import { mockUsageStats } from "./mockUsage"; +import { PageHeader, QueryErrorState } from "../shared"; + +const usageRanges: UsageRange[] = ["session", "24h", "7d", "30d", "all"]; + +const usageRangeLabelKeys: Record = { + session: "overview.range.session", + "24h": "overview.range.24h", + "7d": "overview.range.7d", + "30d": "overview.range.30d", + all: "overview.range.all" +}; + +export function OverviewPage() { + const { t } = useI18n(); + const graph = useConfigGraph(); + const [range, setRange] = useState("session"); + const [demoData, setDemoData] = useState(false); + const usage = useQuery({ + queryKey: [...queryKeys.usageStats, range, demoData ? "demo" : "live"], + queryFn: () => (demoData ? mockUsageStats(range) : getUsageStats(range)), + placeholderData: keepPreviousData + }); + const liveDuration = useLiveUsageDuration( + usage.data?.totals.duration, + range === "session" && !usage.isPlaceholderData && !demoData + ); + + return ( +
    + + {t("overview.description")} + + + {graph.error ? ( +
    +

    {t("common.error")}

    +

    {t("overview.graphUnavailableTitle")}

    +

    {t("overview.graphUnavailableDescription")}

    +
    + ) : null} + +
    +
    +
    +

    {t("overview.usageTitle")}

    +

    {t("overview.usageDescription")}

    +
    +
    + + {usageRanges.map((option) => ( + + {t(usageRangeLabelKeys[option])} + + ))} + + {usage.data ? {liveDuration} : null} + {import.meta.env.DEV ? ( + + ) : null} +
    +
    + + {usage.isLoading ? ( + + ) : usage.error ? ( + + ) : usage.data ? ( + + ) : null} +
    + +
    +
    +
    +

    {t("logs.panelTitle")}

    +

    {t("logs.description")}

    +
    +
    + +
    +
    + ); +} + +function useLiveUsageDuration(rawDuration: string | undefined, active: boolean) { + const { t } = useI18n(); + const [elapsedSeconds, setElapsedSeconds] = useState(0); + const baseSeconds = rawDuration ? parseDurationSeconds(rawDuration) : undefined; + + useEffect(() => { + setElapsedSeconds(0); + if (!active || baseSeconds === undefined) { + return; + } + const intervalId = window.setInterval(() => { + setElapsedSeconds((current) => current + 1); + }, 1000); + return () => window.clearInterval(intervalId); + }, [active, baseSeconds, rawDuration]); + + if (!rawDuration) { + return ""; + } + if (!active || baseSeconds === undefined) { + return formatDuration(rawDuration, translateDurationPart); + } + return formatDurationSeconds(baseSeconds + elapsedSeconds, translateDurationPart); + + function translateDurationPart(key: MessageKey, count: number) { + return t(key, { count }); + } +} + +function UsageDashboard({ stats }: { stats: UsageStats }) { + const { t } = useI18n(); + const hasUsage = stats.totals.requests > 0 || stats.by_model.length > 0; + + return ( + <> + {!hasUsage ? ( +
    +

    {t("overview.usageEmpty")}

    +
    + ) : null} + + + + + + + + + + +
    + + + [row.model, row.cost]), + t("overview.noData") + )} + title={t("overview.costByModel")} + segments={stats.by_model.map((row, index) => ({ + label: row.model, + value: row.cost, + className: `usage-segment--cost-${(index % 6) + 1}` + }))} + /> +
    + +
    + + + + + + + + + + + + + + + + + {stats.by_model.map((row) => ( + + ))} + +
    {t("overview.model")}{t("overview.actualModel")}{t("overview.requests")}{t("overview.inputTokens")}{t("overview.outputTokens")}{t("overview.cacheWrite")}{t("overview.cacheRead")}{t("overview.cacheHit")}{t("overview.cost")}{t("overview.avgCost")}
    +
    + + ); +} + +function UsageMetric({ + label, + value, + icon, + tone = "primary" +}: { + label: string; + value: string; + icon: string; + tone?: "primary" | "tertiary" | "secondary"; +}) { + return ( + + + {label} + {value} + + ); +} + +function UsageBarChart({ + ariaLabel, + title, + segments +}: { + ariaLabel: string; + title: string; + segments: Array<{ label: string; value: number; className: string }>; +}) { + const total = segments.reduce((sum, segment) => sum + Math.max(0, segment.value), 0); + return ( +
    +
    +

    {title}

    + {formatNumber(total)} +
    + +
      + {segments.map((segment) => ( +
    • +
    • + ))} +
    +
    + ); +} + +function UsageModelRow({ row }: { row: UsageStatsModelRow }) { + const { t } = useI18n(); + return ( + + {row.model} + {row.actual_model || "-"} + {formatNumber(row.requests)} + {formatTokenValue(row.input_tokens)} + {formatTokenValue(row.output_tokens)} + {formatTokenValue(row.cache_creation)} + {formatTokenValue(row.cache_read)} + {formatPercent(row.cache_hit_rate)} + {formatCurrency(row.cost)} + {formatCurrency(row.avg_cost_per_mtoken)}{t("overview.costPerMillionSuffix")} + + ); +} + +function formatNumber(value: number) { + return new Intl.NumberFormat().format(value); +} + +function formatDuration( + raw: string, + translatePart: (key: MessageKey, count: number) => string +) { + if (!raw || raw === "N/A") { + return "—"; + } + const seconds = parseDurationSeconds(raw); + if (seconds === undefined) { + return raw.trim(); + } + return formatDurationSeconds(seconds, translatePart); +} + +function parseDurationSeconds(raw: string) { + const match = /^(?:(\d+)h)?(?:(\d+)m)?(?:([\d.]+)s)?$/.exec(raw.trim()); + if (!match || (!match[1] && !match[2] && !match[3])) { + return undefined; + } + const hours = match[1] ? parseInt(match[1], 10) : 0; + const minutes = match[2] ? parseInt(match[2], 10) : 0; + const seconds = match[3] ? Math.round(parseFloat(match[3])) : 0; + return hours * 3600 + minutes * 60 + seconds; +} + +function formatDurationSeconds( + totalSeconds: number, + translatePart: (key: MessageKey, count: number) => string +) { + const roundedSeconds = Math.max(0, Math.round(totalSeconds)); + const hours = Math.floor(roundedSeconds / 3600); + const minutes = Math.floor((roundedSeconds % 3600) / 60); + const seconds = roundedSeconds % 60; + const parts: string[] = []; + if (hours) { + parts.push(translatePart("overview.duration.hoursShort", hours)); + } + if (minutes) { + parts.push(translatePart("overview.duration.minutesShort", minutes)); + } + if (seconds || parts.length === 0) { + parts.push(translatePart("overview.duration.secondsShort", seconds)); + } + return parts.join(" "); +} + +function formatTokenValue(value: number) { + return formatNumber(value); +} + +function formatPercent(value: number) { + return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 }).format(value)}%`; +} + +function formatRatio(value: number) { + return new Intl.NumberFormat(undefined, { + maximumFractionDigits: 2 + }).format(value); +} + +function formatCurrency(value: number) { + return new Intl.NumberFormat(undefined, { + style: "currency", + currency: "CNY", + currencyDisplay: "narrowSymbol", + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }).format(value); +} + +function chartAriaLabel( + t: (key: MessageKey, values?: Record) => string, + title: string, + values: Array<[string, number]>, + emptySummary: string +) { + const summary = values.length > 0 + ? values + .map(([label, value]) => t("overview.chartAriaItem", { label, value: formatNumber(value) })) + .join(t("overview.chartAriaSeparator")) + : emptySummary; + return t("overview.chartAriaLabel", { title, summary }); +} diff --git a/webui/src/features/overview/mockUsage.ts b/webui/src/features/overview/mockUsage.ts new file mode 100644 index 00000000..45457497 --- /dev/null +++ b/webui/src/features/overview/mockUsage.ts @@ -0,0 +1,93 @@ +import type { UsageRange } from "../../rpc/management"; +import type { UsageStats, UsageStatsModelRow, UsageStatsTotals } from "../../rpc/types"; + +/** + * Dev-only mock usage data so the Overview dashboard can be previewed without a live + * backend. Eight models spanning three providers (Anthropic / OpenAI / Google). + * + * Never referenced in production builds — OverviewPage only enables the demo toggle + * when `import.meta.env.DEV` is true. + */ + +const DEMO_MODELS: Array<{ model: string; actual: string }> = [ + { model: "claude-sonnet", actual: "claude-3-5-sonnet" }, + { model: "claude-opus", actual: "claude-3-opus" }, + { model: "claude-haiku", actual: "claude-3-5-haiku" }, + { model: "gpt-4o", actual: "gpt-4o-2024-08-06" }, + { model: "gpt-4o-mini", actual: "gpt-4o-mini" }, + { model: "o3-mini", actual: "o3-mini" }, + { model: "gemini-pro", actual: "gemini-1.5-pro" }, + { model: "gemini-flash", actual: "gemini-1.5-flash" } +]; + +const RANGE_SCALE: Record = { + session: 1, + "24h": 4, + "7d": 26, + "30d": 110, + all: 340 +}; + +const RANGE_DURATION: Record = { + session: "1h 12m", + "24h": "23h 48m", + "7d": "167h", + "30d": "718h", + all: "2400h" +}; + +export function mockUsageStats(range: UsageRange = "session"): UsageStats { + const scale = RANGE_SCALE[range] ?? 1; + const byModel: UsageStatsModelRow[] = DEMO_MODELS.map((entry, index) => { + const requests = Math.max(1, Math.round((38 + index * 19 + ((index * 7) % 5) * 11) * scale)); + const inputTokens = requests * (1400 + (index % 4) * 520); + const outputTokens = requests * (260 + (index % 3) * 180); + const cacheCreation = Math.round(inputTokens * (0.22 + (index % 3) * 0.05)); + const cacheRead = Math.round(inputTokens * (0.7 + (index % 4) * 0.12)); + const cacheHitRate = Math.min(99, Math.round((58 + (index * 6) % 38) * 10) / 10); + const cost = Math.round( + (inputTokens * 0.000003 + outputTokens * 0.000015 + cacheRead * 0.0000004) * 100 + ) / 100; + return { + model: entry.model, + actual_model: entry.actual, + requests, + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation: cacheCreation, + cache_read: cacheRead, + cache_hit_rate: cacheHitRate, + cost, + avg_cost_per_mtoken: Math.round((cost / Math.max(1, inputTokens + outputTokens)) * 1_000_000 * 100) / 100 + }; + }); + + return { totals: aggregateTotals(byModel, range), by_model: byModel }; +} + +function aggregateTotals(byModel: UsageStatsModelRow[], range: UsageRange): UsageStatsTotals { + const sum = (selector: (row: UsageStatsModelRow) => number) => + byModel.reduce((total, row) => total + selector(row), 0); + + const requests = sum((row) => row.requests); + const inputTokens = sum((row) => row.input_tokens); + const outputTokens = sum((row) => row.output_tokens); + const cacheCreation = sum((row) => row.cache_creation); + const cacheRead = sum((row) => row.cache_read); + const totalCost = Math.round(sum((row) => row.cost) * 100) / 100; + + return { + requests, + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation: cacheCreation, + cache_read: cacheRead, + cache_hit_rate: inputTokens + cacheRead > 0 + ? Math.round((cacheRead / (inputTokens + cacheRead)) * 1000) / 10 + : 0, + cache_write_rate: inputTokens > 0 ? Math.round((cacheCreation / inputTokens) * 1000) / 10 : 0, + cache_rw_ratio: cacheCreation > 0 ? Math.round((cacheRead / cacheCreation) * 100) / 100 : 0, + total_cost: totalCost, + duration: RANGE_DURATION[range] ?? "0s" + }; +} diff --git a/webui/src/features/routes/RoutesPage.test.tsx b/webui/src/features/routes/RoutesPage.test.tsx new file mode 100644 index 00000000..79618027 --- /dev/null +++ b/webui/src/features/routes/RoutesPage.test.tsx @@ -0,0 +1,319 @@ +import { act, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { renderWithConsoleProviders } from "../../test/renderWithConsoleProviders"; +import * as configGraph from "../../rpc/configGraph"; +import { configGraphFixture } from "../../test/configGraphFixtures"; +import { RoutesPage } from "./RoutesPage"; + +describe("RoutesPage", () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + test("renders route graph fields without priority or fallback controls", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + + renderWithConsoleProviders(); + + // The list shows a compact summary row; operational markers are hidden until the editor opens. + expect(await screen.findByRole("heading", { level: 3, name: "primary" })).toBeInTheDocument(); + + await openRouteEditor(); + + expect(screen.getByText("8 fields")).toBeInTheDocument(); + expect(screen.getByText("Hot reload")).toBeInTheDocument(); + // Route model + provider are selects populated from configured models/providers. + const routeModelField = getMaterialSelect(document, "Route model"); + expect(routeModelField).toBeInTheDocument(); + expectLobeLeadingIcon(routeModelField); + expect(getMaterialSelect(document, "Route provider")).toBeInTheDocument(); + expectLobeLeadingIcon(getMaterialTextField(document, "Route display name")); + expect(getMaterialTextField(document, "Route context window")).toBeInTheDocument(); + const advancedFeatures = screen.getByRole("group", { name: "Advanced Features" }); + expect(getMaterialSelect(advancedFeatures, "Route web search mode")).toBeInTheDocument(); + expect(getMaterialTextField(advancedFeatures, "Route web search max uses")).toHaveAttribute("spellcheck", "false"); + expect(getMaterialTextField(advancedFeatures, "Route web search search max rounds")).toHaveAttribute("spellcheck", "false"); + expect(Array.from(advancedFeatures.querySelectorAll("md-outlined-text-field")).some( + (candidate) => materialElementLabel(candidate as MaterialTextFieldElement) === "Route web search JSON" + )).toBe(false); + expect(Array.from(advancedFeatures.querySelectorAll("md-outlined-text-field")).some( + (candidate) => materialElementLabel(candidate as MaterialTextFieldElement) === "Route extensions JSON" + )).toBe(false); + expect(queryMaterialOutlinedButton(advancedFeatures, /Route web search.*1 key/)).not.toBeInTheDocument(); + expect(queryMaterialOutlinedButton(advancedFeatures, /Route extensions.*0 keys/)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/priority/i)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/fallback/i)).not.toBeInTheDocument(); + }); + + test("autosaves route edits through graph patches", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + const patch = vi.spyOn(configGraph, "patchConfigGraph").mockResolvedValue({ + result: "committed", + revision: "rev-2" + }); + + renderWithConsoleProviders(); + + await screen.findByLabelText("Route primary"); + await openRouteEditor(); + vi.useFakeTimers(); + const displayNameField = getMaterialTextField(document, "Route display name"); + setMaterialTextFieldValue(displayNameField, "Fast Route"); + fireEvent.blur(displayNameField); + + await advanceAutosave(); + + expect(patch).toHaveBeenCalledWith({ + baseRevision: "rev-1", + changes: [ + { + kind: "route", + id: "primary", + field: "display_name", + value: "Fast Route" + } + ] + }); + }); + + test("creates a route from current graph model and provider options", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + const create = vi.spyOn(configGraph, "createConfigResource").mockResolvedValue({ + result: "committed", + revision: "rev-2", + graph: configGraphFixture({ revision: "rev-2" }) + }); + + renderWithConsoleProviders(); + + await waitFor(() => expect(getMaterialButton(document, "Add Route")).toBeInTheDocument()); + await userEvent.click(getMaterialButton(document, "Add Route")); + const form = screen.getByRole("form", { name: "Create Route" }); + setMaterialTextFieldValue(getMaterialTextField(form, "Route alias"), "fast"); + expect(getMaterialSelect(form, "Model").value).toBe("claude-sonnet"); + expect(getMaterialSelect(form, "Provider").value).toBe("anthropic"); + submitMaterialForm(form, "Create Route"); + + await waitFor(() => expect(create).toHaveBeenCalledWith("route", { + baseRevision: "rev-1", + id: "fast", + value: { + model: "claude-sonnet", + provider: "anthropic" + } + })); + }); + + test("renders create route controls with official Material field labels", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + + renderWithConsoleProviders(); + + await waitFor(() => expect(getMaterialButton(document, "Add Route")).toBeInTheDocument()); + await userEvent.click(getMaterialButton(document, "Add Route")); + const form = screen.getByRole("form", { name: "Create Route" }); + const aliasField = getMaterialTextField(form, "Route alias"); + const modelSelect = getMaterialSelect(form, "Model"); + + expect(aliasField.label).toBe("Route alias"); + expect(aliasField).not.toHaveAttribute("aria-labelledby"); + expect(aliasField).toHaveAttribute("spellcheck", "false"); + expect(aliasField.closest(".form-field--create-track")?.querySelector(".schema-field__label")).not.toBeInTheDocument(); + expect(getMaterialTrailingIconButton(aliasField, "Help for Route alias")).toBeInTheDocument(); + expect(modelSelect.label).toBe("Model"); + expect(modelSelect.querySelector("[slot='leading-icon'] svg")).toBeInTheDocument(); + expect(getMaterialSelectOptions(modelSelect).find((option) => option.value === "claude-sonnet") + ?.querySelector("[slot='start'] svg")).toBeInTheDocument(); + expect(modelSelect).not.toHaveAttribute("aria-labelledby"); + expect(modelSelect.closest(".form-field--create-track")?.querySelector(".schema-field__label")).not.toBeInTheDocument(); + expect(modelSelect.supportingText).toBe(""); + expect(modelSelect.closest(".mb-field__select-shell")).not.toBeInTheDocument(); + expect(modelSelect.querySelector("[slot='trailing-icon']")).not.toBeInTheDocument(); + const modelHelp = getMaterialIconButton(form, "Help for Model"); + expect(modelHelp).toHaveClass("mb-field__select-help"); + expect(modelHelp.closest(".mb-field__select-actions")).toBeInTheDocument(); + expect(getComputedStyle(modelHelp).position).not.toBe("absolute"); + expect(modelSelect).not.toContainElement(modelHelp); + await userEvent.click(modelHelp); + expect(within(form).getByRole("tooltip")).toHaveTextContent("Model this alias points to."); + }); + + test("deletes a route after inline confirmation", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + const remove = vi.spyOn(configGraph, "deleteConfigResource").mockResolvedValue({ + result: "committed", + revision: "rev-2", + graph: configGraphFixture({ + revision: "rev-2", + resources: configGraphFixture().resources.filter((resource) => resource.kind !== "route") + }) + }); + + renderWithConsoleProviders(); + + const routePanel = await screen.findByLabelText("Route primary"); + await userEvent.click(getMaterialButton(routePanel, "Delete Route primary")); + expect(remove).not.toHaveBeenCalled(); + await userEvent.click(getMaterialButton(routePanel, "Confirm delete primary")); + + expect(remove).toHaveBeenCalledWith("route", "primary", "rev-1"); + expect(screen.queryByLabelText("Route primary")).not.toBeInTheDocument(); + }); +}); + +async function advanceAutosave() { + await act(async () => { + await vi.advanceTimersByTimeAsync(450); + await Promise.resolve(); + }); +} + +type MaterialSelectElement = HTMLElement & { + label: string; + supportingText: string; + value: string; +}; + +type MaterialTextFieldElement = HTMLElement & { + label: string; + value: string; +}; + +function getMaterialTextField(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-outlined-text-field")).find( + (candidate) => materialElementLabel(candidate) === label + ); + if (!element) { + throw new Error(`Expected a Material Web outlined text field labelled "${label}".`); + } + return element; +} + +function getMaterialSelect(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-outlined-select")).find( + (candidate) => materialElementLabel(candidate) === label + ); + if (!element) { + throw new Error(`Expected a Material Web select labelled "${label}".`); + } + return element; +} + +type MaterialSelectOptionElement = HTMLElement & { + displayText: string; + selected: boolean; + value: string; +}; + +function getMaterialSelectOptions(select: ParentNode) { + const options = Array.from(select.querySelectorAll("md-select-option")); + if (options.length === 0) { + throw new Error("Expected Material Web select options to be rendered."); + } + return options; +} + +function expectLobeLeadingIcon(fieldElement: HTMLElement) { + const leadingIcon = fieldElement.querySelector("[slot='leading-icon']"); + expect(leadingIcon).toBeInTheDocument(); + expect(leadingIcon?.querySelector("svg")).toBeInTheDocument(); +} + +function materialElementLabel(element: HTMLElement & { label?: string }) { + const labelledBy = element.getAttribute("aria-labelledby"); + if (labelledBy) { + return labelledBy + .split(/\s+/) + .map((id) => document.getElementById(id)?.textContent?.trim() ?? "") + .filter(Boolean) + .join(" "); + } + return element.label || element.getAttribute("aria-label") || element.getAttribute("label") || ""; +} + +function getMaterialButton(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-filled-button")).find( + (candidate) => { + const accessibleLabel = candidate.getAttribute("aria-label") ?? candidate.textContent ?? ""; + return accessibleLabel.includes(label); + } + ); + if (!element) { + throw new Error(`Expected a Material Web filled button labelled "${label}".`); + } + return element as HTMLElement; +} + +function getMaterialOutlinedButton(container: ParentNode, label: RegExp) { + const element = Array.from(container.querySelectorAll("md-outlined-button")).find( + (candidate) => label.test(candidate.getAttribute("aria-label") ?? candidate.textContent ?? "") + ); + if (!element) { + throw new Error(`Expected a Material Web outlined button labelled "${label}".`); + } + return element as HTMLElement; +} + +function queryMaterialOutlinedButton(container: ParentNode, label: RegExp) { + return Array.from(container.querySelectorAll("md-outlined-button")).find( + (candidate) => label.test(candidate.getAttribute("aria-label") ?? candidate.textContent ?? "") + ) ?? null; +} + +function getMaterialTrailingIconButton(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-icon-button")).find( + (candidate) => candidate.getAttribute("slot") === "trailing-icon" && candidate.getAttribute("aria-label") === label + ); + if (!element) { + throw new Error(`Expected a Material Web trailing icon button labelled "${label}".`); + } + return element as HTMLElement; +} + +function getMaterialIconButton(container: ParentNode, label: string) { + const element = queryMaterialIconButton(container, label); + if (!element) { + throw new Error(`Expected a Material Web icon button labelled "${label}".`); + } + return element as HTMLElement; +} + +function queryMaterialIconButton(container: ParentNode, label: string) { + return Array.from(container.querySelectorAll("md-icon-button")).find( + (candidate) => candidate.getAttribute("aria-label") === label + ) ?? null; +} + +function setMaterialTextFieldValue(element: MaterialTextFieldElement, value: string) { + act(() => { + element.value = value; + element.dispatchEvent(new InputEvent("input", { bubbles: true, composed: true })); + }); +} + +function submitMaterialForm(container: ParentNode, submitLabel: string) { + const button = getMaterialButton(container, submitLabel); + const form = button.closest("form"); + if (!form) { + throw new Error("Expected Material Web submit button inside a form."); + } + fireEvent.submit(form); +} + +function getOutlinedButton(container: ParentNode, label: string): HTMLElement { + const element = Array.from(container.querySelectorAll("md-outlined-button")).find( + (candidate) => (candidate.getAttribute("aria-label") ?? candidate.textContent ?? "").includes(label) + ); + if (!element) { + throw new Error(`Expected a Material Web outlined button labelled "${label}".`); + } + return element as HTMLElement; +} + +/** Opens the route editor dialog from its summary row. */ +async function openRouteEditor() { + await userEvent.click(getOutlinedButton(document, "Edit Route primary")); +} + diff --git a/webui/src/features/routes/RoutesPage.tsx b/webui/src/features/routes/RoutesPage.tsx new file mode 100644 index 00000000..f3bd0c4f --- /dev/null +++ b/webui/src/features/routes/RoutesPage.tsx @@ -0,0 +1,67 @@ +import { useState } from "react"; +import { LoadingState } from "../../components/LoadingState"; +import { useI18n } from "../../i18n/I18nProvider"; +import { CreateResourcePanel } from "../configGraph/CreateResourcePanel"; +import { ResourceEditorDialog } from "../configGraph/ResourceEditorDialog"; +import { ResourceEditorCard } from "../configGraph/ResourceEditorCard"; +import { useConfigGraph } from "../configGraph/useConfigGraph"; +import { modelDisplayNamesById } from "../configGraph/modelProviderIcons"; +import { PageHeader, QueryErrorState } from "../shared"; + +export function RoutesPage() { + const { t } = useI18n(); + const graph = useConfigGraph(); + const [editingId, setEditingId] = useState(null); + + if (graph.error) { + return ; + } + if (graph.isLoading || !graph.data) { + return ; + } + + const routes = graph.data.resources.filter((resource) => resource.kind === "route"); + const routeTitle = t("routes.resourceTitle"); + const modelDisplayNames = modelDisplayNamesById(graph.data.resources); + const editing = editingId ? routes.find((route) => route.id === editingId) : undefined; + + return ( +
    + + {t("routes.description")} + + +
    +
    +

    {t("routes.listTitle", { count: routes.length })}

    + +
    +
    + {routes.map((route) => ( + setEditingId(route.id)} + resource={route} + revision={graph.data!.revision} + title={routeTitle} + variant="summary" + /> + ))} +
    +
    + + {editing ? ( + setEditingId(null)} + modelDisplayNames={modelDisplayNames} + resource={editing} + revision={graph.data!.revision} + title={routeTitle} + /> + ) : null} +
    + ); +} diff --git a/webui/src/features/rpcTest/RpcTestPage.test.tsx b/webui/src/features/rpcTest/RpcTestPage.test.tsx new file mode 100644 index 00000000..e4c776f7 --- /dev/null +++ b/webui/src/features/rpcTest/RpcTestPage.test.tsx @@ -0,0 +1,194 @@ +import { act, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; +import { AppShell } from "../../app/App"; +import { renderWithConsoleProviders } from "../../test/renderWithConsoleProviders"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import * as responses from "../../rpc/responses"; +import { RpcTestPage } from "./RpcTestPage"; + +if (!Element.prototype.animate) { + Object.defineProperty(Element.prototype, "animate", { + configurable: true, + value: () => ({ + addEventListener: () => undefined, + cancel: () => undefined, + commitStyles: () => undefined, + finish: () => undefined, + finished: Promise.resolve(), + pause: () => undefined, + persist: () => undefined, + play: () => undefined, + ready: Promise.resolve(), + removeEventListener: () => undefined, + reverse: () => undefined, + updatePlaybackRate: () => undefined + } as unknown as Animation) + }); +} + +describe("RpcTestPage", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("renders official Material Web form controls", async () => { + vi.spyOn(responses, "listResponseModels").mockResolvedValue({ + models: [{ slug: "moonbridge", name: "Moon Bridge", provider: "route" }] + }); + + const { container } = renderWithConsoleProviders(); + + await screen.findByText("moonbridge"); + + expect(getMaterialSelect(container, "Model")).toBeInTheDocument(); + expect(getMaterialTextField(container, "Input")).toHaveProperty("type", "textarea"); + expect(getMaterialTextField(container, "Input")).not.toHaveClass("material-text-field--single-line"); + expect(getMaterialTextField(container, "Max Output Tokens")).toHaveProperty("type", "number"); + expect(getMaterialTextField(container, "Max Output Tokens")).toHaveClass("material-text-field--single-line"); + expect(getMaterialTextField(container, "Temperature")).toHaveProperty("type", "number"); + expect(getMaterialTextField(container, "Temperature")).toHaveClass("material-text-field--single-line"); + expect(getMaterialButton(container, "Send")).toBeInTheDocument(); + }); + + test("keeps Material selects aligned with single-line text field density", async () => { + vi.spyOn(responses, "listResponseModels").mockResolvedValue({ + models: [{ slug: "moonbridge", name: "Moon Bridge", provider: "route" }] + }); + + renderWithConsoleProviders( + + } /> + + ); + + await screen.findByText("moonbridge"); + + const materialSelect = getMaterialSelect(document, "Model"); + const materialTextField = getMaterialTextField(document, "Max Output Tokens"); + const selectStyle = getComputedStyle(materialSelect); + const textFieldStyle = getComputedStyle(materialTextField); + + expect(materialSelect).toHaveClass("material-select--single-line"); + expect(selectStyle.getPropertyValue("--md-outlined-field-top-space").trim()).toBe( + textFieldStyle.getPropertyValue("--md-outlined-text-field-top-space").trim() + ); + expect(selectStyle.getPropertyValue("--md-outlined-field-bottom-space").trim()).toBe( + textFieldStyle.getPropertyValue("--md-outlined-text-field-bottom-space").trim() + ); + expect(selectStyle.getPropertyValue("--md-outlined-select-text-field-input-text-line-height").trim()).toBe( + textFieldStyle.getPropertyValue("--md-outlined-text-field-input-text-line-height").trim() + ); + }); + + test("sends a responses smoke test from Material Web controls and shows latency/result", async () => { + vi.spyOn(responses, "listResponseModels").mockResolvedValue({ + models: [{ slug: "moonbridge", name: "Moon Bridge", provider: "route" }] + }); + const createResponse = vi.spyOn(responses, "createResponse").mockResolvedValue({ + id: "resp_1", + status: "completed", + model: "moonbridge", + output: [], + output_text: "pong" + }); + + const { container } = renderWithConsoleProviders(); + + await screen.findByText("moonbridge"); + setMaterialSelectValue(getMaterialSelect(container, "Model"), "moonbridge"); + setMaterialTextFieldValue(getMaterialTextField(container, "Input"), "ping"); + setMaterialTextFieldValue(getMaterialTextField(container, "Max Output Tokens"), "128"); + setMaterialTextFieldValue(getMaterialTextField(container, "Temperature"), "0.4"); + await submitMaterialForm(container); + + await waitFor(() => expect(createResponse).toHaveBeenCalledWith(expect.objectContaining({ + model: "moonbridge", + input: "ping", + max_output_tokens: 128, + temperature: 0.4 + }))); + expect(await screen.findByText(/pong/)).toBeInTheDocument(); + expect(screen.getByText(/latency/i)).toBeInTheDocument(); + }); +}); + +type MaterialSelectElement = HTMLElement & { + label: string; + value: string; +}; + +type MaterialTextFieldElement = HTMLElement & { + label: string; + type: string; + value: string; +}; + +function getMaterialSelect(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-outlined-select")).find( + (candidate) => candidate.label === label + ); + if (!element) { + throw new Error(`Expected a Material Web select labelled "${label}".`); + } + return element; +} + +function getMaterialTextField(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-outlined-text-field")).find( + (candidate) => candidate.label === label + ); + if (!element) { + throw new Error(`Expected a Material Web text field labelled "${label}".`); + } + return element; +} + +function getMaterialButton(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-filled-button")).find( + (candidate) => candidate.textContent?.trim() === label + ); + if (!element) { + throw new Error(`Expected a Material Web filled button labelled "${label}".`); + } + return element; +} + +function setMaterialSelectValue(element: MaterialSelectElement, value: string) { + act(() => { + element.value = value; + element.dispatchEvent(new Event("change", { bubbles: true })); + }); +} + +function setMaterialTextFieldValue(element: MaterialTextFieldElement, value: string) { + act(() => { + element.value = value; + element.dispatchEvent(new InputEvent("input", { bubbles: true, composed: true })); + }); +} + +async function submitMaterialForm(container: ParentNode) { + const button = getMaterialButton(container, "Send"); + const form = button.closest("form"); + if (!form) { + throw new Error("Expected Material submit button to be inside a form."); + } + let clicked = false; + let submitted = false; + button.addEventListener("click", () => { + clicked = true; + }, { once: true }); + form.addEventListener("submit", () => { + submitted = true; + }, { once: true }); + await userEvent.click(button); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(clicked).toBe(true); + if (!submitted) { + await act(async () => { + form.requestSubmit(); + await Promise.resolve(); + }); + } +} diff --git a/webui/src/features/rpcTest/RpcTestPage.tsx b/webui/src/features/rpcTest/RpcTestPage.tsx new file mode 100644 index 00000000..086fb3b3 --- /dev/null +++ b/webui/src/features/rpcTest/RpcTestPage.tsx @@ -0,0 +1,118 @@ +import { useQuery } from "@tanstack/react-query"; +import { type FormEvent, useState } from "react"; +import { MaterialFilledButton } from "../../components/MaterialButton"; +import { MaterialSelect } from "../../components/MaterialSelect"; +import { MaterialOutlinedTextField } from "../../components/MaterialTextField"; +import { + createResponse, + listResponseModels, + type CreateResponseResult +} from "../../rpc/responses"; +import { useI18n } from "../../i18n/I18nProvider"; +import { PageHeader, QueryErrorState } from "../shared"; + +export function RpcTestPage() { + const { t } = useI18n(); + const models = useQuery({ + queryKey: ["responses", "models"], + queryFn: listResponseModels + }); + const [model, setModel] = useState(""); + const [input, setInput] = useState("ping"); + const [maxTokens, setMaxTokens] = useState("256"); + const [temperature, setTemperature] = useState("0.2"); + const [latency, setLatency] = useState(null); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + + if (models.error) { + return ; + } + + async function submit(event: FormEvent) { + event.preventDefault(); + setError(null); + const started = performance.now(); + try { + const response = await createResponse({ + model: model || models.data?.models[0]?.slug || "", + input, + max_output_tokens: Number(maxTokens), + temperature: Number(temperature) + }); + setLatency(Math.round(performance.now() - started)); + setResult(response); + } catch (caught) { + setLatency(Math.round(performance.now() - started)); + setError(caught); + } + } + + return ( +
    + + {t("rpc.description")} + + +
    +
    +

    {t("rpc.request")}

    +
    + ({ label: item.slug, value: item.slug })) ?? []) + ]} + /> + + + +
    + {t("action.send")} +
    + +
    +
    +

    {t("rpc.response")}

    + {latency !== null ?

    {t("feedback.latency", { latency })}

    : null} + {error ? ( +
    {JSON.stringify(error, null, 2)}
    + ) : ( +
    {JSON.stringify(result ?? {}, null, 2)}
    + )} +
    +
    +
    + ); +} + +const rpcTestStyles = ` + .rpc-test-field { + width: 100%; + min-width: 0; + } +`; diff --git a/webui/src/features/searchTools/SearchToolsPage.test.tsx b/webui/src/features/searchTools/SearchToolsPage.test.tsx new file mode 100644 index 00000000..dbbc92c2 --- /dev/null +++ b/webui/src/features/searchTools/SearchToolsPage.test.tsx @@ -0,0 +1,252 @@ +import { act, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { renderWithConsoleProviders } from "../../test/renderWithConsoleProviders"; +import * as configGraph from "../../rpc/configGraph"; +import * as management from "../../rpc/management"; +import { configGraphFixture } from "../../test/configGraphFixtures"; +import { SearchToolsPage } from "./SearchToolsPage"; + +describe("SearchToolsPage", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("renders web search, extensions, and proxy graph resources without YAML controls", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + + renderWithConsoleProviders(); + + expect(await screen.findByRole("heading", { level: 2, name: "Web Search" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { level: 2, name: "Extensions" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { level: 2, name: "Proxy" })).toBeInTheDocument(); + + const webSearch = screen.getByLabelText("Web Search"); + expect(within(webSearch).getByRole("heading", { level: 3, name: "main" })).toBeInTheDocument(); + expect(within(screen.getByLabelText("Web Search main status")).getByText("Saved")).toBeInTheDocument(); + expect(within(screen.getByLabelText("Extension db_sqlite status")).getByText("Saved")).toBeInTheDocument(); + expect(within(screen.getByLabelText("Proxy main status")).getByText("Critical")).toBeInTheDocument(); + + expect(getMaterialSelect(document, "Web search mode").value).toBe("auto"); + expect(screen.getByText("db_sqlite")).toBeInTheDocument(); + expect(getStructuredObject(document, "Extension config")).not.toHaveTextContent("Structured editor"); + expect(getMaterialTextField(document, "path")).toHaveAttribute("spellcheck", "false"); + expect(getStructuredObject(document, "OpenAI capture proxy")).not.toHaveTextContent("Structured editor"); + expect(getMaterialTextField(document, "base_url")).toHaveAttribute("spellcheck", "false"); + expect(getMaterialTextField(document, "api_key")).toHaveAttribute("spellcheck", "false"); + expect(queryMaterialOutlinedButton(document, /OpenAI capture proxy.*2 keys/)).not.toBeInTheDocument(); + expect(document.querySelector(".schema-json-editor")).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/yaml/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/yaml/i)).not.toBeInTheDocument(); + }); + + test("localizes page chrome in Chinese locale", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + + renderWithConsoleProviders(, { locale: "zh-CN" }); + + expect(await screen.findByRole("heading", { level: 2, name: "联网搜索" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { level: 2, name: "扩展" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { level: 2, name: "代理" })).toBeInTheDocument(); + expect(within(screen.getByLabelText("代理 main 状态")).getByText("关键运行时")).toBeInTheDocument(); + }); + + test("creates an extension from the extensions section", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + vi.spyOn(management, "listExtensions").mockResolvedValue(["db_sqlite", "metrics"]); + const create = vi.spyOn(configGraph, "createConfigResource").mockResolvedValue({ + result: "committed", + revision: "rev-2", + graph: configGraphFixture({ revision: "rev-2" }) + }); + + renderWithConsoleProviders(); + + await waitFor(() => expect(getMaterialButton(document, "Add Extension")).toBeInTheDocument()); + await userEvent.click(getMaterialButton(document, "Add Extension")); + const form = screen.getByRole("form", { name: "Create Extension" }); + expect(within(form).queryByRole("textbox", { name: "Extension ID" })).not.toBeInTheDocument(); + expect(getMaterialSelect(form, "Extension ID")).toBeInTheDocument(); + setMaterialSelectValue(getMaterialSelect(form, "Extension ID"), "metrics"); + submitMaterialForm(form, "Create Extension"); + + await waitFor(() => expect(create).toHaveBeenCalledWith("extension", { + baseRevision: "rev-1", + id: "metrics", + value: { + enabled: true + } + })); + }); + + test("lets users disable a new extension and read create field help", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + vi.spyOn(management, "listExtensions").mockResolvedValue(["db_sqlite", "metrics"]); + const create = vi.spyOn(configGraph, "createConfigResource").mockResolvedValue({ + result: "committed", + revision: "rev-2", + graph: configGraphFixture({ revision: "rev-2" }) + }); + + renderWithConsoleProviders(); + + await waitFor(() => expect(getMaterialButton(document, "Add Extension")).toBeInTheDocument()); + await userEvent.click(getMaterialButton(document, "Add Extension")); + const form = screen.getByRole("form", { name: "Create Extension" }); + const enabledSwitch = getMaterialSwitch(form, "Enabled"); + expect(form.querySelector(".schema-switch")).not.toBeInTheDocument(); + expect(enabledSwitch.closest(".schema-field__switch-line")).toBeInTheDocument(); + expect(enabledSwitch.closest(".schema-field")).toBeInTheDocument(); + expect(enabledSwitch.selected).toBe(true); + expect(getMaterialSelect(form, "Extension ID")).toBeInTheDocument(); + await userEvent.click(getMaterialIconButton(form, "Help for Enabled")); + expect(within(form).getByRole("tooltip")).toHaveTextContent("Turn this extension on or off"); + setMaterialSelectValue(getMaterialSelect(form, "Extension ID"), "metrics"); + setMaterialSwitchSelected(enabledSwitch, false); + submitMaterialForm(form, "Create Extension"); + + await waitFor(() => expect(create).toHaveBeenCalledWith("extension", { + baseRevision: "rev-1", + id: "metrics", + value: { + enabled: false + } + })); + }); + + test("deletes extensions but not singleton search or proxy resources", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + const remove = vi.spyOn(configGraph, "deleteConfigResource").mockResolvedValue({ + result: "committed", + revision: "rev-2", + graph: configGraphFixture({ + revision: "rev-2", + resources: configGraphFixture().resources.filter((resource) => resource.id !== "db_sqlite") + }) + }); + + renderWithConsoleProviders(); + + expect(await screen.findByRole("heading", { level: 2, name: "Web Search" })).toBeInTheDocument(); + expect(queryMaterialButton(document, "Delete Web Search main")).not.toBeInTheDocument(); + expect(queryMaterialButton(document, "Delete Proxy main")).not.toBeInTheDocument(); + + const extensionPanel = screen.getByText("db_sqlite").closest("section")!; + await userEvent.click(getMaterialButton(extensionPanel, "Delete Extension db_sqlite")); + await userEvent.click(getMaterialButton(extensionPanel, "Confirm delete db_sqlite")); + + expect(remove).toHaveBeenCalledWith("extension", "db_sqlite", "rev-1"); + expect(screen.queryByText("db_sqlite")).not.toBeInTheDocument(); + }); +}); + +function getMaterialSwitch(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-switch")).find( + (switchElement) => switchElement.getAttribute("aria-label") === label + ); + if (!element) { + throw new Error(`Expected a Material Web switch labelled "${label}".`); + } + return element as HTMLElement & { selected: boolean }; +} + +type MaterialSelectElement = HTMLElement & { + label: string; + value: string; +}; + +function queryMaterialOutlinedButton(container: ParentNode, label: RegExp) { + return Array.from(container.querySelectorAll("md-outlined-button")).find( + (candidate) => label.test(candidate.getAttribute("aria-label") ?? candidate.textContent ?? "") + ) ?? null; +} + +function getStructuredObject(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll(".schema-structured-object")).find( + (summary) => summary.getAttribute("aria-label")?.startsWith(`${label},`) + ); + if (!element) { + throw new Error(`Expected a structured object editor labelled "${label}".`); + } + return element as HTMLElement; +} + +function getMaterialSelect(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-outlined-select")).find( + (candidate) => materialElementLabel(candidate) === label + ); + if (!element) { + throw new Error(`Expected a Material Web select labelled "${label}".`); + } + return element; +} + +function getMaterialTextField(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-outlined-text-field")).find( + (candidate) => materialElementLabel(candidate as HTMLElement & { label?: string }) === label + ); + if (!element) { + throw new Error(`Expected a Material Web text field labelled "${label}".`); + } + return element as HTMLElement; +} + +function materialElementLabel(element: HTMLElement & { label?: string }) { + const labelledBy = element.getAttribute("aria-labelledby"); + if (labelledBy) { + return labelledBy + .split(/\s+/) + .map((id) => document.getElementById(id)?.textContent?.trim() ?? "") + .filter(Boolean) + .join(" "); + } + return element.label || element.getAttribute("aria-label") || element.getAttribute("label") || ""; +} + +function getMaterialIconButton(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-icon-button")).find( + (candidate) => candidate.getAttribute("aria-label") === label + ); + if (!element) { + throw new Error(`Expected a Material Web icon button labelled "${label}".`); + } + return element as HTMLElement; +} + +function getMaterialButton(container: ParentNode, label: string) { + const element = queryMaterialButton(container, label); + if (!element) { + throw new Error(`Expected a Material Web filled button labelled "${label}".`); + } + return element as HTMLElement; +} + +function queryMaterialButton(container: ParentNode, label: string) { + return Array.from(container.querySelectorAll("md-filled-button")).find((candidate) => { + const accessibleLabel = candidate.getAttribute("aria-label") ?? candidate.textContent ?? ""; + return accessibleLabel.includes(label); + }) ?? null; +} + +function setMaterialSelectValue(element: MaterialSelectElement, value: string) { + act(() => { + element.value = value; + element.dispatchEvent(new Event("change", { bubbles: true })); + }); +} + +function setMaterialSwitchSelected(element: HTMLElement & { selected: boolean }, selected: boolean) { + act(() => { + element.selected = selected; + element.dispatchEvent(new Event("change", { bubbles: true })); + }); +} + +function submitMaterialForm(container: ParentNode, submitLabel: string) { + const button = getMaterialButton(container, submitLabel); + const form = button.closest("form"); + if (!form) { + throw new Error("Expected Material Web submit button inside a form."); + } + fireEvent.submit(form); +} diff --git a/webui/src/features/searchTools/SearchToolsPage.tsx b/webui/src/features/searchTools/SearchToolsPage.tsx new file mode 100644 index 00000000..72fb4646 --- /dev/null +++ b/webui/src/features/searchTools/SearchToolsPage.tsx @@ -0,0 +1,112 @@ +import { useQuery } from "@tanstack/react-query"; +import { LoadingState } from "../../components/LoadingState"; +import { useI18n } from "../../i18n/I18nProvider"; +import { listExtensions } from "../../rpc/management"; +import { queryKeys } from "../../rpc/queryKeys"; +import type { ConfigResource } from "../../rpc/types"; +import { CreateResourcePanel } from "../configGraph/CreateResourcePanel"; +import { ResourceEditorCard } from "../configGraph/ResourceEditorCard"; +import { useConfigGraph } from "../configGraph/useConfigGraph"; +import { PageHeader, QueryErrorState } from "../shared"; + +export function SearchToolsPage() { + const { t } = useI18n(); + const graph = useConfigGraph(); + const registeredExtensions = useQuery({ + queryKey: queryKeys.extensions, + queryFn: listExtensions + }); + + if (graph.error) { + return ; + } + if (graph.isLoading || !graph.data) { + return ; + } + + const webSearch = graph.data.resources.find((resource) => resource.kind === "web_search"); + const extensions = graph.data.resources.filter((resource) => resource.kind === "extension"); + const proxy = graph.data.resources.find((resource) => resource.kind === "proxy"); + const existingExtensionIds = new Set(extensions.map((extension) => extension.id)); + const availableExtensionIds = (registeredExtensions.data ?? []) + .filter((extensionId) => !existingExtensionIds.has(extensionId)); + + return ( +
    + + {t("searchTools.description")} + + + {webSearch ? ( + + ) : null} + +
    +
    +

    {t("searchTools.extensions")}

    + {registeredExtensions.isLoading ? ( + {t("common.loading")} + ) : registeredExtensions.error ? ( + + {registeredExtensions.error instanceof Error + ? registeredExtensions.error.message + : t("error.unknownRequest")} + + ) : ( + + )} +
    +
    + {extensions.map((extension) => ( + + ))} +
    +
    + + {proxy ? ( + + ) : null} +
    + ); +} + +function ResourceSection({ + resource, + revision, + title +}: { + resource: ConfigResource; + revision: string; + title: string; +}) { + const { t } = useI18n(); + return ( +
    +

    {title}

    + +
    + ); +} diff --git a/webui/src/features/security/SecurityPage.test.tsx b/webui/src/features/security/SecurityPage.test.tsx new file mode 100644 index 00000000..d751749b --- /dev/null +++ b/webui/src/features/security/SecurityPage.test.tsx @@ -0,0 +1,56 @@ +import { screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { renderWithConsoleProviders } from "../../test/renderWithConsoleProviders"; +import * as configGraph from "../../rpc/configGraph"; +import { configGraphFixture } from "../../test/configGraphFixtures"; +import { SecurityPage } from "./SecurityPage"; + +describe("SecurityPage", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("renders server security fields with write-only auth token", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + + renderWithConsoleProviders(); + + expect(await screen.findByRole("heading", { level: 2, name: "Server" })).toBeInTheDocument(); + expect(within(screen.getByLabelText("Server")).getByRole("heading", { level: 3, name: "main" })).toBeInTheDocument(); + expect(within(screen.getByLabelText("Server main status")).getByText("Restart required")).toBeInTheDocument(); + expect(within(screen.getByLabelText("Server main status")).getByText("Critical")).toBeInTheDocument(); + expect(screen.getByLabelText("Listen address")).toHaveValue(":38440"); + expect(screen.getByLabelText("Max sessions")).toHaveValue("64"); + expect(screen.getByLabelText("Session TTL")).toHaveValue("24h"); + expect(screen.getByLabelText("Auth token")).toHaveValue(""); + expect(screen.queryByDisplayValue("******")).not.toBeInTheDocument(); + expect(screen.getByText("Restart required")).toBeInTheDocument(); + }); + + test("localizes page chrome in Chinese locale", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + + renderWithConsoleProviders(, { locale: "zh-CN" }); + + expect(await screen.findByRole("heading", { level: 2, name: "服务访问" })).toBeInTheDocument(); + expect(within(screen.getByLabelText("服务访问 main 状态")).getByText("需要重启")).toBeInTheDocument(); + expect(getMaterialTextField(document, "认证 Token").supportingText).toBe("输入新值以替换已保存的密钥。"); + expect(getMaterialTextField(document, "认证 Token")).toHaveAttribute("aria-label", "认证 Token"); + expect(screen.queryByLabelText("Auth Token")).not.toBeInTheDocument(); + }); +}); + +type MaterialTextFieldElement = HTMLElement & { + label: string; + supportingText: string; +}; + +function getMaterialTextField(container: ParentNode, label: string) { + const element = Array.from(container.querySelectorAll("md-outlined-text-field")).find( + (candidate) => candidate.label === label || candidate.getAttribute("aria-label") === label + ); + if (!element) { + throw new Error(`Expected a Material Web outlined text field labelled "${label}".`); + } + return element; +} diff --git a/webui/src/features/security/SecurityPage.tsx b/webui/src/features/security/SecurityPage.tsx new file mode 100644 index 00000000..533449ee --- /dev/null +++ b/webui/src/features/security/SecurityPage.tsx @@ -0,0 +1,52 @@ +import { LoadingState } from "../../components/LoadingState"; +import { useI18n } from "../../i18n/I18nProvider"; +import type { ConfigResource } from "../../rpc/types"; +import { ResourceEditorCard } from "../configGraph/ResourceEditorCard"; +import { useConfigGraph } from "../configGraph/useConfigGraph"; +import { PageHeader, QueryErrorState } from "../shared"; + +export function SecurityPage() { + const { t } = useI18n(); + const graph = useConfigGraph(); + + if (graph.error) { + return ; + } + if (graph.isLoading || !graph.data) { + return ; + } + + const server = graph.data.resources.find((resource) => resource.kind === "server"); + + return ( +
    + + {t("security.description")} + + + {server ? : null} +
    + ); +} + +function ServerSection({ + resource, + revision +}: { + resource: ConfigResource; + revision: string; +}) { + const { t } = useI18n(); + const title = t("security.server"); + return ( +
    +

    {title}

    + +
    + ); +} diff --git a/webui/src/features/shared.tsx b/webui/src/features/shared.tsx new file mode 100644 index 00000000..8d4438b2 --- /dev/null +++ b/webui/src/features/shared.tsx @@ -0,0 +1,97 @@ +import type { ReactNode } from "react"; +import { ApiError } from "../rpc/http"; +import { ErrorState } from "../components/ErrorState"; +import { useI18n } from "../i18n/I18nProvider"; +import { type ConfigPath, getConfigDescription } from "../configDocs/configDescriptions"; + +export const defaultPage = { limit: 20, offset: 0 }; + +export function PageHeader({ + title +}: { + eyebrow: string; + title: string; + children?: ReactNode; +}) { + return ( +
    +

    {title}

    +
    + ); +} + +export function StoreUnavailableState() { + const { t } = useI18n(); + return ( + + ); +} + +export function QueryErrorState({ error }: { error: unknown }) { + if (error instanceof ApiError && (error.code === "store_unavailable" || error.status === 404)) { + return ; + } + const { t } = useI18n(); + const message = error instanceof Error ? error.message : t("error.unknownRequest"); + return ; +} + +export function formatNumber(value: number | undefined) { + return typeof value === "number" ? new Intl.NumberFormat().format(value) : "0"; +} + +export function FieldHint({ children }: { children: ReactNode }) { + return {children}; +} + +export function ConfigHint({ path, id }: { path: ConfigPath; id?: string }) { + const { locale, t } = useI18n(); + const description = getConfigDescription(path, locale); + + return ( + + {description.description} + {t("configDoc.type")}: {localizedConfigMetaValue(description.type, t)} + {description.defaultValue ? ( + {t("configDoc.default")}: {localizedConfigMetaValue(description.defaultValue, t)} + ) : null} + {description.sensitive ? {t("configDoc.sensitive")} : null} + + ); +} + +function localizedConfigMetaValue(value: string, t: ReturnType["t"]) { + const normalized = value.trim().toLowerCase(); + const localized: Record = { + boolean: t("configDoc.type.boolean"), + empty: t("configDoc.default.empty"), + "host:port": t("configDoc.type.hostPort"), + number: t("configDoc.type.number"), + object: t("configDoc.type.object"), + string: t("configDoc.type.string"), + url: t("configDoc.type.url") + }; + return localized[normalized] ?? value; +} + +export function FieldWithHint({ + children, + className, + hintPath, + hintId +}: { + children: ReactNode; + className?: string; + hintPath: ConfigPath; + hintId: string; +}) { + return ( +
    + {children} + +
    + ); +} diff --git a/webui/src/features/storage/StoragePage.test.tsx b/webui/src/features/storage/StoragePage.test.tsx new file mode 100644 index 00000000..debd46c4 --- /dev/null +++ b/webui/src/features/storage/StoragePage.test.tsx @@ -0,0 +1,52 @@ +import { screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { renderWithConsoleProviders } from "../../test/renderWithConsoleProviders"; +import * as configGraph from "../../rpc/configGraph"; +import { configGraphFixture } from "../../test/configGraphFixtures"; +import { StoragePage } from "./StoragePage"; + +describe("StoragePage", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("renders cache and persistence resources with runtime storage errors", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue( + configGraphFixture({ + runtime: { + status: "runtimeRejected", + errors: [ + { + resourceKind: "persistence", + resourceId: "main", + field: "active_provider", + code: "databaseUnavailable", + message: "database unavailable" + } + ] + } + }) + ); + + renderWithConsoleProviders(); + + expect(await screen.findByRole("heading", { level: 2, name: "Cache" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { level: 2, name: "Persistence" })).toBeInTheDocument(); + expect(within(screen.getByLabelText("Cache")).getByRole("heading", { level: 3, name: "main" })).toBeInTheDocument(); + expect(within(screen.getByLabelText("Cache main status")).getByText("Saved")).toBeInTheDocument(); + expect(within(screen.getByLabelText("Persistence main status")).getByText("Saved")).toBeInTheDocument(); + expect(screen.getByLabelText("Cache mode")).toHaveValue("memory"); + expect(screen.getByLabelText("Persistence provider")).toHaveValue("db_sqlite"); + expect(screen.getByText("database unavailable")).toBeInTheDocument(); + }); + + test("localizes page chrome in Chinese locale", async () => { + vi.spyOn(configGraph, "getConfigGraph").mockResolvedValue(configGraphFixture()); + + renderWithConsoleProviders(, { locale: "zh-CN" }); + + expect(await screen.findByRole("heading", { level: 2, name: "缓存" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { level: 2, name: "持久化" })).toBeInTheDocument(); + expect(within(screen.getByLabelText("缓存 main 状态")).getByText("已保存")).toBeInTheDocument(); + }); +}); diff --git a/webui/src/features/storage/StoragePage.tsx b/webui/src/features/storage/StoragePage.tsx new file mode 100644 index 00000000..e73436b7 --- /dev/null +++ b/webui/src/features/storage/StoragePage.tsx @@ -0,0 +1,90 @@ +import { LoadingState } from "../../components/LoadingState"; +import { useI18n } from "../../i18n/I18nProvider"; +import type { ConfigResource, FieldError } from "../../rpc/types"; +import { ResourceEditorCard } from "../configGraph/ResourceEditorCard"; +import { useConfigGraph } from "../configGraph/useConfigGraph"; +import { PageHeader, QueryErrorState } from "../shared"; + +const storageKinds = new Set(["cache", "persistence"]); + +export function StoragePage() { + const { t } = useI18n(); + const graph = useConfigGraph(); + + if (graph.error) { + return ; + } + if (graph.isLoading || !graph.data) { + return ; + } + + const cache = graph.data.resources.find((resource) => resource.kind === "cache"); + const persistence = graph.data.resources.find((resource) => resource.kind === "persistence"); + const storageErrors = (graph.data.runtime.errors ?? []).filter((error) => + storageKinds.has(error.resourceKind) + ); + + return ( +
    + + {t("storage.description")} + + + {storageErrors.length > 0 ? : null} + {cache ? ( + + ) : null} + {persistence ? ( + + ) : null} +
    + ); +} + +function ErrorList({ errors }: { errors: FieldError[] }) { + const { t } = useI18n(); + return ( +
    +

    {t("storage.status")}

    +
      + {errors.map((error) => ( +
    • + {error.resourceId || error.resourceKind} + {error.message} +
    • + ))} +
    +
    + ); +} + +function ResourceSection({ + resource, + revision, + title +}: { + resource: ConfigResource; + revision: string; + title: string; +}) { + const { t } = useI18n(); + return ( +
    +

    {title}

    + +
    + ); +} diff --git a/webui/src/i18n/I18nProvider.test.tsx b/webui/src/i18n/I18nProvider.test.tsx new file mode 100644 index 00000000..dc6e6c0f --- /dev/null +++ b/webui/src/i18n/I18nProvider.test.tsx @@ -0,0 +1,108 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + CONSOLE_LOCALE_STORAGE_KEY, + I18nProvider, + translateMessage, + useI18n +} from "./I18nProvider"; + +function Probe() { + const { locale, setLocale, t } = useI18n(); + return ( +
    +

    {locale}

    +

    {t("nav.overview")}

    +

    {t("overview.routes")}

    + + +
    + ); +} + +describe("I18nProvider", () => { + afterEach(() => { + localStorage.clear(); + vi.restoreAllMocks(); + }); + + test("defaults to Chinese when navigator language is zh", () => { + vi.spyOn(window.navigator, "language", "get").mockReturnValue("zh-CN"); + + render( + + + + ); + + expect(screen.getByTestId("locale")).toHaveTextContent("zh-CN"); + expect(screen.getByTestId("title")).toHaveTextContent("概览"); + expect(screen.getByTestId("routes")).toHaveTextContent("路由"); + }); + + test("supports switching to English and persists the choice", async () => { + const user = userEvent.setup(); + vi.spyOn(window.navigator, "language", "get").mockReturnValue("zh-CN"); + + render( + + + + ); + + await user.click(screen.getByRole("button", { name: "English" })); + + expect(screen.getByTestId("locale")).toHaveTextContent("en-US"); + expect(screen.getByTestId("title")).toHaveTextContent("Overview"); + expect(localStorage.getItem(CONSOLE_LOCALE_STORAGE_KEY)).toBe("en-US"); + }); + + test("uses stored language before navigator language", () => { + localStorage.setItem(CONSOLE_LOCALE_STORAGE_KEY, "en-US"); + vi.spyOn(window.navigator, "language", "get").mockReturnValue("zh-CN"); + + render( + + + + ); + + expect(screen.getByTestId("locale")).toHaveTextContent("en-US"); + expect(screen.getByTestId("title")).toHaveTextContent("Overview"); + }); + + test("translates messages outside React from the stored locale", () => { + localStorage.setItem(CONSOLE_LOCALE_STORAGE_KEY, "zh-CN"); + + expect(translateMessage("error.requestFailedWithStatus", { status: 502 })).toBe("请求失败,状态码 502"); + }); + + test("keeps working when localStorage is unavailable", () => { + const original = Object.getOwnPropertyDescriptor(window, "localStorage"); + Object.defineProperty(window, "localStorage", { + configurable: true, + get() { + throw new DOMException("blocked", "SecurityError"); + } + }); + vi.spyOn(window.navigator, "language", "get").mockReturnValue("en-US"); + + render( + + + + ); + + expect(screen.getByTestId("locale")).toHaveTextContent("en-US"); + expect(screen.getByTestId("title")).toHaveTextContent("Overview"); + + if (original) { + Object.defineProperty(window, "localStorage", original); + } + }); +}); diff --git a/webui/src/i18n/I18nProvider.tsx b/webui/src/i18n/I18nProvider.tsx new file mode 100644 index 00000000..f1e273c8 --- /dev/null +++ b/webui/src/i18n/I18nProvider.tsx @@ -0,0 +1,97 @@ +import { + createContext, + type ReactNode, + useCallback, + useContext, + useMemo, + useState +} from "react"; +import { type Locale, type MessageKey, messages, normalizeLocale } from "./messages"; + +export const CONSOLE_LOCALE_STORAGE_KEY = "moonbridge.console.locale"; + +type InterpolationValue = string | number; + +type I18nContextValue = { + locale: Locale; + setLocale: (locale: Locale) => void; + t: (key: MessageKey, values?: Record) => string; +}; + +const I18nContext = createContext(undefined); + +export function I18nProvider({ children }: { children: ReactNode }) { + const [locale, setLocaleState] = useState(readInitialLocale); + + const setLocale = useCallback((nextLocale: Locale) => { + setLocaleState(nextLocale); + safeSetStorage(CONSOLE_LOCALE_STORAGE_KEY, nextLocale); + }, []); + + const t = useCallback( + (key: MessageKey, values?: Record) => + translateMessageForLocale(locale, key, values), + [locale] + ); + + const value = useMemo(() => ({ locale, setLocale, t }), [locale, setLocale, t]); + + return {children}; +} + +export function useI18n() { + const context = useContext(I18nContext); + if (!context) { + throw new Error("useI18n must be used within I18nProvider"); + } + return context; +} + +export function translateMessage(key: MessageKey, values?: Record) { + return translateMessageForLocale(readInitialLocale(), key, values); +} + +function translateMessageForLocale( + locale: Locale, + key: MessageKey, + values?: Record +) { + return interpolate(messages[locale][key] ?? messages["zh-CN"][key], values); +} + +function readInitialLocale(): Locale { + const stored = safeGetStorage(CONSOLE_LOCALE_STORAGE_KEY); + if (stored === "en-US" || stored === "zh-CN") { + return stored; + } + if (typeof window === "undefined") { + return "zh-CN"; + } + return normalizeLocale(window.navigator.language); +} + +function interpolate(message: string, values?: Record) { + if (!values) { + return message; + } + return Object.entries(values).reduce( + (result, [key, value]) => result.replaceAll(`{${key}}`, String(value)), + message + ); +} + +function safeGetStorage(key: string): string | null { + try { + return window.localStorage?.getItem(key) ?? null; + } catch { + return null; + } +} + +function safeSetStorage(key: string, value: string) { + try { + window.localStorage?.setItem(key, value); + } catch { + // Storage can be disabled in hardened browser contexts. + } +} diff --git a/webui/src/i18n/locales/en.ts b/webui/src/i18n/locales/en.ts new file mode 100644 index 00000000..2caddd49 --- /dev/null +++ b/webui/src/i18n/locales/en.ts @@ -0,0 +1,344 @@ +export const en = { + "action.saveToken": "Save token", + "action.send": "Send", + "app.console": "Console", + "app.consoleSections": "Console sections", + "app.language": "Language", + "app.language.en": "English", + "app.language.zh": "中文", + "app.routeContent": "Console route content", + "app.signOut": "Sign out", + "app.switchTheme": "Switch to {theme} theme", + "theme.dark": "dark", + "theme.light": "light", + "nav.overview": "Overview", + "nav.modelsProviders": "Models & Providers", + "nav.routes": "Routes", + "nav.defaults": "Defaults", + "nav.searchTools": "Search & Tools", + "nav.storage": "Storage", + "nav.security": "Security", + "nav.logs": "Logs", + "nav.rpcTest": "RPC Test", + "auth.eyebrow": "Authentication required", + "auth.title": "Enter Moon Bridge Token", + "auth.token": "Token", + "auth.remember": "Remember on this device", + "auth.signedOut": "Signed out — re-enter your token.", + "auth.verifying": "Verifying…", + "auth.revealToken": "Show token", + "auth.hideToken": "Hide token", + "common.error": "Error", + "common.loading": "Loading", + "common.unknown": "unknown", + "config.description": "Adjust how Moon Bridge runs. Changes apply instantly.", + "configDoc.default": "Default", + "configDoc.default.empty": "empty", + "configDoc.optional": "Optional", + "configDoc.required": "Required", + "configDoc.sensitive": "Sensitive", + "configDoc.savedRealtime": "Saved in realtime", + "configDoc.restartMayBeRequired": "May require restart", + "configDoc.type": "Type", + "configDoc.type.array": "array", + "configDoc.type.boolean": "boolean", + "configDoc.type.hostPort": "host:port", + "configDoc.type.number": "number", + "configDoc.type.object": "object", + "configDoc.type.string": "string", + "configDoc.type.url": "URL", + "editor.liveSaving": "Saving\u2026", + "editor.liveUnsaved": "Unsaved changes", + "editor.liveError": "Save failed", + "create.cancel": "Cancel", + "create.close": "Close create panel", + "create.extension.add": "Add Extension", + "create.extension.enabled": "Enabled", + "create.extension.id": "Extension ID", + "create.extension.submit": "Create Extension", + "create.extension.title": "Create Extension", + "create.contextWindowPresets": "{label} presets", + "create.contextWindowPreset.128k": "128k", + "create.contextWindowPreset.400k": "400k", + "create.contextWindowPreset.1m": "1m", + "create.help.extensionEnabled": "Enables the extension.", + "create.help.extensionId": "Stable id, e.g. metrics.", + "create.help.modelContextWindow": "Maximum context tokens.", + "create.help.modelDisplayName": "Name shown in the console.", + "create.help.modelId": "Stable model id.", + "create.help.offerBilling": "Optional pricing for cost tracking.", + "create.help.offerCacheReadPrice": "Cache-read price for cost tracking.", + "create.help.offerCacheWritePrice": "Cache-write price for cost tracking.", + "create.help.offerInputPrice": "Input token price for cost tracking.", + "create.help.offerModel": "Model this binding supplies.", + "create.help.offerOutputPrice": "Output token price for cost tracking.", + "create.help.offerPriority": "Order weight; lower is tried first.", + "create.help.offerProvider": "Provider for this binding.", + "create.help.offerUpstreamName": "Real upstream model name.", + "create.help.providerApiKey": "API key sent to the provider.", + "create.help.providerBaseUrl": "Upstream API base URL.", + "create.help.providerId": "Stable provider id.", + "create.help.providerProtocol": "Upstream API format.", + "create.help.routeId": "The alias clients use.", + "create.help.routeModel": "Model this route points to.", + "create.help.routeProvider": "Provider that serves this route.", + "create.invalidNumber": "{field} must be a valid number.", + "create.positiveNumber": "{field} must be greater than zero.", + "create.model.add": "Add Model", + "create.model.contextWindow": "Context window", + "create.model.displayName": "Display name", + "create.model.id": "Model ID", + "create.model.submit": "Create Model", + "create.model.title": "Create Model", + "create.offer.add": "Add Provider", + "create.offer.billing": "Billing", + "create.offer.cacheReadPrice": "Cache read price", + "create.offer.cacheWritePrice": "Cache write price", + "create.offer.inputPrice": "Input price", + "create.offer.model": "Model", + "create.offer.outputPrice": "Output price", + "create.offer.priority": "Priority", + "create.offer.provider": "Provider", + "create.offer.submit": "Create Provider", + "create.offer.title": "Create Provider", + "create.offer.upstreamName": "Upstream name", + "create.provider.add": "Add Provider", + "create.provider.apiKey": "API key", + "create.provider.baseUrl": "Base URL", + "create.provider.id": "Provider ID", + "create.provider.protocol": "Protocol", + "create.provider.submit": "Create Provider", + "create.provider.title": "Create Provider", + "create.route.add": "Add Route", + "create.route.id": "Route alias", + "create.route.model": "Model", + "create.route.provider": "Provider", + "create.route.submit": "Create Route", + "create.route.title": "Create Route", + "empty.resources": "No resources", + "error.requestFailed": "Request failed", + "error.requestFailedWithStatus": "Request failed with status {status}", + "field.configUpdateFailed": "Config update {result} failed.", + "error.storeTitle": "Management API unavailable", + "error.storeMessage": "Saved settings aren't available right now. Enable persistence to edit here.", + "error.unknownRequest": "Unknown request error", + "feedback.latency": "Latency: {latency}ms", + "field.input": "Input", + "field.helpFor": "Help for {label}", + "field.editableList.add": "Add", + "field.editableList.addAction": "Add {label} item", + "field.editableList.addInput": "Add {label}", + "field.editableList.remove": "Remove {item} from {label}", + "field.extensions.addAction": "Add {label} extension", + "field.extensions.addInput": "Add extension to {label}", + "field.extensions.enable": "Enable {name} extension", + "field.extensions.remove": "Remove {name} extension", + "field.invalidNumber": "Invalid number", + "field.maxOutputTokens": "Max Output Tokens", + "field.model": "Model", + "field.providerOverrides.baseInstructions": "Override base instructions", + "field.providerOverrides.contextWindow": "Override context window", + "field.providerOverrides.defaultReasoningLevel": "Override default reasoning level", + "field.providerOverrides.defaultReasoningSummary": "Override default reasoning summary", + "field.providerOverrides.description": "Override description", + "field.providerOverrides.disabled": "Disabled", + "field.providerOverrides.displayName": "Override display name", + "field.providerOverrides.enabled": "Enabled", + "field.providerOverrides.inherit": "Inherit", + "field.providerOverrides.inputModalities": "Override input modalities", + "field.providerOverrides.maxOutputTokens": "Override max output tokens", + "field.providerOverrides.supportedReasoningLevels": "Override supported reasoning levels", + "field.providerOverrides.supportsImageDetailOriginal": "Override supports original image detail", + "field.providerOverrides.supportsReasoning": "Override supports reasoning", + "field.providerOverrides.supportsReasoningSummaries": "Override supports reasoning summaries", + "field.providerOverrides.title": "Provider overrides", + "field.secretReplacementHint": "Enter a new value to replace the saved secret.", + "field.structuredEditable": "Structured editor", + "field.structuredEmpty": "No configured entries.", + "field.structuredEmptyValue": "Empty", + "field.structuredEditorLabel": "{label}, structured editor", + "field.structuredNestedValues": "Nested values", + "field.structuredReadonly": "Structured summary", + "field.structuredSummary": "Structured summary", + "field.structuredSummaryLabel": "{label}, structured summary", + "field.summary.items.many": "{count} items", + "field.summary.items.one": "1 item", + "field.summary.keys.many": "{count} keys", + "field.summary.keys.one": "1 key", + "field.temperature": "Temperature", + "field.webSearch.firecrawlAPIKey": "{label} Firecrawl API key", + "field.webSearch.maxUses": "{label} max uses", + "field.webSearch.searchMaxRounds": "{label} search max rounds", + "field.webSearch.support": "{label} mode", + "field.webSearch.tavilyAPIKey": "{label} Tavily API key", + "loading.overview": "Loading console overview", + "loading.providers": "Loading providers", + "loading.routes": "Loading routes", + "logs.copy": "Copy", + "logs.description": "Recent logs from the server.", + "logs.download": "Download", + "logs.empty": "No log entries yet.", + "logs.emptyFiltered": "No logs match the current filters.", + "logs.follow": "Follow", + "logs.followMode": "Live follow mode", + "logs.levelAll": "All", + "logs.levelFilter": "Log level filter", + "logs.output": "Log output", + "logs.pause": "Pause", + "logs.panelLabel": "Backend logs", + "logs.panelTitle": "Backend logs", + "logs.search": "Search logs", + "logs.clearSearch": "Clear log search", + "logs.rowLabel": "Log {index}", + "logs.streamDisconnected": "Live stream disconnected", + "logs.streamBodyEmpty": "Log stream response body is empty", + "logs.streamFailedWithStatus": "Log stream failed with status {status}", + "logs.visibleCount": "{visible} of {total} logs", + "modelsProviders.description": "Manage providers and models. Changes apply in real time.", + "modelsProviders.models": "Models ({count})", + "modelsProviders.modelProvidersToggle": "Providers", + "modelsProviders.modelRegion": "Model {id}", + "modelsProviders.offers": "Providers ({count})", + "modelsProviders.modelProviders": "Providers ({count})", + "modelsProviders.providerRegion": "Provider {id}", + "modelsProviders.providers": "Providers ({count})", + "modelsProviders.unmatchedSupplies": "Unmatched Providers ({count})", + "modelsProviders.upstreamSupply": "Providers ({count})", + "overview.description": "Usage, tokens, cache, cost, and recent logs.", + "overview.actualModel": "Actual model", + "overview.avgCost": "Avg cost", + "overview.cacheHit": "Cache hit", + "overview.cacheHitValue": "{rate}", + "overview.cacheRead": "Cache read", + "overview.cacheReadWrite": "Read/write", + "overview.cacheRatioValue": "{ratio}", + "overview.cacheSplit": "Cache split", + "overview.cacheSplitChart": "Cache split chart", + "overview.cacheWrite": "Cache write", + "overview.chartAriaItem": "{label}: {value}", + "overview.chartAriaLabel": "{title}. {summary}", + "overview.chartAriaSeparator": ", ", + "overview.cost": "Cost", + "overview.costByModel": "Cost by model", + "overview.costByModelChart": "Cost by model chart", + "overview.costPerMillionSuffix": "/M", + "overview.duration.hoursShort": "{count}h", + "overview.duration.minutesShort": "{count}m", + "overview.duration.secondsShort": "{count}s", + "overview.graph": "Graph", + "overview.graphUnavailableDescription": "Settings are temporarily unavailable. Usage and logs still work.", + "overview.graphUnavailableTitle": "Configuration graph unavailable", + "overview.inputTokens": "Input tokens", + "overview.inputValue": "{count}", + "overview.invalid": "Invalid", + "overview.mode": "Mode", + "overview.model": "Model", + "overview.modelUsageRow": "{model} usage", + "overview.modelUsageTable": "Model usage table", + "overview.models": "Models", + "overview.noData": "no data", + "overview.outputTokens": "Output tokens", + "overview.outputValue": "{count}", + "overview.providers": "Providers", + "overview.requests": "Requests", + "overview.requestsValue": "{count}", + "overview.restart": "Restart", + "overview.restartCount.many": "{count} restart", + "overview.restartCount.one": "1 restart", + "overview.revision": "Revision", + "overview.routes": "Routes", + "overview.runtime": "Runtime", + "overview.tokenSplit": "Token split", + "overview.tokenSplitChart": "Token split chart", + "overview.totalCost": "Total cost", + "overview.totalCostValue": "{cost}", + "overview.usageDescription": "Model volume, cache, and cost for the selected range.", + "overview.usageEmpty": "No usage has been recorded yet.", + "overview.usageTitle": "Usage Analytics", + "overview.rangeLabel": "Time range", + "overview.demoData": "Demo data", + "overview.range.session": "This session", + "overview.range.24h": "24h", + "overview.range.7d": "7d", + "overview.range.30d": "30d", + "overview.range.all": "All time", + "overview.valid": "Valid", + "overview.validation": "Validation", + "pageEyebrow.aliases": "Aliases", + "pageEyebrow.analytics": "Analytics", + "pageEyebrow.config": "Configuration", + "pageEyebrow.runtime": "Runtime", + "pageEyebrow.smokeTest": "Smoke Test", + "pageEyebrow.upstream": "Upstream", + "placeholder.description": "This section is a work in progress.", + "placeholder.eyebrow": "Console workspace", + "routes.description": "Map each client alias to a model and provider.", + "routes.listTitle": "Routes ({count})", + "routes.resourceTitle": "Route", + "rpc.description": "Send a test request to check that everything works.", + "rpc.request": "Request", + "rpc.response": "Response", + "rpc.selectModel": "Select model", + "resource.fieldCount.many": "{count} fields", + "resource.fieldCount.one": "1 field", + "resource.cancelDelete": "Cancel", + "resource.confirmDelete": "Confirm delete {id}", + "resource.confirmDeleteShort": "Delete", + "resource.delete": "Delete {title} {id}", + "resource.cardLabel": "{title} {id}", + "resource.deletePrompt": "Delete {id}? This takes effect after saving.", + "resource.deleteShort": "Delete", + "resource.openEditor": "Edit {title} {id}", + "resource.editShort": "Edit", + "resource.editorHeading": "Edit {title}", + "resource.editorAriaLabel": "{title} {id} editor", + "resource.closeEditor": "Close", + "resource.fact.keySet": "Key set", + "route.warning.modelMissing": "Select a model", + "route.warning.modelUnknown": "Model \"{value}\" is not configured", + "route.warning.providerMissing": "Select a provider", + "route.warning.providerUnknown": "Provider \"{value}\" is not configured", + "resource.group.advancedFeatures": "Advanced Features", + "resource.group.basic": "Basic", + "resource.group.billing": "Billing", + "resource.group.identity": "Identity", + "resource.group.multimodal": "Multimodal", + "resource.group.reasoning": "Reasoning", + "resource.group.settings": "Settings", + "resource.group.toggle": "Toggle {label}", + "resource.impact.critical": "Critical", + "resource.impact.normal": "Runtime safe", + "resource.kind.model": "Model", + "resource.kind.offer": "Provider", + "resource.kind.provider": "Provider", + "resource.kind.defaults": "Defaults", + "resource.kind.log": "Log", + "resource.kind.trace": "Trace", + "provider.protocol.anthropic": "Anthropic", + "provider.protocol.googleGenai": "Gemini", + "provider.protocol.openaiChat": "OpenAI Chat", + "provider.protocol.openaiResponses": "OpenAI Responses", + "resource.reload.hot": "Hot reload", + "resource.reload.restart": "Restart on change", + "resource.statusGroupLabel": "{label} status", + "resource.status.needsAttention": "Needs attention", + "resource.status.restartRequired": "Restart required", + "resource.status.saved": "Saved", + "searchTools.description": "Web search, extensions, and proxy settings.", + "searchTools.extension": "Extension", + "searchTools.extensions": "Extensions", + "searchTools.proxy": "Proxy", + "searchTools.webSearch": "Web Search", + "security.description": "Access, sign-in, and active sessions.", + "security.server": "Server", + "saveStatus.dirty": "Unsaved", + "saveStatus.error": "Error", + "saveStatus.saved": "Saved", + "saveStatus.saving": "Saving", + "saveStatus.idle": "Saved", + "storage.cache": "Cache", + "storage.description": "Cache and where your settings are saved.", + "storage.errors": "Storage errors", + "storage.persistence": "Persistence", + "storage.status": "Storage Status" +} as const; diff --git a/webui/src/i18n/locales/zh.ts b/webui/src/i18n/locales/zh.ts new file mode 100644 index 00000000..3afa889f --- /dev/null +++ b/webui/src/i18n/locales/zh.ts @@ -0,0 +1,346 @@ +import type { Messages } from "../messages"; + +export const zh = { + "action.saveToken": "保存 Token", + "action.send": "发送", + "app.console": "控制台", + "app.consoleSections": "控制台分区", + "app.language": "语言", + "app.language.en": "English", + "app.language.zh": "中文", + "app.routeContent": "控制台路由内容", + "app.signOut": "登出", + "app.switchTheme": "切换到{theme}主题", + "theme.dark": "暗色", + "theme.light": "亮色", + "nav.overview": "概览", + "nav.modelsProviders": "模型与提供商", + "nav.routes": "路由", + "nav.defaults": "默认值", + "nav.searchTools": "搜索与工具", + "nav.storage": "存储", + "nav.security": "安全", + "nav.logs": "日志", + "nav.rpcTest": "RPC 测试", + "auth.eyebrow": "需要认证", + "auth.title": "输入 Moon Bridge Token", + "auth.token": "Token", + "auth.remember": "在此设备记住", + "auth.signedOut": "已登出,请重新输入令牌。", + "auth.verifying": "验证中…", + "auth.revealToken": "显示令牌", + "auth.hideToken": "隐藏令牌", + "common.error": "错误", + "common.loading": "加载中", + "common.unknown": "未知", + "config.description": "调整 Moon Bridge 的运行方式,改动即时生效。", + "configDoc.default": "默认值", + "configDoc.default.empty": "空", + "configDoc.optional": "可选", + "configDoc.required": "必填", + "configDoc.sensitive": "敏感项", + "configDoc.savedRealtime": "实时保存", + "configDoc.restartMayBeRequired": "可能需要重启", + "configDoc.type": "类型", + "configDoc.type.array": "数组", + "configDoc.type.boolean": "布尔值", + "configDoc.type.hostPort": "主机:端口", + "configDoc.type.number": "数字", + "configDoc.type.object": "对象", + "configDoc.type.string": "字符串", + "configDoc.type.url": "URL", + "editor.liveSaving": "保存中…", + "editor.liveUnsaved": "未保存的更改", + "editor.liveError": "保存失败", + "create.cancel": "取消", + "create.close": "关闭创建面板", + "create.extension.add": "添加扩展", + "create.extension.enabled": "启用", + "create.extension.id": "扩展 ID", + "create.extension.submit": "创建扩展", + "create.extension.title": "创建扩展", + "create.contextWindowPresets": "{label} 预设", + "create.contextWindowPreset.128k": "128K", + "create.contextWindowPreset.400k": "400K", + "create.contextWindowPreset.1m": "100 万", + "create.help.extensionEnabled": "启用该扩展。", + "create.help.extensionId": "稳定的标识,例如 metrics。", + "create.help.modelContextWindow": "最大上下文 Token 数。", + "create.help.modelDisplayName": "控制台中显示的名称。", + "create.help.modelId": "稳定的模型标识。", + "create.help.offerBilling": "可选的计费信息,用于费用统计。", + "create.help.offerCacheReadPrice": "缓存读取单价,用于费用统计。", + "create.help.offerCacheWritePrice": "缓存写入单价,用于费用统计。", + "create.help.offerInputPrice": "输入 Token 单价,用于费用统计。", + "create.help.offerModel": "此绑定提供的模型。", + "create.help.offerOutputPrice": "输出 Token 单价,用于费用统计。", + "create.help.offerPriority": "排序权重,数值越小越优先。", + "create.help.offerProvider": "此绑定使用的提供商。", + "create.help.offerUpstreamName": "实际的上游模型名。", + "create.help.providerApiKey": "发送给提供商的 API 密钥。", + "create.help.providerBaseUrl": "上游 API 基础 URL。", + "create.help.providerId": "稳定的提供商标识。", + "create.help.providerProtocol": "上游 API 格式。", + "create.help.routeId": "客户端使用的别名。", + "create.help.routeModel": "此路由指向的模型。", + "create.help.routeProvider": "为此路由提供服务的提供商。", + "create.invalidNumber": "{field} 必须是有效数字。", + "create.positiveNumber": "{field} 必须大于 0。", + "create.model.add": "添加模型", + "create.model.contextWindow": "上下文窗口", + "create.model.displayName": "显示名称", + "create.model.id": "模型 ID", + "create.model.submit": "创建模型", + "create.model.title": "创建模型", + "create.offer.add": "添加提供商", + "create.offer.billing": "计费", + "create.offer.cacheReadPrice": "缓存读取价格", + "create.offer.cacheWritePrice": "缓存写入价格", + "create.offer.inputPrice": "输入价格", + "create.offer.model": "模型", + "create.offer.outputPrice": "输出价格", + "create.offer.priority": "优先级", + "create.offer.provider": "提供商", + "create.offer.submit": "创建提供商", + "create.offer.title": "创建提供商", + "create.offer.upstreamName": "上游名称", + "create.provider.add": "添加提供商", + "create.provider.apiKey": "API key", + "create.provider.baseUrl": "Base URL", + "create.provider.id": "提供商 ID", + "create.provider.protocol": "协议", + "create.provider.submit": "创建提供商", + "create.provider.title": "创建提供商", + "create.route.add": "添加路由", + "create.route.id": "路由别名", + "create.route.model": "模型", + "create.route.provider": "提供商", + "create.route.submit": "创建路由", + "create.route.title": "创建路由", + "empty.resources": "没有资源", + "error.requestFailed": "请求失败", + "error.requestFailedWithStatus": "请求失败,状态码 {status}", + "field.configUpdateFailed": "配置更新 {result} 失败。", + "error.storeTitle": "管理 API 不可用", + "error.storeMessage": "暂无法读取已保存的设置。启用持久化后即可在此编辑。", + "error.unknownRequest": "未知请求错误", + "feedback.latency": "延迟:{latency}ms", + "field.input": "输入", + "field.helpFor": "{label} 帮助", + "field.editableList.add": "添加", + "field.editableList.addAction": "添加{label}条目", + "field.editableList.addInput": "添加{label}", + "field.editableList.remove": "从{label}移除 {item}", + "field.extensions.addAction": "向{label}添加扩展", + "field.extensions.addInput": "向{label}添加扩展", + "field.extensions.enable": "启用 {name} 扩展", + "field.extensions.remove": "移除 {name} 扩展", + "field.invalidNumber": "无效数字", + "field.maxOutputTokens": "最大输出 Token", + "field.model": "模型", + "field.providerOverrides.baseInstructions": "覆盖基础指令", + "field.providerOverrides.contextWindow": "覆盖上下文窗口", + "field.providerOverrides.defaultReasoningLevel": "覆盖默认思考深度", + "field.providerOverrides.defaultReasoningSummary": "覆盖默认思考摘要", + "field.providerOverrides.description": "覆盖描述", + "field.providerOverrides.disabled": "禁用", + "field.providerOverrides.displayName": "覆盖显示名称", + "field.providerOverrides.enabled": "启用", + "field.providerOverrides.inherit": "继承", + "field.providerOverrides.inputModalities": "覆盖输入模态", + "field.providerOverrides.maxOutputTokens": "覆盖最大输出 Token", + "field.providerOverrides.supportedReasoningLevels": "覆盖支持的思考深度", + "field.providerOverrides.supportsImageDetailOriginal": "覆盖原始图像细节支持", + "field.providerOverrides.supportsReasoning": "覆盖思考支持", + "field.providerOverrides.supportsReasoningSummaries": "覆盖思考摘要支持", + "field.providerOverrides.title": "提供商覆盖配置", + "field.secretReplacementHint": "输入新值以替换已保存的密钥。", + "field.structuredEditable": "结构化编辑器", + "field.structuredEmpty": "暂无配置条目。", + "field.structuredEmptyValue": "空", + "field.structuredEditorLabel": "{label},结构化编辑器", + "field.structuredNestedValues": "嵌套值", + "field.structuredReadonly": "只读结构化摘要", + "field.structuredSummary": "结构化摘要", + "field.structuredSummaryLabel": "{label},只读结构化摘要", + "field.summary.items.many": "{count} 个条目", + "field.summary.items.one": "1 个条目", + "field.summary.keys.many": "{count} 个键", + "field.summary.keys.one": "1 个键", + "field.temperature": "Temperature", + "field.webSearch.firecrawlAPIKey": "{label} Firecrawl API Key", + "field.webSearch.maxUses": "{label}最大使用次数", + "field.webSearch.searchMaxRounds": "{label}最大搜索轮数", + "field.webSearch.support": "{label}模式", + "field.webSearch.tavilyAPIKey": "{label} Tavily API Key", + "loading.overview": "正在加载控制台概览", + "loading.providers": "正在加载提供商", + "loading.routes": "正在加载路由", + "logs.copy": "复制", + "logs.description": "服务器的近期日志。", + "logs.download": "下载", + "logs.empty": "暂无日志条目。", + "logs.emptyFiltered": "没有符合当前过滤条件的日志。", + "logs.follow": "跟随", + "logs.followMode": "实时跟随模式", + "logs.levelAll": "全部", + "logs.levelFilter": "日志级别过滤", + "logs.output": "日志输出", + "logs.pause": "暂停", + "logs.panelLabel": "后台日志", + "logs.panelTitle": "后台日志", + "logs.search": "搜索日志", + "logs.clearSearch": "清除日志搜索", + "logs.rowLabel": "日志 {index}", + "logs.streamDisconnected": "实时日志流已断开", + "logs.streamBodyEmpty": "日志流响应体为空", + "logs.streamFailedWithStatus": "日志流请求失败,状态码 {status}", + "logs.visibleCount": "{visible} / {total} 条日志", + "modelsProviders.description": "管理提供商和模型,改动实时生效。", + "modelsProviders.models": "模型 ({count})", + "modelsProviders.modelProvidersToggle": "提供商", + "modelsProviders.modelRegion": "模型 {id}", + "modelsProviders.offers": "提供商 ({count})", + "modelsProviders.modelProviders": "提供商 ({count})", + "modelsProviders.providerRegion": "提供商 {id}", + "modelsProviders.providers": "提供商 ({count})", + "modelsProviders.unmatchedSupplies": "未匹配提供商 ({count})", + "modelsProviders.upstreamSupply": "提供商 ({count})", + "overview.description": "用量、Token、缓存、费用和近期日志。", + "overview.actualModel": "实际模型", + "overview.avgCost": "平均费用", + "overview.cacheHit": "缓存命中", + "overview.cacheHitValue": "{rate}", + "overview.cacheRead": "缓存读取", + "overview.cacheReadWrite": "读写比", + "overview.cacheRatioValue": "{ratio}", + "overview.cacheSplit": "缓存拆分", + "overview.cacheSplitChart": "缓存拆分图表", + "overview.cacheWrite": "缓存写入", + "overview.chartAriaItem": "{label}:{value}", + "overview.chartAriaLabel": "{title}。{summary}", + "overview.chartAriaSeparator": ";", + "overview.cost": "费用", + "overview.costByModel": "模型费用", + "overview.costByModelChart": "模型费用图表", + "overview.costPerMillionSuffix": "/百万", + "overview.duration.hoursShort": "{count} 小时", + "overview.duration.minutesShort": "{count} 分", + "overview.duration.secondsShort": "{count} 秒", + "overview.graph": "配置图", + "overview.graphUnavailableDescription": "设置暂不可用,但用量和日志仍可查看。", + "overview.graphUnavailableTitle": "配置图暂不可用", + "overview.inputTokens": "输入 Token", + "overview.inputValue": "{count}", + "overview.invalid": "无效", + "overview.mode": "模式", + "overview.model": "模型", + "overview.modelUsageRow": "{model} 用量", + "overview.modelUsageTable": "模型用量表", + "overview.models": "模型", + "overview.noData": "暂无数据", + "overview.outputTokens": "输出 Token", + "overview.outputValue": "{count}", + "overview.providers": "提供商", + "overview.requests": "请求", + "overview.requestsValue": "{count}", + "overview.restart": "重启", + "overview.restartCount.many": "{count} 个需要重启", + "overview.restartCount.one": "1 个需要重启", + "overview.revision": "修订版本", + "overview.routes": "路由", + "overview.runtime": "运行时", + "overview.tokenSplit": "Token 拆分", + "overview.tokenSplitChart": "Token 拆分图表", + "overview.totalCost": "总费用", + "overview.totalCostValue": "{cost}", + "overview.usageDescription": "所选时间范围内的模型用量、缓存和费用。", + "overview.usageEmpty": "还没有记录到用量。", + "overview.usageTitle": "用量分析", + "overview.rangeLabel": "时间范围", + "overview.demoData": "演示数据", + "overview.range.session": "本次会话", + "overview.range.24h": "24 小时", + "overview.range.7d": "7 天", + "overview.range.30d": "30 天", + "overview.range.all": "全部", + "overview.valid": "有效", + "overview.validation": "校验", + "pageEyebrow.aliases": "别名", + "pageEyebrow.analytics": "用量分析", + "pageEyebrow.config": "配置", + "pageEyebrow.runtime": "运行时", + "pageEyebrow.smokeTest": "冒烟测试", + "pageEyebrow.upstream": "上游", + "placeholder.description": "此页面尚在建设中。", + "placeholder.eyebrow": "控制台工作区", + "routes.description": "把每个客户端别名映射到一个模型和提供商。", + "routes.listTitle": "路由 ({count})", + "routes.resourceTitle": "路由", + "rpc.description": "发送一个测试请求,确认一切正常。", + "rpc.request": "请求", + "rpc.response": "响应", + "rpc.selectModel": "选择模型", + "resource.fieldCount.many": "{count} 个字段", + "resource.fieldCount.one": "1 个字段", + "resource.cancelDelete": "取消", + "resource.confirmDelete": "确认删除 {id}", + "resource.confirmDeleteShort": "删除", + "resource.delete": "删除{title} {id}", + "resource.cardLabel": "{title} {id}", + "resource.deletePrompt": "删除 {id}?保存后生效。", + "resource.deleteShort": "删除", + "resource.openEditor": "编辑{title} {id}", + "resource.editShort": "编辑", + "resource.editorHeading": "编辑{title}", + "resource.editorAriaLabel": "{title} {id} 编辑器", + "resource.closeEditor": "关闭", + "resource.fact.keySet": "已设置密钥", + "route.warning.modelMissing": "请选择模型", + "route.warning.modelUnknown": "模型「{value}」尚未配置", + "route.warning.providerMissing": "请选择提供商", + "route.warning.providerUnknown": "提供商「{value}」尚未配置", + "resource.group.advancedFeatures": "高级功能", + "resource.group.basic": "基础", + "resource.group.billing": "计费", + "resource.group.identity": "标识", + "resource.group.multimodal": "多模态", + "resource.group.reasoning": "思考", + "resource.group.settings": "设置", + "resource.group.toggle": "切换 {label}", + "resource.impact.critical": "关键运行时", + "resource.impact.normal": "运行时安全", + "resource.kind.model": "模型", + "resource.kind.offer": "提供商", + "resource.kind.provider": "提供商", + "resource.kind.defaults": "默认值", + "resource.kind.log": "日志", + "resource.kind.trace": "追踪", + "provider.protocol.anthropic": "Anthropic", + "provider.protocol.googleGenai": "Gemini", + "provider.protocol.openaiChat": "OpenAI Chat", + "provider.protocol.openaiResponses": "OpenAI Responses", + "resource.reload.hot": "热重载", + "resource.reload.restart": "变更后重启", + "resource.statusGroupLabel": "{label} 状态", + "resource.status.needsAttention": "需要处理", + "resource.status.restartRequired": "需要重启", + "resource.status.saved": "已保存", + "searchTools.description": "联网搜索、扩展和代理设置。", + "searchTools.extension": "扩展", + "searchTools.extensions": "扩展", + "searchTools.proxy": "代理", + "searchTools.webSearch": "联网搜索", + "security.description": "访问、登录和会话管理。", + "security.server": "服务访问", + "saveStatus.dirty": "未保存", + "saveStatus.error": "错误", + "saveStatus.saved": "已保存", + "saveStatus.saving": "保存中", + "saveStatus.idle": "已保存", + "storage.cache": "缓存", + "storage.description": "缓存以及设置保存的位置。", + "storage.errors": "存储错误", + "storage.persistence": "持久化", + "storage.status": "存储状态" +} satisfies Messages; diff --git a/webui/src/i18n/messages.ts b/webui/src/i18n/messages.ts new file mode 100644 index 00000000..a10d06ed --- /dev/null +++ b/webui/src/i18n/messages.ts @@ -0,0 +1,18 @@ +import { en } from "./locales/en"; +import { zh } from "./locales/zh"; + +export type Locale = "en-US" | "zh-CN"; +export type MessageKey = keyof typeof en; +export type Messages = Record; + +export const messages: Record = { + "en-US": en, + "zh-CN": zh +}; + +export function normalizeLocale(value: string | undefined): Locale { + if (value?.toLowerCase().startsWith("en")) { + return "en-US"; + } + return "zh-CN"; +} diff --git a/webui/src/main.tsx b/webui/src/main.tsx new file mode 100644 index 00000000..c037181b --- /dev/null +++ b/webui/src/main.tsx @@ -0,0 +1,32 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { RouterProvider } from "react-router-dom"; +import { MotionConfig } from "motion/react"; +import { router } from "./app/routes"; +import { queryClient } from "./app/queryClient"; +import { ConsoleAuthProvider } from "./app/auth/ConsoleAuthContext"; +import { I18nProvider } from "./i18n/I18nProvider"; +import { ThemeProvider } from "./theme/ThemeProvider"; + +const rootElement = document.getElementById("root"); + +if (!rootElement) { + throw new Error("Root element #root was not found"); +} + +createRoot(rootElement).render( + + + + + + + + + + + + + +); diff --git a/webui/src/rpc/configGenerator.test.ts b/webui/src/rpc/configGenerator.test.ts new file mode 100644 index 00000000..204c21fa --- /dev/null +++ b/webui/src/rpc/configGenerator.test.ts @@ -0,0 +1,100 @@ +import { parse } from "yaml"; +import { describe, expect, test } from "vitest"; +import { generateConfigYAML, type GeneratedConfigDraft } from "./configGenerator"; + +describe("generateConfigYAML", () => { + test("generates transform config with provider, model, offer, route, and sqlite persistence", () => { + const draft: GeneratedConfigDraft = { + mode: "Transform", + server: { addr: "127.0.0.1:38440", auth_token: "secret-token" }, + persistence: { active_provider: "db_sqlite" }, + defaults: { model: "moonbridge", max_tokens: 4096 }, + providers: [ + { + key: "anthropic", + base_url: "https://api.anthropic.com", + api_key: "sk-ant", + protocol: "anthropic", + version: "2023-06-01", + offers: [ + { + model: "claude-sonnet", + upstream_name: "claude-3-5-sonnet", + input_price: 3, + output_price: 15, + cache_write_price: 3.75, + cache_read_price: 0.3 + } + ] + } + ], + models: [ + { + slug: "claude-sonnet", + display_name: "Claude Sonnet", + context_window: 200000, + max_output_tokens: 64000 + } + ], + routes: [ + { + alias: "moonbridge", + model: "claude-sonnet", + provider: "anthropic", + display_name: "Moon Bridge" + } + ] + }; + + const yaml = generateConfigYAML(draft); + const parsed = parse(yaml); + + expect(parsed.mode).toBe("Transform"); + expect(parsed.server.auth_token).toBe("secret-token"); + expect(parsed.persistence.active_provider).toBe("db_sqlite"); + expect(parsed.providers.anthropic.offers[0].pricing.cache_read_price).toBe(0.3); + expect(parsed.models["claude-sonnet"].context_window).toBe(200000); + expect(parsed.routes.moonbridge.provider).toBe("anthropic"); + }); + + test("generates capture response proxy config", () => { + const yaml = generateConfigYAML({ + mode: "CaptureResponse", + server: { addr: "127.0.0.1:38440" }, + proxy: { + response: { + base_url: "https://api.openai.com", + api_key: "openai-key", + model: "gpt-5.4" + } + } + }); + const parsed = parse(yaml); + + expect(parsed.mode).toBe("CaptureResponse"); + expect(parsed.proxy.response.base_url).toBe("https://api.openai.com"); + expect(parsed.proxy.response.api_key).toBe("openai-key"); + expect(parsed.proxy.response.model).toBe("gpt-5.4"); + }); + + test("generates capture anthropic proxy config", () => { + const yaml = generateConfigYAML({ + mode: "CaptureAnthropic", + server: { addr: "127.0.0.1:38440" }, + proxy: { + anthropic: { + base_url: "https://provider.example.com", + api_key: "anthropic-key", + version: "2023-06-01", + model: "claude-sonnet" + } + } + }); + const parsed = parse(yaml); + + expect(parsed.mode).toBe("CaptureAnthropic"); + expect(parsed.proxy.anthropic.base_url).toBe("https://provider.example.com"); + expect(parsed.proxy.anthropic.version).toBe("2023-06-01"); + expect(parsed.proxy.anthropic.model).toBe("claude-sonnet"); + }); +}); diff --git a/webui/src/rpc/configGenerator.ts b/webui/src/rpc/configGenerator.ts new file mode 100644 index 00000000..aa100fca --- /dev/null +++ b/webui/src/rpc/configGenerator.ts @@ -0,0 +1,215 @@ +import { stringify } from "yaml"; + +export type GeneratedConfigDraft = { + mode: "Transform" | "CaptureResponse" | "CaptureAnthropic"; + server?: { + addr?: string; + auth_token?: string; + }; + defaults?: { + model?: string; + max_tokens?: number; + system_prompt?: string; + }; + persistence?: { + active_provider?: string; + }; + cache?: { + mode?: string; + ttl?: string; + prompt_caching?: boolean; + automatic_prompt_cache?: boolean; + explicit_cache_breakpoints?: boolean; + allow_retention_downgrade?: boolean; + max_breakpoints?: number; + min_cache_tokens?: number; + expected_reuse?: number; + minimum_value_score?: number; + min_breakpoint_tokens?: number; + }; + web_search?: { + support?: string; + max_uses?: number; + tavily_api_key?: string; + firecrawl_api_key?: string; + search_max_rounds?: number; + }; + models?: GeneratedModel[]; + providers?: GeneratedProvider[]; + routes?: GeneratedRoute[]; + extensions?: GeneratedExtension[]; + proxy?: { + response?: GeneratedProxyTarget; + anthropic?: GeneratedProxyTarget; + }; +}; + +export type GeneratedModel = { + slug: string; + context_window?: number; + max_output_tokens?: number; + display_name?: string; + description?: string; +}; + +export type GeneratedProvider = { + key: string; + base_url: string; + api_key: string; + version?: string; + user_agent?: string; + protocol?: string; + offers?: GeneratedOffer[]; +}; + +export type GeneratedOffer = { + model: string; + upstream_name?: string; + priority?: number; + input_price?: number; + output_price?: number; + cache_write_price?: number; + cache_read_price?: number; +}; + +export type GeneratedRoute = { + alias: string; + model?: string; + provider?: string; + display_name?: string; + description?: string; + context_window?: number; +}; + +export type GeneratedExtension = { + name: string; + enabled?: boolean; + config?: Record; +}; + +export type GeneratedProxyTarget = { + base_url: string; + api_key: string; + model?: string; + version?: string; +}; + +export function generateConfigYAML(draft: GeneratedConfigDraft): string { + const doc = pruneEmpty({ + mode: draft.mode, + server: pruneEmpty({ + addr: draft.server?.addr || "127.0.0.1:38440", + auth_token: draft.server?.auth_token + }), + persistence: pruneEmpty({ + active_provider: draft.persistence?.active_provider ?? "db_sqlite" + }), + defaults: pruneEmpty(draft.defaults ?? { model: firstRouteModel(draft), max_tokens: 4096 }), + cache: pruneEmpty( + draft.cache ?? { + mode: "explicit", + ttl: "5m", + prompt_caching: true, + automatic_prompt_cache: false, + explicit_cache_breakpoints: true, + allow_retention_downgrade: false, + max_breakpoints: 4, + min_cache_tokens: 1024, + expected_reuse: 2, + minimum_value_score: 2048, + min_breakpoint_tokens: 1024 + } + ), + web_search: pruneEmpty(draft.web_search), + models: keyed(draft.models, "slug", (model) => + pruneEmpty({ + context_window: model.context_window, + max_output_tokens: model.max_output_tokens, + display_name: model.display_name, + description: model.description + }) + ), + providers: keyed(draft.providers, "key", (provider) => + pruneEmpty({ + base_url: provider.base_url, + api_key: provider.api_key, + version: provider.version, + user_agent: provider.user_agent, + protocol: provider.protocol, + offers: provider.offers?.map((offer) => + pruneEmpty({ + model: offer.model, + upstream_name: offer.upstream_name, + priority: offer.priority, + pricing: pruneEmpty({ + input_price: offer.input_price, + output_price: offer.output_price, + cache_write_price: offer.cache_write_price, + cache_read_price: offer.cache_read_price + }) + }) + ) + }) + ), + routes: keyed(draft.routes, "alias", (route) => + pruneEmpty({ + model: route.model, + provider: route.provider, + display_name: route.display_name, + description: route.description, + context_window: route.context_window + }) + ), + extensions: keyed(draft.extensions, "name", (extension) => + pruneEmpty({ + enabled: extension.enabled, + config: pruneEmpty(extension.config) + }) + ), + proxy: pruneEmpty({ + response: pruneEmpty(draft.proxy?.response), + anthropic: pruneEmpty(draft.proxy?.anthropic) + }) + }); + + return stringify(doc, { indent: 2 }); +} + +function firstRouteModel(draft: GeneratedConfigDraft) { + return draft.routes?.[0]?.alias ?? draft.models?.[0]?.slug ?? ""; +} + +function keyed>( + rows: T[] | undefined, + key: keyof T, + build: (row: T) => unknown +) { + if (!rows?.length) { + return undefined; + } + return rows.reduce>((out, row) => { + const name = String(row[key] ?? ""); + if (name) { + out[name] = build(row); + } + return out; + }, {}); +} + +function pruneEmpty(value: T): T | undefined { + if (value === undefined || value === null || value === "") { + return undefined; + } + if (Array.isArray(value)) { + const next = value.map(pruneEmpty).filter((entry) => entry !== undefined); + return (next.length ? next : undefined) as T | undefined; + } + if (typeof value === "object") { + const entries = Object.entries(value).flatMap(([key, entry]) => { + const next = pruneEmpty(entry); + return next === undefined ? [] : [[key, next]]; + }); + return (entries.length ? Object.fromEntries(entries) : undefined) as T | undefined; + } + return value; +} diff --git a/webui/src/rpc/configGraph.test.ts b/webui/src/rpc/configGraph.test.ts new file mode 100644 index 00000000..88610668 --- /dev/null +++ b/webui/src/rpc/configGraph.test.ts @@ -0,0 +1,145 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + createConfigResource, + deleteConfigResource, + getConfigGraph, + patchConfigGraph, + validateConfigGraph +} from "./configGraph"; + +function jsonResponse(body: unknown, init?: ResponseInit) { + return new Response(JSON.stringify(body), { + headers: { "Content-Type": "application/json" }, + ...init + }); +} + +describe("config graph RPC client", () => { + afterEach(() => { + vi.restoreAllMocks(); + sessionStorage.clear(); + localStorage.clear(); + }); + + test("gets the current config graph", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + jsonResponse({ + revision: "rev-1", + resources: [], + validation: { valid: true }, + runtime: { status: "ready" }, + capabilities: { autosave: true, logs: true } + }) + ); + + const graph = await getConfigGraph(); + + expect(graph.revision).toBe("rev-1"); + expect(fetchMock.mock.calls[0][0]).toBe("/api/v1/config/graph"); + expect(fetchMock.mock.calls[0][1]?.method).toBeUndefined(); + }); + + test("patches graph fields with a base revision", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + jsonResponse({ result: "committed", revision: "rev-2" }) + ); + + await patchConfigGraph({ + baseRevision: "rev-1", + changes: [ + { + kind: "provider", + id: "anthropic", + field: "base_url", + value: "https://api.anthropic.com" + } + ] + }); + + expect(fetchMock.mock.calls[0][0]).toBe("/api/v1/config/graph"); + expect(fetchMock.mock.calls[0][1]).toMatchObject({ + method: "PATCH", + body: JSON.stringify({ + baseRevision: "rev-1", + changes: [ + { + kind: "provider", + id: "anthropic", + field: "base_url", + value: "https://api.anthropic.com" + } + ] + }) + }); + }); + + test("validates graph patches without committing", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + jsonResponse({ result: "committed", revision: "rev-1" }) + ); + + await validateConfigGraph({ + baseRevision: "rev-1", + changes: [{ kind: "defaults", id: "main", field: "max_tokens", value: 2048 }] + }); + + expect(fetchMock.mock.calls[0][0]).toBe("/api/v1/config/graph/validate"); + expect(fetchMock.mock.calls[0][1]).toMatchObject({ + method: "POST" + }); + }); + + test("creates resources with an explicit base revision", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + jsonResponse({ result: "committed", revision: "rev-2" }) + ); + + await createConfigResource("model", { + id: "claude-sonnet", + baseRevision: "rev-1", + value: { + display_name: "Claude Sonnet", + context_window: 200000 + } + }); + + expect(fetchMock.mock.calls[0][0]).toBe("/api/v1/config/resources/model"); + expect(fetchMock.mock.calls[0][1]).toMatchObject({ + method: "POST", + body: JSON.stringify({ + id: "claude-sonnet", + baseRevision: "rev-1", + value: { + display_name: "Claude Sonnet", + context_window: 200000 + } + }) + }); + }); + + test("deletes resources with base revision in the request body", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + jsonResponse({ result: "committed", revision: "rev-2" }) + ); + + await deleteConfigResource("route", "primary", "rev-1"); + + expect(fetchMock.mock.calls[0][0]).toBe("/api/v1/config/resources/route/primary"); + expect(fetchMock.mock.calls[0][1]).toMatchObject({ + method: "DELETE", + body: JSON.stringify({ baseRevision: "rev-1" }) + }); + }); + + test("encodes resource identifiers in URLs", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + jsonResponse({ result: "committed", revision: "rev-2" }) + ); + + await deleteConfigResource("provider_offer", "openai/gpt-4.1", "rev-1"); + + expect(fetchMock.mock.calls[0][0]).toBe( + "/api/v1/config/resources/provider_offer/openai%2Fgpt-4.1" + ); + }); +}); diff --git a/webui/src/rpc/configGraph.ts b/webui/src/rpc/configGraph.ts new file mode 100644 index 00000000..39c65f10 --- /dev/null +++ b/webui/src/rpc/configGraph.ts @@ -0,0 +1,41 @@ +import { apiFetch } from "./http"; +import type { + ConfigGraph, + CreateConfigResourceRequest, + PatchRequest, + PatchResponse, + ResourceKind +} from "./types"; + +export const getConfigGraph = () => apiFetch("/config/graph"); + +export const patchConfigGraph = (body: PatchRequest) => + apiFetch("/config/graph", { method: "PATCH", body }); + +export const validateConfigGraph = (body: PatchRequest) => + apiFetch("/config/graph/validate", { method: "POST", body }); + +export const createConfigResource = ( + kind: ResourceKind, + body: CreateConfigResourceRequest +) => + apiFetch(`/config/resources/${encodeURIComponent(kind)}`, { + method: "POST", + body: { + ...body, + value: body.value ?? {} + } + }); + +export const deleteConfigResource = ( + kind: ResourceKind, + id: string, + baseRevision: string +) => + apiFetch( + `/config/resources/${encodeURIComponent(kind)}/${encodeURIComponent(id)}`, + { + method: "DELETE", + body: { baseRevision } + } + ); diff --git a/webui/src/rpc/http.test.ts b/webui/src/rpc/http.test.ts new file mode 100644 index 00000000..e04cd6a3 --- /dev/null +++ b/webui/src/rpc/http.test.ts @@ -0,0 +1,183 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + ApiError, + REMEMBER_TOKEN_STORAGE_KEY, + TOKEN_STORAGE_KEY, + apiFetch, + clearStoredToken, + readStoredToken, + saveToken +} from "./http"; + +function jsonResponse(body: unknown, init?: ResponseInit) { + return new Response(JSON.stringify(body), { + headers: { "Content-Type": "application/json" }, + ...init + }); +} + +describe("apiFetch", () => { + afterEach(() => { + vi.restoreAllMocks(); + sessionStorage.clear(); + localStorage.clear(); + }); + + test("sends JSON requests and bearer token from session storage", async () => { + sessionStorage.setItem(TOKEN_STORAGE_KEY, "session-token"); + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(jsonResponse({ ok: true })); + + const result = await apiFetch<{ ok: boolean }>("/providers", { + method: "POST", + body: { name: "anthropic" } + }); + + expect(result).toEqual({ ok: true }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("/api/v1/providers"); + expect(init?.headers).toMatchObject({ + Authorization: "Bearer session-token", + "Content-Type": "application/json" + }); + expect(init?.body).toBe(JSON.stringify({ name: "anthropic" })); + }); + + test("falls back to remembered local storage token", async () => { + localStorage.setItem(REMEMBER_TOKEN_STORAGE_KEY, "local-token"); + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(jsonResponse({ ok: true })); + + await apiFetch("/status"); + + expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({ + Authorization: "Bearer local-token" + }); + }); + + test("normalizes management error responses", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + jsonResponse( + { error: { code: "invalid_auth", message: "missing token" } }, + { status: 401 } + ) + ); + + await expect(apiFetch("/status")).rejects.toMatchObject({ + status: 401, + code: "invalid_auth", + message: "missing token", + raw: { error: { code: "invalid_auth", message: "missing token" } } + }); + }); + + test("normalizes OpenAI-style error responses", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + jsonResponse( + { + error: { + type: "invalid_request_error", + code: "bad_request", + message: "bad request" + } + }, + { status: 400 } + ) + ); + + const promise = apiFetch("/responses"); + await expect(promise).rejects.toBeInstanceOf(ApiError); + await expect(promise).rejects.toMatchObject({ + status: 400, + code: "bad_request", + message: "bad request" + }); + }); + + test("continues when storage is unavailable", async () => { + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("blocked"); + }); + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(jsonResponse({ ok: true })); + + await expect(apiFetch("/status")).resolves.toEqual({ ok: true }); + expect(fetchMock.mock.calls[0][1]?.headers).not.toHaveProperty("Authorization"); + }); + + test("keeps a session token in memory when storage writes are blocked", () => { + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("blocked"); + }); + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("blocked"); + }); + + saveToken("volatile-token", false); + + expect(readStoredToken()).toBe("volatile-token"); + }); + + test("normalizes empty error responses", async () => { + localStorage.setItem("moonbridge.console.locale", "zh-CN"); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("", { + status: 401, + headers: { "Content-Type": "application/json" } + }) + ); + + await expect(apiFetch("/status")).rejects.toMatchObject({ + status: 401, + code: "request_error", + message: "请求失败,状态码 401" + }); + }); + + test("normalizes malformed JSON error responses", async () => { + localStorage.setItem("moonbridge.console.locale", "zh-CN"); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("{", { + status: 502, + headers: { "Content-Type": "application/json" } + }) + ); + + await expect(apiFetch("/status")).rejects.toMatchObject({ + status: 502, + code: "request_error", + message: "请求失败,状态码 502" + }); + }); +}); + +describe("clearStoredToken", () => { + afterEach(() => { + sessionStorage.clear(); + localStorage.clear(); + }); + + test("removes remembered token from both stores and memory", () => { + saveToken("remembered-token", true); + expect(readStoredToken()).toBe("remembered-token"); + + clearStoredToken(); + + expect(sessionStorage.getItem(TOKEN_STORAGE_KEY)).toBeNull(); + expect(localStorage.getItem(REMEMBER_TOKEN_STORAGE_KEY)).toBeNull(); + expect(readStoredToken()).toBe(""); + }); + + test("clears a session-only token too", () => { + saveToken("session-token", false); + expect(readStoredToken()).toBe("session-token"); + + clearStoredToken(); + + expect(sessionStorage.getItem(TOKEN_STORAGE_KEY)).toBeNull(); + expect(readStoredToken()).toBe(""); + }); +}); diff --git a/webui/src/rpc/http.ts b/webui/src/rpc/http.ts new file mode 100644 index 00000000..7c578eb3 --- /dev/null +++ b/webui/src/rpc/http.ts @@ -0,0 +1,181 @@ +import { translateMessage } from "../i18n/I18nProvider"; + +export const API_BASE = "/api/v1"; +export const TOKEN_STORAGE_KEY = "moonbridge.console.token"; +export const REMEMBER_TOKEN_STORAGE_KEY = "moonbridge.console.rememberedToken"; + +let volatileToken = ""; + +export type ApiFetchOptions = Omit & { + body?: unknown; + headers?: HeadersInit; + rawBody?: BodyInit | null; +}; + +export class ApiError extends Error { + status: number; + code: string; + raw: unknown; + + constructor(status: number, code: string, message: string, raw?: unknown) { + super(message); + this.name = "ApiError"; + this.status = status; + this.code = code; + this.raw = raw; + } +} + +export function isAuthError(error: unknown): error is ApiError { + return error instanceof ApiError && error.status === 401; +} + +export function readStoredToken(): string { + const sessionToken = safeGetStorage(getStorage("sessionStorage"), TOKEN_STORAGE_KEY); + if (sessionToken) { + return sessionToken; + } + return safeGetStorage(getStorage("localStorage"), REMEMBER_TOKEN_STORAGE_KEY) ?? volatileToken; +} + +export function saveToken(token: string, remember: boolean) { + volatileToken = token; + safeSetStorage(getStorage("sessionStorage"), TOKEN_STORAGE_KEY, token); + if (remember) { + safeSetStorage(getStorage("localStorage"), REMEMBER_TOKEN_STORAGE_KEY, token); + } else { + safeRemoveStorage(getStorage("localStorage"), REMEMBER_TOKEN_STORAGE_KEY); + } +} + +export function clearStoredToken() { + volatileToken = ""; + safeRemoveStorage(getStorage("sessionStorage"), TOKEN_STORAGE_KEY); + safeRemoveStorage(getStorage("localStorage"), REMEMBER_TOKEN_STORAGE_KEY); +} + +export async function apiFetch(path: string, options: ApiFetchOptions = {}): Promise { + const url = normalizeURL(path); + const headers = headersToRecord(options.headers); + const token = readStoredToken(); + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + let body: BodyInit | null | undefined = options.rawBody; + if (options.body !== undefined) { + headers["Content-Type"] = "application/json"; + body = JSON.stringify(options.body); + } + + const response = await fetch(url, { + ...options, + headers, + body + }); + + const payload = await readPayload(response); + if (!response.ok) { + throw normalizeError(response.status, payload); + } + return payload as T; +} + +export function normalizeURL(path: string): string { + if (/^https?:\/\//.test(path)) { + return path; + } + if ( + path.startsWith("/api/v1/") || + path === "/api/v1" || + path.startsWith("/v1/") || + path === "/v1" + ) { + return path; + } + if (path.startsWith("/")) { + return `${API_BASE}${path}`; + } + return `${API_BASE}/${path}`; +} + +async function readPayload(response: Response): Promise { + const contentType = response.headers.get("Content-Type") ?? ""; + const text = await response.text(); + if (!text) { + return undefined; + } + if (contentType.includes("application/json")) { + try { + return JSON.parse(text); + } catch { + return text; + } + } + return text; +} + +function normalizeError(status: number, raw: unknown): ApiError { + if (isObject(raw) && isObject(raw.error)) { + const code = stringValue(raw.error.code) ?? stringValue(raw.error.type) ?? "request_error"; + const message = stringValue(raw.error.message) ?? translateMessage("error.requestFailedWithStatus", { status }); + return new ApiError(status, code, message, raw); + } + return new ApiError(status, "request_error", translateMessage("error.requestFailedWithStatus", { status }), raw); +} + +function safeGetStorage(storage: Storage | undefined, key: string): string | null { + try { + return storage?.getItem(key) ?? null; + } catch { + return null; + } +} + +function getStorage(name: "sessionStorage" | "localStorage"): Storage | undefined { + if (typeof window === "undefined") { + return undefined; + } + try { + return window[name]; + } catch { + return undefined; + } +} + +function headersToRecord(headers?: HeadersInit): Record { + if (!headers) { + return {}; + } + if (headers instanceof Headers) { + return Object.fromEntries(headers.entries()); + } + if (Array.isArray(headers)) { + return Object.fromEntries(headers); + } + return { ...headers }; +} + +function safeSetStorage(storage: Storage | undefined, key: string, value: string) { + try { + storage?.setItem(key, value); + } catch { + // Storage may be disabled in hardened browser contexts. + } +} + +function safeRemoveStorage(storage: Storage | undefined, key: string) { + try { + storage?.removeItem(key); + } catch { + // Storage may be disabled in hardened browser contexts. + } +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value ? value : undefined; +} diff --git a/webui/src/rpc/logs.test.ts b/webui/src/rpc/logs.test.ts new file mode 100644 index 00000000..790951b0 --- /dev/null +++ b/webui/src/rpc/logs.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + REMEMBER_TOKEN_STORAGE_KEY, + TOKEN_STORAGE_KEY +} from "./http"; +import { createLogStream, getRecentLogs } from "./logs"; + +function jsonResponse(body: unknown, init?: ResponseInit) { + return new Response(JSON.stringify(body), { + headers: { "Content-Type": "application/json" }, + ...init + }); +} + +describe("logs RPC client", () => { + afterEach(() => { + vi.restoreAllMocks(); + sessionStorage.clear(); + localStorage.clear(); + }); + + test("gets recent logs with a limit", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + jsonResponse([ + { + timestamp: "2026-06-07T00:00:00Z", + level: "INFO", + message: "started", + raw: "time=2026-06-07T00:00:00Z level=INFO msg=started" + } + ]) + ); + + const logs = await getRecentLogs({ limit: 2 }); + + expect(logs).toHaveLength(1); + expect(fetchMock.mock.calls[0][0]).toBe("/api/v1/logs/recent?limit=2"); + }); + + test("omits limit when recent logs options are empty", async () => { + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(jsonResponse([])); + + await getRecentLogs(); + + expect(fetchMock.mock.calls[0][0]).toBe("/api/v1/logs/recent"); + }); + + test("opens log streams with bearer token from session storage", async () => { + sessionStorage.setItem(TOKEN_STORAGE_KEY, "session-token"); + const stream = new ReadableStream(); + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(stream, { + headers: { "Content-Type": "text/event-stream" } + }) + ); + + const response = await createLogStream(); + + expect(response.body).toBe(stream); + expect(fetchMock.mock.calls[0][0]).toBe("/api/v1/logs/stream"); + expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({ + Authorization: "Bearer session-token" + }); + }); + + test("opens log streams with remembered token when session token is absent", async () => { + localStorage.setItem(REMEMBER_TOKEN_STORAGE_KEY, "remembered-token"); + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response(new ReadableStream())); + + await createLogStream(); + + expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({ + Authorization: "Bearer remembered-token" + }); + }); + + test("localizes stream fallback failures from the stored locale", async () => { + localStorage.setItem("moonbridge.console.locale", "zh-CN"); + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("", { status: 503 })); + + await expect(createLogStream()).rejects.toMatchObject({ + code: "log_stream_error", + message: "日志流请求失败,状态码 503" + }); + }); + + test("localizes empty stream body failures from the stored locale", async () => { + localStorage.setItem("moonbridge.console.locale", "zh-CN"); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(null, { + status: 200 + }) + ); + + await expect(createLogStream()).rejects.toMatchObject({ + code: "log_stream_error", + message: "日志流响应体为空" + }); + }); +}); diff --git a/webui/src/rpc/logs.ts b/webui/src/rpc/logs.ts new file mode 100644 index 00000000..15532663 --- /dev/null +++ b/webui/src/rpc/logs.ts @@ -0,0 +1,47 @@ +import { ApiError, apiFetch, normalizeURL, readStoredToken } from "./http"; +import { translateMessage } from "../i18n/I18nProvider"; +import type { LogEntry } from "./types"; + +export type RecentLogsOptions = { + limit?: number; +}; + +export const getRecentLogs = (options: RecentLogsOptions = {}) => { + const params = new URLSearchParams(); + if (options.limit !== undefined) { + params.set("limit", String(options.limit)); + } + const query = params.toString(); + return apiFetch(`/logs/recent${query ? `?${query}` : ""}`); +}; + +export type LogStreamOptions = { + signal?: AbortSignal; +}; + +export async function createLogStream(options: LogStreamOptions = {}) { + const headers: Record = { + Accept: "text/event-stream" + }; + const token = readStoredToken(); + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const response = await fetch(normalizeURL("/logs/stream"), { + method: "GET", + headers, + signal: options.signal + }); + if (!response.ok) { + throw new ApiError( + response.status, + "log_stream_error", + translateMessage("logs.streamFailedWithStatus", { status: response.status }) + ); + } + if (!response.body) { + throw new ApiError(response.status, "log_stream_error", translateMessage("logs.streamBodyEmpty")); + } + return response; +} diff --git a/webui/src/rpc/management.test.ts b/webui/src/rpc/management.test.ts new file mode 100644 index 00000000..1d6c7572 --- /dev/null +++ b/webui/src/rpc/management.test.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + exportConfig, + getUsageStats, + importConfig, + listProviders, + validateConfig +} from "./management"; + +function response(body: unknown, init?: ResponseInit) { + return new Response(typeof body === "string" ? body : JSON.stringify(body), { + headers: { + "Content-Type": + typeof body === "string" ? "application/x-yaml" : "application/json" + }, + ...init + }); +} + +describe("management RPC client", () => { + afterEach(() => { + vi.restoreAllMocks(); + sessionStorage.clear(); + localStorage.clear(); + }); + + test("listProviders encodes pagination query params", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + response({ + data: [], + total: 0, + limit: 10, + offset: 20 + }) + ); + + await listProviders({ limit: 10, offset: 20 }); + + expect(fetchMock.mock.calls[0][0]).toBe( + "/api/v1/providers?limit=10&offset=20" + ); + }); + + test("validateConfig posts config payload", async () => { + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(response({ valid: true })); + + await validateConfig("providers: {}"); + + expect(fetchMock.mock.calls[0][0]).toBe("/api/v1/config/validate"); + expect(fetchMock.mock.calls[0][1]).toMatchObject({ + method: "POST", + body: JSON.stringify({ config: "providers: {}" }) + }); + }); + + test("importConfig posts yaml payload", async () => { + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(response({ changes: [], count: 0 })); + + await importConfig("providers: {}"); + + expect(fetchMock.mock.calls[0][0]).toBe("/api/v1/config/import"); + expect(fetchMock.mock.calls[0][1]).toMatchObject({ + method: "POST", + body: JSON.stringify({ yaml: "providers: {}" }) + }); + }); + + test("exportConfig adds include_secrets query and confirmation header", async () => { + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(response("providers: {}\n")); + + await exportConfig({ includeSecrets: true }); + + expect(fetchMock.mock.calls[0][0]).toBe( + "/api/v1/config/export?include_secrets=true" + ); + expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({ + "X-Confirm-Secrets": "true" + }); + }); + + test("getUsageStats reads stable model usage metrics", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + response({ + totals: { + requests: 2, + input_tokens: 300, + output_tokens: 80, + cache_creation: 40, + cache_read: 120, + cache_hit_rate: 40, + cache_write_rate: 13.3, + cache_rw_ratio: 3, + total_cost: 0.42, + duration: "1m" + }, + by_model: [ + { + model: "claude-sonnet", + actual_model: "claude-3-5-sonnet", + requests: 2, + input_tokens: 300, + output_tokens: 80, + cache_creation: 40, + cache_read: 120, + cache_hit_rate: 40, + cost: 0.42, + avg_cost_per_mtoken: 1105.26 + } + ] + }) + ); + + const stats = await getUsageStats(); + + expect(fetchMock.mock.calls[0][0]).toBe("/api/v1/stats/usage"); + expect(stats.by_model[0].actual_model).toBe("claude-3-5-sonnet"); + expect(stats.totals.cache_rw_ratio).toBe(3); + }); +}); diff --git a/webui/src/rpc/management.ts b/webui/src/rpc/management.ts new file mode 100644 index 00000000..5d0209f3 --- /dev/null +++ b/webui/src/rpc/management.ts @@ -0,0 +1,122 @@ +import { apiFetch } from "./http"; +import type { + ApplyResult, + ChangeRow, + DefaultsSettings, + ImportResult, + ModelDetail, + ModelSummary, + ModelUpsert, + MutationAccepted, + Paginated, + ProviderDetail, + ProviderSummary, + ProviderUpsert, + RouteDetail, + RouteSummary, + RouteUpsert, + SessionInfo, + StatsSummary, + StatusResponse, + UsageStats, + ValidationResult, + WebSearchSettings +} from "./types"; + +type Page = { + limit?: number; + offset?: number; +}; + +function pageQuery(page: Page = {}) { + const params = new URLSearchParams(); + if (page.limit !== undefined) { + params.set("limit", String(page.limit)); + } + if (page.offset !== undefined) { + params.set("offset", String(page.offset)); + } + const query = params.toString(); + return query ? `?${query}` : ""; +} + +export const getStatus = () => apiFetch("/status"); + +export const listProviders = (page?: Page) => + apiFetch>(`/providers${pageQuery(page)}`); +export const getProvider = (key: string) => apiFetch(`/providers/${encodeURIComponent(key)}`); +export const putProvider = (key: string, body: ProviderUpsert) => + apiFetch(`/providers/${encodeURIComponent(key)}`, { method: "PUT", body }); +export const patchProvider = (key: string, body: Partial) => + apiFetch(`/providers/${encodeURIComponent(key)}`, { method: "PATCH", body }); +export const deleteProvider = (key: string) => + apiFetch(`/providers/${encodeURIComponent(key)}`, { method: "DELETE" }); +export const testProvider = (key: string) => + apiFetch>(`/providers/${encodeURIComponent(key)}/test`, { method: "POST" }); + +export const createOffer = (providerKey: string, body: Record) => + apiFetch(`/providers/${encodeURIComponent(providerKey)}/offers`, { + method: "POST", + body + }); +export const updateOffer = (providerKey: string, model: string, body: Record) => + apiFetch( + `/providers/${encodeURIComponent(providerKey)}/offers/${encodeURIComponent(model)}`, + { method: "PATCH", body } + ); +export const deleteOffer = (providerKey: string, model: string) => + apiFetch( + `/providers/${encodeURIComponent(providerKey)}/offers/${encodeURIComponent(model)}`, + { method: "DELETE" } + ); + +export const listModels = (page?: Page) => + apiFetch>(`/models${pageQuery(page)}`); +export const getModel = (slug: string) => apiFetch(`/models/${encodeURIComponent(slug)}`); +export const putModel = (slug: string, body: ModelUpsert) => + apiFetch(`/models/${encodeURIComponent(slug)}`, { method: "PUT", body }); +export const deleteModel = (slug: string) => + apiFetch(`/models/${encodeURIComponent(slug)}`, { method: "DELETE" }); + +export const listRoutes = (page?: Page) => + apiFetch>(`/routes${pageQuery(page)}`); +export const getRoute = (alias: string) => apiFetch(`/routes/${encodeURIComponent(alias)}`); +export const putRoute = (alias: string, body: RouteUpsert) => + apiFetch(`/routes/${encodeURIComponent(alias)}`, { method: "PUT", body }); +export const deleteRoute = (alias: string) => + apiFetch(`/routes/${encodeURIComponent(alias)}`, { method: "DELETE" }); + +export const getChanges = () => apiFetch("/changes"); +export const applyChanges = () => apiFetch("/changes/apply", { method: "POST" }); +export const discardChanges = () => apiFetch("/changes/discard", { method: "POST" }); + +export const getEffectiveConfig = () => apiFetch>("/config/effective"); +export const validateConfig = (config: string) => + apiFetch("/config/validate", { method: "POST", body: { config } }); +export const importConfig = (yaml: string) => + apiFetch("/config/import", { method: "POST", body: { yaml } }); +export const exportConfig = ({ includeSecrets = false }: { includeSecrets?: boolean } = {}) => + apiFetch(`/config/export?include_secrets=${includeSecrets ? "true" : "false"}`, { + headers: includeSecrets ? { "X-Confirm-Secrets": "true" } : undefined + }); + +export const getDefaults = () => apiFetch("/defaults"); +export const putDefaults = (body: DefaultsSettings) => + apiFetch("/defaults", { method: "PUT", body }); + +export const getWebSearch = () => apiFetch("/web-search"); +export const putWebSearch = (body: WebSearchSettings) => + apiFetch("/web-search", { method: "PUT", body }); + +export const listExtensions = () => apiFetch("/extensions"); +export const getExtension = (name: string) => + apiFetch>(`/extensions/${encodeURIComponent(name)}`); +export const putExtension = (name: string, body: Record) => + apiFetch(`/extensions/${encodeURIComponent(name)}`, { method: "PUT", body }); + +export const getStatsSummary = () => apiFetch("/stats/summary"); + +export type UsageRange = "session" | "24h" | "7d" | "30d" | "all"; +export const getUsageStats = (range: UsageRange = "session") => + apiFetch(range === "session" ? "/stats/usage" : `/stats/usage?range=${range}`); +export const getSessions = () => apiFetch("/sessions"); diff --git a/webui/src/rpc/queryKeys.ts b/webui/src/rpc/queryKeys.ts new file mode 100644 index 00000000..cddc320b --- /dev/null +++ b/webui/src/rpc/queryKeys.ts @@ -0,0 +1,13 @@ +export const queryKeys = { + status: ["status"] as const, + providers: (page: { limit: number; offset: number }) => ["providers", page] as const, + models: (page: { limit: number; offset: number }) => ["models", page] as const, + routes: (page: { limit: number; offset: number }) => ["routes", page] as const, + configGraph: ["config", "graph"] as const, + changes: ["changes"] as const, + logsRecent: (limit?: number) => ["logs", "recent", { limit }] as const, + extensions: ["extensions"] as const, + statsSummary: ["stats", "summary"] as const, + usageStats: ["stats", "usage"] as const, + sessions: ["sessions"] as const +}; diff --git a/webui/src/rpc/responses.test.ts b/webui/src/rpc/responses.test.ts new file mode 100644 index 00000000..da3c25d8 --- /dev/null +++ b/webui/src/rpc/responses.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { ApiError } from "./http"; +import { createResponse, listResponseModels } from "./responses"; + +describe("responses RPC client", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("lists models from /v1/models", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ models: [{ slug: "moonbridge", name: "Moon Bridge", provider: "route" }] }), { + headers: { "Content-Type": "application/json" } + }) + ); + + await expect(listResponseModels()).resolves.toEqual({ + models: [{ slug: "moonbridge", name: "Moon Bridge", provider: "route" }] + }); + expect(fetchMock.mock.calls[0][0]).toBe("/v1/models"); + }); + + test("posts a non-streaming response request", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ id: "resp_1", status: "completed", output_text: "ok", output: [] }), { + headers: { "Content-Type": "application/json" } + }) + ); + + await createResponse({ model: "moonbridge", input: "ping" }); + + expect(fetchMock.mock.calls[0][0]).toBe("/v1/responses"); + expect(fetchMock.mock.calls[0][1]).toMatchObject({ + method: "POST", + body: JSON.stringify({ model: "moonbridge", input: "ping", stream: false }) + }); + }); + + test("normalizes OpenAI-style errors", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ error: { code: "bad_request", message: "bad request" } }), { + status: 400, + headers: { "Content-Type": "application/json" } + }) + ); + + await expect(createResponse({ model: "missing", input: "ping" })).rejects.toBeInstanceOf(ApiError); + }); +}); diff --git a/webui/src/rpc/responses.ts b/webui/src/rpc/responses.ts new file mode 100644 index 00000000..fa9a99ba --- /dev/null +++ b/webui/src/rpc/responses.ts @@ -0,0 +1,42 @@ +import { apiFetch } from "./http"; + +export type ResponseModel = { + slug: string; + name: string; + provider: string; + model?: string; +}; + +export type ResponseModelsResult = { + models: ResponseModel[]; +}; + +export type CreateResponseRequest = { + model: string; + input: unknown; + max_output_tokens?: number; + temperature?: number; + stream?: boolean; +}; + +export type CreateResponseResult = { + id: string; + object?: string; + status: string; + model?: string; + output: unknown[]; + output_text?: string; + usage?: Record; + error?: { message: string; type?: string; code?: string; param?: string }; +}; + +export function listResponseModels() { + return apiFetch("/v1/models"); +} + +export function createResponse(request: CreateResponseRequest) { + return apiFetch("/v1/responses", { + method: "POST", + body: { ...request, stream: request.stream ?? false } + }); +} diff --git a/webui/src/rpc/types.ts b/webui/src/rpc/types.ts new file mode 100644 index 00000000..9bc68aec --- /dev/null +++ b/webui/src/rpc/types.ts @@ -0,0 +1,324 @@ +export type Paginated = { + data: T[]; + total: number; + limit: number; + offset: number; +}; + +export type StatusResponse = { + uptime: string; + version: string; + mode: string; + provider_count: number; + route_count: number; + addr: string; + timestamp: string; +}; + +export type Offer = { + model: string; + upstream_name?: string; + priority: number; + input_price: number; + output_price: number; + cache_write: number; + cache_read: number; +}; + +export type ProviderSummary = { + key: string; + protocol: string; + offer_count: number; + base_url: string; + health_status: string; +}; + +export type ProviderDetail = Omit & { + health_status?: string; + api_key: string; + version: string; + user_agent: string; + offers: Offer[]; + web_search: string; + web_search_max_uses: number; +}; + +export type ProviderUpsert = { + base_url: string; + api_key: string; + version?: string; + protocol?: string; + user_agent?: string; +}; + +export type ModelSummary = { + slug: string; + display_name?: string; + context_window: number; + providers: string[]; +}; + +export type ModelDetail = ModelSummary & { + description: string; + max_output_tokens: number; + input_modalities: string[]; +}; + +export type ModelUpsert = { + display_name?: string; + description?: string; + context_window?: number; + max_output_tokens?: number; +}; + +export type RouteSummary = { + alias: string; + model: string; + provider: string; + display_name?: string; +}; + +export type RouteDetail = RouteSummary & { + context_window: number; +}; + +export type RouteUpsert = { + model: string; + provider?: string; + display_name?: string; + context_window?: number; +}; + +export type ChangeRow = { + ID?: number; + BatchID?: string; + Action?: string; + Resource?: string; + TargetKey?: string; + Before?: string; + After?: string; + Applied?: boolean; + Error?: string; + Revision?: number; + CreatedAt?: string; + AppliedAt?: string; + id?: number; + change_id?: number; + action?: string; + resource?: string; + target_key?: string; + target?: string; + before?: string; + after?: string; + created_at?: string; +}; + +export type StatsSummary = { + requests: number; + input_tokens: number; + output_tokens: number; + cache_hit_rate: number; + total_cost: number; + duration: string; +}; + +export type UsageStats = { + totals: UsageStatsTotals; + by_model: UsageStatsModelRow[]; +}; + +export type UsageStatsTotals = { + requests: number; + input_tokens: number; + output_tokens: number; + cache_creation: number; + cache_read: number; + cache_hit_rate: number; + cache_write_rate: number; + cache_rw_ratio: number; + total_cost: number; + duration: string; +}; + +export type UsageStatsModelRow = { + model: string; + actual_model: string; + requests: number; + input_tokens: number; + output_tokens: number; + cache_creation: number; + cache_read: number; + cache_hit_rate: number; + cost: number; + avg_cost_per_mtoken: number; +}; + +export type SessionInfo = { + key: string; + model?: string; + created_at: string; + last_used: string; +}; + +export type DefaultsSettings = { + model: string; + max_tokens: number; + system_prompt: string; +}; + +export type WebSearchSettings = { + support: string; + max_uses: number; + tavily_api_key: string; + firecrawl_api_key: string; + search_max_rounds: number; +}; + +export type ValidationResult = { + valid: boolean; + errors?: string[]; +}; + +export type ImportResult = { + changes: Array<{ change_id: number; resource: string; target: string }>; + count: number; + message: string; +}; + +export type MutationAccepted = { + change_id: number; + status: string; + message?: string; +}; + +export type ApplyResult = { + status: string; + message: string; +}; + +export type ResourceKind = + | "mode" + | "trace" + | "log" + | "server" + | "defaults" + | "model" + | "provider" + | "provider_offer" + | "route" + | "web_search" + | "cache" + | "persistence" + | "extension" + | "proxy"; + +export type ResourceStatus = "saved" | "needsAttention" | "restartRequired"; + +export type RuntimeImpact = "normal" | "critical"; + +export type ConfigGraph = { + revision: string; + resources: ConfigResource[]; + validation: ValidationState; + runtime: RuntimeState; + capabilities: ConfigCapabilities; +}; + +export type ConfigResource = { + kind: ResourceKind; + id: string; + label: string; + value: Record; + schema: ResourceSchema; + status: ResourceStatus; + runtimeImpact: RuntimeImpact; + hotReloadable: boolean; + references?: ResourceRef[]; +}; + +export type ResourceSchema = { + fields: FieldSchema[]; +}; + +export type FieldSchema = { + path: string; + type: "string" | "number" | "boolean" | "array" | "object" | string; + label: string; + required?: boolean; + secret?: boolean; + control?: string; + enum?: string[]; + hotReloadable: boolean; + runtimeImpact?: string; +}; + +export type ResourceRef = { + kind: ResourceKind; + id: string; +}; + +export type ValidationState = { + valid: boolean; + errors?: FieldError[]; +}; + +export type RuntimeState = { + status: string; + errors?: FieldError[]; + message?: string; +}; + +export type ConfigCapabilities = { + autosave: boolean; + logs: boolean; +}; + +export type FieldError = { + resourceKind: ResourceKind | ""; + resourceId: string; + field?: string; + code: string; + message: string; +}; + +export type PatchRequest = { + baseRevision: string; + changes: PatchOp[]; +}; + +export type PatchOp = { + kind: ResourceKind; + id: string; + field: string; + value: unknown; +}; + +export type PatchResult = + | "committed" + | "restartRequired" + | "revisionConflict" + | "validationRejected" + | "runtimeRejected" + | "draftRejected"; + +export type PatchResponse = { + result: PatchResult; + revision: string; + graph?: ConfigGraph; + errors?: FieldError[]; + rollbackValue?: unknown; +}; + +export type CreateConfigResourceRequest = { + baseRevision: string; + id: string; + value?: Record; +}; + +export type LogEntry = { + timestamp: string; + level: string; + message: string; + attrs?: Record; + raw?: string; +}; diff --git a/webui/src/test/configGraphFixtures.ts b/webui/src/test/configGraphFixtures.ts new file mode 100644 index 00000000..c7412473 --- /dev/null +++ b/webui/src/test/configGraphFixtures.ts @@ -0,0 +1,196 @@ +import type { ConfigGraph, ConfigResource, FieldSchema, ResourceKind } from "../rpc/types"; + +export function configGraphFixture(overrides: Partial = {}): ConfigGraph { + const resources = overrides.resources ?? [ + resource("mode", "main", "Mode", { mode: "Transform" }, [ + field("mode", "Mode", "string", "select", ["Transform", "CaptureResponse", "CaptureAnthropic"]) + ]), + resource("defaults", "main", "Defaults", { + model: "claude-sonnet", + max_tokens: 4096, + system_prompt: "Be concise." + }, [ + field("model", "Model"), + field("max_tokens", "Max Tokens", "number", "number"), + field("system_prompt", "System Prompt", "string", "textarea") + ]), + resource("trace", "main", "Trace", { enabled: true }, [ + field("enabled", "Enabled", "boolean", "switch") + ]), + resource("log", "main", "Log", { level: "info", format: "text" }, [ + field("level", "Level", "string", "select", ["debug", "info", "warn", "error"]), + field("format", "Format", "string", "select", ["text", "json"]) + ]), + resource("provider", "anthropic", "anthropic", { + base_url: "https://api.anthropic.com", + api_key: "******", + version: "2023-06-01", + user_agent: "MoonBridge", + protocol: "anthropic", + web_search: { support: "auto" }, + extensions: {} + }, [ + field("base_url", "Base URL"), + field("api_key", "API Key", "string", "secret", undefined, true), + field("version", "Version"), + field("user_agent", "User Agent"), + field("protocol", "Protocol", "string", "select", ["anthropic", "openai-response"]), + field("web_search", "Web Search", "object", "object"), + field("extensions", "Extensions", "object", "object") + ]), + resource("provider_offer", "anthropic/claude-sonnet", "anthropic/claude-sonnet", { + model: "claude-sonnet", + upstream_name: "claude-3-5-sonnet", + priority: 1, + pricing: { + input_price: 3, + output_price: 15 + }, + overrides: {} + }, [ + field("model", "Model"), + field("upstream_name", "Upstream Name"), + field("priority", "Priority", "number", "number"), + field("pricing", "Pricing", "object", "object"), + field("overrides", "Overrides", "object", "object") + ]), + resource("model", "claude-sonnet", "Claude Sonnet", { + context_window: 200000, + max_output_tokens: 8192, + display_name: "Claude Sonnet", + description: "Balanced model" + }, [ + field("context_window", "Context Window", "number", "number"), + field("max_output_tokens", "Max Output Tokens", "number", "number"), + field("display_name", "Display Name"), + field("description", "Description", "string", "textarea") + ]), + resource("route", "primary", "Primary Route", { + to: "primary", + model: "claude-sonnet", + provider: "anthropic", + display_name: "Primary Route", + description: "Default route", + context_window: 200000, + web_search: { support: "auto" }, + extensions: {} + }, [ + field("to", "Route Target"), + field("model", "Model"), + field("provider", "Provider"), + field("display_name", "Display Name"), + field("description", "Description", "string", "textarea"), + field("context_window", "Context Window", "number", "number"), + field("web_search", "Web Search", "object", "object"), + field("extensions", "Extensions", "object", "object") + ]), + resource("web_search", "main", "Web Search", { + support: "auto", + max_uses: 4, + tavily_api_key: "******", + firecrawl_api_key: "******", + search_max_rounds: 2 + }, [ + field("support", "Support", "string", "select", ["auto", "enabled", "disabled", "injected"]), + field("max_uses", "Max Uses", "number", "number"), + field("tavily_api_key", "Tavily API Key", "string", "secret", undefined, true), + field("firecrawl_api_key", "Firecrawl API Key", "string", "secret", undefined, true), + field("search_max_rounds", "Search Max Rounds", "number", "number") + ]), + resource("extension", "db_sqlite", "db_sqlite", { + enabled: true, + config: { path: "~/.moon-bridge/moonbridge.db" } + }, [ + field("enabled", "Enabled", "boolean", "switch"), + field("config", "Config", "object", "object") + ]), + resource("proxy", "main", "Proxy", { + response: { base_url: "https://response.proxy", api_key: "******" }, + anthropic: { base_url: "https://anthropic.proxy", api_key: "******" } + }, [ + field("response", "Response Proxy", "object", "object"), + field("anthropic", "Anthropic Proxy", "object", "object") + ], { hotReloadable: false, runtimeImpact: "critical" }), + resource("cache", "main", "Cache", { + mode: "memory", + ttl: "1h", + prompt_caching: true, + automatic_prompt_cache: false + }, [ + field("mode", "Mode"), + field("ttl", "TTL"), + field("prompt_caching", "Prompt Caching", "boolean", "switch"), + field("automatic_prompt_cache", "Automatic Prompt Cache", "boolean", "switch") + ]), + resource("persistence", "main", "Persistence", { + active_provider: "db_sqlite" + }, [ + field("active_provider", "Active Provider") + ]), + resource("server", "main", "Server", { + addr: ":38440", + auth_token: "******", + max_sessions: 64, + session_ttl: "24h" + }, [ + field("addr", "Address"), + field("auth_token", "Auth Token", "string", "secret", undefined, true), + field("max_sessions", "Max Sessions", "number", "number"), + field("session_ttl", "Session TTL") + ], { + hotReloadable: false, + runtimeImpact: "critical", + status: "restartRequired" + }) + ]; + + return { + revision: "rev-1", + resources, + validation: { valid: true }, + runtime: { status: "ok" }, + capabilities: { autosave: true, logs: true }, + ...overrides + }; +} + +export function resource( + kind: ResourceKind, + id: string, + label: string, + value: Record, + fields: FieldSchema[], + overrides: Partial = {} +): ConfigResource { + return { + kind, + id, + label, + value, + schema: { fields }, + status: "saved", + runtimeImpact: "normal", + hotReloadable: true, + ...overrides + }; +} + +export function field( + path: string, + label: string, + type: FieldSchema["type"] = "string", + control = type === "number" ? "number" : "text", + enumValues?: string[], + secret = false +): FieldSchema { + return { + path, + type, + label, + control, + enum: enumValues, + secret, + hotReloadable: true, + runtimeImpact: "normal" + }; +} diff --git a/webui/src/test/panelStyleAssertions.ts b/webui/src/test/panelStyleAssertions.ts new file mode 100644 index 00000000..85946d17 --- /dev/null +++ b/webui/src/test/panelStyleAssertions.ts @@ -0,0 +1,134 @@ +export function expectPanelElementToBeFlat(element: Element) { + const style = getComputedStyle(element); + expect(isZeroLength(style.borderTopWidth)).toBe(true); + expect(isZeroLength(style.borderRightWidth)).toBe(true); + expect(isZeroLength(style.borderBottomWidth)).toBe(true); + expect(isZeroLength(style.borderLeftWidth)).toBe(true); + expect(isZeroLength(style.outlineWidth)).toBe(true); + expect(style.boxShadow === "" || style.boxShadow === "none").toBe(true); + expect(style.filter === "" || style.filter === "none").toBe(true); +} + +export function expectPanelRuleToAvoidEdges(selector: string) { + const edgeProperties = [ + "border", + "border-block", + "border-inline", + "border-top", + "border-right", + "border-bottom", + "border-left", + "border-color", + "border-width", + "border-style", + "outline", + "outline-color", + "outline-width", + "outline-style", + "box-shadow", + "filter" + ]; + const rule = findStyleRule(selector); + if (!rule) { + const ruleText = findRawStyleRule(selector); + for (const property of edgeProperties) { + expect(ruleText).not.toMatch(new RegExp(`(^|;)\\s*${escapeRegExp(property)}\\s*:`)); + } + return; + } + for (const property of edgeProperties) { + const value = rule.style.getPropertyValue(property).trim(); + expect(value === "" || value === "0" || value === "0px" || value === "none").toBe(true); + } +} + +export function expectPanelStateRuleToStayFlat(selector: string) { + expectPanelRuleToAvoidEdges(selector); + const rule = findStyleRule(selector); + if (!rule) { + expect(findRawStyleRule(selector)).not.toMatch(/(^|;)\s*transform\s*:/); + return; + } + expect(rule.style.getPropertyValue("transform").trim()).toBe(""); +} + +function findStyleRule(selector: string): CSSStyleRule | undefined { + for (const styleSheet of Array.from(document.styleSheets)) { + const rule = findStyleRuleInList(styleSheet.cssRules, selector); + if (rule) { + return rule; + } + } + return undefined; +} + +function findStyleRuleInList(rules: CSSRuleList, selector: string): CSSStyleRule | undefined { + for (const rule of Array.from(rules)) { + if (rule instanceof CSSStyleRule && selectorList(rule.selectorText).includes(selector)) { + return rule; + } + if (rule instanceof CSSMediaRule) { + const nestedRule = findStyleRuleInList(rule.cssRules, selector); + if (nestedRule) { + return nestedRule; + } + } + } + return undefined; +} + +function selectorList(selectorText: string) { + return selectorText.split(",").map((selector) => selector.trim()); +} + +function isZeroLength(value: string) { + return value === "" || value === "0px"; +} + +function findRawStyleRule(selector: string) { + for (const styleElement of Array.from(document.querySelectorAll("style"))) { + const css = styleElement.textContent ?? ""; + if (!css.includes(selector)) { + continue; + } + const ruleText = findRawStyleRuleInText(css, selector); + if (ruleText) { + return ruleText; + } + } + throw new Error(`Expected stylesheet rule for selector "${selector}".`); +} + +function findRawStyleRuleInText(css: string, selector: string) { + let searchFrom = 0; + while (searchFrom < css.length) { + const selectorIndex = css.indexOf(selector, searchFrom); + if (selectorIndex === -1) { + return undefined; + } + const openBrace = css.indexOf("{", selectorIndex); + if (openBrace === -1) { + return undefined; + } + const selectorTextStart = css.lastIndexOf("}", selectorIndex) + 1; + const atRuleStart = css.lastIndexOf("@", selectorIndex); + if (atRuleStart > selectorTextStart) { + searchFrom = selectorIndex + selector.length; + continue; + } + const selectorText = css.slice(selectorTextStart, openBrace).trim(); + if (selectorList(selectorText).includes(selector)) { + const closeBrace = css.indexOf("}", openBrace); + if (closeBrace === -1) { + return undefined; + } + return css.slice(openBrace + 1, closeBrace); + } + searchFrom = selectorIndex + selector.length; + } + return undefined; +} + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/webui/src/test/renderWithConsoleProviders.tsx b/webui/src/test/renderWithConsoleProviders.tsx new file mode 100644 index 00000000..0b82b8ee --- /dev/null +++ b/webui/src/test/renderWithConsoleProviders.tsx @@ -0,0 +1,29 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render } from "@testing-library/react"; +import type { ReactElement } from "react"; +import type { Locale } from "../i18n/messages"; +import { I18nProvider, CONSOLE_LOCALE_STORAGE_KEY } from "../i18n/I18nProvider"; +import { ThemeProvider } from "../theme/ThemeProvider"; +import { ConsoleAuthProvider } from "../app/auth/ConsoleAuthContext"; +import { shellStyles } from "../app/styles/shellStyles"; + +export function renderWithConsoleProviders( + ui: ReactElement, + options: { locale?: Locale } = {} +) { + localStorage.setItem(CONSOLE_LOCALE_STORAGE_KEY, options.locale ?? "en-US"); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } } + }); + + return render( + + + + + {ui} + + + + ); +} diff --git a/webui/src/test/setup.ts b/webui/src/test/setup.ts new file mode 100644 index 00000000..57fceed4 --- /dev/null +++ b/webui/src/test/setup.ts @@ -0,0 +1,170 @@ +import "@testing-library/jest-dom/vitest"; + +type TestElementInternals = { + checkValidity: () => boolean; + reportValidity: () => boolean; + setFormValue: (value: unknown, state?: unknown) => void; + setValidity: (flags?: ValidityStateFlags, message?: string, anchor?: HTMLElement) => void; + validationMessage: string; + validity: ValidityState; + willValidate: boolean; +}; + +const validValidityState = { + badInput: false, + customError: false, + patternMismatch: false, + rangeOverflow: false, + rangeUnderflow: false, + stepMismatch: false, + tooLong: false, + tooShort: false, + typeMismatch: false, + valid: true, + valueMissing: false +} as ValidityState; + +if (typeof ElementInternals !== "undefined") { + if (typeof ElementInternals.prototype.setFormValue !== "function") { + Object.defineProperty(ElementInternals.prototype, "setFormValue", { + configurable: true, + value: () => undefined + }); + } + + if (typeof ElementInternals.prototype.setValidity !== "function") { + Object.defineProperty(ElementInternals.prototype, "setValidity", { + configurable: true, + value: () => undefined + }); + } + + if (typeof ElementInternals.prototype.checkValidity !== "function") { + Object.defineProperty(ElementInternals.prototype, "checkValidity", { + configurable: true, + value: () => true + }); + } + + if (typeof ElementInternals.prototype.reportValidity !== "function") { + Object.defineProperty(ElementInternals.prototype, "reportValidity", { + configurable: true, + value: () => true + }); + } + + if (!("validity" in ElementInternals.prototype)) { + Object.defineProperty(ElementInternals.prototype, "validity", { + configurable: true, + get: () => validValidityState + }); + } + + if (!("validationMessage" in ElementInternals.prototype)) { + Object.defineProperty(ElementInternals.prototype, "validationMessage", { + configurable: true, + get: () => "" + }); + } + + if (!("willValidate" in ElementInternals.prototype)) { + Object.defineProperty(ElementInternals.prototype, "willValidate", { + configurable: true, + get: () => true + }); + } +} + +if (!HTMLElement.prototype.attachInternals) { + Object.defineProperty(HTMLElement.prototype, "attachInternals", { + configurable: true, + value: function attachInternals(): TestElementInternals { + return { + checkValidity: () => true, + reportValidity: () => true, + setFormValue: () => undefined, + setValidity: () => undefined, + validationMessage: "", + validity: validValidityState, + willValidate: true + }; + } + }); +} + +if (!Element.prototype.animate) { + Object.defineProperty(Element.prototype, "animate", { + configurable: true, + value: () => + ({ + addEventListener: () => undefined, + cancel: () => undefined, + commitStyles: () => undefined, + finish: () => undefined, + finished: Promise.resolve(), + pause: () => undefined, + persist: () => undefined, + play: () => undefined, + ready: Promise.resolve(), + removeEventListener: () => undefined, + reverse: () => undefined, + updatePlaybackRate: () => undefined + }) as unknown as Animation + }); +} + +// md-dialog (and other Material Web surfaces) observe scrolling/sizing with +// IntersectionObserver/ResizeObserver, which jsdom does not implement. +if (!globalThis.IntersectionObserver) { + class IntersectionObserverStub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + Object.defineProperty(globalThis, "IntersectionObserver", { + configurable: true, + writable: true, + value: IntersectionObserverStub + }); +} + +if (!globalThis.ResizeObserver) { + class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} + } + Object.defineProperty(globalThis, "ResizeObserver", { + configurable: true, + writable: true, + value: ResizeObserverStub + }); +} + +if (!globalThis.localStorage) { + const store = new Map(); + + const storage: Storage = { + get length() { + return store.size; + }, + clear: () => store.clear(), + getItem: (key) => store.get(key) ?? null, + key: (index) => Array.from(store.keys())[index] ?? null, + removeItem: (key) => store.delete(key), + setItem: (key, value) => store.set(key, value) + }; + + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage + }); + + Object.defineProperty(window, "localStorage", { + configurable: true, + value: storage + }); +} diff --git a/webui/src/theme/ThemeProvider.test.tsx b/webui/src/theme/ThemeProvider.test.tsx new file mode 100644 index 00000000..3355f2c4 --- /dev/null +++ b/webui/src/theme/ThemeProvider.test.tsx @@ -0,0 +1,109 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it } from "vitest"; +import { + CONSOLE_THEME_STORAGE_KEY, + ThemeProvider, + useConsoleTheme +} from "./ThemeProvider"; + +function ThemeProbe() { + const { theme, setTheme, toggleTheme } = useConsoleTheme(); + + return ( +
    +

    {theme}

    + + +
    + ); +} + +describe("ThemeProvider", () => { + afterEach(() => { + localStorage.clear(); + document.documentElement.removeAttribute("data-theme"); + document.documentElement.removeAttribute("style"); + }); + + it("defaults to the dark theme", () => { + render( + + + + ); + + expect(screen.getByTestId("theme-value")).toHaveTextContent("dark"); + expect(document.documentElement).toHaveAttribute("data-theme", "dark"); + }); + + it("switches to light theme", async () => { + const user = userEvent.setup(); + + render( + + + + ); + + await user.click(screen.getByRole("button", { name: "Set light" })); + + expect(screen.getByTestId("theme-value")).toHaveTextContent("light"); + expect(document.documentElement).toHaveAttribute("data-theme", "light"); + }); + + it("applies container tokens used by expressive resource cards", () => { + render( + + + + ); + + expect(document.documentElement.style.getPropertyValue("--mb-color-secondary-container")).not.toBe(""); + expect(document.documentElement.style.getPropertyValue("--mb-color-on-secondary-container")).not.toBe(""); + expect(document.documentElement.style.getPropertyValue("--mb-color-tertiary-container")).not.toBe(""); + expect(document.documentElement.style.getPropertyValue("--mb-color-on-tertiary-container")).not.toBe(""); + expect(document.documentElement.style.getPropertyValue("--mb-color-error-container")).not.toBe(""); + expect(document.documentElement.style.getPropertyValue("--mb-color-on-error-container")).not.toBe(""); + }); + + it("persists the selected theme in localStorage", async () => { + const user = userEvent.setup(); + + render( + + + + ); + + await user.click(screen.getByRole("button", { name: "Toggle" })); + + expect(localStorage.getItem(CONSOLE_THEME_STORAGE_KEY)).toBe("light"); + }); + + it("falls back to dark when localStorage is unavailable", () => { + const original = Object.getOwnPropertyDescriptor(window, "localStorage"); + Object.defineProperty(window, "localStorage", { + configurable: true, + get() { + throw new DOMException("blocked", "SecurityError"); + } + }); + + render( + + + + ); + + expect(screen.getByTestId("theme-value")).toHaveTextContent("dark"); + + if (original) { + Object.defineProperty(window, "localStorage", original); + } + }); +}); diff --git a/webui/src/theme/ThemeProvider.tsx b/webui/src/theme/ThemeProvider.tsx new file mode 100644 index 00000000..2cde2384 --- /dev/null +++ b/webui/src/theme/ThemeProvider.tsx @@ -0,0 +1,80 @@ +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useState +} from "react"; +import { applyThemeTokens, type ConsoleTheme } from "./tokens"; + +export const CONSOLE_THEME_STORAGE_KEY = "moonbridge.console.theme"; + +type ConsoleThemeContextValue = { + theme: ConsoleTheme; + setTheme: (theme: ConsoleTheme) => void; + toggleTheme: () => void; +}; + +const ConsoleThemeContext = createContext( + undefined +); + +function readStoredTheme(): ConsoleTheme { + if (typeof window === "undefined") { + return "dark"; + } + + try { + if (!window.localStorage) { + return "dark"; + } + const stored = window.localStorage.getItem(CONSOLE_THEME_STORAGE_KEY); + return stored === "light" || stored === "dark" ? stored : "dark"; + } catch { + return "dark"; + } +} + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [theme, setThemeState] = useState(readStoredTheme); + + const setTheme = useCallback((nextTheme: ConsoleTheme) => { + setThemeState(nextTheme); + }, []); + + const toggleTheme = useCallback(() => { + setThemeState((current) => (current === "dark" ? "light" : "dark")); + }, []); + + useEffect(() => { + const root = document.documentElement; + root.dataset.theme = theme; + applyThemeTokens(theme, root); + try { + window.localStorage?.setItem(CONSOLE_THEME_STORAGE_KEY, theme); + } catch { + // Storage may be disabled in hardened browser contexts. + } + }, [theme]); + + const value = useMemo( + () => ({ theme, setTheme, toggleTheme }), + [theme, setTheme, toggleTheme] + ); + + return ( + + {children} + + ); +} + +export function useConsoleTheme(): ConsoleThemeContextValue { + const context = useContext(ConsoleThemeContext); + if (!context) { + throw new Error("useConsoleTheme must be used within ThemeProvider"); + } + return context; +} diff --git a/webui/src/theme/motion.ts b/webui/src/theme/motion.ts new file mode 100644 index 00000000..a9e2c1d4 --- /dev/null +++ b/webui/src/theme/motion.ts @@ -0,0 +1,84 @@ +import type { Transition, Variants } from "motion/react"; + +/** + * Material Design 3 Expressive motion-physics tokens, expressed as + * framer-`motion` transitions. + * + * M3 Expressive replaces fixed easing/duration with a spring system: + * - **Spatial** springs animate position, size and shape; they slightly + * overshoot and settle with a gentle bounce. + * - **Effects** springs animate colour and opacity; they never overshoot. + * + * Each family has three speeds — fast (small components such as switches and + * buttons), default (partial-surface motion like the navigation rail) and slow + * (full-surface transitions). Stiffness/damping pairs below map the published + * M3 Expressive phone tokens to framer-motion's absolute-damping springs. + */ +export const springs = { + spatialFast: { type: "spring", stiffness: 1400, damping: 50, mass: 1 }, + spatial: { type: "spring", stiffness: 700, damping: 36, mass: 1 }, + spatialSlow: { type: "spring", stiffness: 320, damping: 24, mass: 1 }, + effectsFast: { type: "spring", stiffness: 3800, damping: 120, mass: 1 }, + effects: { type: "spring", stiffness: 1600, damping: 80, mass: 1 }, + effectsSlow: { type: "spring", stiffness: 800, damping: 60, mass: 1 } +} as const satisfies Record; + +/** CSS easing curves matching the M3 standard scheme (for plain transitions). */ +export const easing = { + standard: "cubic-bezier(0.2, 0, 0, 1)", + standardDecelerate: "cubic-bezier(0, 0, 0, 1)", + standardAccelerate: "cubic-bezier(0.3, 0, 1, 1)", + emphasized: "cubic-bezier(0.2, 0, 0, 1)", + emphasizedDecelerate: "cubic-bezier(0.05, 0.7, 0.1, 1)", + emphasizedAccelerate: "cubic-bezier(0.3, 0, 0.8, 0.15)" +} as const; + +/** Page/route content enters with an emphasized spatial spring. */ +export const pageMotion = { + initial: { opacity: 0, y: 10 }, + animate: { opacity: 1, y: 0 }, + transition: { ...springs.spatial, opacity: springs.effects } +} as const; + +/** Cards and panels rise into place with a softer spatial spring. */ +export const surfaceMotion = { + initial: { opacity: 0, y: 12 }, + animate: { opacity: 1, y: 0 }, + transition: { ...springs.spatial, opacity: springs.effects } +} as const; + +/** Staggered list container — children animate in sequence. */ +export const listContainer: Variants = { + hidden: {}, + show: { + transition: { staggerChildren: 0.05, delayChildren: 0.02 } + } +}; + +/** Individual staggered list item. */ +export const listItem: Variants = { + hidden: { opacity: 0, y: 12 }, + show: { + opacity: 1, + y: 0, + transition: { ...springs.spatial, opacity: springs.effects } + } +}; + +/** Interactive press feedback for buttons / pressable surfaces. */ +export const pressable = { + whileHover: { scale: 1.015 }, + whileTap: { scale: 0.97 }, + transition: springs.spatialFast +} as const; + +/** Expand / collapse helper for revealing panels (delete confirm, drawers). */ +export const collapse: Variants = { + hidden: { opacity: 0, height: 0, y: -4 }, + show: { + opacity: 1, + height: "auto", + y: 0, + transition: { ...springs.spatial, opacity: springs.effectsFast } + } +}; diff --git a/webui/src/theme/tokens.ts b/webui/src/theme/tokens.ts new file mode 100644 index 00000000..ac642d81 --- /dev/null +++ b/webui/src/theme/tokens.ts @@ -0,0 +1,136 @@ +export type ConsoleTheme = "dark" | "light"; + +export const primarySeed = "#7AA7A2"; + +type ThemeTokens = Record; + +/** + * Material Design 3 (Expressive) colour roles for the Moon Bridge console. + * + * Built from the teal primary seed (#7AA7A2) and extended with secondary + * (muted teal), tertiary (cool blue), warning (warm amber) and success (green) + * families so the UI can establish hierarchy through colour contrast — a core + * M3 Expressive tactic. A full surface tonal scale (lowest → highest) supplies + * expressive elevation without relying solely on shadows, which matters most in + * the dark theme. + */ +export const themeTokens: Record = { + dark: { + // Primary — teal + "--mb-color-primary": primarySeed, + "--mb-color-on-primary": "#08201d", + "--mb-color-primary-container": "#274e4a", + "--mb-color-on-primary-container": "#d4f3ee", + "--mb-color-primary-fixed": "#c3eae4", + "--mb-color-primary-fixed-dim": "#a7cec8", + // Secondary — desaturated teal + "--mb-color-secondary": "#bbc8c5", + "--mb-color-on-secondary": "#253331", + "--mb-color-secondary-container": "#3b4947", + "--mb-color-on-secondary-container": "#dbe5e2", + // Tertiary — cool blue (accents + the "output" data series) + "--mb-color-tertiary": "#a7c8e8", + "--mb-color-on-tertiary": "#0c2438", + "--mb-color-tertiary-container": "#3b4858", + "--mb-color-on-tertiary-container": "#d8e4f8", + // Warning — warm amber (restart-required / needs-attention) + "--mb-color-warning": "#f3c06b", + "--mb-color-on-warning": "#412d00", + "--mb-color-warning-container": "#5b4300", + "--mb-color-on-warning-container": "#ffdfa3", + // Success — green (healthy / saved confirmations) + "--mb-color-success": "#86d7a8", + "--mb-color-on-success": "#003920", + "--mb-color-success-container": "#1d5236", + "--mb-color-on-success-container": "#a2f4c3", + // Error + "--mb-color-error": "#ffb4ab", + "--mb-color-on-error": "#690005", + "--mb-color-error-container": "#93000a", + "--mb-color-on-error-container": "#ffdad6", + // Surfaces — tonal elevation scale + "--mb-color-surface": "#0e1413", + "--mb-color-surface-dim": "#0e1413", + "--mb-color-surface-bright": "#343b39", + "--mb-color-surface-container-lowest": "#090f0e", + "--mb-color-surface-container-low": "#161d1b", + "--mb-color-surface-container": "#1a2120", + "--mb-color-surface-container-high": "#252b2a", + "--mb-color-surface-container-highest": "#2f3635", + "--mb-color-on-surface": "#e0e3e1", + "--mb-color-on-surface-variant": "#bec9c6", + // Outline + utility + "--mb-color-outline": "#899390", + "--mb-color-outline-variant": "#3f4946", + "--mb-color-shadow": "#000000", + "--mb-color-scrim": "#000000", + "--mb-color-inverse-surface": "#e0e3e1", + "--mb-color-inverse-on-surface": "#2b3231", + "--mb-color-inverse-primary": "#3a615c", + "--mb-motion-standard": "180ms cubic-bezier(0.2, 0, 0, 1)" + }, + light: { + // Primary — teal + "--mb-color-primary": "#406863", + "--mb-color-on-primary": "#ffffff", + "--mb-color-primary-container": "#c2ebe5", + "--mb-color-on-primary-container": "#00201d", + "--mb-color-primary-fixed": "#c2ebe5", + "--mb-color-primary-fixed-dim": "#a6cfc9", + // Secondary + "--mb-color-secondary": "#4b6360", + "--mb-color-on-secondary": "#ffffff", + "--mb-color-secondary-container": "#cde8e3", + "--mb-color-on-secondary-container": "#05201d", + // Tertiary — cool blue + "--mb-color-tertiary": "#41617d", + "--mb-color-on-tertiary": "#ffffff", + "--mb-color-tertiary-container": "#d4e4f7", + "--mb-color-on-tertiary-container": "#101d2b", + // Warning — warm amber + "--mb-color-warning": "#7c5800", + "--mb-color-on-warning": "#ffffff", + "--mb-color-warning-container": "#ffdfa3", + "--mb-color-on-warning-container": "#271900", + // Success — green + "--mb-color-success": "#236c47", + "--mb-color-on-success": "#ffffff", + "--mb-color-success-container": "#a8f4c5", + "--mb-color-on-success-container": "#002111", + // Error + "--mb-color-error": "#ba1a1a", + "--mb-color-on-error": "#ffffff", + "--mb-color-error-container": "#ffdad6", + "--mb-color-on-error-container": "#410002", + // Surfaces — tonal elevation scale + "--mb-color-surface": "#f4fbf8", + "--mb-color-surface-dim": "#d5dbd8", + "--mb-color-surface-bright": "#f4fbf8", + "--mb-color-surface-container-lowest": "#ffffff", + "--mb-color-surface-container-low": "#eef5f1", + "--mb-color-surface-container": "#e8efeb", + "--mb-color-surface-container-high": "#e2e9e6", + "--mb-color-surface-container-highest": "#dde4e0", + "--mb-color-on-surface": "#171d1b", + "--mb-color-on-surface-variant": "#3f4946", + // Outline + utility + "--mb-color-outline": "#6f7976", + "--mb-color-outline-variant": "#bec9c5", + "--mb-color-shadow": "#000000", + "--mb-color-scrim": "#000000", + "--mb-color-inverse-surface": "#2b3231", + "--mb-color-inverse-on-surface": "#eff1ee", + "--mb-color-inverse-primary": "#a6cfc9", + "--mb-motion-standard": "180ms cubic-bezier(0.2, 0, 0, 1)" + } +}; + +export function applyThemeTokens(theme: ConsoleTheme, root: HTMLElement): void { + Object.entries(themeTokens[theme]).forEach(([name, value]) => { + root.style.setProperty(name, value); + if (name.startsWith("--mb-color-")) { + const mdSysName = name.replace("--mb-color-", "--md-sys-color-"); + root.style.setProperty(mdSysName, value); + } + }); +} diff --git a/webui/src/vite-env.d.ts b/webui/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/webui/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/webui/tsconfig.json b/webui/tsconfig.json new file mode 100644 index 00000000..ef7e5ed6 --- /dev/null +++ b/webui/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "incremental": false, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "types": ["vitest/globals", "@testing-library/jest-dom"] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/webui/tsconfig.node.json b/webui/tsconfig.node.json new file mode 100644 index 00000000..cbd2a63d --- /dev/null +++ b/webui/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/webui/vite.config.ts b/webui/vite.config.ts new file mode 100644 index 00000000..7f0cd1c1 --- /dev/null +++ b/webui/vite.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; + +const backendTarget = process.env.MOONBRIDGE_CONSOLE_BACKEND ?? "http://127.0.0.1:38440"; + +export default defineConfig({ + base: "/console/", + plugins: [react()], + server: { + proxy: { + "/api": backendTarget, + "/v1": backendTarget, + "/responses": backendTarget, + "/models": backendTarget + } + }, + test: { + environment: "jsdom", + environmentOptions: { + jsdom: { + url: "http://127.0.0.1:5173/console/" + } + }, + globals: true, + setupFiles: "./src/test/setup.ts" + } +});