@aiwuk/autocomplete 0.0.0-stage → 1.0.4
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/LICENSE +21 -0
- package/README.md +88 -2
- package/client.js +1170 -0
- package/cordis.patch.yml +6 -0
- package/fleet-defaults.js +95 -0
- package/i18n/schema.en.json +13 -0
- package/i18n/schema.zh.json +13 -0
- package/icon.svg +1 -0
- package/index.js +105 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/managed-route.js +55 -0
- package/package.json +33 -4
- package/predictor.js +110 -0
- package/schema.js +52 -0
package/client.js
ADDED
|
@@ -0,0 +1,1170 @@
|
|
|
1
|
+
// @generated by packages/autocomplete/scripts/build-client.mjs from client/*.js — edit those files, then run npm run build:autocomplete.
|
|
2
|
+
// Composer ghost-text suggestions and the settings card. React comes from the host module table.
|
|
3
|
+
window.__ModuleLoader__.load({
|
|
4
|
+
id: '@aiwuk/autocomplete',
|
|
5
|
+
factory(require) {
|
|
6
|
+
const React = require('react');
|
|
7
|
+
const h = React.createElement;
|
|
8
|
+
// Relative to the page (<base href="./">), so it works on loopback, behind the hub tunnel and under any path prefix.
|
|
9
|
+
const ENDPOINT = 'autocomplete/predict';
|
|
10
|
+
const NS = 'capability-autocomplete';
|
|
11
|
+
const PACKAGE = '@aiwuk/autocomplete';
|
|
12
|
+
const FORM = 'autocomplete';
|
|
13
|
+
const DEFAULTS = { enabled: true, routeMode: 'session', provider: '', model: '', maxSuggestions: 3, typePauseMs: 700, mobileBar: true };
|
|
14
|
+
const NO_GHOST = { mode: 'next', items: [], index: 0, stream: '', active: false };
|
|
15
|
+
const en = {
|
|
16
|
+
title: 'Composer autocomplete', hint: 'Inline continuation while you pause and next-message suggestions after replies; Tab on desktop or tap the bar on mobile accepts, nothing is sent automatically.',
|
|
17
|
+
enabled: 'Enable suggestions', enabledHint: 'When off, no prediction is requested or shown.', route: 'Model routing',
|
|
18
|
+
routeHint: 'Follow the session model, or pin a low-latency model.', session: 'Follow session (recommended)', fixed: 'Fixed model',
|
|
19
|
+
provider: 'Provider ID', model: 'Model ID', fixedHint: 'Fixed mode needs an existing provider ID and model ID (not a key).',
|
|
20
|
+
count: 'Candidates', countHint: 'Maximum candidates per prediction; Alt+Up/Down cycles them.', pause: 'Typing pause (ms)',
|
|
21
|
+
pauseHint: 'How long after typing stops before a continuation is requested.', mobileBar: 'Touch suggestion bar',
|
|
22
|
+
mobileBarHint: 'On phones and tablets, show suggestions as tappable buttons above the composer.',
|
|
23
|
+
saved: 'Saved', failed: 'Save failed', loading: 'Loading settings…',
|
|
24
|
+
unavailable: 'Settings unavailable: make sure the plugin is enabled.', summary: 'Inline composer completion and next-message suggestions',
|
|
25
|
+
barLabel: 'Suggestions', suggest: 'Suggest', thinking: 'Thinking…', accept: 'Accept: {text}', dismiss: 'Dismiss suggestions', none: 'No suggestion',
|
|
26
|
+
};
|
|
27
|
+
/** Touch-first devices get the tap bar; a phone has no Tab key. Re-evaluated when the input mode changes. */
|
|
28
|
+
function useTouch() {
|
|
29
|
+
const query = typeof window.matchMedia === 'function' ? window.matchMedia('(pointer: coarse)') : null;
|
|
30
|
+
const [touch, setTouch] = React.useState(() => query?.matches === true);
|
|
31
|
+
React.useEffect(() => {
|
|
32
|
+
if (!query) return undefined;
|
|
33
|
+
const update = () => setTouch(query.matches === true);
|
|
34
|
+
query.addEventListener?.('change', update);
|
|
35
|
+
return () => query.removeEventListener?.('change', update);
|
|
36
|
+
}, []);
|
|
37
|
+
return touch;
|
|
38
|
+
}
|
|
39
|
+
const fill = (template, values) => template.replace(/\{(\w+)\}/g, (_m, key) => values[key] ?? '');
|
|
40
|
+
/** Re-render on a `{getSnapshot, subscribe}` store such as a ConfigForm. */
|
|
41
|
+
function useStoreSnapshot(store) {
|
|
42
|
+
const [, redraw] = React.useState(0);
|
|
43
|
+
React.useEffect(() => (typeof store?.subscribe === 'function' ? store.subscribe(() => redraw(n => n + 1)) : undefined), [store]);
|
|
44
|
+
return store?.getSnapshot?.() ?? { status: 'unavailable' };
|
|
45
|
+
}
|
|
46
|
+
const configOf = snapshot => ({ ...DEFAULTS, ...(snapshot?.status === 'ready' ? snapshot.value ?? {} : {}) });
|
|
47
|
+
function parseSse(text) {
|
|
48
|
+
const events = [];
|
|
49
|
+
for (const block of text.split(/\r?\n\r?\n/)) {
|
|
50
|
+
if (!block.trim()) continue;
|
|
51
|
+
let type = 'message', data = '';
|
|
52
|
+
for (const line of block.split(/\r?\n/)) {
|
|
53
|
+
if (line.startsWith('event:')) type = line.slice(6).trim();
|
|
54
|
+
else if (line.startsWith('data:')) data += line.slice(5).trim();
|
|
55
|
+
}
|
|
56
|
+
if (data) { try { events.push({ ...JSON.parse(data), type }); } catch { /* skip malformed frame */ } }
|
|
57
|
+
}
|
|
58
|
+
return events;
|
|
59
|
+
}
|
|
60
|
+
async function consumeStream(stream, signal, onEvent) {
|
|
61
|
+
const reader = stream.getReader();
|
|
62
|
+
const decoder = new TextDecoder();
|
|
63
|
+
let buffer = '';
|
|
64
|
+
try {
|
|
65
|
+
while (!signal.aborted) {
|
|
66
|
+
const { done, value } = await reader.read();
|
|
67
|
+
if (done) break;
|
|
68
|
+
buffer += decoder.decode(value, { stream: true });
|
|
69
|
+
const parts = buffer.split(/\r?\n\r?\n/);
|
|
70
|
+
buffer = parts.pop() ?? '';
|
|
71
|
+
for (const part of parts) for (const event of parseSse(part)) onEvent(event);
|
|
72
|
+
}
|
|
73
|
+
} finally { reader.releaseLock(); }
|
|
74
|
+
}
|
|
75
|
+
/** Slash commands and @-references own the composer; suggestions stay out of their way. */
|
|
76
|
+
const isTriggerToken = draft => /(?:^|\s)\/[^\s]*$/u.test(draft) || /(?:^|\s)@"?[^\s]*$/u.test(draft);
|
|
77
|
+
const collapse = text => text.replace(/\s+/gu, ' ').trim();
|
|
78
|
+
function composerNear(anchor) {
|
|
79
|
+
if (!anchor?.isConnected) return null;
|
|
80
|
+
const card = anchor.closest('[data-composer-card]') || anchor.closest('form') || anchor.parentElement;
|
|
81
|
+
const editor = card?.querySelector('[contenteditable="true"]') || card?.querySelector('[role="textbox"]') || card?.querySelector('textarea');
|
|
82
|
+
return editor ? { card, editor } : null;
|
|
83
|
+
}
|
|
84
|
+
function caretRect(range) {
|
|
85
|
+
let rect = range.getBoundingClientRect();
|
|
86
|
+
if (!rect.width && !rect.height) rect = range.getClientRects()[0] ?? rect;
|
|
87
|
+
if ((!rect.width && !rect.height) || (!rect.top && !rect.left)) {
|
|
88
|
+
let node = range.startContainer, offset = range.startOffset;
|
|
89
|
+
if (node?.nodeType === Node.ELEMENT_NODE && offset > 0) {
|
|
90
|
+
node = node.childNodes[offset - 1] || node;
|
|
91
|
+
offset = node.nodeType === Node.TEXT_NODE ? node.textContent?.length ?? 0 : node.childNodes?.length ?? 0;
|
|
92
|
+
}
|
|
93
|
+
if (node?.nodeType === Node.TEXT_NODE && offset > 0) {
|
|
94
|
+
const probe = document.createRange();
|
|
95
|
+
probe.setStart(node, offset - 1); probe.setEnd(node, offset);
|
|
96
|
+
const r = probe.getBoundingClientRect();
|
|
97
|
+
if (r.width || r.height) return { left: r.right, top: r.top, height: r.height };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return !rect.width && !rect.height ? null : { left: rect.left, top: rect.top, height: rect.height };
|
|
101
|
+
}
|
|
102
|
+
/** The caret position, only when it sits at the end of the draft (continuations append, never splice). */
|
|
103
|
+
function caretAtEnd(editor, _draft) {
|
|
104
|
+
const selection = window.getSelection();
|
|
105
|
+
if (!selection?.rangeCount) return null;
|
|
106
|
+
const range = selection.getRangeAt(0);
|
|
107
|
+
if (!range.collapsed || !editor.contains(range.startContainer)) return null;
|
|
108
|
+
const suffix = document.createRange();
|
|
109
|
+
suffix.selectNodeContents(editor);
|
|
110
|
+
suffix.setStart(range.startContainer, range.startOffset);
|
|
111
|
+
if (suffix.toString().trim() !== '') return null;
|
|
112
|
+
if (!editor.textContent?.trim()) return null;
|
|
113
|
+
return caretRect(range);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const zh = {
|
|
117
|
+
title: '输入框自动补全', hint: '打字停顿时行内续写,回复后建议下一句;电脑按 Tab、手机点建议栏采纳,绝不自动发送。',
|
|
118
|
+
enabled: '启用补全', enabledHint: '关闭后不再请求或展示任何候选。', route: '模型路由',
|
|
119
|
+
routeHint: '跟随当前会话模型,或固定一个低延迟模型。', session: '跟随会话(推荐)', fixed: '固定模型',
|
|
120
|
+
provider: '提供商 ID', model: '模型 ID', fixedHint: '固定模式需要同时填写已有的提供商 ID 与模型 ID(不是密钥)。',
|
|
121
|
+
count: '候选数', countHint: '每次最多生成的候选,Alt+↑/↓ 切换。', pause: '停顿延迟(毫秒)',
|
|
122
|
+
pauseHint: '停止输入后多久请求续写。', mobileBar: '触屏建议栏', mobileBarHint: '在手机、平板上把建议显示为输入框上方可点击的按钮。',
|
|
123
|
+
saved: '已保存', failed: '保存失败', loading: '正在读取设置…',
|
|
124
|
+
unavailable: '设置暂不可用:请确认已启用该插件。', summary: '输入框行内补全与下一句建议',
|
|
125
|
+
barLabel: '补全建议', suggest: '获取建议', thinking: '正在生成…', accept: '采纳:{text}', dismiss: '关闭建议', none: '暂无建议',
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Whether the composer is busy and suggestions must stand down. Multi-agent sessions add two cases besides a running
|
|
130
|
+
* turn: a confirmation card docked above the composer (e.g. a team dispatch plan the user must approve), and a
|
|
131
|
+
* read-only teammate view whose composer cannot send. Composer DOM is checked, not plugin internals, so this works
|
|
132
|
+
* with any plugin that docks a card and keeps working if that plugin is absent.
|
|
133
|
+
*/
|
|
134
|
+
function useComposerBusy(useSession) {
|
|
135
|
+
const session = useSession ? useSession(s => ({ running: s?.running === true, readonly: s?.subagent != null && s.subagent.address?.mode !== 'continuable' })) : null;
|
|
136
|
+
const running = session?.running === true, readonly = session?.readonly === true;
|
|
137
|
+
const [docked, setDocked] = React.useState(false);
|
|
138
|
+
React.useEffect(() => {
|
|
139
|
+
if (typeof document === 'undefined' || typeof MutationObserver !== 'function') return undefined;
|
|
140
|
+
// Any dock content other than our own strip means another surface (e.g. a dispatch confirmation) is in use.
|
|
141
|
+
const check = () => setDocked([...document.querySelectorAll('.ctm-dock, [data-composer-dock-busy]')].some(el => el.getBoundingClientRect().height > 0));
|
|
142
|
+
check();
|
|
143
|
+
const observer = new MutationObserver(check);
|
|
144
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
145
|
+
return () => observer.disconnect();
|
|
146
|
+
}, []);
|
|
147
|
+
return { busy: running || readonly || docked, running, readonly, docked };
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Shared prediction state for both input modes: one in-flight request per composer, streamed deltas, abort on
|
|
151
|
+
* draft change and during IME composition, and a "dismissed" marker so a closed suggestion does not reappear.
|
|
152
|
+
*/
|
|
153
|
+
function usePredictions(sessionId, draft) {
|
|
154
|
+
const [ghost, setGhost] = React.useState(NO_GHOST);
|
|
155
|
+
const abortRef = React.useRef(null);
|
|
156
|
+
const composingRef = React.useRef(false);
|
|
157
|
+
const dismissedRef = React.useRef(null);
|
|
158
|
+
const request = React.useCallback(async (mode, text) => {
|
|
159
|
+
if (!sessionId || document.hidden) return;
|
|
160
|
+
abortRef.current?.abort();
|
|
161
|
+
const controller = new AbortController();
|
|
162
|
+
abortRef.current = controller;
|
|
163
|
+
setGhost({ mode, items: [], index: 0, stream: '', active: true });
|
|
164
|
+
const settle = items => {
|
|
165
|
+
if (controller.signal.aborted) return;
|
|
166
|
+
setGhost(prev => (prev.mode !== mode ? prev : items.length ? { mode, items, index: 0, stream: '', active: false } : NO_GHOST));
|
|
167
|
+
};
|
|
168
|
+
try {
|
|
169
|
+
const response = await fetch(ENDPOINT, { method: 'POST', signal: controller.signal, credentials: 'same-origin',
|
|
170
|
+
headers: { 'content-type': 'application/json', accept: 'text/event-stream' },
|
|
171
|
+
body: JSON.stringify({ sessionId, mode, draft: text }) });
|
|
172
|
+
if (!response.ok || controller.signal.aborted) { settle([]); return; }
|
|
173
|
+
if (!(response.headers.get('content-type') || '').includes('text/event-stream') || !response.body) {
|
|
174
|
+
const done = parseSse(await response.text().catch(() => '')).find(e => e.type === 'done');
|
|
175
|
+
settle(Array.isArray(done?.suggestions) ? done.suggestions : []);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
await consumeStream(response.body, controller.signal, event => {
|
|
179
|
+
if (controller.signal.aborted) return;
|
|
180
|
+
if (event.type === 'delta' && typeof event.text === 'string') {
|
|
181
|
+
setGhost(prev => (prev.active && prev.mode === mode ? { ...prev, stream: prev.stream + event.text } : prev));
|
|
182
|
+
} else if (event.type === 'done') settle(Array.isArray(event.suggestions) ? event.suggestions : []);
|
|
183
|
+
else if (event.type === 'error') settle([]);
|
|
184
|
+
});
|
|
185
|
+
} catch { if (!controller.signal.aborted) settle([]); }
|
|
186
|
+
}, [sessionId]);
|
|
187
|
+
React.useEffect(() => {
|
|
188
|
+
const start = () => { composingRef.current = true; abortRef.current?.abort(); setGhost(NO_GHOST); };
|
|
189
|
+
const end = () => { composingRef.current = false; };
|
|
190
|
+
document.addEventListener('compositionstart', start, true);
|
|
191
|
+
document.addEventListener('compositionend', end, true);
|
|
192
|
+
return () => { document.removeEventListener('compositionstart', start, true); document.removeEventListener('compositionend', end, true); abortRef.current?.abort(); };
|
|
193
|
+
}, []);
|
|
194
|
+
React.useEffect(() => {
|
|
195
|
+
abortRef.current?.abort();
|
|
196
|
+
setGhost(NO_GHOST);
|
|
197
|
+
if (dismissedRef.current && dismissedRef.current.draft !== draft) dismissedRef.current = null;
|
|
198
|
+
}, [draft]);
|
|
199
|
+
return { ghost, setGhost, request, abortRef, composingRef, dismissedRef };
|
|
200
|
+
}
|
|
201
|
+
/** The candidate shown for a draft: continuation text after the draft, or a whole next message. */
|
|
202
|
+
function candidateOf(ghost, draft) {
|
|
203
|
+
const raw = ghost.items.length ? ghost.items[Math.min(ghost.index, ghost.items.length - 1)] ?? ''
|
|
204
|
+
: ghost.stream.trim().split(/\r?\n/)[0]?.replace(/^[-*0-9.)\s]+/, '') ?? '';
|
|
205
|
+
if (ghost.mode !== 'continue') return raw;
|
|
206
|
+
if (raw.startsWith(draft)) return raw.slice(draft.length);
|
|
207
|
+
const lowerRaw = raw.toLowerCase(), lowerDraft = draft.toLowerCase();
|
|
208
|
+
if (lowerRaw.startsWith(lowerDraft)) return raw.slice(draft.length);
|
|
209
|
+
return raw;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Insert into the shell-owned draft; never submits. Falls back to the editor's own insert on older hosts. */
|
|
213
|
+
function insertSuggestion(inputActions, value) {
|
|
214
|
+
if (inputActions?.insertText && inputActions.captureInsertion && inputActions.insertText(value, inputActions.captureInsertion())) return;
|
|
215
|
+
document.execCommand('insertText', false, value);
|
|
216
|
+
}
|
|
217
|
+
/** Keyboard devices: ghost text inside the composer. Touch devices use the dock strip instead (TouchDock). */
|
|
218
|
+
function ComposerOverlay(props) {
|
|
219
|
+
const config = configOf(useStoreSnapshot(props.form));
|
|
220
|
+
const touch = useTouch();
|
|
221
|
+
if (!config.enabled || (touch && config.mobileBar)) return null;
|
|
222
|
+
return h(GhostOverlay, { ...props, config });
|
|
223
|
+
}
|
|
224
|
+
/** Touch devices: the suggestion strip, rendered in the dock above the composer. */
|
|
225
|
+
function TouchDock(props) {
|
|
226
|
+
const config = configOf(useStoreSnapshot(props.form));
|
|
227
|
+
const touch = useTouch();
|
|
228
|
+
if (!config.enabled || !touch || !config.mobileBar) return null;
|
|
229
|
+
return h(TouchSuggestionBar, { ...props, config });
|
|
230
|
+
}
|
|
231
|
+
function GhostOverlay({ sessionId, useInput, useSession, inputActions, config }) {
|
|
232
|
+
const draft = useInput ? useInput(s => (s?.draft ? String(s.draft) : '')) ?? '' : '';
|
|
233
|
+
// "running" here means any busy state (running turn, docked confirmation, read-only teammate view).
|
|
234
|
+
const { busy: running } = useComposerBusy(useSession);
|
|
235
|
+
const { ghost, setGhost, request, abortRef, composingRef, dismissedRef } = usePredictions(sessionId, draft);
|
|
236
|
+
React.useEffect(() => { if (running) { abortRef.current?.abort(); setGhost(NO_GHOST); } }, [running]);
|
|
237
|
+
const [box, setBox] = React.useState(null);
|
|
238
|
+
const anchorRef = React.useRef(null);
|
|
239
|
+
const currentRef = React.useRef('');
|
|
240
|
+
React.useEffect(() => {
|
|
241
|
+
if (!config.enabled || running || draft !== '' || !sessionId || dismissedRef.current?.draft === draft) return undefined;
|
|
242
|
+
const timer = setTimeout(() => { if (!composingRef.current) void request('next', ''); }, 600);
|
|
243
|
+
return () => clearTimeout(timer);
|
|
244
|
+
}, [sessionId, running, draft, request, config.enabled]);
|
|
245
|
+
React.useEffect(() => {
|
|
246
|
+
if (!config.enabled || !sessionId || draft.trim().length < 3 || isTriggerToken(draft) || dismissedRef.current?.draft === draft) return undefined;
|
|
247
|
+
const timer = setTimeout(() => {
|
|
248
|
+
const found = composerNear(anchorRef.current);
|
|
249
|
+
if (composingRef.current || !found || !caretAtEnd(found.editor, draft)) return;
|
|
250
|
+
void request('continue', draft.slice(-4000));
|
|
251
|
+
}, Math.max(200, config.typePauseMs));
|
|
252
|
+
return () => clearTimeout(timer);
|
|
253
|
+
}, [sessionId, draft, request, config.enabled, config.typePauseMs]);
|
|
254
|
+
const current = candidateOf(ghost, draft);
|
|
255
|
+
currentRef.current = current;
|
|
256
|
+
const visible = Boolean(config.enabled && current && box && (ghost.mode === 'next' ? !draft && !running : draft.trim() && !isTriggerToken(draft)));
|
|
257
|
+
React.useLayoutEffect(() => {
|
|
258
|
+
if (!current) { setBox(null); return undefined; }
|
|
259
|
+
const measure = () => {
|
|
260
|
+
const found = composerNear(anchorRef.current);
|
|
261
|
+
if (!found) { setBox(null); return; }
|
|
262
|
+
const card = found.card.getBoundingClientRect(), editor = found.editor.getBoundingClientRect();
|
|
263
|
+
if (editor.width <= 0) { setBox(null); return; }
|
|
264
|
+
if (ghost.mode === 'next') {
|
|
265
|
+
const style = window.getComputedStyle(found.editor);
|
|
266
|
+
setBox({ top: Math.round(Math.max(0, editor.top - card.top + (parseFloat(style.paddingTop) || 8))),
|
|
267
|
+
left: Math.round(Math.max(0, editor.left - card.left + (parseFloat(style.paddingLeft) || 12))), height: Math.round(parseFloat(style.lineHeight) || 22) });
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const caret = caretAtEnd(found.editor, draft);
|
|
271
|
+
if (!caret) { setBox(null); return; }
|
|
272
|
+
const left = Math.max(0, caret.left - card.left);
|
|
273
|
+
setBox({ top: Math.round(caret.top - card.top), left: Math.round(left), maxWidth: Math.round(Math.max(48, card.right - card.left - left - 16)), height: Math.round(caret.height || 22) });
|
|
274
|
+
};
|
|
275
|
+
measure();
|
|
276
|
+
window.addEventListener('resize', measure);
|
|
277
|
+
const scrollEl = anchorRef.current?.closest('[data-composer-card]')?.querySelector('[data-input-scroll]');
|
|
278
|
+
scrollEl?.addEventListener('scroll', measure, { passive: true });
|
|
279
|
+
return () => {
|
|
280
|
+
window.removeEventListener('resize', measure);
|
|
281
|
+
scrollEl?.removeEventListener('scroll', measure);
|
|
282
|
+
};
|
|
283
|
+
}, [ghost.mode, draft, sessionId, current]);
|
|
284
|
+
React.useLayoutEffect(() => {
|
|
285
|
+
if (typeof document === 'undefined') return undefined;
|
|
286
|
+
const ID = 'aiwuk-autocomplete-ghost-style';
|
|
287
|
+
if (!document.getElementById(ID)) {
|
|
288
|
+
const tag = document.createElement('style');
|
|
289
|
+
tag.id = ID;
|
|
290
|
+
tag.textContent = '[data-aiwuk-ghost-next] [data-composer-placeholder]{visibility:hidden !important;}';
|
|
291
|
+
(document.head || document.documentElement).appendChild(tag);
|
|
292
|
+
}
|
|
293
|
+
const card = anchorRef.current?.closest('[data-composer-card]') || anchorRef.current?.parentElement;
|
|
294
|
+
if (!card) return undefined;
|
|
295
|
+
if (visible && ghost.mode === 'next') card.setAttribute('data-aiwuk-ghost-next', '');
|
|
296
|
+
else card.removeAttribute('data-aiwuk-ghost-next');
|
|
297
|
+
return () => { card.removeAttribute('data-aiwuk-ghost-next'); };
|
|
298
|
+
}, [visible, ghost.mode]);
|
|
299
|
+
React.useEffect(() => {
|
|
300
|
+
if (!visible) return undefined;
|
|
301
|
+
const onSelectionChange = () => {
|
|
302
|
+
const found = composerNear(anchorRef.current);
|
|
303
|
+
if (!found) return;
|
|
304
|
+
if (ghost.mode === 'continue' && !caretAtEnd(found.editor, draft)) setBox(null);
|
|
305
|
+
};
|
|
306
|
+
document.addEventListener('selectionchange', onSelectionChange);
|
|
307
|
+
return () => document.removeEventListener('selectionchange', onSelectionChange);
|
|
308
|
+
}, [visible, ghost.mode, draft]);
|
|
309
|
+
React.useEffect(() => {
|
|
310
|
+
if (!visible) return undefined;
|
|
311
|
+
const onKeyDown = event => {
|
|
312
|
+
if (event.defaultPrevented || event.isComposing || event.keyCode === 229) return;
|
|
313
|
+
const found = composerNear(anchorRef.current);
|
|
314
|
+
if (!found || !found.card.contains(document.activeElement)) return;
|
|
315
|
+
if (event.key === 'Escape') {
|
|
316
|
+
event.preventDefault(); event.stopPropagation();
|
|
317
|
+
dismissedRef.current = { draft }; abortRef.current?.abort(); setGhost(NO_GHOST);
|
|
318
|
+
} else if (event.altKey && (event.key === 'ArrowDown' || event.key === 'ArrowUp') && ghost.items.length > 1) {
|
|
319
|
+
event.preventDefault(); event.stopPropagation();
|
|
320
|
+
const step = event.key === 'ArrowDown' ? 1 : -1;
|
|
321
|
+
setGhost(prev => ({ ...prev, index: (prev.index + step + prev.items.length) % prev.items.length }));
|
|
322
|
+
} else if (event.key === 'Tab' && !event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey && !ghost.active && currentRef.current) {
|
|
323
|
+
event.preventDefault(); event.stopPropagation();
|
|
324
|
+
const value = currentRef.current;
|
|
325
|
+
dismissedRef.current = { draft: ghost.mode === 'continue' ? draft + value : value };
|
|
326
|
+
setGhost(NO_GHOST);
|
|
327
|
+
insertSuggestion(inputActions, value);
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
document.addEventListener('keydown', onKeyDown, true);
|
|
331
|
+
return () => document.removeEventListener('keydown', onKeyDown, true);
|
|
332
|
+
}, [visible, draft, ghost.items.length, ghost.active, ghost.mode, inputActions]);
|
|
333
|
+
const anchor = h('span', { key: 'anchor', ref: anchorRef, style: { display: 'none' }, 'aria-hidden': 'true' });
|
|
334
|
+
if (!visible || !box) return anchor;
|
|
335
|
+
const tag = { fontSize: '10px', borderRadius: 'var(--dsw-radius-xs, 3px)', padding: '0 4px', border: '1px solid var(--dsw-alias-border-l2, #555)', opacity: 0.75 };
|
|
336
|
+
return h(React.Fragment, null, anchor,
|
|
337
|
+
h('div', { key: 'ghost', 'data-composer-prediction': ghost.mode, style: { position: 'absolute', top: `${box.top}px`, left: `${box.left}px`,
|
|
338
|
+
maxWidth: box.maxWidth ? `${box.maxWidth}px` : undefined, height: `${box.height}px`, display: 'flex', alignItems: 'center', gap: '6px',
|
|
339
|
+
color: 'var(--dsw-alias-label-tertiary, #888)', pointerEvents: 'none', userSelect: 'none', whiteSpace: 'pre', overflow: 'hidden', zIndex: 10 } },
|
|
340
|
+
h('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', opacity: 0.85, whiteSpace: 'pre' } }, current),
|
|
341
|
+
ghost.items.length > 1 ? h('span', { style: tag }, `${ghost.index + 1}/${ghost.items.length}`) : null,
|
|
342
|
+
ghost.active ? null : h('kbd', { style: tag }, 'Tab')));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* One touch store per session, shared by the ✦ button (composer tool row) and the suggestion strip (dock): the two
|
|
347
|
+
* live in different slots, so they meet through this small subscribable store instead of React state.
|
|
348
|
+
*/
|
|
349
|
+
const touchStores = new Map();
|
|
350
|
+
function touchStore(sessionId) {
|
|
351
|
+
let store = touchStores.get(sessionId);
|
|
352
|
+
if (!store) {
|
|
353
|
+
const listeners = new Set();
|
|
354
|
+
store = { askSeq: 0, ask() { this.askSeq += 1; for (const l of listeners) l(); }, subscribe(l) { listeners.add(l); return () => listeners.delete(l); } };
|
|
355
|
+
touchStores.set(sessionId, store);
|
|
356
|
+
}
|
|
357
|
+
return store;
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Below this width the native tool row has no free space (measured at 320px: even a 28px button wrapped it onto a
|
|
361
|
+
* second line), so the ✦ moves from the tool row to the start of the suggestion strip.
|
|
362
|
+
*/
|
|
363
|
+
const NARROW = '(max-width: 359px)';
|
|
364
|
+
function useNarrow() {
|
|
365
|
+
const query = typeof window.matchMedia === 'function' ? window.matchMedia(NARROW) : null;
|
|
366
|
+
const [narrow, setNarrow] = React.useState(() => query?.matches === true);
|
|
367
|
+
React.useEffect(() => {
|
|
368
|
+
if (!query) return undefined;
|
|
369
|
+
const update = () => setNarrow(query.matches === true);
|
|
370
|
+
query.addEventListener?.('change', update);
|
|
371
|
+
return () => query.removeEventListener?.('change', update);
|
|
372
|
+
}, []);
|
|
373
|
+
return narrow;
|
|
374
|
+
}
|
|
375
|
+
/** The card of THIS composer: the nearest ancestor of the strip that contains one (a page may hold several). */
|
|
376
|
+
function composerCardOf(strip) {
|
|
377
|
+
for (let el = strip?.parentElement; el; el = el.parentElement) {
|
|
378
|
+
const card = el.querySelector('[data-composer-card]');
|
|
379
|
+
if (card) return card;
|
|
380
|
+
}
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Measure the strip's own box against the input box and correct its current margins by the difference. The dock's
|
|
385
|
+
* direct parents use `display: contents` (no box of their own), so a parent rect cannot be the reference.
|
|
386
|
+
*/
|
|
387
|
+
function measureCardInset(strip, setInset) {
|
|
388
|
+
const card = composerCardOf(strip);
|
|
389
|
+
if (!card) return;
|
|
390
|
+
const own = strip.getBoundingClientRect(), target = card.getBoundingClientRect();
|
|
391
|
+
if (!target.width || !own.width) return;
|
|
392
|
+
setInset(prev => {
|
|
393
|
+
const next = { left: Math.max(0, Math.round(prev.left + target.left - own.left)), right: Math.max(0, Math.round(prev.right + own.right - target.right)) };
|
|
394
|
+
return prev.left === next.left && prev.right === next.right ? prev : next;
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Insets that line the strip up with the input box. The dock spans the whole composer column, which is wider than the
|
|
399
|
+
* input box (measured on a phone: 56px on the left, 16px on the right), so without this the chips start left of it.
|
|
400
|
+
* Re-measured on resize and whenever the input box resizes; no inset when there is no input box to measure.
|
|
401
|
+
*/
|
|
402
|
+
function useCardAlignment(stripRef) {
|
|
403
|
+
const [inset, setInset] = React.useState({ left: 0, right: 0 });
|
|
404
|
+
React.useLayoutEffect(() => {
|
|
405
|
+
const strip = stripRef.current;
|
|
406
|
+
if (!strip) return undefined;
|
|
407
|
+
const measure = () => measureCardInset(strip, setInset);
|
|
408
|
+
measure();
|
|
409
|
+
window.addEventListener('resize', measure);
|
|
410
|
+
const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(measure) : null;
|
|
411
|
+
const card = composerCardOf(strip);
|
|
412
|
+
if (observer && card) observer.observe(card);
|
|
413
|
+
return () => { window.removeEventListener('resize', measure); observer?.disconnect(); };
|
|
414
|
+
}, [stripRef.current]);
|
|
415
|
+
return inset;
|
|
416
|
+
}
|
|
417
|
+
/** ✦ button in the composer's own tool row (conversation.input.right): no extra height, nothing covered. */
|
|
418
|
+
function TouchAskButton({ sessionId, useSession, form, t }) {
|
|
419
|
+
const config = configOf(useStoreSnapshot(form));
|
|
420
|
+
const touch = useTouch();
|
|
421
|
+
const narrow = useNarrow();
|
|
422
|
+
const { busy } = useComposerBusy(useSession);
|
|
423
|
+
if (!touch || narrow || !config.enabled || !config.mobileBar || !sessionId || busy) return null;
|
|
424
|
+
const label = typeof t === 'function' ? t : key => en[key] ?? key;
|
|
425
|
+
return h('button', { type: 'button', 'data-action': 'suggest', 'aria-label': label('suggest'), title: label('suggest'),
|
|
426
|
+
onMouseDown: event => event.preventDefault(), onClick: () => touchStore(sessionId).ask(),
|
|
427
|
+
// Sized like the native tool-row icons (measured at 320px: a wider button wrapped the row onto two lines).
|
|
428
|
+
style: { width: '28px', height: '28px', padding: 0, flex: '0 0 auto', border: 'none', borderRadius: 'var(--dsw-radius-md, 14px)', background: 'transparent',
|
|
429
|
+
color: 'var(--dsw-alias-label-secondary, inherit)', fontSize: 'var(--dsw-font-base-16-font-size, 15px)', lineHeight: 1 } }, '✦');
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Touch suggestion strip (phones, tablets): a phone has no Tab key, so suggestions are tappable chips. It renders in
|
|
433
|
+
* the dock above the composer, which DSH lays out in normal flow — it pushes content instead of covering the native
|
|
434
|
+
* controls there. It takes no space at all until there is something to show.
|
|
435
|
+
*/
|
|
436
|
+
function TouchSuggestionBar({ sessionId, useInput, useSession, inputActions, config, t }) {
|
|
437
|
+
const draft = useInput ? useInput(s => (s?.draft ? String(s.draft) : '')) ?? '' : '';
|
|
438
|
+
const { busy, running } = useComposerBusy(useSession);
|
|
439
|
+
const { ghost, setGhost, request, abortRef, composingRef, dismissedRef } = usePredictions(sessionId, draft);
|
|
440
|
+
const narrow = useNarrow();
|
|
441
|
+
const stripRef = React.useRef(null);
|
|
442
|
+
const align = useCardAlignment(stripRef);
|
|
443
|
+
const label = typeof t === 'function' ? t : key => en[key] ?? key;
|
|
444
|
+
const blocked = !sessionId || busy || isTriggerToken(draft);
|
|
445
|
+
// A dispatch card or a running teammate turn appearing must also cancel a request already in flight.
|
|
446
|
+
React.useEffect(() => { if (busy) { abortRef.current?.abort(); setGhost(NO_GHOST); } }, [busy]);
|
|
447
|
+
const ask = () => {
|
|
448
|
+
if (blocked || composingRef.current) return;
|
|
449
|
+
dismissedRef.current = null;
|
|
450
|
+
void (draft.trim() ? request('continue', draft.slice(-4000)) : request('next', ''));
|
|
451
|
+
};
|
|
452
|
+
const askRef = React.useRef(ask);
|
|
453
|
+
askRef.current = ask;
|
|
454
|
+
React.useEffect(() => (sessionId ? touchStore(sessionId).subscribe(() => askRef.current()) : undefined), [sessionId]);
|
|
455
|
+
// Next-message suggestions arrive by themselves after a reply; continuations wait for an explicit tap.
|
|
456
|
+
React.useEffect(() => {
|
|
457
|
+
if (blocked || draft !== '' || dismissedRef.current?.draft === draft) return undefined;
|
|
458
|
+
const timer = setTimeout(() => { if (!composingRef.current) void request('next', ''); }, 600);
|
|
459
|
+
return () => clearTimeout(timer);
|
|
460
|
+
}, [sessionId, running, draft, request]);
|
|
461
|
+
const items = ghost.items.length ? ghost.items : [candidateOf(ghost, draft)].filter(Boolean);
|
|
462
|
+
// Nothing to show: render nothing, so the dock keeps zero height and the layout is untouched. On narrow screens
|
|
463
|
+
// the strip also carries the ✦ — but only once there is a draft to continue, so an idle composer stays compact.
|
|
464
|
+
const askHere = narrow && draft.trim() !== '';
|
|
465
|
+
if (blocked || (!ghost.active && !items.length && !askHere)) return null;
|
|
466
|
+
const accept = item => {
|
|
467
|
+
const value = ghost.mode === 'continue' && item.startsWith(draft) ? item.slice(draft.length) : item;
|
|
468
|
+
dismissedRef.current = { draft: ghost.mode === 'continue' ? draft + value : value };
|
|
469
|
+
setGhost(NO_GHOST);
|
|
470
|
+
insertSuggestion(inputActions, value);
|
|
471
|
+
};
|
|
472
|
+
const close = () => { dismissedRef.current = { draft }; abortRef.current?.abort(); setGhost(NO_GHOST); };
|
|
473
|
+
// 36px+ targets, horizontal scroll for long chips, and no focus steal so the soft keyboard stays open.
|
|
474
|
+
const keep = event => event.preventDefault();
|
|
475
|
+
const chip = { flex: '0 0 auto', maxWidth: '80vw', minHeight: '36px', padding: '6px 12px', borderRadius: 'var(--dsw-radius-lg, 18px)', overflow: 'hidden',
|
|
476
|
+
textOverflow: 'ellipsis', whiteSpace: 'nowrap', border: '1px solid var(--dsw-alias-border-l2, #555)',
|
|
477
|
+
background: 'var(--dsw-alias-bg-layer-2, transparent)', color: 'var(--dsw-alias-label-primary, inherit)', fontSize: 'var(--dsw-font-s-14-font-size, 14px)' };
|
|
478
|
+
// In flow (no absolute positioning): one row, scrolls sideways, never wraps into a second line.
|
|
479
|
+
return h('div', { ref: stripRef, role: 'toolbar', 'aria-label': label('barLabel'), 'data-autocomplete-bar': ghost.mode,
|
|
480
|
+
// flexShrink 0: the composer stack is a fixed-height flex column and would otherwise squeeze the chips flat.
|
|
481
|
+
// The dock is wider than the composer card, so the strip is inset to the card's own left and right edges.
|
|
482
|
+
style: { display: 'flex', flexShrink: 0, gap: '8px', alignItems: 'center', overflowX: 'auto', overflowY: 'hidden', WebkitOverflowScrolling: 'touch',
|
|
483
|
+
scrollbarWidth: 'none', padding: '2px 0 6px', boxSizing: 'border-box', marginLeft: `${align.left}px`, marginRight: `${align.right}px` } },
|
|
484
|
+
askHere ? h('button', { type: 'button', key: 'ask', 'data-action': 'suggest', 'aria-label': label('suggest'), onMouseDown: keep, onClick: ask,
|
|
485
|
+
disabled: ghost.active, style: { ...chip, minWidth: '36px', padding: '6px 10px' } }, ghost.active ? '…' : '✦') : null,
|
|
486
|
+
ghost.active && !items.length ? h('span', { key: 'busy', style: { fontSize: 'var(--dsw-font-xs-13-font-size, 13px)', opacity: 0.7 } }, label('thinking')) : null,
|
|
487
|
+
...items.map((item, index) => h('button', { type: 'button', key: `s${index}`, 'data-action': 'accept', disabled: ghost.active,
|
|
488
|
+
'aria-label': fill(label('accept'), { text: item }), onMouseDown: keep, onClick: () => accept(item), style: chip }, item)),
|
|
489
|
+
items.length ? h('button', { type: 'button', key: 'close', 'data-action': 'dismiss', 'aria-label': label('dismiss'), onMouseDown: keep, onClick: close,
|
|
490
|
+
style: { ...chip, minWidth: '36px', padding: '6px 10px' } }, '×') : null);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Settings card bound to this plugin's ConfigForm: the host config is the single source of truth. */
|
|
494
|
+
function AutocompleteSettings({ form, t, view }) {
|
|
495
|
+
const snapshot = useStoreSnapshot(form);
|
|
496
|
+
const [draft, setDraft] = React.useState({});
|
|
497
|
+
const [result, setResult] = React.useState(null);
|
|
498
|
+
if (view === 'summary') return t('summary');
|
|
499
|
+
if (snapshot.status === 'loading') return h('p', null, t('loading'));
|
|
500
|
+
if (snapshot.status !== 'ready') return h('p', { 'data-unavailable': '' }, t('unavailable'));
|
|
501
|
+
const value = { ...configOf(snapshot), ...draft };
|
|
502
|
+
const locked = snapshot.writable === false;
|
|
503
|
+
const save = async (key, next) => {
|
|
504
|
+
setDraft(prev => ({ ...prev, [key]: next }));
|
|
505
|
+
let ok = false;
|
|
506
|
+
try { ok = await form.mutate([{ op: 'set', path: [key], value: next }], snapshot.revision); } catch { ok = false; }
|
|
507
|
+
setResult(ok ? 'saved' : 'failed');
|
|
508
|
+
if (ok) setDraft(prev => { const rest = { ...prev }; delete rest[key]; return rest; });
|
|
509
|
+
};
|
|
510
|
+
const muted = { fontSize: '12px', color: 'var(--dsw-alias-label-secondary, #888)' };
|
|
511
|
+
const row = (label, hint, control) => h('label', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '12px', padding: '8px 0' } },
|
|
512
|
+
h('span', { style: { display: 'flex', flexDirection: 'column', gap: '2px' } }, h('span', null, label), h('span', { style: muted }, hint)), control);
|
|
513
|
+
const number = (key, min, max, step) => h('input', { type: 'number', name: key, min, max, step, disabled: locked, value: value[key],
|
|
514
|
+
style: { width: '96px' }, onChange: event => { const n = Number(event.target.value); if (Number.isInteger(n) && n >= min && n <= max) void save(key, n); } });
|
|
515
|
+
const text = key => h('input', { type: 'text', name: key, disabled: locked, value: value[key] ?? '',
|
|
516
|
+
onChange: event => setDraft(prev => ({ ...prev, [key]: event.target.value })), onBlur: event => void save(key, event.target.value.trim()) });
|
|
517
|
+
return h('div', { 'data-autocomplete-settings': '', style: { display: 'flex', flexDirection: 'column', gap: '4px' } },
|
|
518
|
+
h('strong', null, t('title')), h('span', { style: muted }, t('hint')),
|
|
519
|
+
row(t('enabled'), t('enabledHint'), h('input', { type: 'checkbox', name: 'enabled', role: 'switch', disabled: locked, checked: value.enabled === true,
|
|
520
|
+
onChange: event => void save('enabled', event.target.checked) })),
|
|
521
|
+
row(t('route'), t('routeHint'), h('select', { name: 'routeMode', disabled: locked, value: value.routeMode, onChange: event => void save('routeMode', event.target.value) },
|
|
522
|
+
h('option', { value: 'session' }, t('session')), h('option', { value: 'fixed' }, t('fixed')))),
|
|
523
|
+
value.routeMode === 'fixed' ? h('div', { 'data-fixed-route': '' },
|
|
524
|
+
h('span', { style: muted }, t('fixedHint')), row(t('provider'), '', text('provider')), row(t('model'), '', text('model'))) : null,
|
|
525
|
+
row(t('count'), t('countHint'), number('maxSuggestions', 1, 5, 1)),
|
|
526
|
+
row(t('pause'), t('pauseHint'), number('typePauseMs', 200, 5000, 50)),
|
|
527
|
+
row(t('mobileBar'), t('mobileBarHint'), h('input', { type: 'checkbox', name: 'mobileBar', role: 'switch', disabled: locked, checked: value.mobileBar !== false,
|
|
528
|
+
onChange: event => void save('mobileBar', event.target.checked) })),
|
|
529
|
+
result ? h('span', { role: 'status', style: muted }, t(result)) : null);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// @generated by shared-ui/build.mjs from shared-ui/*.js, catalog.json and locale/*.json — edit those, then run node shared-ui/build.mjs.
|
|
533
|
+
const aiwukConfig = (() => {
|
|
534
|
+
const BRAND_TEXT = {"mothership":{"en":"AIWUK Mothership","zh":"AI\u609f\u7a7a \u6bcd\u8230"}};
|
|
535
|
+
// Pure logic of the config panel (no DOM, no React, no host imports) so it is testable and reused verbatim in the
|
|
536
|
+
// browser bundle. Nothing here ever receives a secret value: secrets are described (configured / source) only.
|
|
537
|
+
|
|
538
|
+
/** Credentials `describe` accepts at most this many refs per call. */
|
|
539
|
+
const DESCRIBE_BATCH = 64;
|
|
540
|
+
const SECRET_STATES = Object.freeze(['unset', 'stored', 'env', 'managed']);
|
|
541
|
+
|
|
542
|
+
const chunk = (list, size = DESCRIBE_BATCH) => {
|
|
543
|
+
const out = [];
|
|
544
|
+
for (let i = 0; i < list.length; i += size) out.push(list.slice(i, i + size));
|
|
545
|
+
return out;
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
/** Relative (base-href aware) URL of an extension's values-free manifest route; mirrors managed-config-route.js. */
|
|
549
|
+
const managedConfigUrl = extension => `api/aiwuk.managed-config.${extension}`;
|
|
550
|
+
|
|
551
|
+
/** Remote replies are `{ok, value}` / `{ok:false, error}`; a bare value passes through. Throws on a refusal. */
|
|
552
|
+
function unwrapReply(reply) {
|
|
553
|
+
if (reply && typeof reply === 'object' && typeof reply.ok === 'boolean') {
|
|
554
|
+
if (!reply.ok) throw new Error(String(reply.error?.code ?? reply.error?.message ?? 'remote-error'));
|
|
555
|
+
return reply.value;
|
|
556
|
+
}
|
|
557
|
+
return reply;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Wire manifest → Map(ref → {locked, secret, value?}); null when the payload is not a manifest. A secret never keeps a value. */
|
|
561
|
+
function manifestFromWire(wire) {
|
|
562
|
+
if (!wire || typeof wire !== 'object' || !Array.isArray(wire.entries)) return null;
|
|
563
|
+
const entries = new Map();
|
|
564
|
+
for (const item of wire.entries) {
|
|
565
|
+
if (!item || typeof item.ref !== 'string') continue;
|
|
566
|
+
const secret = item.secret === true;
|
|
567
|
+
entries.set(item.ref, { locked: item.locked === true, secret, ...(!secret && typeof item.value === 'string' ? { value: item.value } : {}) });
|
|
568
|
+
}
|
|
569
|
+
return { revision: Number.isSafeInteger(wire.revision) ? wire.revision : 0, entries };
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** The Mothership's value of a non-secret managed ref (for display), or undefined. */
|
|
573
|
+
const managedValueOf = (manifest, ref) => (ref ? manifest?.entries?.get(ref)?.value : undefined);
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Panel-level managed status: how many of the panel's integration fields the Mothership manages and how many it
|
|
577
|
+
* locks. `mode` is 'none' (self-configured), 'full' (every integration field managed) or 'partial'.
|
|
578
|
+
*/
|
|
579
|
+
function managedStatus(fields, manifest) {
|
|
580
|
+
const refs = fields.filter(field => field.ref).map(field => field.ref);
|
|
581
|
+
const managed = refs.filter(ref => manifest?.entries?.has(ref));
|
|
582
|
+
const locked = managed.filter(ref => manifest.entries.get(ref).locked);
|
|
583
|
+
return { managed: managed.length, locked: locked.length, total: refs.length, mode: !managed.length ? 'none' : managed.length === refs.length ? 'full' : 'partial' };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** 'locked' | 'default' (managed but editable) | 'none' for a ref. */
|
|
587
|
+
function lockMode(manifest, ref) {
|
|
588
|
+
const entry = ref ? manifest?.entries?.get(ref) : undefined;
|
|
589
|
+
return !entry ? 'none' : entry.locked ? 'locked' : 'default';
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* State of a secret field from the host's `describe` view (`{configured, source?, writable}`) and the lock mode.
|
|
594
|
+
* Locked managed refs read as managed whatever the store says; the value itself is never involved.
|
|
595
|
+
*/
|
|
596
|
+
function secretState(info, mode = 'none') {
|
|
597
|
+
const readOnly = mode === 'locked' || info?.writable === false;
|
|
598
|
+
if (mode === 'locked') return { kind: 'managed', configured: info?.configured === true, editable: false, clearable: false };
|
|
599
|
+
const configured = info?.configured === true;
|
|
600
|
+
const kind = !configured ? 'unset' : info?.source === 'file' ? 'stored' : 'env';
|
|
601
|
+
// The environment is not writable by the product: Replace writes a stored value that then takes precedence,
|
|
602
|
+
// Clear only removes the stored value (it cannot unset the environment), so it is offered for stored values only.
|
|
603
|
+
return { kind, configured, editable: !readOnly, clearable: !readOnly && kind === 'stored' };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** Text of an input for a stored settings value. */
|
|
607
|
+
function fieldText(field, value) {
|
|
608
|
+
if (value === undefined || value === null) return '';
|
|
609
|
+
return field.multiple && Array.isArray(value) ? value.join(',') : String(value);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** Whether a (non-secret) field currently has a value, for the summary count. */
|
|
613
|
+
function hasValue(field, value) {
|
|
614
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
615
|
+
return value !== undefined && value !== null && value !== '';
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** Validation key (a dictionary key under `err.`) for an edited value, or null when acceptable. */
|
|
619
|
+
function validateValue(field, raw) {
|
|
620
|
+
if (field.kind === 'number') {
|
|
621
|
+
const n = Number(raw);
|
|
622
|
+
return Number.isInteger(n) && n >= field.min && n <= field.max ? null : 'number';
|
|
623
|
+
}
|
|
624
|
+
if (field.kind === 'url') {
|
|
625
|
+
if (raw === '') return null;
|
|
626
|
+
try { const url = new URL(raw); return url.protocol === 'https:' || url.protocol === 'http:' ? null : 'url'; } catch { return 'url'; }
|
|
627
|
+
}
|
|
628
|
+
if (field.kind === 'enum' && field.multiple) return Array.isArray(raw) && raw.every(v => field.options.includes(v)) ? null : 'enum';
|
|
629
|
+
if (field.kind === 'enum') return raw === '' || field.options.includes(raw) ? null : 'enum';
|
|
630
|
+
return null;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** Whether a field is visible given the current (draft over saved) values. */
|
|
634
|
+
const isShown = (field, current) => !field.showWhen || Object.entries(field.showWhen).every(([key, want]) => current[key] === want);
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* form.mutate operations for the edited fields. `values` are the saved settings, `drafts` maps field id → edited
|
|
638
|
+
* value (string, boolean or array). Locked managed fields are never written; an emptied field is unset so the layers
|
|
639
|
+
* below apply again. Returns `{ops, invalid}` where `invalid` maps field id → error key.
|
|
640
|
+
*/
|
|
641
|
+
function buildOps(fields, values, drafts, manifest) {
|
|
642
|
+
const ops = [], invalid = {};
|
|
643
|
+
for (const field of fields) {
|
|
644
|
+
if (field.kind === 'secret' || !field.path || !Object.hasOwn(drafts, field.id)) continue;
|
|
645
|
+
if (lockMode(manifest, field.ref) === 'locked') continue;
|
|
646
|
+
let next = drafts[field.id];
|
|
647
|
+
if (typeof next === 'string') next = next.trim();
|
|
648
|
+
if (field.kind === 'number') {
|
|
649
|
+
if (next === '') { if (hasValue(field, values?.[field.path])) ops.push({ op: 'unset', path: [field.path] }); continue; }
|
|
650
|
+
const problem = validateValue(field, next);
|
|
651
|
+
if (problem) { invalid[field.id] = problem; continue; }
|
|
652
|
+
next = Number(next);
|
|
653
|
+
} else if (field.multiple && typeof next === 'string') {
|
|
654
|
+
next = next.split(',').map(v => v.trim()).filter(Boolean);
|
|
655
|
+
}
|
|
656
|
+
const problem = field.kind === 'number' ? null : validateValue(field, next);
|
|
657
|
+
if (problem) { invalid[field.id] = problem; continue; }
|
|
658
|
+
const saved = values?.[field.path];
|
|
659
|
+
const same = Array.isArray(next) ? Array.isArray(saved) && saved.length === next.length && saved.every((v, i) => v === next[i]) : saved === next;
|
|
660
|
+
if (same) continue;
|
|
661
|
+
if (next === '' || (Array.isArray(next) && !next.length)) { if (hasValue(field, saved)) ops.push({ op: 'unset', path: [field.path] }); continue; }
|
|
662
|
+
ops.push({ op: 'set', path: [field.path], value: next });
|
|
663
|
+
}
|
|
664
|
+
return { ops, invalid };
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Summary numbers: how many integration fields (those with a ref) hold a value, and how many are managed.
|
|
669
|
+
* `states` maps secret ref → secretState(); `values` are saved settings.
|
|
670
|
+
*/
|
|
671
|
+
function summaryCounts(fields, values, states, manifest) {
|
|
672
|
+
let configured = 0, total = 0, managed = 0;
|
|
673
|
+
for (const field of fields) {
|
|
674
|
+
if (!field.ref) continue;
|
|
675
|
+
total++;
|
|
676
|
+
const mode = lockMode(manifest, field.ref);
|
|
677
|
+
if (mode !== 'none') managed++;
|
|
678
|
+
const set = field.kind === 'secret' ? states?.[field.ref]?.configured === true : hasValue(field, values?.[field.path]);
|
|
679
|
+
if (set || mode === 'locked') configured++;
|
|
680
|
+
}
|
|
681
|
+
return { configured, total, managed };
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** Refs whose state the panel asks the host to describe. */
|
|
685
|
+
const secretRefs = fields => fields.filter(field => field.kind === 'secret').map(field => field.ref);
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Describe the refs through `credentials.describe` in batches of DESCRIBE_BATCH. Returns `{ref: {configured, source?,
|
|
689
|
+
* writable}}`; a failed batch leaves its refs out (the panel then shows them as unknown, never as "set").
|
|
690
|
+
*/
|
|
691
|
+
async function describeSecrets(credentials, refs) {
|
|
692
|
+
return (await describeSecretsResult(credentials, refs)).described;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** One describe attempt must settle within this time; a hung call counts as a failure so the row never waits forever. */
|
|
696
|
+
const DESCRIBE_TIMEOUT_MS = 1200;
|
|
697
|
+
/** Pause before the single automatic retry of a failed describe. Worst case until "Could not check": 2 × timeout + this. */
|
|
698
|
+
const DESCRIBE_RETRY_DELAY_MS = 300;
|
|
699
|
+
|
|
700
|
+
/** Reject after `ms` when `work` has not settled; the timer is always cleared. */
|
|
701
|
+
function withTimeout(work, ms) {
|
|
702
|
+
return new Promise((resolve, reject) => {
|
|
703
|
+
const timer = setTimeout(() => reject(new Error('timeout')), ms);
|
|
704
|
+
Promise.resolve(work).then(value => { clearTimeout(timer); resolve(value); }, error => { clearTimeout(timer); reject(error); });
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Like describeSecrets, but also says whether any batch FAILED (thrown error, refusal, timeout, malformed reply, missing API).
|
|
710
|
+
* A ref absent from a successful reply is reported as not configured: the host answered, it just holds nothing for it.
|
|
711
|
+
* Refs of a failed batch are left out (unknown), never guessed.
|
|
712
|
+
*/
|
|
713
|
+
async function describeSecretsResult(credentials, refs, timeoutMs = DESCRIBE_TIMEOUT_MS) {
|
|
714
|
+
const described = {};
|
|
715
|
+
let failed = false;
|
|
716
|
+
if (!refs.length) return { described, failed };
|
|
717
|
+
if (typeof credentials?.describe !== 'function') return { described, failed: true };
|
|
718
|
+
for (const batch of chunk(refs)) {
|
|
719
|
+
try {
|
|
720
|
+
const view = unwrapReply(await withTimeout(Promise.resolve().then(() => credentials.describe(batch)), timeoutMs));
|
|
721
|
+
if (!view || typeof view !== 'object') throw new Error('malformed-reply');
|
|
722
|
+
for (const ref of batch) {
|
|
723
|
+
const item = view[ref];
|
|
724
|
+
described[ref] = item && typeof item === 'object'
|
|
725
|
+
? { configured: item.configured === true, source: item.source, writable: item.writable !== false }
|
|
726
|
+
: { configured: false, writable: true };
|
|
727
|
+
}
|
|
728
|
+
} catch { failed = true; /* leave this batch unknown */ }
|
|
729
|
+
}
|
|
730
|
+
return { described, failed };
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** Store a new value (write-only). Rejects an empty value, a locked ref and a missing API; resolves to true. */
|
|
734
|
+
async function writeSecret(credentials, ref, value, mode = 'none') {
|
|
735
|
+
if (mode === 'locked') throw new Error('locked');
|
|
736
|
+
if (typeof value !== 'string' || !value.trim()) throw new Error('empty');
|
|
737
|
+
if (typeof credentials?.set !== 'function') throw new Error('unavailable');
|
|
738
|
+
unwrapReply(await credentials.set(ref, value));
|
|
739
|
+
return true;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/** Remove the stored value of a ref. Rejects a locked ref and a missing API; resolves to true. */
|
|
743
|
+
async function clearSecret(credentials, ref, mode = 'none') {
|
|
744
|
+
if (mode === 'locked') throw new Error('locked');
|
|
745
|
+
if (typeof credentials?.unset !== 'function') throw new Error('unavailable');
|
|
746
|
+
unwrapReply(await credentials.unset(ref));
|
|
747
|
+
return true;
|
|
748
|
+
}
|
|
749
|
+
// Per-extension panel field lists (contract section E). A field is addressed by:
|
|
750
|
+
// path - key in the bundle's volatile DSH settings section (non-secret values, written with form.mutate)
|
|
751
|
+
// ref - env-style name shared with the managed manifest and the credential store (see catalog.js)
|
|
752
|
+
// Secret fields have no path: they are credentials (ctx.remote.credentials), never settings values.
|
|
753
|
+
// `enabled` is deliberately absent: on/off is DSH's own plugin switch on the Plugins page.
|
|
754
|
+
// Labels live in the dictionaries (locale/*.json, plus catalog.js for ref fields) under `field.<ref or id>`.
|
|
755
|
+
const field = (id, kind, extra = {}) => Object.freeze({ id, kind, ...extra });
|
|
756
|
+
const num = (id, min, max, step = 1) => field(id, 'number', { path: id, min, max, step });
|
|
757
|
+
const secret = ref => field(ref, 'secret', { ref });
|
|
758
|
+
|
|
759
|
+
const PANELS = Object.freeze({
|
|
760
|
+
autocomplete: Object.freeze({
|
|
761
|
+
extension: 'autocomplete', bundle: '@aiwuk/autocomplete', namespace: 'autocomplete',
|
|
762
|
+
fields: Object.freeze([
|
|
763
|
+
field('routeMode', 'enum', { path: 'routeMode', ref: 'AUTOCOMPLETE_ROUTE_MODE', options: ['session', 'fixed'] }),
|
|
764
|
+
field('provider', 'text', { path: 'provider', ref: 'AUTOCOMPLETE_PROVIDER', showWhen: { routeMode: 'fixed' } }),
|
|
765
|
+
field('model', 'text', { path: 'model', ref: 'AUTOCOMPLETE_MODEL', showWhen: { routeMode: 'fixed' } }),
|
|
766
|
+
num('maxSuggestions', 1, 5), num('typePauseMs', 200, 5000, 50), num('maxOutputTokens', 16, 2048),
|
|
767
|
+
num('timeoutMs', 1000, 120000, 500), num('maxMessages', 1, 50), num('maxChars', 500, 32000, 100),
|
|
768
|
+
field('mobileBar', 'switch', { path: 'mobileBar' }),
|
|
769
|
+
]),
|
|
770
|
+
}),
|
|
771
|
+
memory: Object.freeze({
|
|
772
|
+
extension: 'memory', bundle: '@aiwuk/memory', namespace: 'global-memory',
|
|
773
|
+
fields: Object.freeze([
|
|
774
|
+
field('autoRecall', 'switch', { path: 'autoRecall' }),
|
|
775
|
+
num('injectChars', 200, 6000, 100), num('recallK', 1, 20),
|
|
776
|
+
field('memoryBackend', 'enum', { path: 'memoryBackend', ref: 'MEMORY_BACKEND', options: ['local', 'openviking'] }),
|
|
777
|
+
field('memoryUrl', 'url', { path: 'memoryUrl', ref: 'OPENVIKING_URL' }),
|
|
778
|
+
field('embeddingBaseUrl', 'url', { path: 'embeddingBaseUrl', ref: 'EMBEDDING_BASE_URL' }),
|
|
779
|
+
field('embeddingModel', 'text', { path: 'embeddingModel', ref: 'EMBEDDING_MODEL' }),
|
|
780
|
+
secret('OPENVIKING_API_KEY'), secret('EMBEDDING_API_KEY'),
|
|
781
|
+
]),
|
|
782
|
+
}),
|
|
783
|
+
search: Object.freeze({
|
|
784
|
+
extension: 'search', bundle: '@aiwuk/search', namespace: 'omni-search',
|
|
785
|
+
// Selects this extension as DSH's web search provider: `searchProvider` on the host `web` entry (another namespace).
|
|
786
|
+
// Unsetting the user layer restores the host default (`deepseek-official` in the shipped base).
|
|
787
|
+
hostSelection: Object.freeze({ id: 'webSearch', entry: 'web', path: 'searchProvider', value: 'aiwuk-search' }),
|
|
788
|
+
fields: Object.freeze([
|
|
789
|
+
field('engines', 'enum', { path: 'engines', ref: 'OMNI_SEARCH_ENGINES', options: ['tavily', 'brave', 'searxng', 'custom'], multiple: true }),
|
|
790
|
+
field('fetchEngines', 'enum', { path: 'fetchEngines', ref: 'OMNI_FETCH_ENGINES', options: ['jina', 'tavily', 'custom'], multiple: true }),
|
|
791
|
+
field('baseUrl', 'url', { path: 'baseUrl', ref: 'OMNI_SEARCH_BASE_URL' }),
|
|
792
|
+
field('searxngUrl', 'url', { path: 'searxngUrl', ref: 'SEARXNG_URL' }),
|
|
793
|
+
field('audioBaseUrl', 'url', { path: 'audioBaseUrl', ref: 'OMNI_AUDIO_BASE_URL' }),
|
|
794
|
+
secret('OMNI_SEARCH_API_KEY'), secret('TAVILY_API_KEY'), secret('BRAVE_API_KEY'),
|
|
795
|
+
secret('SEARXNG_API_KEY'), secret('JINA_API_KEY'), secret('OMNI_AUDIO_API_KEY'),
|
|
796
|
+
]),
|
|
797
|
+
}),
|
|
798
|
+
// The team bundle has no editable integration fields: its page summarizes the Memory and Search panels.
|
|
799
|
+
team: Object.freeze({ extension: 'team', bundle: '@aiwuk/team', namespace: 'capability-team-ui', fields: Object.freeze([]) }),
|
|
800
|
+
});
|
|
801
|
+
const DICTIONARIES = {"en":{"lock.locked":"Managed by {mothership}","lock.default":"Default from Mothership","lock.hint":"Change it in the Mothership","value.managed":"Managed","value.default":"Using Mothership default","secret.unset":"Not set","secret.stored":"Set (stored)","secret.env":"Set (environment)","secret.managed":"Managed by {mothership}","secret.unknown":"Checking...","secret.failed":"Could not check","secret.note.saved":"Saved. The value is not shown again.","secret.note.cleared":"Cleared.","secret.note.failed":"The change failed. Nothing was stored.","action.set":"Set","action.replace":"Replace","action.clear":"Clear","action.confirmClear":"Confirm clear","action.save":"Save","action.cancel":"Cancel","action.retry":"Retry","state.loading":"Loading...","state.unavailable":"Settings are unavailable: make sure the plugin is enabled.","state.saved":"Saved.","state.failed":"Save failed. Reload and retry.","summary.configured":"Configured {configured}/{total}","summary.managed":"Managed {managed}","err.number":"Enter a whole number in range.","err.url":"Enter a valid http(s) address.","err.enum":"Choose a supported value.","opt.default":"Default","opt.session":"Follow session","opt.fixed":"Fixed model","opt.local":"Local","opt.openviking":"OpenViking","opt.current":"Current project","opt.none":"Whole account","opt.tavily":"Tavily","opt.brave":"Brave","opt.searxng":"SearXNG","opt.jina":"Jina","opt.custom":"Custom gateway","field.maxSuggestions":"Max suggestions","field.typePauseMs":"Typing pause (ms)","field.maxOutputTokens":"Max output tokens","field.timeoutMs":"Timeout (ms)","field.maxMessages":"Context messages","field.maxChars":"Context characters","field.mobileBar":"Touch suggestion bar","field.autoRecall":"Auto recall","field.injectChars":"Injected characters","field.recallK":"Recall count","team.integrations":"Integrations","team.memory":"Memory","team.search":"Search","team.hint":"Memory and Search keep their own settings pages under Plugins.","team.unmanaged":"Nothing is managed by the Mothership.","team.managedCount":"{count} setting(s) managed by {mothership}.","host.webSearch.label":"Use AIWUK Search for DSH web search","host.webSearch.current":"Current provider: {provider}.","host.webSearch.auto":"automatic","host.webSearch.hint":"Turn it off to restore the DSH default.","host.webSearch.unavailable":"DSH web search settings are unavailable.","host.webSearch.failed":"Could not change the web search provider.","secret.managedSet":"Configured by {mothership}","secret.managedEmpty":"Managed by {mothership} (not set there)","banner.full":"Configured by {mothership}. Nothing to set up here.","banner.partial":"Partly configured by {mothership}.","banner.detail":"{managed} of {total} settings come from the Mothership ({locked} locked). Locked settings can only be changed in the Mothership.","optional.title":"Optional settings not sent by the Mothership ({count})","optional.hint":"Leave these empty unless you want an extra engine on this machine only."},"zh":{"lock.locked":"\u7531 {mothership}\u6258\u7ba1","lock.default":"\u6bcd\u8230\u9ed8\u8ba4\u503c","lock.hint":"\u8bf7\u5728\u6bcd\u8230\u4e2d\u4fee\u6539","value.managed":"\u5df2\u6258\u7ba1","value.default":"\u4f7f\u7528\u6bcd\u8230\u9ed8\u8ba4\u503c","secret.unset":"\u672a\u8bbe\u7f6e","secret.stored":"\u5df2\u8bbe\u7f6e\uff08\u5df2\u4fdd\u5b58\uff09","secret.env":"\u5df2\u8bbe\u7f6e\uff08\u73af\u5883\u53d8\u91cf\uff09","secret.managed":"\u7531 {mothership}\u6258\u7ba1","secret.unknown":"\u68c0\u67e5\u4e2d\u2026","secret.failed":"\u65e0\u6cd5\u68c0\u67e5","secret.note.saved":"\u5df2\u4fdd\u5b58\uff0c\u503c\u4e0d\u4f1a\u518d\u6b21\u663e\u793a\u3002","secret.note.cleared":"\u5df2\u6e05\u9664\u3002","secret.note.failed":"\u64cd\u4f5c\u5931\u8d25\uff0c\u672a\u4fdd\u5b58\u4efb\u4f55\u5185\u5bb9\u3002","action.set":"\u8bbe\u7f6e","action.replace":"\u66ff\u6362","action.clear":"\u6e05\u9664","action.confirmClear":"\u786e\u8ba4\u6e05\u9664","action.save":"\u4fdd\u5b58","action.cancel":"\u53d6\u6d88","action.retry":"\u91cd\u8bd5","state.loading":"\u8bfb\u53d6\u4e2d\u2026","state.unavailable":"\u8bbe\u7f6e\u6682\u4e0d\u53ef\u7528\uff1a\u8bf7\u786e\u8ba4\u5df2\u542f\u7528\u8be5\u63d2\u4ef6\u3002","state.saved":"\u5df2\u4fdd\u5b58\u3002","state.failed":"\u4fdd\u5b58\u5931\u8d25\uff0c\u8bf7\u5237\u65b0\u540e\u91cd\u8bd5\u3002","summary.configured":"\u5df2\u914d\u7f6e {configured}/{total}","summary.managed":"\u6258\u7ba1 {managed}","err.number":"\u8bf7\u8f93\u5165\u8303\u56f4\u5185\u7684\u6574\u6570\u3002","err.url":"\u8bf7\u8f93\u5165\u6709\u6548\u7684 http(s) \u5730\u5740\u3002","err.enum":"\u8bf7\u9009\u62e9\u53d7\u652f\u6301\u7684\u503c\u3002","opt.default":"\u9ed8\u8ba4","opt.session":"\u8ddf\u968f\u4f1a\u8bdd","opt.fixed":"\u56fa\u5b9a\u6a21\u578b","opt.local":"\u672c\u5730","opt.openviking":"OpenViking","opt.current":"\u5f53\u524d\u9879\u76ee","opt.none":"\u6574\u4e2a\u8d26\u6237","opt.tavily":"Tavily","opt.brave":"Brave","opt.searxng":"SearXNG","opt.jina":"Jina","opt.custom":"\u81ea\u5b9a\u4e49\u7f51\u5173","field.maxSuggestions":"\u5019\u9009\u6570","field.typePauseMs":"\u505c\u987f\u65f6\u95f4\uff08\u6beb\u79d2\uff09","field.maxOutputTokens":"\u6700\u5927\u8f93\u51fa","field.timeoutMs":"\u8d85\u65f6\uff08\u6beb\u79d2\uff09","field.maxMessages":"\u4e0a\u4e0b\u6587\u6d88\u606f\u6570","field.maxChars":"\u4e0a\u4e0b\u6587\u5b57\u7b26\u6570","field.mobileBar":"\u89e6\u5c4f\u5efa\u8bae\u680f","field.autoRecall":"\u81ea\u52a8\u53ec\u56de","field.injectChars":"\u6ce8\u5165\u5b57\u6570","field.recallK":"\u53ec\u56de\u6761\u6570","team.integrations":"\u96c6\u6210","team.memory":"\u5168\u5c40\u8bb0\u5fc6","team.search":"\u5168\u80fd\u641c\u7d22","team.hint":"\u5168\u5c40\u8bb0\u5fc6\u4e0e\u5168\u80fd\u641c\u7d22\u7684\u8bbe\u7f6e\u9875\u4f4d\u4e8e\u201c\u63d2\u4ef6\u201d\u9875\u5bf9\u5e94\u6761\u76ee\u4e0b\u3002","team.unmanaged":"\u6ca1\u6709\u4efb\u4f55\u8bbe\u7f6e\u7531\u6bcd\u8230\u6258\u7ba1\u3002","team.managedCount":"{count} \u9879\u8bbe\u7f6e\u7531 {mothership}\u6258\u7ba1\u3002","host.webSearch.label":"\u8ba9 DSH \u7f51\u9875\u641c\u7d22\u4f7f\u7528 AI\u609f\u7a7a \u5168\u80fd\u641c\u7d22","host.webSearch.current":"\u5f53\u524d\u63d0\u4f9b\u65b9\uff1a{provider}\u3002","host.webSearch.auto":"\u81ea\u52a8\u9009\u62e9","host.webSearch.hint":"\u5173\u95ed\u540e\u6062\u590d DSH \u9ed8\u8ba4\u3002","host.webSearch.unavailable":"DSH \u7f51\u9875\u641c\u7d22\u8bbe\u7f6e\u4e0d\u53ef\u7528\u3002","host.webSearch.failed":"\u65e0\u6cd5\u66f4\u6539\u7f51\u9875\u641c\u7d22\u63d0\u4f9b\u65b9\u3002","secret.managedSet":"\u5df2\u7531 {mothership}\u914d\u7f6e","secret.managedEmpty":"\u7531 {mothership}\u6258\u7ba1\uff08\u6bcd\u8230\u672a\u8bbe\u7f6e\uff09","banner.full":"\u5df2\u901a\u8fc7 {mothership}\u914d\u7f6e\u5b8c\u6210\uff0c\u65e0\u9700\u5728\u8fd9\u91cc\u8bbe\u7f6e\u3002","banner.partial":"\u90e8\u5206\u8bbe\u7f6e\u5df2\u7531 {mothership}\u914d\u7f6e\u3002","banner.detail":"{total} \u9879\u8bbe\u7f6e\u4e2d\u6709 {managed} \u9879\u6765\u81ea\u6bcd\u8230\uff08\u5176\u4e2d {locked} \u9879\u5df2\u9501\u5b9a\uff09\u3002\u9501\u5b9a\u7684\u8bbe\u7f6e\u53ea\u80fd\u5728\u6bcd\u8230\u4e2d\u4fee\u6539\u3002","optional.title":"\u6bcd\u8230\u672a\u4e0b\u53d1\u7684\u53ef\u9009\u8bbe\u7f6e\uff08{count} \u9879\uff09","optional.hint":"\u9664\u975e\u53ea\u60f3\u5728\u8fd9\u53f0\u673a\u5668\u4e0a\u989d\u5916\u542f\u7528\u67d0\u4e2a\u5f15\u64ce\uff0c\u5426\u5219\u4fdd\u6301\u4e3a\u7a7a\u5373\u53ef\u3002"}};
|
|
802
|
+
const CATALOG = [{"ref":"OMNI_SEARCH_BASE_URL","extension":"search","label":{"en":"Search gateway URL","zh":"\u641c\u7d22\u7f51\u5173\u5730\u5740"},"kind":"url","secret":false,"defaultLocked":true},{"ref":"SEARXNG_URL","extension":"search","label":{"en":"SearXNG URL","zh":"SearXNG \u5730\u5740"},"kind":"url","secret":false,"defaultLocked":true},{"ref":"OMNI_SEARCH_ENGINES","extension":"search","label":{"en":"Search engines","zh":"\u641c\u7d22\u5f15\u64ce"},"kind":"enum","options":["tavily","brave","searxng","custom"],"multiple":true,"secret":false,"defaultLocked":true},{"ref":"OMNI_FETCH_ENGINES","extension":"search","label":{"en":"Fetch engines","zh":"\u6293\u53d6\u5f15\u64ce"},"kind":"enum","options":["jina","tavily","custom"],"multiple":true,"secret":false,"defaultLocked":true,"default":"jina"},{"ref":"OMNI_SEARCH_PROVIDERS_PATH","extension":"search","label":{"en":"Search gateway providers path","zh":"\u641c\u7d22\u7f51\u5173\u63d0\u4f9b\u65b9\u8def\u5f84"},"kind":"text","secret":false,"defaultLocked":true},{"ref":"OMNI_AUDIO_BASE_URL","extension":"search","label":{"en":"Speech-to-text URL","zh":"\u8bed\u97f3\u8f6c\u5199\u5730\u5740"},"kind":"url","secret":false,"defaultLocked":true},{"ref":"OMNI_SEARCH_API_KEY","extension":"search","label":{"en":"Search gateway API key","zh":"\u641c\u7d22\u7f51\u5173\u5bc6\u94a5"},"kind":"secret","secret":true,"defaultLocked":true},{"ref":"TAVILY_API_KEY","extension":"search","label":{"en":"Tavily API key","zh":"Tavily \u5bc6\u94a5"},"kind":"secret","secret":true,"defaultLocked":true},{"ref":"BRAVE_API_KEY","extension":"search","label":{"en":"Brave API key","zh":"Brave \u5bc6\u94a5"},"kind":"secret","secret":true,"defaultLocked":true},{"ref":"SEARXNG_API_KEY","extension":"search","label":{"en":"SearXNG API key","zh":"SearXNG \u5bc6\u94a5"},"kind":"secret","secret":true,"defaultLocked":true},{"ref":"JINA_API_KEY","extension":"search","label":{"en":"Jina API key","zh":"Jina \u5bc6\u94a5"},"kind":"secret","secret":true,"defaultLocked":true},{"ref":"OMNI_AUDIO_API_KEY","extension":"search","label":{"en":"Speech-to-text API key","zh":"\u8bed\u97f3\u8f6c\u5199\u5bc6\u94a5"},"kind":"secret","secret":true,"defaultLocked":true},{"ref":"EMBEDDING_BASE_URL","extension":"memory","label":{"en":"Embedding URL","zh":"\u5411\u91cf\u670d\u52a1\u5730\u5740"},"kind":"url","secret":false,"defaultLocked":true,"legacyRefs":["CPA_EMBED_BASE_URL"]},{"ref":"EMBEDDING_MODEL","extension":"memory","label":{"en":"Embedding model","zh":"\u5411\u91cf\u6a21\u578b"},"kind":"text","secret":false,"defaultLocked":true,"legacyRefs":["CPA_EMBED_MODEL"]},{"ref":"EMBEDDING_DIMENSIONS","extension":"memory","label":{"en":"Embedding dimensions","zh":"\u5411\u91cf\u7ef4\u5ea6"},"kind":"number","secret":false,"defaultLocked":true,"legacyRefs":["CPA_EMBED_DIMENSIONS"]},{"ref":"OPENVIKING_URL","extension":"memory","label":{"en":"Memory URL","zh":"\u8bb0\u5fc6\u670d\u52a1\u5730\u5740"},"kind":"url","secret":false,"defaultLocked":true},{"ref":"MEMORY_BACKEND","extension":"memory","label":{"en":"Backend","zh":"\u5b58\u50a8\u540e\u7aef"},"kind":"enum","options":["local","openviking"],"secret":false,"defaultLocked":true,"default":"local"},{"ref":"MEMORY_SHARED_RECALL","extension":"memory","label":{"en":"Shared recall scope","zh":"\u5171\u4eab\u53ec\u56de\u8303\u56f4"},"kind":"enum","options":["current","none"],"secret":false,"defaultLocked":true,"default":"current"},{"ref":"EMBEDDING_API_KEY","extension":"memory","label":{"en":"Embedding API key","zh":"\u5411\u91cf\u670d\u52a1\u5bc6\u94a5"},"kind":"secret","secret":true,"defaultLocked":true,"legacyRefs":["CPA_EMBED_API_KEY"]},{"ref":"OPENVIKING_API_KEY","extension":"memory","label":{"en":"Memory API key","zh":"\u8bb0\u5fc6\u670d\u52a1\u5bc6\u94a5"},"kind":"secret","secret":true,"defaultLocked":true},{"ref":"AUTOCOMPLETE_ROUTE_MODE","extension":"autocomplete","label":{"en":"Model routing","zh":"\u6a21\u578b\u8def\u7531"},"kind":"enum","options":["session","fixed"],"secret":false,"defaultLocked":true,"default":"session"},{"ref":"AUTOCOMPLETE_PROVIDER","extension":"autocomplete","label":{"en":"Provider","zh":"\u63d0\u4f9b\u65b9"},"kind":"text","secret":false,"defaultLocked":true},{"ref":"AUTOCOMPLETE_MODEL","extension":"autocomplete","label":{"en":"Model","zh":"\u6a21\u578b"},"kind":"text","secret":false,"defaultLocked":true}];
|
|
803
|
+
/** en / zh dictionaries of one panel: shared texts, catalog labels (ref.<REF>) and the Mothership name filled in. */
|
|
804
|
+
function panelDictionaries(panel) {
|
|
805
|
+
const out = {};
|
|
806
|
+
for (const lang of ['en', 'zh']) {
|
|
807
|
+
const dict = { ...DICTIONARIES[lang] };
|
|
808
|
+
for (const field of panel.fields) {
|
|
809
|
+
const entry = CATALOG.find(item => item.ref === field.ref);
|
|
810
|
+
if (entry) dict['ref.' + entry.ref] = entry.label[lang];
|
|
811
|
+
}
|
|
812
|
+
for (const key of Object.keys(dict)) dict[key] = dict[key].replaceAll('{mothership}', BRAND_TEXT.mothership[lang]);
|
|
813
|
+
out[lang] = dict;
|
|
814
|
+
}
|
|
815
|
+
return out;
|
|
816
|
+
}
|
|
817
|
+
// Data-driven React config panel for the AIWUK extensions (contract section E). React comes from the host module
|
|
818
|
+
// table and is passed in; nothing here imports a DSH client package. The browser bundle is produced by
|
|
819
|
+
// shared-ui/build.mjs, which strips the `export`/`import` keywords so this file shares one factory scope with
|
|
820
|
+
// config-logic.js. Secrets are shown as a STATE only: values are never fetched, rendered or logged, and the
|
|
821
|
+
// write-only input is cleared after every save attempt. Styling uses --dsw-* tokens only.
|
|
822
|
+
|
|
823
|
+
function createPanelKit(React) {
|
|
824
|
+
const h = React.createElement;
|
|
825
|
+
const fmt = (text, values) => String(text).replace(/\{(\w+)\}/g, (_, key) => values?.[key] ?? '');
|
|
826
|
+
const ui = {
|
|
827
|
+
col: { display: 'flex', flexDirection: 'column', gap: '8px' },
|
|
828
|
+
row: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '12px', padding: '6px 0', borderBottom: '1px solid var(--dsw-alias-border-l2)' },
|
|
829
|
+
text: { display: 'flex', flexDirection: 'column', gap: '2px', minWidth: 0 },
|
|
830
|
+
label: { color: 'var(--dsw-alias-label-primary)', fontSize: '1em' },
|
|
831
|
+
muted: { color: 'var(--dsw-alias-label-secondary)', fontSize: '0.85em' },
|
|
832
|
+
err: { color: 'var(--dsw-alias-state-error-primary)', fontSize: '0.85em' },
|
|
833
|
+
input: { width: '220px', color: 'var(--dsw-alias-label-primary)', background: 'var(--dsw-alias-bg-layer-1)', border: '1px solid var(--dsw-alias-border-l2)', borderRadius: 'var(--dsw-radius-lg)', padding: '4px 8px', fontSize: '0.9em' },
|
|
834
|
+
button: { color: 'var(--dsw-alias-label-primary)', background: 'var(--dsw-alias-bg-layer-2)', border: '1px solid var(--dsw-alias-border-l2)', borderRadius: 'var(--dsw-radius-lg)', padding: '4px 10px', fontSize: '0.9em', cursor: 'pointer' },
|
|
835
|
+
primary: { color: 'var(--dsw-alias-label-primary-inverted)', background: 'var(--dsw-alias-brand-primary)', border: '1px solid var(--dsw-alias-brand-primary)' },
|
|
836
|
+
badge: { borderRadius: '999px', padding: '0 8px', fontSize: '0.85em', whiteSpace: 'nowrap' },
|
|
837
|
+
controls: { display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap', justifyContent: 'flex-end' },
|
|
838
|
+
banner: { display: 'flex', flexDirection: 'column', gap: '4px', padding: '10px 12px', borderRadius: 'var(--dsw-radius-md)', border: '1px solid var(--dsw-alias-state-success-primary)', background: 'var(--dsw-alias-state-success-tertiary, var(--dsw-alias-bg-layer-2))' },
|
|
839
|
+
details: { marginTop: '4px' },
|
|
840
|
+
summary: { cursor: 'pointer', color: 'var(--dsw-alias-label-secondary)', fontSize: '0.9em', padding: '6px 0' },
|
|
841
|
+
};
|
|
842
|
+
const badgeStyle = mode => {
|
|
843
|
+
const tone = mode === 'locked' ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-state-success-primary)';
|
|
844
|
+
return { ...ui.badge, color: tone, border: `1px solid ${tone}`, background: 'var(--dsw-alias-bg-layer-2)' };
|
|
845
|
+
};
|
|
846
|
+
|
|
847
|
+
/** Re-render on a `{getSnapshot, subscribe}` store (a ConfigForm). */
|
|
848
|
+
function useStoreSnapshot(store) {
|
|
849
|
+
const [, redraw] = React.useState(0);
|
|
850
|
+
React.useEffect(() => (typeof store?.subscribe === 'function' ? store.subscribe(() => redraw(n => n + 1)) : undefined), [store]);
|
|
851
|
+
return store?.getSnapshot?.() ?? { status: 'unavailable', value: undefined, revision: undefined, writable: false };
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Fetch the values-free managed manifest. Resolves to `{manifest}` (null = the route is missing, an unmanaged
|
|
856
|
+
* install) or to `undefined` when the answer is unknown (network error, 5xx): the caller then keeps what it knew,
|
|
857
|
+
* so a transient failure never drops a lock.
|
|
858
|
+
*/
|
|
859
|
+
async function fetchManifest(extension, services) {
|
|
860
|
+
try {
|
|
861
|
+
const fetcher = services.fetch ?? (typeof fetch === 'function' ? fetch : null);
|
|
862
|
+
const res = fetcher ? await fetcher(managedConfigUrl(extension), { headers: { accept: 'application/json' } }) : null;
|
|
863
|
+
if (res?.ok) return { manifest: manifestFromWire(await res.json()) };
|
|
864
|
+
return res?.status === 404 ? { manifest: null } : undefined;
|
|
865
|
+
} catch { return undefined; }
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* The managed manifest, refreshed when a credential changes (the Connector writes managed values there) and when the
|
|
870
|
+
* window regains focus. `reload()` returns the freshest known manifest so every write can re-check the lock first.
|
|
871
|
+
* `services` is created once per registration (a stable object), so the callback identity does not change.
|
|
872
|
+
*/
|
|
873
|
+
function useManifest(extension, services) {
|
|
874
|
+
const [manifest, setManifest] = React.useState(null);
|
|
875
|
+
const known = React.useRef(null), latest = React.useRef(0);
|
|
876
|
+
const reload = React.useCallback(async () => {
|
|
877
|
+
const ticket = ++latest.current;
|
|
878
|
+
const answer = await fetchManifest(extension, services);
|
|
879
|
+
if (answer && ticket === latest.current) { known.current = answer.manifest; setManifest(answer.manifest); }
|
|
880
|
+
return answer ? answer.manifest : known.current;
|
|
881
|
+
}, [extension, services]);
|
|
882
|
+
React.useEffect(() => {
|
|
883
|
+
void reload();
|
|
884
|
+
const off = services.on?.('credentials/reference-updated', () => { void reload(); });
|
|
885
|
+
const onFocus = () => { void reload(); };
|
|
886
|
+
if (typeof window !== 'undefined') window.addEventListener?.('focus', onFocus);
|
|
887
|
+
return () => {
|
|
888
|
+
if (typeof off === 'function') off();
|
|
889
|
+
if (typeof window !== 'undefined') window.removeEventListener?.('focus', onFocus);
|
|
890
|
+
};
|
|
891
|
+
}, [reload, services.on]);
|
|
892
|
+
return [manifest, reload];
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Secret states from `credentials.describe`, refreshed on `credentials/reference-updated`. Returns
|
|
897
|
+
* `[described, refresh, failed]`. Every row leaves "checking" in bounded time: a result from a superseded or
|
|
898
|
+
* unmounted run is dropped (ticket), the credentials service is read at call time (never captured stale), a failed
|
|
899
|
+
* run is retried once automatically, and after that `failed` is true so rows offer Retry instead of waiting forever.
|
|
900
|
+
*/
|
|
901
|
+
function useSecretStates(services, refs) {
|
|
902
|
+
const [described, setDescribed] = React.useState({});
|
|
903
|
+
const [failed, setFailed] = React.useState(false);
|
|
904
|
+
const key = refs.join(',');
|
|
905
|
+
const ticket = React.useRef(0), alive = React.useRef(true), timer = React.useRef(null), retried = React.useRef(false), refsRef = React.useRef(refs);
|
|
906
|
+
refsRef.current = refs;
|
|
907
|
+
const run = React.useCallback(async () => {
|
|
908
|
+
const mine = ++ticket.current;
|
|
909
|
+
clearTimeout(timer.current);
|
|
910
|
+
const list = refsRef.current;
|
|
911
|
+
if (!list.length) return;
|
|
912
|
+
const result = await describeSecretsResult(services.credentials, list);
|
|
913
|
+
if (!alive.current || mine !== ticket.current) return; // stale: a newer run or an unmount took over
|
|
914
|
+
if (!result.failed) { retried.current = false; setDescribed(result.described); setFailed(false); return; }
|
|
915
|
+
setDescribed(prev => ({ ...prev, ...result.described }));
|
|
916
|
+
if (retried.current) { setFailed(true); return; }
|
|
917
|
+
retried.current = true;
|
|
918
|
+
timer.current = setTimeout(() => { if (alive.current && mine === ticket.current) void run(); }, DESCRIBE_RETRY_DELAY_MS);
|
|
919
|
+
}, [services]);
|
|
920
|
+
const reload = React.useCallback(() => { retried.current = false; setFailed(false); return run(); }, [run]);
|
|
921
|
+
React.useEffect(() => {
|
|
922
|
+
alive.current = true;
|
|
923
|
+
retried.current = false;
|
|
924
|
+
setFailed(false);
|
|
925
|
+
void run();
|
|
926
|
+
const off = services.on?.('credentials/reference-updated', ref => { if (refsRef.current.includes(ref)) void run(); });
|
|
927
|
+
return () => { alive.current = false; ticket.current++; clearTimeout(timer.current); if (typeof off === 'function') off(); };
|
|
928
|
+
}, [key, services.credentials, run]);
|
|
929
|
+
return [described, run, failed, reload];
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Switch that makes this extension the host's provider for a capability (DSH web search): it writes
|
|
934
|
+
* `spec.path` on the host entry `spec.entry` through `ctx.configForms.get(entry)`, and unsets it to restore the
|
|
935
|
+
* host default. The current selection is always shown.
|
|
936
|
+
*/
|
|
937
|
+
function HostSelectionRow({ spec, services, t }) {
|
|
938
|
+
const store = services.forms?.get?.(spec.entry) ?? null;
|
|
939
|
+
const snapshot = useStoreSnapshot(store);
|
|
940
|
+
const [note, setNote] = React.useState(null);
|
|
941
|
+
const [pending, setPending] = React.useState(false);
|
|
942
|
+
const ready = snapshot.status === 'ready' && snapshot.writable !== false && typeof store?.mutate === 'function';
|
|
943
|
+
const current = snapshot.value?.[spec.path];
|
|
944
|
+
const on = current === spec.value;
|
|
945
|
+
const toggle = async next => {
|
|
946
|
+
setNote(null); setPending(true);
|
|
947
|
+
let ok = false;
|
|
948
|
+
try { ok = await store.mutate([next ? { op: 'set', path: [spec.path], value: spec.value } : { op: 'unset', path: [spec.path] }], snapshot.revision); } catch { ok = false; }
|
|
949
|
+
setPending(false); setNote(ok ? 'saved' : 'failed');
|
|
950
|
+
};
|
|
951
|
+
const shown = typeof current === 'string' && current ? current : t(`host.${spec.id}.auto`);
|
|
952
|
+
return h(FieldRow, { id: `host.${spec.id}`, label: t(`host.${spec.id}.label`), t,
|
|
953
|
+
hint: snapshot.status === 'unavailable' ? t(`host.${spec.id}.unavailable`) : `${fmt(t(`host.${spec.id}.current`), { provider: shown })} ${t(`host.${spec.id}.hint`)}`,
|
|
954
|
+
error: note === 'failed' ? t(`host.${spec.id}.failed`) : undefined },
|
|
955
|
+
note === 'saved' ? h('span', { role: 'status', style: ui.muted }, t('state.saved')) : null,
|
|
956
|
+
h('input', { type: 'checkbox', role: 'switch', name: `host.${spec.id}`, 'data-host-selection': on ? 'on' : 'off', checked: on, disabled: !ready || pending, onChange: event => { void toggle(event.target.checked); } }));
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
function LockBadge({ mode, t }) {
|
|
960
|
+
if (!mode || mode === 'none') return null;
|
|
961
|
+
return h('span', { 'data-lock': mode, title: mode === 'locked' ? t('lock.hint') : undefined, style: badgeStyle(mode) }, t(mode === 'locked' ? 'lock.locked' : 'lock.default'));
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function FieldRow({ id, label, hint, mode, error, children, t }) {
|
|
965
|
+
return h('div', { 'data-field': id, style: ui.row },
|
|
966
|
+
h('span', { style: ui.text }, h('span', { style: ui.label }, label), hint ? h('span', { style: ui.muted }, hint) : null, error ? h('span', { role: 'alert', style: ui.err }, error) : null),
|
|
967
|
+
h('span', { style: ui.controls }, h(LockBadge, { mode, t }), children));
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/** State line plus Set / Replace / Clear. The typed value lives only in this component and is dropped after save. */
|
|
971
|
+
function SecretField({ field, label, info, mode, failed, t, onSet, onClear, onRetry }) {
|
|
972
|
+
const [typed, setTyped] = React.useState(null); // null = input hidden
|
|
973
|
+
const [confirming, setConfirming] = React.useState(false);
|
|
974
|
+
const [note, setNote] = React.useState(null);
|
|
975
|
+
const state = secretState(info, mode);
|
|
976
|
+
const save = async () => {
|
|
977
|
+
const value = typed;
|
|
978
|
+
setTyped(null); // cleared first: the value is never kept after the attempt
|
|
979
|
+
try { await onSet(field.ref, value); setNote('saved'); } catch { setNote('failed'); }
|
|
980
|
+
};
|
|
981
|
+
const clear = async () => { setConfirming(false); try { await onClear(field.ref); setNote('cleared'); } catch { setNote('failed'); } };
|
|
982
|
+
const unresolved = info === undefined && mode !== 'locked';
|
|
983
|
+
const phase = unresolved ? (failed ? 'failed' : 'unknown') : state.kind;
|
|
984
|
+
const statusText = unresolved ? t(failed ? 'secret.failed' : 'secret.unknown')
|
|
985
|
+
: state.kind === 'managed' ? t(info === undefined || state.configured ? 'secret.managedSet' : 'secret.managedEmpty') : t(`secret.${state.kind}`);
|
|
986
|
+
return h(FieldRow, { id: field.id, label, mode, t, hint: note ? t(`secret.note.${note}`) : undefined },
|
|
987
|
+
h('span', { 'data-secret-state': phase, style: unresolved && failed ? ui.err : ui.muted }, statusText),
|
|
988
|
+
unresolved && failed ? h('button', { type: 'button', 'data-action': 'retry-secret', style: ui.button, onClick: onRetry }, t('action.retry')) : null,
|
|
989
|
+
typed !== null ? h('input', { type: 'password', name: field.id, autoComplete: 'new-password', value: typed, style: ui.input, 'aria-label': label, onChange: event => setTyped(event.target.value) }) : null,
|
|
990
|
+
typed !== null ? h('button', { type: 'button', 'data-action': 'save-secret', style: { ...ui.button, ...ui.primary }, disabled: !typed.trim(), onClick: save }, t('action.save')) : null,
|
|
991
|
+
typed !== null ? h('button', { type: 'button', 'data-action': 'cancel-secret', style: ui.button, onClick: () => setTyped(null) }, t('action.cancel')) : null,
|
|
992
|
+
typed === null && !confirming && mode !== 'locked' && state.editable ? h('button', { type: 'button', 'data-action': 'set-secret', style: ui.button, onClick: () => { setNote(null); setTyped(''); } }, t(state.configured ? 'action.replace' : 'action.set')) : null,
|
|
993
|
+
typed === null && !confirming && state.clearable ? h('button', { type: 'button', 'data-action': 'clear-secret', style: ui.button, onClick: () => setConfirming(true) }, t('action.clear')) : null,
|
|
994
|
+
confirming ? h('button', { type: 'button', 'data-action': 'confirm-clear', style: { ...ui.button, ...ui.primary }, onClick: clear }, t('action.confirmClear')) : null,
|
|
995
|
+
confirming ? h('button', { type: 'button', 'data-action': 'cancel-clear', style: ui.button, onClick: () => setConfirming(false) }, t('action.cancel')) : null);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function control({ field, value, readOnly, mode, onChange, t }) {
|
|
999
|
+
const common = { name: field.id, disabled: readOnly, readOnly };
|
|
1000
|
+
if (field.kind === 'switch') return h('input', { ...common, type: 'checkbox', role: 'switch', checked: value === true, onChange: event => onChange(event.target.checked) });
|
|
1001
|
+
if (field.kind === 'enum' && field.multiple) {
|
|
1002
|
+
const chosen = Array.isArray(value) ? value : String(value ?? '').split(',').filter(Boolean);
|
|
1003
|
+
return h('span', { style: ui.controls }, field.options.map(option => h('label', { key: option, style: ui.muted },
|
|
1004
|
+
h('input', { type: 'checkbox', name: `${field.id}.${option}`, disabled: readOnly, checked: chosen.includes(option),
|
|
1005
|
+
onChange: event => onChange(event.target.checked ? [...chosen, option] : chosen.filter(v => v !== option)) }), ` ${t(`opt.${option}`)}`)));
|
|
1006
|
+
}
|
|
1007
|
+
if (field.kind === 'enum') return h('select', { ...common, value: value ?? '', style: ui.input, onChange: event => onChange(event.target.value) },
|
|
1008
|
+
h('option', { value: '' }, t('opt.default')), field.options.map(option => h('option', { key: option, value: option }, t(`opt.${option}`))));
|
|
1009
|
+
const props = { ...common, style: ui.input, value: value ?? '', title: readOnly && value ? String(value) : undefined, placeholder: mode === 'locked' ? t('value.managed') : mode === 'default' ? t('value.default') : undefined, onChange: event => onChange(event.target.value) };
|
|
1010
|
+
return field.kind === 'number' ? h('input', { ...props, type: 'number', min: field.min, max: field.max, step: field.step }) : h('input', { ...props, type: field.kind === 'url' ? 'url' : 'text' });
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/** "Configured by the Mothership" notice: shown when the Mothership manages any integration field of this panel. */
|
|
1014
|
+
function ManagedBanner({ status, t }) {
|
|
1015
|
+
if (status.mode === 'none') return null;
|
|
1016
|
+
return h('div', { 'data-managed-banner': status.mode, role: 'note', style: ui.banner },
|
|
1017
|
+
h('span', { style: { ...ui.label, fontWeight: 600 } }, t(status.mode === 'full' ? 'banner.full' : 'banner.partial')),
|
|
1018
|
+
h('span', { style: ui.muted }, fmt(t('banner.detail'), status)));
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
function ConfigPanel(props) {
|
|
1022
|
+
const { panel, view, services, t } = props;
|
|
1023
|
+
const store = props.form ? null : (services.forms?.get?.(panel.namespace) ?? null);
|
|
1024
|
+
const live = useStoreSnapshot(store);
|
|
1025
|
+
const snapshot = props.form ? props.form.state : live;
|
|
1026
|
+
const mutate = props.form ? props.form.mutate : store?.mutate?.bind(store);
|
|
1027
|
+
const [manifest, reloadManifest] = useManifest(panel.extension, services);
|
|
1028
|
+
const [described, refresh, describeFailed, retryDescribe] = useSecretStates(services, secretRefs(panel.fields));
|
|
1029
|
+
// Every write re-reads the lock first: a Mothership lock applied while this page is open must win.
|
|
1030
|
+
const freshMode = async ref => lockMode(await reloadManifest(), ref);
|
|
1031
|
+
const [drafts, setDrafts] = React.useState({});
|
|
1032
|
+
const [result, setResult] = React.useState(null);
|
|
1033
|
+
const values = snapshot?.value ?? {};
|
|
1034
|
+
const states = Object.fromEntries(Object.entries(described).map(([ref, info]) => [ref, secretState(info, lockMode(manifest, ref))]));
|
|
1035
|
+
if (view === 'summary') {
|
|
1036
|
+
const c = summaryCounts(panel.fields, values, states, manifest);
|
|
1037
|
+
return `${fmt(t('summary.configured'), c)}${c.managed ? ` · ${fmt(t('summary.managed'), c)}` : ''}`;
|
|
1038
|
+
}
|
|
1039
|
+
const ready = snapshot?.status === 'ready';
|
|
1040
|
+
const readOnly = !ready || snapshot.writable === false || typeof mutate !== 'function';
|
|
1041
|
+
const current = { ...values, ...drafts };
|
|
1042
|
+
const { ops, invalid } = buildOps(panel.fields, values, drafts, manifest);
|
|
1043
|
+
const save = async () => {
|
|
1044
|
+
let ok = false;
|
|
1045
|
+
try {
|
|
1046
|
+
// Rebuild the ops against the fresh manifest so a newly locked field is dropped, not written.
|
|
1047
|
+
const fresh = buildOps(panel.fields, values, drafts, await reloadManifest());
|
|
1048
|
+
ok = fresh.ops.length > 0 && Object.keys(fresh.invalid).length === 0 && await mutate(fresh.ops, snapshot.revision);
|
|
1049
|
+
} catch { ok = false; }
|
|
1050
|
+
setResult(ok ? 'saved' : 'failed');
|
|
1051
|
+
if (ok) setDrafts({});
|
|
1052
|
+
};
|
|
1053
|
+
const labelOf = field => t(field.ref ? `ref.${field.ref}` : `field.${field.id}`);
|
|
1054
|
+
const status = managedStatus(panel.fields, manifest);
|
|
1055
|
+
// A locked Mothership value also decides which dependent rows show (e.g. provider/model under a fixed route).
|
|
1056
|
+
const effective = { ...current };
|
|
1057
|
+
for (const field of panel.fields) {
|
|
1058
|
+
const fromHub = managedValueOf(manifest, field.ref);
|
|
1059
|
+
if (field.path && fromHub !== undefined && lockMode(manifest, field.ref) === 'locked' && !Object.hasOwn(drafts, field.id)) effective[field.path] = fromHub;
|
|
1060
|
+
}
|
|
1061
|
+
const visible = panel.fields.filter(field => isShown(field, effective));
|
|
1062
|
+
// Once the Mothership configures this extension, integration fields it does not send are optional extras.
|
|
1063
|
+
const isOptional = field => status.mode !== 'none' && field.ref && lockMode(manifest, field.ref) === 'none';
|
|
1064
|
+
const main = visible.filter(field => !isOptional(field)), optional = visible.filter(isOptional);
|
|
1065
|
+
const renderField = field => {
|
|
1066
|
+
const mode = lockMode(manifest, field.ref);
|
|
1067
|
+
if (field.kind === 'secret') {
|
|
1068
|
+
return h(SecretField, { key: field.id, field, label: labelOf(field), info: described[field.ref], mode, failed: describeFailed, t, onRetry: retryDescribe,
|
|
1069
|
+
onSet: async (ref, value) => { await writeSecret(services.credentials, ref, value, await freshMode(ref)); await refresh(); },
|
|
1070
|
+
onClear: async ref => { await clearSecret(services.credentials, ref, await freshMode(ref)); await refresh(); } });
|
|
1071
|
+
}
|
|
1072
|
+
const fromHub = managedValueOf(manifest, field.ref);
|
|
1073
|
+
const shown = Object.hasOwn(drafts, field.id) ? drafts[field.id] : field.kind === 'switch' ? values[field.path]
|
|
1074
|
+
: mode === 'locked' ? (fromHub === undefined ? '' : field.multiple ? fromHub.split(',').map(v => v.trim()).filter(Boolean) : fromHub)
|
|
1075
|
+
: field.multiple ? values[field.path] : fieldText(field, values[field.path]);
|
|
1076
|
+
return h(FieldRow, { key: field.id, id: field.id, label: labelOf(field), mode, t, error: invalid[field.id] ? t(`err.${invalid[field.id]}`) : undefined },
|
|
1077
|
+
control({ field, value: shown, readOnly: readOnly || mode === 'locked', mode, t, onChange: next => { setResult(null); setDrafts(prev => ({ ...prev, [field.id]: next })); } }));
|
|
1078
|
+
};
|
|
1079
|
+
return h('div', { 'data-config-panel': panel.extension, style: ui.col },
|
|
1080
|
+
panel.hostSelection && view !== 'summary' ? h(HostSelectionRow, { spec: panel.hostSelection, services, t }) : null,
|
|
1081
|
+
!ready ? h('p', { style: ui.muted, 'data-unavailable': snapshot?.status === 'unavailable' ? '' : undefined }, t(snapshot?.status === 'loading' ? 'state.loading' : 'state.unavailable')) : null,
|
|
1082
|
+
h(ManagedBanner, { status, t }),
|
|
1083
|
+
main.map(renderField),
|
|
1084
|
+
optional.length ? h('details', { 'data-optional-fields': '', style: ui.details },
|
|
1085
|
+
h('summary', { style: ui.summary }, fmt(t('optional.title'), { count: optional.length })),
|
|
1086
|
+
h('p', { style: ui.muted }, t('optional.hint')),
|
|
1087
|
+
optional.map(renderField)) : null,
|
|
1088
|
+
panel.fields.some(field => field.kind !== 'secret') ? h('div', { style: ui.controls },
|
|
1089
|
+
result ? h('span', { role: 'status', style: result === 'saved' ? ui.muted : ui.err }, t(`state.${result}`)) : null,
|
|
1090
|
+
h('button', { type: 'button', 'data-action': 'save', style: { ...ui.button, ...ui.primary }, disabled: readOnly || !ops.length || Object.keys(invalid).length > 0, onClick: save }, t('action.save'))) : null);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/** Team bundle entry: how many settings the Mothership manages, with a pointer to the Memory and Search pages. */
|
|
1094
|
+
function IntegrationsSummary({ view, services, t }) {
|
|
1095
|
+
const [manifest] = useManifest('team', services);
|
|
1096
|
+
const count = manifest ? manifest.entries.size : 0;
|
|
1097
|
+
const line = count ? fmt(t('team.managedCount'), { count }) : t('team.unmanaged');
|
|
1098
|
+
if (view === 'summary') return `${t('team.integrations')}: ${line}`;
|
|
1099
|
+
return h('div', { 'data-integrations-summary': '', style: ui.col },
|
|
1100
|
+
h('span', { style: ui.label }, t('team.integrations')), h('span', { style: ui.muted }, line), h('span', { style: ui.muted }, t('team.hint')));
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/** Contract entry point: the element for one bundle's `plugins.bundle.config` slot. */
|
|
1104
|
+
function renderConfigPanel({ services, panel, view, form, t }) {
|
|
1105
|
+
return h(ConfigPanel, { services, panel, view, form, t });
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* Register dictionaries and the `plugins.bundle.config` slot entry of one panel. The host services
|
|
1110
|
+
* (`remote.credentials`, `configForms`) come from the plugin's static `inject` list; null checks keep a
|
|
1111
|
+
* test or odd host mounting (non-secret fields then read-only).
|
|
1112
|
+
*/
|
|
1113
|
+
function registerConfigPanel(ctx, { panel, ns, dictionaries, summaryOnly = false }) {
|
|
1114
|
+
const services = { credentials: null, on: null, forms: null };
|
|
1115
|
+
// A summary-only entry reuses the host plugin's own namespace (its dictionaries are merged by the caller).
|
|
1116
|
+
if (!summaryOnly) ctx.effect?.(() => ctx.locale.register(ns, dictionaries), `${ns}: dictionaries`);
|
|
1117
|
+
// The plugin declares `remote.credentials` and `configForms` statically in its `inject` list (a dynamic
|
|
1118
|
+
// ctx.inject callback never fired in the real host), so both are read directly from the context here.
|
|
1119
|
+
const remote = ctx.remote;
|
|
1120
|
+
if (remote?.credentials) {
|
|
1121
|
+
services.credentials = remote.credentials;
|
|
1122
|
+
services.on = typeof remote.$on === 'function' ? (event, listener) => remote.$on(event, listener) : null;
|
|
1123
|
+
}
|
|
1124
|
+
if (ctx.configForms) services.forms = ctx.configForms;
|
|
1125
|
+
if (!summaryOnly) ctx.effect?.(() => () => { services.credentials = null; services.on = null; services.forms = null; }, `${ns}: services`);
|
|
1126
|
+
const t = ctx.locale.bind(ns);
|
|
1127
|
+
ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({ name: 'plugins.bundle.config', key: panel.bundle, locale: ns },
|
|
1128
|
+
props => (summaryOnly ? h(IntegrationsSummary, { view: props?.view, services, t: props?.t ?? t })
|
|
1129
|
+
: renderConfigPanel({ services, panel, view: props?.view, form: props?.form, t: props?.t ?? t }))));
|
|
1130
|
+
return services;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
return { IntegrationsSummary, ConfigPanel, FieldRow, SecretField, LockBadge, renderConfigPanel, registerConfigPanel, useManifest, unwrapReply, validateValue };
|
|
1134
|
+
}
|
|
1135
|
+
return { createPanelKit, PANELS, panelDictionaries };
|
|
1136
|
+
})();
|
|
1137
|
+
|
|
1138
|
+
const plugin = {
|
|
1139
|
+
inject: ['slots', 'locale', 'remote', 'remote.credentials', 'configForms'],
|
|
1140
|
+
apply(ctx) {
|
|
1141
|
+
ctx.effect?.(() => ctx.locale.register(NS, { zh, en }), 'autocomplete: dictionaries');
|
|
1142
|
+
const t = ctx.locale.bind(NS);
|
|
1143
|
+
// `configForms` is a static inject; the null check keeps a missing form from breaking the overlay (defaults apply).
|
|
1144
|
+
const form = ctx.configForms?.get?.(FORM) ?? null;
|
|
1145
|
+
ctx.slots.inject('conversation.input.overlay', () => ctx.slots.register({
|
|
1146
|
+
name: 'conversation.input.overlay', id: 'capability-autocomplete', order: 10, locale: NS,
|
|
1147
|
+
}, props => h(ComposerOverlay, { ...props, form, t: props?.t ?? t })));
|
|
1148
|
+
// Touch: the strip lives in the dock (laid out in flow above the composer, so it never covers native controls)
|
|
1149
|
+
// and the ✦ button lives in the composer's own tool row (no extra height).
|
|
1150
|
+
ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
|
|
1151
|
+
name: 'conversation.input.dock', id: 'capability-autocomplete-touch', order: 90, locale: NS,
|
|
1152
|
+
}, props => h(TouchDock, { ...props, form, t: props?.t ?? t })));
|
|
1153
|
+
ctx.slots.inject('conversation.input.right', () => ctx.slots.register({
|
|
1154
|
+
name: 'conversation.input.right', id: 'capability-autocomplete-ask', order: 90, locale: NS,
|
|
1155
|
+
}, props => h(TouchAskButton, { ...props, form, t: props?.t ?? t })));
|
|
1156
|
+
const settings = props => h(AutocompleteSettings, { ...props, form, t: props?.t ?? t });
|
|
1157
|
+
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
|
|
1158
|
+
name: 'settings.general.item', id: 'capability-autocomplete', order: 55, label: () => t('title'), locale: NS,
|
|
1159
|
+
}, settings));
|
|
1160
|
+
// The bundle's Plugins page gets the shared data-driven panel (managed-lock aware); the card above stays in Settings.
|
|
1161
|
+
const panel = aiwukConfig.PANELS.autocomplete;
|
|
1162
|
+
aiwukConfig.createPanelKit(React).registerConfigPanel(ctx, { panel, ns: 'aiwuk-autocomplete', dictionaries: aiwukConfig.panelDictionaries(panel) });
|
|
1163
|
+
},
|
|
1164
|
+
};
|
|
1165
|
+
// Test-only seam: harnesses may define window.__acTestHook; browsers never do.
|
|
1166
|
+
if (typeof window.__acTestHook === 'function') window.__acTestHook({ ComposerOverlay, GhostOverlay, TouchSuggestionBar, TouchDock, TouchAskButton, touchStore, measureCardInset, AutocompleteSettings, parseSse, isTriggerToken, configOf, candidateOf, caretAtEnd, caretRect, ENDPOINT, zh, en });
|
|
1167
|
+
|
|
1168
|
+
return plugin;
|
|
1169
|
+
},
|
|
1170
|
+
});
|