opencode-webui 1.0.9 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,7 +34,7 @@ Options:
34
34
  | `WEBUI_PASSWORD` | generated on first boot | Log in with this passphrase. Set it to pin your own instead of the generated one. |
35
35
  | `WEBUI_HOST` | `127.0.0.1` | Bind address. A wildcard (`0.0.0.0` / `::`) is refused without a password. |
36
36
  | `WEBUI_PROXY_PORT` | `4097` | Port for the UI and `/api/*`. |
37
- | `WEBUI_EXTENSION_DIR` | the global + project dirs | Replace both with ONE directory (the sandbox does this to keep WIP isolated). |
37
+ | `WEBUI_EXTENSION_DIR` | the global + project dirs | Adds a higher-precedence source shadowing both (the sandbox uses this to keep WIP isolated; shipped extensions still load underneath). |
38
38
 
39
39
  Sessions are shared with the `opencode` TUI — open a session in the TUI, continue it in the browser.
40
40
 
@@ -88,14 +88,27 @@ manually.
88
88
 
89
89
  ## Extensions
90
90
 
91
- Plain React, dropped in a folder, loaded at runtime — no rebuild, no restart:
91
+ One extension = one folder, dropped in — no rebuild, no restart:
92
92
 
93
- - `~/.config/opencode/webui-extensions/<name>/main.tsx` — per-user
94
- - `<project>/.opencode/webui-extensions/<name>/main.tsx` — per-project
93
+ - `~/.config/opencode/webui-extensions/<name>/` — per-user
94
+ - `<project>/.opencode/webui-extensions/<name>/` — per-project
95
95
 
96
- Toggle any extension in Settings › Extensions. The full authoring guide — the kind
97
- contract, hooks, region markers, the `window.__opencodeUI` bridge — is
98
- [ui-extensions/README.md](ui-extensions/README.md).
96
+ ```
97
+ my-extension/
98
+ manifest.json id, version, description, disabled?
99
+ index.tsx browser stratum (wrap / replace / contribute / hook / service)
100
+ dom.ts DOM stratum (portals, canvas, post-render tweaks)
101
+ server.ts proxy stratum (routes, middleware, event tap, pollers)
102
+ engine/ opencode plugin payload (model tools, prompt hints)
103
+ ```
104
+
105
+ Presence = installed, `disabled: true` = paused, delete = uninstalled; a
106
+ higher-precedence folder with the same id shadows the shipped one, so user
107
+ customizations survive core updates with no forks. Hot reload everywhere:
108
+ browser edits repaint live via the manifest SSE push, proxy edits reload
109
+ with no restart. The full authoring guide — the five kinds, hook catalog,
110
+ DOM kit, `server.ts` mounts, precedence, and the timestamp worked example —
111
+ is [webui-extensions/README.md](webui-extensions/README.md).
99
112
 
100
113
  The built-in `/report` command files a prefilled GitHub issue with a diagnostics
101
114
  bundle (build version, enabled extensions, error ring); `--agent` hands it to the
@@ -111,7 +124,7 @@ bun run typecheck
111
124
  bun run build && bun start # production: dist/ + API on 4097
