@dsh-enhanced/owner-console 0.0.0-stage → 0.1.66

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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +312 -2
  3. package/cordis.patch.yml +3 -0
  4. package/lib/art.d.ts +7 -0
  5. package/lib/art.d.ts.map +1 -0
  6. package/lib/art.js +22 -0
  7. package/lib/art.js.map +1 -0
  8. package/lib/artwork.d.ts +13 -0
  9. package/lib/artwork.d.ts.map +1 -0
  10. package/lib/artwork.js +13 -0
  11. package/lib/artwork.js.map +1 -0
  12. package/lib/build-approvals.d.ts +11 -0
  13. package/lib/build-approvals.d.ts.map +1 -0
  14. package/lib/build-approvals.js +64 -0
  15. package/lib/build-approvals.js.map +1 -0
  16. package/lib/chat-view.d.ts +50 -0
  17. package/lib/chat-view.d.ts.map +1 -0
  18. package/lib/chat-view.js +560 -0
  19. package/lib/chat-view.js.map +1 -0
  20. package/lib/chat.d.ts +345 -0
  21. package/lib/chat.d.ts.map +1 -0
  22. package/lib/chat.js +418 -0
  23. package/lib/chat.js.map +1 -0
  24. package/lib/client.d.ts +126 -0
  25. package/lib/client.d.ts.map +1 -0
  26. package/lib/client.js +17241 -0
  27. package/lib/client.js.map +1 -0
  28. package/lib/index.d.ts +16 -0
  29. package/lib/index.d.ts.map +1 -0
  30. package/lib/index.js +18 -0
  31. package/lib/index.js.map +1 -0
  32. package/lib/service.d.ts +77 -0
  33. package/lib/service.d.ts.map +1 -0
  34. package/lib/service.js +689 -0
  35. package/lib/service.js.map +1 -0
  36. package/lib/settings.d.ts +136 -0
  37. package/lib/settings.d.ts.map +1 -0
  38. package/lib/settings.js +31 -0
  39. package/lib/settings.js.map +1 -0
  40. package/lib/state.d.ts +35 -0
  41. package/lib/state.d.ts.map +1 -0
  42. package/lib/state.js +84 -0
  43. package/lib/state.js.map +1 -0
  44. package/lib/typert.d.ts +1307 -0
  45. package/lib/typert.d.ts.map +1 -0
  46. package/lib/typert.js +271 -0
  47. package/lib/typert.js.map +1 -0
  48. package/lib/ui.d.ts +142 -0
  49. package/lib/ui.d.ts.map +1 -0
  50. package/lib/ui.js +87 -0
  51. package/lib/ui.js.map +1 -0
  52. package/lib/version.d.ts +2 -0
  53. package/lib/version.d.ts.map +1 -0
  54. package/lib/version.js +2 -0
  55. package/lib/version.js.map +1 -0
  56. package/package.json +99 -3
@@ -0,0 +1,560 @@
1
+ /**
2
+ * 文鳐's dedicated chat window. It is a real native Session surface, not a second agent:
3
+ * messages come from the retained Session's durable event window, sending goes through
4
+ * `SessionFace.prompt`, attachments through the staged `fileUpload` service, permission
5
+ * changes through the Host `/permission` command, and stop through `SessionFace.cancel`.
6
+ * A client bundle may only import `react` at runtime, so all Host services arrive through
7
+ * the injected {@link ChatRuntime}.
8
+ */
9
+ import { useEffect, useRef, useState } from 'react';
10
+ import { jsx } from 'react/jsx-runtime';
11
+ import { QUIET_PHRASE } from './art.js';
12
+ import { WENYAO_BACKDROP_DATA_URI, WENYAO_ICON_DATA_URI } from './artwork.js';
13
+ import { boundedText, chatMessages, openDedicatedChatSession, permissionLabel, permissionOptions, permissionValue, remoteFailureSummary, requiresPermissionConfirmation, sendChatMessage, streamingAssistantText, } from './chat.js';
14
+ import { accent, actionsStyle, backdropImageStyle, backdropStyle, border, borderStrong, buttonStyle, caption, danger, el, hairline, hintStyle, idle, ink, muted, pill, placeholder, primaryButtonStyle, subheadStyle, sunken, surface, tint, } from './ui.js';
15
+ const EMPTY_SESSION = {};
16
+ const EMPTY_CONVERSATION = { activeTargets: new Set() };
17
+ const EMPTY_SESSION_LIST = { byId: {} };
18
+ const readEmptySession = (selector) => selector(EMPTY_SESSION);
19
+ const readEmptyConversation = (selector) => selector(EMPTY_CONVERSATION);
20
+ const readEmptySessionList = (selector) => selector(EMPTY_SESSION_LIST);
21
+ const chatRootStyle = { position: 'relative', isolation: 'isolate', height: '100%', minHeight: 0, display: 'flex',
22
+ flexDirection: 'column', overflow: 'hidden', color: ink, fontSize: 13, lineHeight: 1.5 };
23
+ const chatColumnStyle = { position: 'relative', zIndex: 1, height: '100%', minHeight: 0, width: '100%', maxWidth: 840,
24
+ margin: '0 auto', padding: '16px 18px', boxSizing: 'border-box', display: 'flex', flexDirection: 'column', gap: 10 };
25
+ const headerStyle = { display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', padding: '12px 14px',
26
+ border: `1px solid ${border}`, borderRadius: 14, background: surface, boxShadow: '0 1px 2px rgba(0, 0, 0, 0.04)' };
27
+ const logStyle = { flex: '1 1 auto', minHeight: 96, overflowY: 'auto', overscrollBehavior: 'contain', display: 'grid',
28
+ alignContent: 'start', gap: 10, padding: '12px 12px 14px', border: `1px solid ${border}`, borderRadius: 12,
29
+ background: surface, boxShadow: '0 1px 2px rgba(0, 0, 0, 0.04)' };
30
+ const composerStyle = { display: 'grid', gap: 8, padding: '12px 14px', border: `1px solid ${border}`, borderRadius: 14,
31
+ background: surface, boxShadow: '0 1px 2px rgba(0, 0, 0, 0.04)' };
32
+ const bubbleStyle = { maxWidth: '86%', padding: '9px 12px', borderRadius: 12, fontSize: 13, lineHeight: 1.55,
33
+ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' };
34
+ const assistantBubbleStyle = { ...bubbleStyle, justifySelf: 'start', background: sunken, border: `1px solid ${border}`, color: ink };
35
+ const userBubbleStyle = { ...bubbleStyle, justifySelf: 'end', background: tint(accent, 12), border: `1px solid ${tint(accent, 28)}`, color: ink };
36
+ const toolRowStyle = { justifySelf: 'start', display: 'inline-flex', alignItems: 'center', gap: 8, padding: '2px 8px',
37
+ borderRadius: 999, border: `1px solid ${hairline}`, color: caption, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 11 };
38
+ const errorRowStyle = { padding: '8px 12px', borderRadius: 10, fontSize: 12, color: ink, background: tint(danger, 10),
39
+ border: `1px solid ${tint(danger, 32)}` };
40
+ const smallButtonStyle = { ...buttonStyle, padding: '4px 10px', fontSize: 12 };
41
+ const ghostButtonStyle = { ...buttonStyle, border: '1px solid transparent', color: muted };
42
+ const gearButtonStyle = { ...buttonStyle, padding: '5px 10px', fontSize: 16, lineHeight: 1.15 };
43
+ const noticeStyle = (tone) => ({ ...(tone === 'error' ? errorRowStyle : { padding: '8px 12px', borderRadius: 10,
44
+ fontSize: 12, color: ink, background: tint(accent, 10), border: `1px solid ${tint(accent, 30)}` }) });
45
+ const TOOL_STATUS_LABELS = {
46
+ running: '运行中', ok: '完成', error: '失败',
47
+ };
48
+ /** The factory-local view selection: the native Chat view, exactly as the shipped Conversation uses it. */
49
+ function ChatConversationView({ renderSlot }) {
50
+ return renderSlot?.('conversation.session', { view: 'chat' }) ?? null;
51
+ }
52
+ /**
53
+ * Child slot body rendered under the injected SessionProvider. It computes the native
54
+ * Conversation phase from the Session's own snapshots and renders the shipped
55
+ * `conversation.content` factory with `hero:false` so no workspace picker or catalog appears.
56
+ */
57
+ export function OwnerConsoleConversation({ sessionId, renderFactorySlot, useSession, useConversation, useSessions }) {
58
+ // Hooks are called unconditionally so the fallback functions cannot change hook order.
59
+ const session = (useSession ?? readEmptySession)(value => value);
60
+ const conversation = (useConversation ?? readEmptyConversation)(value => value);
61
+ const summaryBlank = (useSessions ?? readEmptySessionList)(state => state.byId[sessionId ?? '']?.blank);
62
+ if (renderFactorySlot === undefined)
63
+ return placeholder('原生对话组件未加载,可用右上角「原生会话」进入完整会话。');
64
+ const active = conversation.activeTargets.size > 0
65
+ || (session.blank !== true && session.awaitingFirstTurn !== true)
66
+ || session.running === true;
67
+ const shellPhase = active ? 'active' : session.promptAttempted === true ? 'engaging' : 'blank';
68
+ const settling = shellPhase === 'blank' && session.openState === 'loading' && summaryBlank !== true;
69
+ const hero = shellPhase === 'blank' && (session.openState === 'open' || summaryBlank === true);
70
+ const phase = settling ? 'settling' : hero ? 'hero' : 'active';
71
+ // The hero layout is deliberately suppressed here; the sticky active composer layout is what
72
+ // remains usable without the workspace picker, so never hand the native surface a hero phase.
73
+ return renderFactorySlot('conversation.content', { variant: 'embedded', phase: hero ? 'active' : phase, hero: false }, {
74
+ slots: { views: ChatConversationView },
75
+ fallback: placeholder('原生对话组件未加载,可用右上角「原生会话」进入完整会话。'),
76
+ });
77
+ }
78
+ /** Browser voice/read-aloud controls used beside the native composer (outside its single-occupant slots). */
79
+ function NativeToolbar({ voiceAvailable, listening, speakAvailable, speakReady, onVoice, onSpeak }) {
80
+ return el('div', { 'data-owner-console-voice-toolbar': '', style: { ...actionsStyle, padding: '0 2px' } }, el('button', { type: 'button', style: voiceAvailable ? smallButtonStyle : idle(smallButtonStyle, true),
81
+ 'aria-label': voiceAvailable ? (listening ? '停止语音对话' : '语音对话') : '语音对话(当前浏览器不支持)',
82
+ 'aria-pressed': listening,
83
+ title: voiceAvailable
84
+ ? '语音转写会写入原生草稿;识别由浏览器 SpeechRecognition 提供,音频可能发送给浏览器厂商的语音服务,不属于 DSH 权限模型或审计范围'
85
+ : '当前浏览器未提供 SpeechRecognition,语音输入不可用;该功能由浏览器提供,不是 DSH 模型功能',
86
+ disabled: !voiceAvailable, onClick: onVoice }, listening ? '停止语音' : '语音对话'), el('button', { type: 'button', style: speakAvailable && speakReady ? smallButtonStyle : idle(smallButtonStyle, true),
87
+ 'aria-label': '朗读最近一条回复', title: speakAvailable
88
+ ? '用浏览器 SpeechSynthesis 朗读最近一条回复;远端语音可能把文本发送给浏览器厂商,不是 DSH 模型'
89
+ : '当前浏览器未提供 SpeechSynthesis,朗读不可用',
90
+ disabled: !speakAvailable || !speakReady, onClick: onSpeak }, '朗读'), el('span', { style: { ...hintStyle, fontSize: 11 } }, '语音转写只写入本会话的原生草稿;语音识别/合成由浏览器提供,音频或文本可能发送给浏览器厂商的语音服务,不是 DSH 模型功能。'));
91
+ }
92
+ /** Round 11 深海觉醒 whale mark used by the chat header. */
93
+ function ChatMark() {
94
+ return el('img', { src: WENYAO_ICON_DATA_URI, alt: '', 'aria-hidden': 'true', draggable: false,
95
+ 'data-owner-console-art': 'chat-mark', width: 34, height: 34,
96
+ style: { display: 'block', width: 34, height: 34, borderRadius: '50%', objectFit: 'cover', flex: 'none' } });
97
+ }
98
+ /** One compact transcript row; every visual state also carries a text label. */
99
+ function ChatMessageRow({ message }) {
100
+ if (message.kind === 'tool') {
101
+ return el('div', { 'data-chat-message-kind': 'tool', style: toolRowStyle, role: 'status',
102
+ 'aria-label': `工具 ${message.name}:${TOOL_STATUS_LABELS[message.status]}` }, el('span', {}, message.name), el('span', { 'aria-hidden': true }, '·'), el('span', {}, TOOL_STATUS_LABELS[message.status]));
103
+ }
104
+ if (message.kind === 'error') {
105
+ return el('div', { 'data-chat-message-kind': 'error', role: 'alert', style: errorRowStyle }, message.text);
106
+ }
107
+ return el('div', { 'data-chat-message-kind': message.kind, style: message.kind === 'user' ? userBubbleStyle : assistantBubbleStyle }, el('span', { style: { display: 'block', fontSize: 11, color: caption, marginBottom: 3 } }, message.kind === 'user' ? '你' : '文鳐'), message.text);
108
+ }
109
+ /** Session lifecycle as a text pill; absence means the chat is still preparing. */
110
+ function sessionPill(snapshot, ready) {
111
+ if (!ready)
112
+ return pill('准备中', 'neutral');
113
+ if (snapshot.openState === 'error')
114
+ return pill('会话不可用', 'error');
115
+ if (snapshot.removed === true)
116
+ return pill('会话已移除', 'error');
117
+ if (snapshot.running === true)
118
+ return pill('回复中', 'warn');
119
+ if (snapshot.openState === 'loading' || snapshot.openState === 'cold')
120
+ return pill('连接中', 'neutral');
121
+ return pill('就绪', 'ok');
122
+ }
123
+ /** Permission value pill: full access is visibly risky even though text carries the meaning. */
124
+ function permissionTone(value) {
125
+ if (value === undefined)
126
+ return 'neutral';
127
+ if (value === 'danger-full-access')
128
+ return 'error';
129
+ if (value === 'auto')
130
+ return 'warn';
131
+ return 'ok';
132
+ }
133
+ /** Current permission plus the catalog selector; every write goes through the native command. */
134
+ function PermissionBar({ session, permission, catalog, switching, pending, notice, request, confirm, cancel }) {
135
+ // Keep the selector mounted while a switch is in flight (disabled), so a rerender cannot
136
+ // replace the user's focused control with the notice line.
137
+ const selectable = session !== undefined && permission !== undefined && catalog.phase === 'ok';
138
+ const options = catalog.phase === 'ok' ? permissionOptions(catalog.catalog, permission) : [];
139
+ return el('div', { 'data-owner-console-permissions': '', style: { display: 'flex', alignItems: 'center', gap: 8,
140
+ flexWrap: 'wrap', padding: '0 2px', minHeight: 26 } }, el('span', { style: subheadStyle }, '权限'), permission === undefined ? pill('权限不可用', 'neutral') : pill(permissionLabel(permission, catalog.phase === 'ok' ? catalog.catalog : undefined), permissionTone(permission)), selectable
141
+ ? el('select', { 'aria-label': '切换权限预设', title: '切换通过 Host 的 /permission 命令完成,权限策略仍然生效',
142
+ value: permission, disabled: switching, onChange: (event) => request(event.target.value),
143
+ style: { padding: '4px 8px', borderRadius: 8, border: `1px solid ${borderStrong}`, background: 'transparent',
144
+ color: 'inherit', fontSize: 12, maxWidth: 260 } }, ...options.map(option => el('option', { key: option.value, value: option.value }, `${option.name}(${option.value})`)))
145
+ : catalog.phase === 'loading'
146
+ ? el('span', { style: hintStyle }, '正在读取权限目录…')
147
+ : catalog.phase === 'failed'
148
+ ? el('span', { style: hintStyle }, `权限目录读取失败:${boundedText(catalog.error, 120)};只能显示当前值。`)
149
+ : permission === undefined
150
+ ? el('span', { style: hintStyle }, '未加载权限服务;这里不会绕过 Host 策略。')
151
+ : null, catalog.phase === 'ok' && catalog.catalog.defaultPreset !== undefined
152
+ ? el('span', { style: { fontSize: 11, color: caption } }, `新会话默认:${permissionLabel(catalog.catalog.defaultPreset, catalog.catalog)}`)
153
+ : null, pending === undefined ? null : el('div', { role: 'alertdialog', 'aria-label': '确认切换权限',
154
+ style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', padding: '4px 10px', borderRadius: 10,
155
+ fontSize: 12, background: tint(danger, 10), border: `1px solid ${tint(danger, 32)}` } }, `切换到「${permissionLabel(pending, catalog.phase === 'ok' ? catalog.catalog : undefined)}」会扩大文鳐的权限,确认?`, el('button', { type: 'button', style: smallButtonStyle, onClick: () => confirm(pending) }, '确认切换'), el('button', { type: 'button', style: smallButtonStyle, onClick: cancel }, '取消')), notice === '' ? null : el('span', { role: 'status', style: { fontSize: 12, color: muted } }, notice));
156
+ }
157
+ /** Composer: real text, staged attachments, browser speech, stop, and one send path. */
158
+ function Composer(props) {
159
+ const input = useRef(null);
160
+ const blocked = props.disabled || props.sending || (props.draft.trim() === '' && props.files.length === 0);
161
+ return el('form', { 'data-owner-console-composer': '', style: composerStyle, 'aria-label': '消息输入',
162
+ onSubmit: props.onSubmit }, props.files.length === 0 ? null : el('ul', { 'aria-label': '待发送附件', style: { ...actionsStyle, listStyle: 'none', margin: 0, padding: 0 } }, ...props.files.map((file, index) => {
163
+ const name = file.name ?? `附件 ${index + 1}`;
164
+ return el('li', { key: `${name}:${index}`, style: { display: 'inline-flex', alignItems: 'center', gap: 6, padding: '3px 8px',
165
+ borderRadius: 999, border: `1px solid ${border}`, fontSize: 12, background: sunken } }, el('span', { style: { maxWidth: 220, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, name), el('button', { type: 'button', style: { ...ghostButtonStyle, padding: '0 2px', fontSize: 14 },
166
+ 'aria-label': `移除附件 ${name}`, onClick: () => props.onRemoveFile(index) }, '×'));
167
+ })), el('textarea', { value: props.draft, rows: 3, 'aria-label': '给文鳐的消息', placeholder: '对文鳐说点什么…(Enter 发送,Shift+Enter 换行)',
168
+ disabled: props.disabled || props.sending, onChange: (event) => props.onDraft(event.target.value),
169
+ onKeyDown: props.onKeyDown,
170
+ style: { ...buttonlessInputStyle, resize: 'vertical', minHeight: 72 } }), el('div', { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } }, el('input', { ref: input, type: 'file', multiple: true, 'aria-label': '选择附件', tabIndex: -1,
171
+ style: { display: 'none' }, onChange: (event) => {
172
+ props.onFiles(event.target.files ?? null);
173
+ if (event.target.value !== undefined)
174
+ event.target.value = '';
175
+ } }), el('button', { type: 'button', style: smallButtonStyle, onClick: () => input.current?.click(),
176
+ 'aria-label': '添加附件', title: '附件会先上传到本会话,再随消息发送' }, '附件'), el('button', { type: 'button', style: props.voiceAvailable ? smallButtonStyle : idle(smallButtonStyle, true),
177
+ 'aria-label': props.voiceAvailable ? (props.listening ? '停止语音对话' : '语音对话') : '语音对话(当前浏览器不支持)',
178
+ 'aria-pressed': props.listening,
179
+ title: props.voiceAvailable
180
+ ? '语音输入由浏览器 SpeechRecognition 提供,音频可能发送给浏览器厂商的语音服务,不属于 DSH 权限模型或审计范围'
181
+ : '当前浏览器未提供 SpeechRecognition,语音输入不可用;该功能由浏览器提供,不是 DSH 模型功能',
182
+ disabled: !props.voiceAvailable || props.sending, onClick: props.onVoice }, props.listening ? '停止语音' : '语音对话'), el('button', { type: 'button', style: props.speakAvailable && props.speakReady ? smallButtonStyle : idle(smallButtonStyle, true),
183
+ 'aria-label': '朗读最近一条回复', title: props.speakAvailable
184
+ ? '用浏览器 SpeechSynthesis 朗读最近一条回复;远端语音可能把文本发送给浏览器厂商,不是 DSH 模型'
185
+ : '当前浏览器未提供 SpeechSynthesis,朗读不可用',
186
+ disabled: !props.speakAvailable || !props.speakReady || props.sending, onClick: props.onSpeak }, '朗读'), el('span', { 'aria-hidden': true, style: { flex: '1 1 20px' } }), props.running ? el('button', { type: 'button', style: smallButtonStyle, disabled: props.cancelling, onClick: props.onStop }, props.cancelling ? '停止中…' : '停止') : null, el('button', { type: 'submit', style: idle(primaryButtonStyle, blocked), disabled: blocked,
187
+ 'aria-label': props.sending ? '正在发送消息' : '发送消息' }, props.sending ? '发送中…' : '发送')), el('p', { style: { ...hintStyle, fontSize: 11 } }, '消息经由 DSH 会话发送;附件先上传到本会话。语音输入与朗读由浏览器提供,音频或文本可能发送给浏览器厂商的语音服务,不是 DSH 模型功能。'));
188
+ }
189
+ /** One-line textarea style: same input shell without the single-line padding assumptions. */
190
+ const buttonlessInputStyle = { padding: '8px 10px', borderRadius: 10, border: `1px solid ${borderStrong}`,
191
+ background: 'transparent', color: 'inherit', fontSize: 13, lineHeight: 1.5, width: '100%', minWidth: 0,
192
+ boxSizing: 'border-box' };
193
+ /** The sidebar's 文鳐 panel: a native-Session chat window with a settings gear. */
194
+ export function OwnerConsoleChat({ runtime, onOpenSettings, renderer }) {
195
+ const [state, setState] = useState({ phase: 'preparing' });
196
+ const [attempt, setAttempt] = useState(0);
197
+ const [snapshot, setSnapshot] = useState({});
198
+ const [window, setWindow] = useState({});
199
+ const [permission, setPermission] = useState();
200
+ const [catalog, setCatalog] = useState({ phase: 'loading' });
201
+ const [pendingPermission, setPendingPermission] = useState();
202
+ const [switching, setSwitching] = useState(false);
203
+ const [permissionNotice, setPermissionNotice] = useState('');
204
+ const [draft, setDraft] = useState('');
205
+ const [files, setFiles] = useState([]);
206
+ const [sending, setSending] = useState(false);
207
+ const [cancelling, setCancelling] = useState(false);
208
+ const [notice, setNotice] = useState();
209
+ const [listening, setListening] = useState(false);
210
+ const recognition = useRef(null);
211
+ const log = useRef(null);
212
+ const stick = useRef(true);
213
+ const alive = useRef(true);
214
+ useEffect(() => () => { alive.current = false; }, []);
215
+ // Acquire one retained native Session; release it exactly when the chat surface unmounts.
216
+ useEffect(() => {
217
+ alive.current = true;
218
+ let disposed = false;
219
+ let reference;
220
+ const controller = new AbortController();
221
+ const sessions = runtime.sessions();
222
+ if (sessions === undefined) {
223
+ setState({ phase: 'unavailable' });
224
+ return () => { disposed = true; };
225
+ }
226
+ setState({ phase: 'preparing' });
227
+ void (async () => {
228
+ try {
229
+ reference = await openDedicatedChatSession(sessions, runtime.storage(), undefined, runtime.createSession, controller.signal);
230
+ if (disposed) {
231
+ reference.release();
232
+ return;
233
+ }
234
+ setState({ phase: 'ready', reference, binding: reference.binding });
235
+ }
236
+ catch (error) {
237
+ if (disposed)
238
+ return;
239
+ setState({ phase: 'failed', error: error instanceof Error ? boundedText(error.message) : '无法打开对话。' });
240
+ }
241
+ })();
242
+ return () => {
243
+ disposed = true;
244
+ // Cancelling the acquisition stops the Host waiting on a hung open, so this panel
245
+ // cannot leak its retention after unmount.
246
+ controller.abort();
247
+ reference?.release();
248
+ };
249
+ }, [runtime, attempt]);
250
+ const binding = state.phase === 'ready' ? state.binding : undefined;
251
+ const reference = state.phase === 'ready' ? state.reference : undefined;
252
+ const nativeSurface = reference !== undefined && renderer?.SessionProvider !== undefined && renderer.renderSlot !== undefined
253
+ ? { reference, provider: renderer.SessionProvider, renderSlot: renderer.renderSlot }
254
+ : undefined;
255
+ const native = nativeSurface !== undefined;
256
+ // Session lifecycle and durable transcript subscriptions.
257
+ useEffect(() => {
258
+ if (binding === undefined) {
259
+ setSnapshot({});
260
+ setWindow({});
261
+ return;
262
+ }
263
+ const syncSession = () => setSnapshot(binding.session.getSnapshot());
264
+ const syncEvents = () => setWindow(binding.eventSource.getSnapshot());
265
+ syncSession();
266
+ syncEvents();
267
+ const stopSession = binding.session.subscribe(syncSession);
268
+ const stopEvents = binding.eventSource.subscribe(syncEvents);
269
+ return () => { stopSession(); stopEvents(); };
270
+ }, [binding]);
271
+ // Current permission comes from the native Session projection, never from a local guess.
272
+ useEffect(() => {
273
+ if (binding === undefined) {
274
+ setPermission(undefined);
275
+ return;
276
+ }
277
+ const face = binding.session.projections.faceOf('permissions');
278
+ const sync = () => setPermission(permissionValue(face.getSnapshot()));
279
+ sync();
280
+ return face.subscribe(sync);
281
+ }, [binding]);
282
+ // Selectable presets come from the Host's process catalog Remote.
283
+ useEffect(() => {
284
+ let disposed = false;
285
+ let pending;
286
+ try {
287
+ pending = runtime.permissionCatalog();
288
+ }
289
+ catch (error) {
290
+ setCatalog({ phase: 'failed', error: error instanceof Error ? error.message : '权限目录读取失败' });
291
+ return () => { disposed = true; };
292
+ }
293
+ if (pending === undefined) {
294
+ setCatalog({ phase: 'absent' });
295
+ return () => { disposed = true; };
296
+ }
297
+ setCatalog({ phase: 'loading' });
298
+ void pending.then(result => {
299
+ if (disposed)
300
+ return;
301
+ if (result.ok)
302
+ setCatalog({ phase: 'ok', catalog: result.value });
303
+ else
304
+ setCatalog({ phase: 'failed', error: remoteFailureSummary(result.error) });
305
+ }, (error) => {
306
+ if (!disposed)
307
+ setCatalog({ phase: 'failed', error: error instanceof Error ? error.message : '权限目录读取失败' });
308
+ });
309
+ return () => { disposed = true; };
310
+ }, [runtime]);
311
+ const messages = chatMessages(window.entries);
312
+ const streaming = streamingAssistantText(window.entries);
313
+ const lastAssistant = [...messages].reverse().find(message => message.kind === 'assistant');
314
+ const voiceAvailable = runtime.voiceAvailable();
315
+ const speakAvailable = runtime.speakAvailable();
316
+ // The native composer already owns the canonical permission chip. Show our own bar only when
317
+ // that native seat is unavailable (no renderer surface, or no permission-preset remote).
318
+ const showPermissionBar = !native || catalog.phase === 'absent' || catalog.phase === 'failed';
319
+ // Follow new output only while the reader is already at the bottom.
320
+ useEffect(() => {
321
+ const node = log.current;
322
+ if (node !== null && stick.current)
323
+ node.scrollTop = node.scrollHeight;
324
+ }, [messages, streaming, binding]);
325
+ useEffect(() => () => {
326
+ const active = recognition.current;
327
+ recognition.current = null;
328
+ try {
329
+ active?.stop();
330
+ }
331
+ catch { /* a browser may already have ended it */ }
332
+ }, []);
333
+ const submit = () => {
334
+ if (binding === undefined || sending)
335
+ return;
336
+ if (draft.trim() === '' && files.length === 0)
337
+ return;
338
+ setSending(true);
339
+ setNotice(undefined);
340
+ void sendChatMessage({ sessionId: binding.sessionId, session: binding.session, uploads: runtime.fileUploads(),
341
+ text: draft, files }).then(outcome => {
342
+ if (!alive.current)
343
+ return;
344
+ setSending(false);
345
+ if (!outcome.ok) {
346
+ setNotice({ tone: 'error', text: outcome.error });
347
+ return;
348
+ }
349
+ setDraft('');
350
+ setFiles([]);
351
+ }, (error) => {
352
+ if (!alive.current)
353
+ return;
354
+ setSending(false);
355
+ setNotice({ tone: 'error', text: error instanceof Error ? error.message : '消息未发送,稍后重试。' });
356
+ });
357
+ };
358
+ const stop = () => {
359
+ if (binding === undefined || cancelling)
360
+ return;
361
+ setCancelling(true);
362
+ setNotice(undefined);
363
+ void binding.session.cancel().then(result => {
364
+ if (!alive.current)
365
+ return;
366
+ setCancelling(false);
367
+ if (!result.ok)
368
+ setNotice({ tone: 'error', text: `停止失败(${remoteFailureSummary(result.error)})` });
369
+ }, (error) => {
370
+ if (!alive.current)
371
+ return;
372
+ setCancelling(false);
373
+ setNotice({ tone: 'error', text: error instanceof Error ? error.message : '停止失败。' });
374
+ });
375
+ };
376
+ const applyPermission = (value) => {
377
+ if (binding === undefined || switching)
378
+ return;
379
+ setPendingPermission(undefined);
380
+ setSwitching(true);
381
+ setPermissionNotice('');
382
+ void binding.session.command(`/permission ${value}`).then(result => {
383
+ if (!alive.current)
384
+ return;
385
+ setSwitching(false);
386
+ if (!result.ok) {
387
+ setPermissionNotice(`权限切换被拒绝(${remoteFailureSummary(result.error)})`);
388
+ return;
389
+ }
390
+ if (result.value.matched === false) {
391
+ setPermissionNotice('Host 没有提供 /permission 命令,权限未改变。');
392
+ return;
393
+ }
394
+ setPermissionNotice(`已请求切换到「${permissionLabel(value, catalog.phase === 'ok' ? catalog.catalog : undefined)}」;以会话权限投影为准。`);
395
+ }, (error) => {
396
+ if (!alive.current)
397
+ return;
398
+ setSwitching(false);
399
+ setPermissionNotice(error instanceof Error ? boundedText(error.message) : '权限切换失败。');
400
+ });
401
+ };
402
+ const requestPermission = (value) => {
403
+ if (value === permission)
404
+ return;
405
+ if (requiresPermissionConfirmation(value)) {
406
+ setPendingPermission(value);
407
+ return;
408
+ }
409
+ applyPermission(value);
410
+ };
411
+ const toggleVoice = () => {
412
+ if (listening) {
413
+ const active = recognition.current;
414
+ recognition.current = null;
415
+ setListening(false);
416
+ try {
417
+ active?.stop();
418
+ }
419
+ catch { /* already ended */ }
420
+ return;
421
+ }
422
+ const created = runtime.createRecognition();
423
+ if (created === undefined) {
424
+ setNotice({ tone: 'error', text: '当前浏览器未提供 SpeechRecognition,语音输入不可用;语音识别由浏览器提供,不是 DSH 模型功能。' });
425
+ return;
426
+ }
427
+ created.lang = runtime.recognitionLang();
428
+ created.interimResults = true;
429
+ created.continuous = false;
430
+ created.onresult = event => {
431
+ // A late result from a stopped/replaced recognizer (or after unmount) must not touch the draft.
432
+ if (!alive.current || recognition.current !== created)
433
+ return;
434
+ let finalText = '';
435
+ for (let index = event.resultIndex; index < event.results.length; index += 1) {
436
+ const result = event.results[index];
437
+ if (result?.isFinal === true)
438
+ finalText += result[0]?.transcript ?? '';
439
+ }
440
+ if (finalText === '')
441
+ return;
442
+ if (native && reference !== undefined) {
443
+ const inserted = runtime.insertDraft?.(reference, finalText) ?? false;
444
+ if (!inserted) {
445
+ setNotice({ tone: 'error', text: '当前界面未提供原生草稿写入接口(conversation.input 未加载),语音文字未写入。' });
446
+ }
447
+ return;
448
+ }
449
+ setDraft(previous => previous === '' ? finalText : `${previous} ${finalText}`);
450
+ };
451
+ created.onerror = event => {
452
+ if (!alive.current || recognition.current !== created)
453
+ return;
454
+ recognition.current = null;
455
+ setListening(false);
456
+ setNotice({ tone: 'error', text: `语音识别失败:${event.error ?? '未知错误'}。语音识别由浏览器提供,不是 DSH 模型功能。` });
457
+ };
458
+ created.onend = () => {
459
+ // A stopped/replaced recognizer must not clear the instance that replaced it.
460
+ if (!alive.current || recognition.current !== created)
461
+ return;
462
+ recognition.current = null;
463
+ setListening(false);
464
+ };
465
+ recognition.current = created;
466
+ try {
467
+ created.start();
468
+ setListening(true);
469
+ setNotice(undefined);
470
+ }
471
+ catch {
472
+ recognition.current = null;
473
+ setNotice({ tone: 'error', text: '浏览器拒绝启动语音识别。' });
474
+ }
475
+ };
476
+ const speak = () => {
477
+ if (lastAssistant === undefined) {
478
+ setNotice({ tone: 'info', text: '还没有可朗读的回复。' });
479
+ return;
480
+ }
481
+ if (!runtime.speak(lastAssistant.text)) {
482
+ setNotice({ tone: 'error', text: '浏览器语音合成不可用。' });
483
+ }
484
+ };
485
+ const addFiles = (chosen) => {
486
+ if (runtime.fileUploads() === undefined) {
487
+ setNotice({ tone: 'error', text: '当前界面未提供附件上传服务(fileUpload 未加载),无法上传附件。' });
488
+ return;
489
+ }
490
+ const next = [...files];
491
+ for (const file of Array.from(chosen ?? []))
492
+ next.push(file);
493
+ if (next.length > 10) {
494
+ setNotice({ tone: 'error', text: '一次最多附加 10 个文件。' });
495
+ }
496
+ setFiles(next.slice(0, 10));
497
+ };
498
+ const openNative = () => {
499
+ if (binding !== undefined && runtime.openNativeSession(binding.sessionId))
500
+ return;
501
+ setNotice({ tone: 'error', text: '当前界面未提供原生会话视图(uiWorkspace 未加载);文鳐仍在这个窗口中对话。' });
502
+ };
503
+ const keyDown = (event) => {
504
+ if (event.key !== 'Enter' || event.shiftKey === true || event.nativeEvent?.isComposing === true)
505
+ return;
506
+ event.preventDefault();
507
+ submit();
508
+ };
509
+ const ready = binding !== undefined;
510
+ const disabled = !ready || sending;
511
+ const scroll = (event) => {
512
+ stick.current = event.currentTarget.scrollHeight - event.currentTarget.scrollTop - event.currentTarget.clientHeight < 48;
513
+ };
514
+ let transcript;
515
+ if (state.phase === 'preparing')
516
+ transcript = placeholder('正在准备对话…');
517
+ else if (state.phase === 'unavailable') {
518
+ transcript = el('div', { style: { display: 'grid', gap: 10, justifyItems: 'start' } }, placeholder('当前界面未提供会话服务(sessions 未加载),无法在这里对话。可以进入原生会话界面继续。'), el('button', { type: 'button', style: primaryButtonStyle, onClick: () => runtime.startConversation() }, '进入原生会话'));
519
+ }
520
+ else if (state.phase === 'failed') {
521
+ transcript = el('div', { style: { display: 'grid', gap: 10, justifyItems: 'start' } }, el('p', { role: 'alert', style: { ...hintStyle, color: danger } }, `无法打开对话:${state.error}`), el('button', { type: 'button', style: primaryButtonStyle, onClick: () => setAttempt(value => value + 1) }, '重试'));
522
+ }
523
+ else if (messages.length === 0 && streaming === '') {
524
+ transcript = placeholder('和文鳐说点什么吧。', QUIET_PHRASE);
525
+ }
526
+ else {
527
+ transcript = [
528
+ window.hasMore === true ? el('button', { key: 'older', type: 'button', style: smallButtonStyle,
529
+ onClick: () => { void state.binding.session.loadOlder(); } }, '载入更早的消息') : null,
530
+ ...messages.map(message => jsx(ChatMessageRow, { message }, message.key)),
531
+ streaming === '' ? null : el('div', { key: 'streaming', 'data-chat-message-kind': 'streaming', style: assistantBubbleStyle, 'aria-live': 'polite' }, el('span', { style: { display: 'block', fontSize: 11, color: caption, marginBottom: 3 } }, '文鳐正在回复…'), streaming),
532
+ ];
533
+ }
534
+ return el('section', { 'aria-label': '文鳐对话', 'data-owner-console-chat': '', style: chatRootStyle }, el('div', { style: backdropStyle }, el('img', { src: WENYAO_BACKDROP_DATA_URI, alt: '', 'aria-hidden': 'true', draggable: false,
535
+ 'data-owner-console-art': 'backdrop', width: 960, height: 960, style: backdropImageStyle })), el('div', { style: chatColumnStyle }, el('header', { 'data-owner-console-chat-header': '', style: headerStyle }, jsx(ChatMark, {}), el('div', { style: { display: 'grid', gap: 2, minWidth: 0, flex: '1 1 220px' } }, el('div', { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } }, el('h2', { style: { margin: 0, fontSize: 18, fontWeight: 600, letterSpacing: '0.02em' } }, '文鳐 Ray'), sessionPill(snapshot, ready)), el('p', { style: { margin: 0, fontSize: 12, color: muted, fontFamily: "'STKaiti', 'KaiTi', 'Songti SC', 'STSong', serif", letterSpacing: '0.04em' } }, '你的个人助手小鳐 · 小鳐小鳐,游而化飞')), el('div', { style: { ...actionsStyle, marginLeft: 'auto' } }, el('button', { type: 'button', style: smallButtonStyle, onClick: openNative, 'aria-label': '在原生会话中打开' }, '原生会话'), el('button', { type: 'button', style: gearButtonStyle, onClick: onOpenSettings, 'aria-label': '打开设置',
536
+ title: '配置、飞书 / TraeX 与插件管理' }, el('span', { 'aria-hidden': true }, '⚙')))), showPermissionBar
537
+ ? jsx(PermissionBar, { session: binding?.session, permission, catalog, switching, pending: pendingPermission,
538
+ notice: permissionNotice, request: requestPermission, confirm: applyPermission, cancel: () => setPendingPermission(undefined) })
539
+ : null, notice === undefined ? null : el('p', { role: notice.tone === 'error' ? 'alert' : 'status', style: noticeStyle(notice.tone) }, notice.text), ...(nativeSurface === undefined
540
+ ? [
541
+ el('div', { ref: log, 'data-owner-console-chat-log': '', role: 'log', 'aria-label': '对话消息',
542
+ 'aria-live': 'polite', style: logStyle, onScroll: scroll }, transcript),
543
+ jsx(Composer, { disabled, sending, draft, files, running: snapshot.running === true, cancelling,
544
+ voiceAvailable, listening, speakAvailable, speakReady: lastAssistant !== undefined,
545
+ onDraft: setDraft, onSubmit: (event) => { event.preventDefault(); submit(); },
546
+ onKeyDown: keyDown, onFiles: addFiles,
547
+ onRemoveFile: (index) => setFiles(files.filter((_file, at) => at !== index)),
548
+ onVoice: toggleVoice, onSpeak: speak, onStop: stop }),
549
+ ]
550
+ : [
551
+ jsx(NativeToolbar, { voiceAvailable, listening, speakAvailable, speakReady: lastAssistant !== undefined,
552
+ onVoice: toggleVoice, onSpeak: speak }),
553
+ el('div', { 'data-owner-console-native-conversation': '',
554
+ style: { flex: '1 1 auto', minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' } }, jsx(nativeSurface.provider, {
555
+ session: nativeSurface.reference,
556
+ children: nativeSurface.renderSlot('owner-console.conversation', { sessionId: nativeSurface.reference.binding.sessionId }, { fallback: placeholder('正在载入原生对话…') }),
557
+ })),
558
+ ])));
559
+ }
560
+ //# sourceMappingURL=chat-view.js.map