112
125
  ```
113
126
 
114
- - Extension authoring guide: [ui-extensions/README.md](ui-extensions/README.md)
127
+ - Extension authoring guide: [webui-extensions/README.md](webui-extensions/README.md)
115
128
  - Extension contract check: `bun run scripts/uitest/extensions-check.ts`
116
129
  - Architecture, editing rules, roadmap: [AGENTS.md](AGENTS.md)
117
130
 
@@ -0,0 +1 @@
1
+ import{a as e,n as t,t as n}from"./jsx-runtime-B-hcVAMW.js";import{f as r,n as i}from"./index-Bs1cKqOa.js";var a=e(t(),1),o=n(),s=`brother-agent`,c=0,l=new Set;function u(){c++;for(let e of l)e()}function d(e){return l.add(e),()=>{l.delete(e)}}function f(){return(0,a.useSyncExternalStore)(d,()=>c)}var p=new Set,m=new Map;async function h(){try{let e=await fetch(`/api/webui/ext/brother-agent/brothers`);if(!e.ok)return;let t=await e.json();if(!Array.isArray(t.brothers))return;let n=new Set(t.brothers.filter(e=>typeof e==`string`)),r=n.size===p.size;if(r){for(let e of n)if(!p.has(e)){r=!1;break}}if(!r){p.clear();for(let e of n)p.add(e);u()}}catch{}}function g(e){return m.get(e)===`brother-agent`||p.has(e)}var _=window;_.__brotherAgentStarted||(_.__brotherAgentStarted=!0,h(),window.setInterval(()=>{h()},3e4)),r({kind:`hook`,id:`brother-agent`,event:`session.adopted`,handler:(e,t)=>{t();let n=e.sessionID;if(typeof n!=`string`||n===``){h();return}h(),i.getSession(n).then(e=>{let t=e.data.metadata?.origin;typeof t==`string`&&t!==``?m.get(n)!==t&&(m.set(n,t),u()):m.delete(n)&&u()}).catch(()=>{})}}),r({kind:`wrap`,id:`brother-agent-row`,target:`sidebar.sessionRow`,render:(e,t)=>{f();let n=t(),r=e.sessionID;return typeof r!=`string`||!g(r)?n:(0,o.jsxs)(`span`,{className:`relative block min-w-0`,children:[n,(0,o.jsx)(`span`,{title:`Brother agent session`,className:`pointer-events-none absolute top-1.5 right-2 rounded border border-[var(--border-weak-base)] px-1 text-[10px] leading-4 text-[var(--text-weaker)]`,children:`brother`})]})}}),r({kind:`wrap`,id:`brother-agent-header`,target:`conversation.header`,render:(e,t)=>{f();let n=t(),r=e.sessionID;return typeof r!=`string`||!g(r)?n:(0,o.jsxs)(o.Fragment,{children:[n,(0,o.jsx)(`div`,{className:`border-b border-[var(--border-base)] px-4 py-1 text-xs text-[var(--text-weaker)]`,children:`brother agent session — launched via brother_agent · finish notices arrive here automatically`})]})}});export{s as id};
@@ -0,0 +1 @@
1
+ import{a as e,n as t,t as n}from"./jsx-runtime-B-hcVAMW.js";import{a as r,c as i,h as a,i as o,l as s,m as c,o as l,p as u,r as d,s as f,t as p,u as m}from"./index-Bs1cKqOa.js";var h=a(`key-round`,[[`path`,{d:`M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z`,key:`1s6t7t`}],[`circle`,{cx:`16.5`,cy:`7.5`,r:`.5`,fill:`currentColor`,key:`w0ekpg`}]]),g=a(`mic`,[[`path`,{d:`M12 19v3`,key:`npa21l`}],[`path`,{d:`M19 10v2a7 7 0 0 1-14 0v-2`,key:`1vc78b`}],[`rect`,{x:`9`,y:`2`,width:`6`,height:`13`,rx:`3`,key:`s6n7sd`}]]),_=e(t(),1),v=n(),y=`groq-voice`,b=p(),x=`groq-voice.apiKey`,S=`https://api.groq.com/openai/v1/audio/transcriptions`,C=`https://api.groq.com/openai/v1/models`,w=`whisper-large-v3-turbo`,T=12e4,E=6e3,D=`webui.drafts`;function O(){try{return window.localStorage.getItem(x)??``}catch{return``}}function k(e){try{e?window.localStorage.setItem(x,e):window.localStorage.removeItem(x)}catch{}}var A=new Set,j=null,M=null;function N(e){j=e;for(let t of Array.from(A))try{t(e)}catch{}}function P(e){return A.add(e),()=>{A.delete(e)}}function F(){return j}function I(e,t=E){N(e),M!==null&&clearTimeout(M),M=setTimeout(()=>{N(null)},t)}function L(){M!==null&&(clearTimeout(M),M=null),N(null)}function R(){let e=[`audio/webm;codecs=opus`,`audio/webm`,`audio/mp4`,`audio/ogg;codecs=opus`];if(window.MediaRecorder===void 0)return null;for(let t of e)try{if(window.MediaRecorder.isTypeSupported(t))return t}catch{}return null}function z(e){if(e)try{for(let t of e.getTracks())t.stop()}catch{}}function B(e){let t=e instanceof DOMException?e.name:``;return t===`NotAllowedError`||t===`PermissionDeniedError`||t===`SecurityError`?`Microphone permission denied — allow mic access in the browser and try again`:t===`NotFoundError`||t===`DevicesNotFoundError`?`No microphone found — plug one in and try again`:t===`NotReadableError`||t===`TrackStartError`?`Microphone is busy or unreadable — close other apps using it and try again`:`Could not start the microphone: ${e instanceof Error?e.message:String(e)}`}function V(e){let t=e instanceof Error?e.message:String(e);if(t===`missing-key`)return`Groq API key missing — open the key settings (key button next to the mic)`;if(t===`empty-transcript`)return`Groq returned an empty transcript — try speaking a little longer`;let n=/^http (\d{3})/.exec(t);if(n!==null)switch(Number(n[1])){case 401:return`Groq rejected the API key (401) — check it in the key settings`;case 403:return`Groq denied access (403) — check the API key and account status`;case 404:return`Groq endpoint not found (404) — the transcription API may have changed`;case 413:return`Recording too large for Groq (413) — keep messages under ~25 MB`;case 429:return`Groq rate limit hit (429) — wait a moment and try again`;default:{let e=t.indexOf(`: `);return e>=0?`Groq error: ${t.slice(e+2)}`:`Groq error (HTTP ${n[1]})`}}return t===`Failed to fetch`||t.startsWith(`NetworkError`)||t.startsWith(`network`)?`Network error reaching Groq — check your connection`:`Transcription failed: ${t}`}async function H(e,t){let n=O().trim();if(n===``)throw Error(`missing-key`);let r=new FormData,i=t.includes(`mp4`)?`m4a`:`webm`;r.append(`file`,new File([e],`voice-${Date.now()}.${i}`,{type:t||`audio/webm`})),r.append(`model`,w);let a;try{a=await fetch(S,{method:`POST`,headers:{Authorization:`Bearer ${n}`},body:r})}catch(e){throw Error(e instanceof Error?e.message:`network error`)}if(!a.ok){let e=``;try{let t=await a.json();t?.error&&typeof t.error.message==`string`&&(e=t.error.message)}catch{}throw Error(e===``?`http ${a.status}`:`http ${a.status}: ${e}`)}let o=await a.json(),s=typeof o.text==`string`?o.text.trim():``;if(s===``)throw Error(`empty-transcript`);return s}async function U(){let e=O().trim();if(e===``)return{ok:!1,error:`missing-key`};try{let t=await fetch(C,{headers:{Authorization:`Bearer ${e}`}});if(t.ok)return{ok:!0,status:t.status};let n=``;try{let e=await t.json();e?.error&&typeof e.error.message==`string`&&(n=e.error.message)}catch{}return{ok:!1,status:t.status,error:n===``?`http ${t.status}`:n}}catch(e){return{ok:!1,error:e instanceof Error?e.message:String(e)}}}function W(e,t){try{let n=window.localStorage.getItem(D),r=n?JSON.parse(n):{},i=(typeof r[e]?.text==`string`?r[e]?.text:``).replace(/\s+$/,``);r[e]={text:i===``?t:`${i} ${t}`,at:Date.now()},window.localStorage.setItem(D,JSON.stringify(r));let a=document.querySelector(`[data-oc-composer-input]`);return a instanceof HTMLTextAreaElement&&a.focus(),!0}catch{return!1}}function G({open:e,onOpenChange:t}){let[n,a]=(0,_.useState)(O),[c,u]=(0,_.useState)(!1),[p,h]=(0,_.useState)(!1),[g,y]=(0,_.useState)(null);(0,_.useEffect)(()=>{e&&(a(O()),y(null),u(!1))},[e]),(0,_.useEffect)(()=>{if(!c)return;let e=setTimeout(()=>u(!1),1500);return()=>clearTimeout(e)},[c]);function b(){k(n.trim()),u(!0)}function x(){if(n.trim()===``){y({ok:!1,error:`missing-key`});return}h(!0),y(null),k(n.trim()),U().then(e=>{h(!1),y(e)})}let S,C=`text-[color:var(--text-weaker)]`;return g===null?S=`The key stays in this browser (localStorage) and is only ever sent to api.groq.com`:g.ok?(S=`Key works — Groq accepted it (${g.status})`,C=`text-green-600 dark:text-green-400`):g.error===`missing-key`?(S=`Enter a key first`,C=`text-red-600 dark:text-red-400`):(S=`Key rejected: ${g.error??`http ${g.status}`}`,C=`text-red-600 dark:text-red-400`),(0,v.jsx)(o,{open:e,onOpenChange:t,children:(0,v.jsxs)(r,{children:[(0,v.jsxs)(i,{children:[(0,v.jsx)(s,{children:`Groq voice input`}),(0,v.jsxs)(l,{children:[`The mic button in the chat composer records your voice and transcribes it with Groq`,` `,w,`. Get a free API key at console.groq.com (Keys).`]})]}),(0,v.jsxs)(`label`,{className:`flex flex-col gap-1.5 text-sm`,children:[(0,v.jsx)(`span`,{className:`font-medium`,children:`Groq API key`}),(0,v.jsx)(d,{type:`password`,value:n,placeholder:`gsk_…`,autoComplete:`off`,spellCheck:!1,onChange:e=>a(e.target.value)})]}),(0,v.jsx)(`div`,{className:`min-h-5 text-xs ${C}`,role:`status`,children:S}),(0,v.jsxs)(f,{children:[c&&(0,v.jsx)(`span`,{className:`mr-auto self-center text-xs text-green-600 dark:text-green-400`,children:`Saved`}),(0,v.jsx)(m,{type:`button`,variant:`outline`,size:`sm`,onClick:b,children:`Save`}),(0,v.jsx)(m,{type:`button`,size:`sm`,disabled:p||n.trim()===``,onClick:x,children:p?`Testing…`:`Test key`})]})]})})}function K({sessionID:e,appendDraft:t}){let[n,r]=(0,_.useState)(`idle`),[i,a]=(0,_.useState)(0),[o,s]=(0,_.useState)(F),[l,d]=(0,_.useState)(!1),f=(0,_.useRef)(null),p=(0,_.useRef)(null),y=(0,_.useRef)([]),b=(0,_.useRef)(0),x=(0,_.useRef)(null),S=(0,_.useRef)(!1),C=(0,_.useRef)(!0);(0,_.useEffect)(()=>{C.current=!0;let e=P(s);return()=>{C.current=!1,e(),x.current!==null&&(clearInterval(x.current),x.current=null);let t=f.current;if(t&&t.state===`recording`)try{t.onstop=null,t.stop()}catch{}z(p.current),p.current=null,f.current=null,L()}},[]);function w(){x.current!==null&&(clearInterval(x.current),x.current=null),z(p.current),p.current=null,f.current=null,y.current=[],S.current=!1}function E(n){return t?(t(n),!0):W(e,n)}function D(){if(typeof navigator>`u`||!navigator.mediaDevices||typeof navigator.mediaDevices.getUserMedia!=`function`){I({kind:`error`,text:`This browser does not support microphone recording`});return}if(O().trim()===``){I({kind:`error`,text:`Groq API key missing — open the key settings (key button next to the mic)`});return}r(`recording`);let e;try{e=navigator.mediaDevices.getUserMedia({audio:!0})}catch(e){r(`idle`),I({kind:`error`,text:B(e)});return}e.then(e=>{if(!C.current||S.current){z(e);return}p.current=e;let t=R(),n;try{n=t===null?new MediaRecorder(e):new MediaRecorder(e,{mimeType:t})}catch{try{n=new MediaRecorder(e)}catch{if(!C.current){z(e);return}r(`idle`),w(),I({kind:`error`,text:`This browser cannot record audio (MediaRecorder unavailable)`});return}}f.current=n,y.current=[],n.ondataavailable=e=>{e.data&&e.data.size>0&&y.current.push(e.data)},n.onerror=()=>{S.current||!C.current||(r(`idle`),w(),I({kind:`error`,text:`Recording failed — the microphone may be unavailable`}))},n.onstop=()=>{let e=new Blob(y.current,{type:n.mimeType||`audio/webm`}),t=n.mimeType||`audio/webm`,i=C.current;if(w(),e.size===0){i&&(r(`idle`),I({kind:`error`,text:`Recording captured no audio — check the microphone and try again`}));return}N({kind:`working`,text:`Transcribing voice message…`}),H(e,t).then(e=>{let t=E(e);i&&r(`idle`),t?I({kind:`ok`,text:`Transcribed and inserted`},4e3):I({kind:`error`,text:`Transcribed, but could not insert it into the input`})},e=>{i&&r(`idle`),I({kind:`error`,text:V(e)})})};try{n.start(),b.current=Date.now(),a(0),N({kind:`working`,text:`Recording…`}),x.current=setInterval(()=>{if(!C.current)return;let e=Date.now()-b.current,t=Math.round(e/1e3);a(t),N({kind:`working`,text:`Recording… ${t}s`}),e>=T&&(I({kind:`working`,text:`Reached the 2-minute recording limit — sending to Groq…`},4e3),A.current())},500)}catch{r(`idle`),w(),I({kind:`error`,text:`Could not start recording`})}},e=>{S.current||!C.current||(r(`idle`),w(),I({kind:`error`,text:B(e)}))})}function k(){let e=f.current;if(!(!e||e.state!==`recording`)){S.current=!0,x.current!==null&&(clearInterval(x.current),x.current=null),r(`transcribing`);try{e.stop()}catch{w(),r(`idle`),I({kind:`error`,text:`Could not stop the recording`})}}}let A=(0,_.useRef)(k);A.current=k;let j=n===`recording`?`Stop recording`:n===`transcribing`?`Transcribing…`:`Record a voice message`,M=n===`recording`?`Stop recording (${i}s)`:j;return(0,v.jsxs)(`span`,{className:`relative mb-0.5 flex items-center`,"data-groq-voice":!0,children:[n===`recording`?(0,v.jsxs)(m,{type:`button`,variant:`destructive`,size:`sm`,"data-groq-voice-mic":`recording`,"aria-label":j,title:M,onMouseDown:e=>e.preventDefault(),onClick:()=>A.current(),className:`animate-pulse tabular-nums`,children:[(0,v.jsx)(u,{}),i,`s`]}):(0,v.jsx)(m,{type:`button`,variant:`ghost`,size:`icon-sm`,"data-groq-voice-mic":`idle`,"aria-label":j,title:M,disabled:n===`transcribing`,onMouseDown:e=>e.preventDefault(),onClick:D,children:n===`transcribing`?(0,v.jsx)(c,{className:`animate-spin`}):(0,v.jsx)(g,{})}),(0,v.jsx)(m,{type:`button`,variant:`ghost`,size:`icon-sm`,"data-groq-voice-settings":!0,"aria-label":`Groq voice settings`,title:`Groq voice settings — API key`,onMouseDown:e=>e.preventDefault(),onClick:()=>d(!0),children:(0,v.jsx)(h,{})}),o!==null&&(0,v.jsx)(`span`,{role:`status`,"data-groq-voice-status":``,title:o.text,className:`absolute right-0 bottom-full z-30 mb-2 w-max max-w-64 truncate rounded-md border border-[color:var(--border-weak-base)] bg-[color:var(--surface-float-base)] px-2 py-1 text-xs shadow-lg ${o.kind===`error`?`text-red-600 dark:text-red-400`:o.kind===`ok`?`text-green-600 dark:text-green-400`:`text-[color:var(--text-weaker)]`}`,children:o.text}),(0,v.jsx)(G,{open:l,onOpenChange:d})]})}b.register({kind:`wrap`,id:`groq-voice`,target:`composer.sendActions`,render:(e,t)=>{let n=typeof e.sessionID==`string`?e.sessionID:``,r=e.appendDraft,i=typeof r==`function`?r:void 0;return(0,v.jsxs)(v.Fragment,{children:[t(),(0,v.jsx)(K,{sessionID:n,appendDraft:i})]})}});export{y as id};