@kingsoftcloud/agentkit-personal-assistant 0.0.0-stage → 0.1.6

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 (70) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +7 -0
  3. package/README.md +38 -2
  4. package/cordis.patch.yml +6 -0
  5. package/dist/client.js +6 -0
  6. package/package.json +83 -4
  7. package/personal-assistant.plugin.json +31 -0
  8. package/personal-assistant.plugin.schema.json +43 -0
  9. package/runtime/assistant-runtime.mjs +317 -0
  10. package/runtime/check-manifest.mjs +21 -0
  11. package/runtime/cloud-browser-cdp.mjs +313 -0
  12. package/runtime/cloud-browser.mjs +972 -0
  13. package/runtime/cloud-execution.mjs +320 -0
  14. package/runtime/cloud-session-channel.mjs +45 -0
  15. package/runtime/computer-agentd.mjs +488 -0
  16. package/runtime/desktop-clipboard-limit.mjs +2 -0
  17. package/runtime/desktop-launcher.mjs +106 -0
  18. package/runtime/desktop-permit.mjs +42 -0
  19. package/runtime/desktop-session.mjs +840 -0
  20. package/runtime/desktop-ws.mjs +371 -0
  21. package/runtime/prepare-dsh-profile.mjs +17 -0
  22. package/runtime/welcome-server.mjs +144 -0
  23. package/scripts/assistant-runtime-launcher.sh +3 -0
  24. package/scripts/build-artifacts.mjs +81 -0
  25. package/scripts/build-client.mjs +82 -0
  26. package/scripts/computer-agentd-launcher.sh +3 -0
  27. package/scripts/deterministic_tar.py +21 -0
  28. package/scripts/install-local.mjs +262 -0
  29. package/scripts/scan-secrets.mjs +41 -0
  30. package/scripts/sync-public.mjs +52 -0
  31. package/scripts/verification/gateway-desktop-client.mjs +142 -0
  32. package/scripts/verification/verify-desktop-applications-linux.mjs +46 -0
  33. package/scripts/verification/verify-desktop-input-linux.mjs +127 -0
  34. package/scripts/verification/verify-desktop-pod-gateway.mjs +204 -0
  35. package/scripts/verify-cloud-turn.mjs +18 -0
  36. package/scripts/verify-computer-broker.mjs +10 -0
  37. package/src/automation-tools.mjs +18 -0
  38. package/src/bot-private-tools.mjs +23 -0
  39. package/src/bot-registry.mjs +157 -0
  40. package/src/bot-tool-policy.mjs +48 -0
  41. package/src/bot-tool-scope.mjs +40 -0
  42. package/src/client/bot-directory.mjs +23 -0
  43. package/src/client/components/desktop-clipboard.mjs +194 -0
  44. package/src/client/conversation.mjs +558 -0
  45. package/src/client/desktop-control.mjs +70 -0
  46. package/src/client/desktop-view.mjs +68 -0
  47. package/src/client/entry.mjs +8 -0
  48. package/src/client/index.mjs +693 -0
  49. package/src/client/markdown.mjs +130 -0
  50. package/src/client/native-workbench.mjs +62 -0
  51. package/src/cloud-computer-tools.mjs +245 -0
  52. package/src/cloud-files.mjs +57 -0
  53. package/src/cloud-model.mjs +10 -0
  54. package/src/cloud-session-transport.mjs +76 -0
  55. package/src/computer-broker-protocol.mjs +116 -0
  56. package/src/computer-broker.mjs +220 -0
  57. package/src/desktop-tools.mjs +64 -0
  58. package/src/dsh-session-bridge.mjs +207 -0
  59. package/src/dsh-web-bridge.mjs +127 -0
  60. package/src/host-gateway.mjs +228 -0
  61. package/src/host-session-channel.mjs +410 -0
  62. package/src/implementation-capabilities.mjs +10 -0
  63. package/src/index.mjs +148 -0
  64. package/src/persistent-state.mjs +201 -0
  65. package/src/platform-manifest.mjs +24 -0
  66. package/src/plugin-manifest.mjs +82 -0
  67. package/src/schedule-manager.mjs +88 -0
  68. package/src/system-fetch.mjs +72 -0
  69. package/src/workspace-tools.mjs +22 -0
  70. package/src/workspace.mjs +22 -0
@@ -0,0 +1,558 @@
1
+ /**
2
+ * 个人助理主面板的对话视图。
3
+ *
4
+ * 数据全部走 Studio Host 的 personal-assistant.* RPC:
5
+ * sessions.list / session.create / session.snapshot / session.events / turn.submit
6
+ * Host 侧通道尚未就绪时,这里只降级为一条可重试的提示,不抛出、不影响插件激活。
7
+ *
8
+ * ## 本回合进行中的「客户端排队」
9
+ *
10
+ * 服务端在一轮进行中会拒绝第二次 turn.submit(409 DSH_SESSION_BUSY),而一轮思考可能
11
+ * 持续几十秒。因此当「有在途 turn / 助理正在输出」时,发送不直接打 RPC,而是进客户端
12
+ * 待发队列(`queue`),在消息列表里以「已排队,本轮结束后发送」显示,可逐条撤回。
13
+ * 当前回合结束(session.events 的 turns 里不再有 queued/running)后自动发出队首;
14
+ * 每发一条就等它这一轮结束,因此连续多条天然串行,不会互相打断。
15
+ *
16
+ * 边界(有意为之,改动前请先读):
17
+ * 1. 队列只在内存里,不写 localStorage:刷新页面会丢排队消息,所以队列非空时挂
18
+ * beforeunload 提示,避免静默丢失。
19
+ * 2. 切换会话(含宿主自动重建会话 id)时丢弃队列并提示:排队消息带原 sessionId,
20
+ * 跨会话续发等于把用户的话发到别的会话里,比丢弃更糟。
21
+ * 3. 兜底:即使客户端误判空闲,云电脑仍可能忙。此时该条消息回到队首并退避重试
22
+ * (最多 QUEUE_MAX_ATTEMPTS 次),绝不直接丢消息、也不改成"发送失败"吓人。
23
+ * 4. 只有宿主明确报告「没有在途 turn」才推进队列;本地 Host 通道不返回 turns 时
24
+ * 依赖兜底重试,最坏情况是会多试几次,但不会因为排队而丢消息。
25
+ */
26
+
27
+ import { renderMarkdown } from './markdown.mjs';
28
+
29
+ const EMPTY_HINT = '有什么可以帮你?';
30
+ /** 判定回合结束后稍等再发队首:云端把 turn 标记为 running 有延迟,抢跑会撞 409。 */
31
+ const QUEUE_FLUSH_DELAY_MS = 400;
32
+ /** 云电脑仍忙时的退避重试上限:超过后交还给用户手动重试(消息内容仍在列表里)。 */
33
+ const QUEUE_MAX_ATTEMPTS = 8;
34
+ const QUEUE_RETRY_DELAY_MS = 1500;
35
+
36
+ const styles = {
37
+ page: {
38
+ flex: '1 1 0px', height: '100%', maxHeight: '100%', minHeight: 0, minWidth: 0,
39
+ overflow: 'hidden', boxSizing: 'border-box', display: 'flex', flexDirection: 'column', fontSize: 13,
40
+ color: 'var(--dsw-alias-label-primary, #17202c)', background: 'var(--dsw-alias-bg-layer-1, #fff)',
41
+ },
42
+ header: {
43
+ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 10, padding: '10px 16px',
44
+ borderBottom: '1px solid var(--dsw-alias-border-default, #d8dee8)', flex: 'none',
45
+ },
46
+ title: { margin: 0, fontSize: 14, fontWeight: 600 },
47
+ status: { color: 'var(--dsw-alias-label-secondary, #536174)', fontSize: 12 },
48
+ spacer: { marginLeft: 'auto' },
49
+ ghost: {
50
+ border: '1px solid var(--dsw-alias-border-default, #d8dee8)', background: 'transparent',
51
+ color: 'inherit', borderRadius: 8, padding: '5px 10px', cursor: 'pointer', font: 'inherit', fontSize: 12,
52
+ },
53
+ scroller: { flex: '1 1 0px', minHeight: 0, minWidth: 0, overflowY: 'auto', overflowX: 'hidden', overscrollBehavior: 'contain', boxSizing: 'border-box', padding: '20px 16px 8px' },
54
+ thread: { maxWidth: 720, margin: '0 auto', display: 'flex', flexDirection: 'column', gap: 14 },
55
+ row: { display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'flex-start' },
56
+ rowUser: { display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'flex-end' },
57
+ who: { fontSize: 11, color: 'var(--dsw-alias-label-secondary, #536174)' },
58
+ bubble: {
59
+ maxWidth: '86%', padding: '9px 12px', borderRadius: 12, lineHeight: 1.7,
60
+ overflowWrap: 'anywhere', display: 'flex', flexDirection: 'column', gap: 8,
61
+ // 主题 token 在浅色下都是纯白,用 currentColor 混色才能在明暗主题里都有轮廓。
62
+ background: 'color-mix(in srgb, currentColor 5%, transparent)',
63
+ },
64
+ paragraph: { margin: 0, whiteSpace: 'pre-wrap' },
65
+ heading: { fontWeight: 600, fontSize: 13.5 },
66
+ headingStrong: { fontSize: 15 },
67
+ list: { margin: 0, paddingLeft: 20, display: 'flex', flexDirection: 'column', gap: 4 },
68
+ codeBlock: {
69
+ margin: 0, padding: '10px 12px', borderRadius: 8, overflowX: 'auto', fontSize: 12, lineHeight: 1.65,
70
+ background: 'color-mix(in srgb, currentColor 9%, transparent)',
71
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
72
+ },
73
+ inlineCode: {
74
+ padding: '1px 5px', borderRadius: 5, fontSize: 12,
75
+ background: 'color-mix(in srgb, currentColor 9%, transparent)',
76
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
77
+ },
78
+ quote: { margin: 0, paddingLeft: 10, borderLeft: '3px solid var(--dsw-alias-border-default, #d8dee8)', color: 'var(--dsw-alias-label-secondary, #536174)' },
79
+ rule: { border: 'none', borderTop: '1px solid var(--dsw-alias-border-default, #d8dee8)', margin: '2px 0', width: '100%' },
80
+ link: { color: '#2f6bff', textDecoration: 'none' },
81
+ bubbleUser: {
82
+ maxWidth: '86%', padding: '9px 12px', borderRadius: 12, lineHeight: 1.7, whiteSpace: 'pre-wrap',
83
+ overflowWrap: 'anywhere', background: 'color-mix(in srgb, #2f6bff 12%, transparent)',
84
+ },
85
+ pulse: { animation: 'pa-awaiting 1.2s ease-in-out infinite' },
86
+ step: { fontSize: 12, color: 'var(--dsw-alias-label-secondary, #536174)', lineHeight: 1.7, overflowWrap: 'anywhere' },
87
+ centered: { flex: 1, display: 'grid', placeItems: 'center', padding: 24, textAlign: 'center' },
88
+ centeredInner: { maxWidth: 420, display: 'flex', flexDirection: 'column', gap: 10, alignItems: 'center' },
89
+ composer: { flex: '0 0 auto', minWidth: 0, boxSizing: 'border-box', padding: '8px 16px 14px' },
90
+ composerInner: {
91
+ maxWidth: 720, minWidth: 0, margin: '0 auto', display: 'flex', flexDirection: 'column', gap: 6,
92
+ border: '1px solid var(--dsw-alias-border-default, #d8dee8)', borderRadius: 16, padding: '10px 10px 8px 12px',
93
+ background: 'var(--dsw-alias-bg-layer-1, #fff)',
94
+ },
95
+ textarea: {
96
+ flex: 1, width: '100%', minWidth: 0, boxSizing: 'border-box', minHeight: 24, maxHeight: 200, resize: 'none', border: 'none', outline: 'none',
97
+ background: 'transparent', color: 'inherit', font: 'inherit', fontSize: 13, lineHeight: 1.7,
98
+ },
99
+ composerBar: { display: 'flex', alignItems: 'center', gap: 8 },
100
+ iconButton: {
101
+ flex: 'none', width: 28, height: 28, display: 'grid', placeItems: 'center', cursor: 'pointer',
102
+ border: '1px solid var(--dsw-alias-border-default, #d8dee8)', borderRadius: 999,
103
+ background: 'transparent', color: 'var(--dsw-alias-label-secondary, #536174)', padding: 0,
104
+ },
105
+ send: {
106
+ flex: 'none', width: 28, height: 28, display: 'grid', placeItems: 'center', border: 'none', borderRadius: 999,
107
+ cursor: 'pointer', padding: 0, background: 'var(--dsw-alias-label-primary, #17202c)',
108
+ color: 'var(--dsw-alias-bg-layer-1, #fff)',
109
+ },
110
+ sendDisabled: { background: 'var(--dsw-alias-border-default, #d8dee8)', color: 'var(--dsw-alias-label-secondary, #536174)', cursor: 'default' },
111
+ // 排队中的快捷入口:不是禁用态,而是"可继续排队"的强调态,避免看起来点不动。
112
+ sendQueued: {
113
+ flex: 'none', width: 28, height: 28, display: 'grid', placeItems: 'center', borderRadius: 999, cursor: 'pointer', padding: 0,
114
+ border: '1px dashed var(--dsw-alias-label-primary, #17202c)', background: 'transparent',
115
+ color: 'var(--dsw-alias-label-primary, #17202c)',
116
+ },
117
+ queueBadge: {
118
+ flex: 'none', borderRadius: 999, padding: '1px 8px', fontSize: 11, lineHeight: '18px',
119
+ background: 'color-mix(in srgb, #946200 14%, transparent)', color: '#946200',
120
+ },
121
+ queueBubble: {
122
+ maxWidth: '86%', padding: '9px 12px', borderRadius: 12, lineHeight: 1.7, whiteSpace: 'pre-wrap',
123
+ overflowWrap: 'anywhere', border: '1px dashed color-mix(in srgb, currentColor 35%, transparent)',
124
+ background: 'color-mix(in srgb, currentColor 4%, transparent)',
125
+ color: 'var(--dsw-alias-label-secondary, #536174)',
126
+ },
127
+ queueRow: { display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end' },
128
+ queueHint: { fontSize: 11, color: 'var(--dsw-alias-label-secondary, #536174)' },
129
+ queueCancel: {
130
+ border: 'none', background: 'transparent', cursor: 'pointer', padding: '2px 4px', borderRadius: 6,
131
+ color: 'var(--dsw-alias-label-secondary, #536174)', display: 'inline-flex', alignItems: 'center', gap: 3, font: 'inherit', fontSize: 11,
132
+ },
133
+ queueNotice: { maxWidth: 720, margin: '0 auto 6px', fontSize: 12, color: '#946200', lineHeight: 1.6 },
134
+ attach: {
135
+ flex: 'none', display: 'flex', alignItems: 'center', gap: 6, padding: '3px 8px 3px 4px', borderRadius: 8,
136
+ background: 'var(--dsw-alias-bg-layer-2, #f4f6fa)', fontSize: 11, maxWidth: 180,
137
+ },
138
+ attachName: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
139
+ attachThumb: { width: 20, height: 20, borderRadius: 4, objectFit: 'cover', flex: 'none' },
140
+ attachRow: { display: 'flex', flexWrap: 'wrap', gap: 6 },
141
+ };
142
+
143
+ const text = value => (typeof value === 'string' ? value : '');
144
+
145
+ /** 与 DSH 原生对话框一致的发送箭头。 */
146
+ const sendGlyph = h => h('svg', { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2.2, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true },
147
+ h('path', { d: 'M12 19V5M5 12l7-7 7 7' }));
148
+ const plusGlyph = h => h('svg', { width: 15, height: 15, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', 'aria-hidden': true },
149
+ h('path', { d: 'M12 5v14M5 12h14' }));
150
+ const closeGlyph = h => h('svg', { width: 10, height: 10, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2.4, strokeLinecap: 'round', 'aria-hidden': true },
151
+ h('path', { d: 'M6 6l12 12M18 6L6 18' }));
152
+
153
+ export function PersonalAssistantConversation({ React: R, request, scope, onOpenComputer = null } = {}) {
154
+ if (!R) return '个人助理对话尚未就绪,请重新打开';
155
+ const { createElement: h, useCallback, useEffect, useRef, useState } = R;
156
+ const [phase, setPhase] = useState('loading');
157
+ const [error, setError] = useState('');
158
+ const [sessions, setSessions] = useState([]);
159
+ const [sessionId, setSessionId] = useState('');
160
+ const [messages, setMessages] = useState([]);
161
+ const [draft, setDraft] = useState('');
162
+ const [attachments, setAttachments] = useState([]);
163
+ const fileRef = useRef(null);
164
+ const [sending, setSending] = useState(false);
165
+ // 回合已提交、助理还没出第一条消息时的等待态:不给反馈会让人以为没发出去。
166
+ const [awaiting, setAwaiting] = useState(false);
167
+ const [progressText, setProgressText] = useState('');
168
+ // 本轮进行中收到的消息:只存在内存里,本轮结束后逐条发出(见文件头注释的边界说明)。
169
+ const [queue, setQueue] = useState([]);
170
+ const [queueNotice, setQueueNotice] = useState('');
171
+ const cursorRef = useRef(0);
172
+ const outboxRef = useRef(new Map());
173
+ const queueRef = useRef([]);
174
+ const flushingRef = useRef(false);
175
+ const retryTimerRef = useRef(null);
176
+ // 事件轮询/定时器回调里要读到最新值,避免用首次渲染的闭包判断"忙不忙"。
177
+ const sendingRef = useRef(false); sendingRef.current = sending;
178
+ const awaitingRef = useRef(false); awaitingRef.current = awaiting;
179
+ const phaseRef = useRef('loading'); phaseRef.current = phase;
180
+ const generationRef = useRef(0);
181
+ const aliveRef = useRef(true);
182
+ const scrollerRef = useRef(null);
183
+ const followLatestRef = useRef(true);
184
+ const sessionRef = useRef('');
185
+ sessionRef.current = sessionId;
186
+
187
+ const call = useCallback(async (operation, payload = {}) => {
188
+ if (typeof request !== 'function') throw new Error('Studio Host 尚未连接');
189
+ return request(operation, payload);
190
+ }, [request]);
191
+
192
+ const mergeEvents = useCallback(events => {
193
+ if (!Array.isArray(events) || events.length === 0) return;
194
+
195
+ setMessages(previous => {
196
+ const next = [...previous];
197
+ for (const event of events) {
198
+ if (!event || typeof event !== 'object') continue;
199
+ if (event.type === 'message') {
200
+ const id = text(event.id) || `local-${next.length}`;
201
+ const role = text(event.role) || 'assistant';
202
+ const body = text(event.text);
203
+ const index = next.findIndex(item => item.id === id);
204
+ const message = { id, role, text: body, done: event.done !== false, clientTurnId: event.clientTurnId, status: 'accepted' };
205
+ if (index >= 0) next[index] = { ...next[index], ...message };
206
+ else if (role === 'user') {
207
+ // 本地乐观消息与服务端 durable 消息是同一条:认领服务端 id,避免气泡出现两次。
208
+ const optimistic = next.findIndex(item => item.role === 'user' && event.clientTurnId && item.clientTurnId === event.clientTurnId);
209
+ if (optimistic >= 0) next[optimistic] = { ...message };
210
+ else next.push(message);
211
+ } else next.push(message);
212
+ } else if (event.type === 'delta') {
213
+ const id = text(event.id) || `local-${next.length}`;
214
+ const index = next.findIndex(item => item.id === id);
215
+ if (index >= 0) next[index] = { ...next[index], text: next[index].text + text(event.text) };
216
+ else next.push({ id, role: 'assistant', text: text(event.text), done: false });
217
+ } else if (event.type === 'step' && text(event.text)) {
218
+ const step = { id: text(event.id), role: 'step', text: text(event.text), done: true };
219
+ const index = next.findIndex(item => item.id === step.id);
220
+ if (index >= 0) next[index] = step; else next.push(step);
221
+ }
222
+ }
223
+ return next;
224
+ });
225
+ }, []);
226
+
227
+ const openSession = useCallback(async id => {
228
+ if (!id) return;
229
+ // 排队消息带原 sessionId:切到别的会话后继续发等于把用户的话发错地方,所以丢弃并提示。
230
+ // (刷新页面同样丢弃:队列只在内存里,靠 beforeunload 提示用户别在刷新上踩雷。)
231
+ const previousSession = sessionRef.current;
232
+ if (previousSession && previousSession !== id && queueRef.current.length > 0) {
233
+ const dropped = queueRef.current.length;
234
+ queueRef.current = []; setQueue([]);
235
+ if (retryTimerRef.current) { clearTimeout(retryTimerRef.current); retryTimerRef.current = null; }
236
+ setQueueNotice(`已切换会话,${dropped} 条排队消息未发送,已取消`);
237
+ } else if (previousSession && previousSession !== id) setQueueNotice('');
238
+ sessionRef.current = id;
239
+ const generation = ++generationRef.current;
240
+ setSessionId(id);
241
+ setPhase('loading');
242
+ setError('');
243
+ cursorRef.current = 0;
244
+ followLatestRef.current = true;
245
+ setMessages([]);
246
+ try {
247
+ const snapshot = await call('personal-assistant.session.snapshot', { sessionId: id });
248
+ if (!aliveRef.current || sessionRef.current !== id || generation !== generationRef.current) return;
249
+ cursorRef.current = Number(snapshot?.cursor) || 0;
250
+ const history = Array.isArray(snapshot?.messages) ? snapshot.messages : [];
251
+ setMessages(history.map((message, index) => ({
252
+ id: text(message?.id) || `history-${index}`,
253
+ role: text(message?.role) || 'assistant',
254
+ text: text(message?.text),
255
+ done: true,
256
+ })));
257
+ setAwaiting(snapshot.turns?.some(turn => ['queued', 'running'].includes(turn.status)) || false);
258
+ setPhase('ready');
259
+ } catch (failure) {
260
+ if (!aliveRef.current || generation !== generationRef.current) return;
261
+ setPhase('error');
262
+ setError(failure?.message || '无法读取会话内容');
263
+ }
264
+ }, [call]);
265
+
266
+ const bootstrap = useCallback(async () => {
267
+ setPhase('loading');
268
+ setError('');
269
+ if (!aliveRef.current) return;
270
+ try {
271
+ const listed = await call('personal-assistant.sessions.list');
272
+ if (!aliveRef.current) return;
273
+ const available = Array.isArray(listed?.sessions) ? listed.sessions : [];
274
+ setSessions(available);
275
+ if (available.length > 0) { await openSession(text(available[0].sessionId)); return; }
276
+ const created = await call('personal-assistant.session.create', {});
277
+ if (!aliveRef.current) return;
278
+ const createdId = text(created?.sessionId);
279
+ setSessions(createdId ? [{ sessionId: createdId, title: text(created?.title) || '新会话' }] : []);
280
+ await openSession(createdId);
281
+ } catch (failure) {
282
+ if (!aliveRef.current) return;
283
+ setPhase('error');
284
+ setError(failure?.message || '会话通道暂时不可用');
285
+ }
286
+ }, [call, openSession]);
287
+
288
+ useEffect(() => {
289
+ aliveRef.current = true;
290
+ void bootstrap();
291
+ return () => {
292
+ aliveRef.current = false;
293
+ if (retryTimerRef.current) { clearTimeout(retryTimerRef.current); retryTimerRef.current = null; }
294
+ };
295
+ }, [bootstrap]);
296
+
297
+ // 事件增量:宿主侧按 cursor 返回增量,空增量也要前进 cursor。
298
+ useEffect(() => {
299
+ if (phase !== 'ready' || !sessionId) return undefined;
300
+ let cancelled = false;
301
+ let timer = null;
302
+ const tick = async () => {
303
+ try {
304
+ const page = await call('personal-assistant.session.events', { sessionId, cursor: cursorRef.current });
305
+ if (cancelled) return;
306
+ cursorRef.current = Number(page?.cursor ?? cursorRef.current) || 0;
307
+ mergeEvents(page?.events);
308
+ if (Array.isArray(page.turns)) {
309
+ setAwaiting(page.turns.some(turn => ['queued', 'running'].includes(turn.status)));
310
+ const active = page.turns.findLast(turn => ['queued', 'running'].includes(turn.status));
311
+ const labels = { cloud_computer: '操作云电脑', cloud_workspace: '处理云端文件', cloud_bash: '执行云端命令', cloud_run_code: '运行云端代码', assistant_schedule: '设置云端任务' };
312
+ const tool = active?.progress?.tool;
313
+ setProgressText(tool ? `${active.progress.state === 'running' ? '正在' : '已完成'}${labels[tool] || (tool.startsWith('cloud_browser_') ? '操作云端浏览器' : '执行工具')}` : '');
314
+ mergeEvents(page.turns.filter(turn => ['failed', 'interrupted', 'cancelled'].includes(turn.status)).map(turn => ({ type: 'step', id: `turn-status-${turn.turnId}`, text: `任务${turn.status === 'failed' ? '失败' : '已中断'}:${turn.error || '请检查任务状态后重试'}` })));
315
+ }
316
+ setError('');
317
+ } catch (failure) {
318
+ if (!cancelled) {
319
+ if (failure.code === 'SESSION_CURSOR_STALE') { void openSession(sessionId); return; }
320
+ setError(failure.message || '云端连接中断,正在恢复;任务不会改到本机执行');
321
+ }
322
+ }
323
+ if (!cancelled) timer = setTimeout(tick, 600);
324
+ };
325
+ timer = setTimeout(tick, 200);
326
+ return () => { cancelled = true; clearTimeout(timer); };
327
+ }, [phase, sessionId, call, mergeEvents, openSession]);
328
+
329
+ useEffect(() => {
330
+ // 只移动自己拥有的消息区,不能让 scrollIntoView 连带滚动宿主和输入框。
331
+ const scroller = scrollerRef.current;
332
+ if (scroller && followLatestRef.current) scroller.scrollTop = scroller.scrollHeight;
333
+ }, [messages, awaiting]);
334
+
335
+ const pickFiles = async event => {
336
+ const files = Array.from(event.target.files || []);
337
+ event.target.value = '';
338
+ const loaded = [];
339
+ for (const file of files.slice(0, 4)) {
340
+ if (!file || file.size > 512 * 1024) { setError('每个附件最多 512 KiB,请先压缩或拆分文件'); continue; }
341
+ const dataUrl = await new Promise(resolve => {
342
+ const reader = new FileReader();
343
+ reader.onload = () => resolve(String(reader.result || ''));
344
+ reader.onerror = () => resolve('');
345
+ reader.readAsDataURL(file);
346
+ });
347
+ if (!dataUrl) continue;
348
+ loaded.push({ id: `${Date.now()}-${file.name}`, name: file.name, mimeType: file.type || 'application/octet-stream', size: file.size, dataUrl });
349
+ }
350
+ if (loaded.length) setAttachments(previous => [...previous, ...loaded].slice(0, 4));
351
+ };
352
+
353
+ const commitQueue = next => { queueRef.current = next; setQueue(next); };
354
+ const clearRetryTimer = () => { if (retryTimerRef.current) { clearTimeout(retryTimerRef.current); retryTimerRef.current = null; } };
355
+ // 409 不一定都是"忙",但把 409 当忙处理最坏只是多退避几次;反过来误判成失败会直接吞掉用户消息。
356
+ const isBusyFailure = failure => Number(failure?.status) === 409
357
+ || /SESSION_BUSY|active turn|正忙|处理中/u.test(`${failure?.code || ''} ${failure?.message || ''}`);
358
+
359
+ /** 真正发一轮:乐观气泡 → 上传待发附件 → turn.submit;失败原因交回排队层决定重试还是报错。 */
360
+ const sendTurn = async (item, { restoreDraft = false } = {}) => {
361
+ outboxRef.current.set(item.clientTurnId, item);
362
+ followLatestRef.current = true;
363
+ setSending(true); setError(''); setAwaiting(true);
364
+ setMessages(previous => previous.some(row => row.clientTurnId === item.clientTurnId)
365
+ ? previous.map(row => row.clientTurnId === item.clientTurnId ? { ...row, status: 'pending' } : row)
366
+ : [...previous, { id: `local-user-${item.clientTurnId}`, clientTurnId: item.clientTurnId, role: 'user', text: item.text, done: false, status: 'pending' }]);
367
+ let current = item;
368
+ try {
369
+ let attachments = Array.isArray(current.attachments) ? current.attachments : [];
370
+ if (attachments.length === 0 && Array.isArray(current.uploads) && current.uploads.length > 0) {
371
+ const uploaded = [];
372
+ for (const upload of current.uploads) uploaded.push(await call('personal-assistant.file.upload', { sessionId: current.sessionId, name: upload.name, dataUrl: upload.dataUrl }));
373
+ attachments = uploaded.map(({ path, name, mediaType, bytes }) => ({ path, name, mediaType, bytes }));
374
+ // 上传结果回写到重试项上:重试时只重发 turn.submit,不再重复上传。
375
+ current = { ...current, attachments, uploads: [] };
376
+ outboxRef.current.set(current.clientTurnId, current);
377
+ }
378
+ const receipt = await call('personal-assistant.turn.submit', { sessionId: current.sessionId, text: current.text, clientTurnId: current.clientTurnId, attachments });
379
+ if (!aliveRef.current) return { ok: true, item: current };
380
+ outboxRef.current.delete(current.clientTurnId);
381
+ setMessages(previous => previous.map(row => row.clientTurnId === current.clientTurnId ? { ...row, status: 'accepted', turnId: receipt.turnId } : row));
382
+ return { ok: true, item: current };
383
+ } catch (failure) {
384
+ if (!aliveRef.current) return { ok: false, failure, item: current };
385
+ // 忙:说明服务端认为还有回合在跑,保持"进行中"而不是假装失败;其余失败才复位等待态。
386
+ setAwaiting(isBusyFailure(failure));
387
+ // 忙失败会转成排队(内容已在气泡里),不再同时塞回输入框,避免一份内容出现两处。
388
+ if (restoreDraft && !isBusyFailure(failure)) setDraft(value => value || current.text);
389
+ setMessages(previous => previous.map(row => row.clientTurnId === current.clientTurnId ? { ...row, status: 'failed', failure: failure.message || '发送结果待确认,重试将复用原标识' } : row));
390
+ return { ok: false, failure, item: current };
391
+ } finally { if (aliveRef.current) setSending(false); }
392
+ };
393
+
394
+ /** 没发出去的消息回到队首退避重试;到上限就把内容还给输入框,绝不静默丢弃。 */
395
+ const requeueForRetry = (item, failure) => {
396
+ const attempts = (item.attempts || 0) + 1;
397
+ outboxRef.current.delete(item.clientTurnId);
398
+ setMessages(previous => previous.filter(row => row.clientTurnId !== item.clientTurnId));
399
+ if (attempts > QUEUE_MAX_ATTEMPTS) {
400
+ setQueueNotice(`排队消息多次未发出(${failure?.message || '云电脑仍忙'}),已放回输入框,请手动重试`);
401
+ setDraft(value => value || item.text);
402
+ return;
403
+ }
404
+ commitQueue([{ ...item, attempts }, ...queueRef.current]);
405
+ const wait = (QUEUE_RETRY_DELAY_MS * Math.min(attempts, 3) / 1000).toFixed(1); // 1.5 / 3.0 / 4.5 秒
406
+ setQueueNotice(`云电脑仍在处理上一轮,消息已排队,约 ${wait} 秒后自动重发`);
407
+ clearRetryTimer();
408
+ retryTimerRef.current = setTimeout(() => { retryTimerRef.current = null; void attemptQueuedHead(true); }, QUEUE_RETRY_DELAY_MS * Math.min(attempts, 3));
409
+ };
410
+
411
+ /** 取队首发出。force=true 是退避重试:绕过 awaiting 守卫,但仍要求会话一致。 */
412
+ const attemptQueuedHead = async force => {
413
+ const session = sessionRef.current;
414
+ if (flushingRef.current || !aliveRef.current || phaseRef.current !== 'ready' || !session) return false;
415
+ if (sendingRef.current) return false; // 已有提交在途时一律不抢,避免重复提交
416
+ if (!force && awaitingRef.current) return false;
417
+ const head = queueRef.current[0];
418
+ if (!head) return false;
419
+ if (head.sessionId !== session) { commitQueue([]); setQueueNotice('会话已变化,排队消息未发送'); return false; }
420
+ flushingRef.current = true;
421
+ commitQueue(queueRef.current.slice(1));
422
+ try {
423
+ const result = await sendTurn(head);
424
+ if (result.ok) return true;
425
+ if (isBusyFailure(result.failure)) requeueForRetry(result.item || head, result.failure);
426
+ else setQueueNotice(`排队消息发送失败:${result.failure?.message || '请点击重试'}`);
427
+ return false;
428
+ } finally { flushingRef.current = false; }
429
+ };
430
+
431
+ const cancelQueued = clientTurnId => {
432
+ outboxRef.current.delete(clientTurnId);
433
+ commitQueue(queueRef.current.filter(item => item.clientTurnId !== clientTurnId));
434
+ setQueueNotice('已撤回排队消息');
435
+ };
436
+
437
+ const submit = async (retryItem = null) => {
438
+ if (phase !== 'ready' || !sessionId) return;
439
+ if (retryItem) { if (!sending) void sendTurn(retryItem, { restoreDraft: true }); return; }
440
+ const content = draft.trim() || (attachments.length ? '请查看附件。' : '');
441
+ if (!content) return;
442
+ const uploads = attachments.map(({ name, mimeType, size, dataUrl }) => ({ name, mimeType, size, dataUrl }));
443
+ // 本轮还在进行:只入队,不打 turn.submit —— 服务端此时必定 409,排队才不会让用户看到"任务失败"。
444
+ if (sending || awaiting) {
445
+ commitQueue([...queueRef.current, { clientTurnId: crypto.randomUUID(), text: content, sessionId, uploads, attempts: 0, queuedAt: Date.now() }]);
446
+ followLatestRef.current = true;
447
+ setDraft(''); setAttachments([]); setError(''); setQueueNotice('');
448
+ return;
449
+ }
450
+ setDraft(''); setAttachments([]); setError(''); setQueueNotice('');
451
+ const result = await sendTurn({ clientTurnId: crypto.randomUUID(), text: content, sessionId, uploads, attempts: 0 }, { restoreDraft: true });
452
+ if (!result.ok && isBusyFailure(result.failure)) requeueForRetry(result.item, result.failure);
453
+ };
454
+
455
+ // 回合结束(events 的 turns 回到空闲)后自动推进队列:每发一条就等它这一轮结束,连续多条自然串行。
456
+ useEffect(() => {
457
+ if (phase !== 'ready' || !sessionId || sending || awaiting || queue.length === 0) return undefined;
458
+ // 延后一拍:云端把 turn 标成 running 有延迟,刚结束就抢发容易撞 409(撞了会退避重试)。
459
+ const timer = setTimeout(() => { void attemptQueuedHead(false); }, QUEUE_FLUSH_DELAY_MS);
460
+ return () => clearTimeout(timer);
461
+ }, [phase, sessionId, sending, awaiting, queue]);
462
+
463
+ // 队列不落盘:刷新会丢排队消息,因此在有排队内容时拦住一次误刷新(见文件头"边界")。
464
+ useEffect(() => {
465
+ if (queue.length === 0 || typeof window === 'undefined' || typeof window.addEventListener !== 'function') return undefined;
466
+ const warn = event => { event.preventDefault(); event.returnValue = ''; };
467
+ window.addEventListener('beforeunload', warn);
468
+ return () => window.removeEventListener('beforeunload', warn);
469
+ }, [queue.length]);
470
+
471
+ const header = h('header', { style: styles.header },
472
+ h('h1', { style: styles.title }, '个人助理'),
473
+ h('span', { style: styles.status }, phase === 'ready' ? '云端常驻,关掉窗口也会继续' : phase === 'loading' ? '正在连接…' : '连接不可用'),
474
+ h('span', { style: styles.spacer }),
475
+ onOpenComputer ? h('button', { type: 'button', onClick: onOpenComputer, 'aria-label': '打开云电脑侧栏', title: '打开或重新打开云电脑侧栏', style: { ...styles.ghost, display: 'inline-flex', alignItems: 'center', gap: 6 } }, h('svg', { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, 'aria-hidden': true }, h('rect', { x: 3, y: 4, width: 18, height: 13, rx: 2 }), h('path', { d: 'M8 21h8M12 17v4' })), '云电脑') : null);
476
+
477
+ return h('section', { 'data-personal-assistant-conversation': 'true', style: styles.page, onClick: async event => {
478
+ const link = event.target.closest?.('a[href^="cloud-file:"]');
479
+ if (!link) return;
480
+ event.preventDefault();
481
+ try {
482
+ const value = await call('personal-assistant.file.download', { sessionId, path: decodeURIComponent(link.getAttribute('href').slice('cloud-file:'.length)) });
483
+ const bytes = Uint8Array.from(atob(value.data), char => char.charCodeAt(0));
484
+ const url = URL.createObjectURL(new Blob([bytes], { type: 'application/octet-stream' }));
485
+ const download = document.createElement('a'); download.href = url; download.download = value.name; download.click();
486
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
487
+ } catch (failure) { setError(failure.message || '云端文件下载失败'); }
488
+ } }, header,
489
+ h('style', null, '@keyframes pa-awaiting { 0%, 100% { opacity: 0.35 } 50% { opacity: 1 } }'),
490
+ error && messages.length > 0 ? h('div', { role: 'alert', style: { padding: '8px 16px', background: '#fff0d6' } }, error) : null,
491
+ h('div', { ref: scrollerRef, 'data-personal-assistant-messages': 'true', style: styles.scroller, onScroll: event => {
492
+ const element = event.currentTarget;
493
+ followLatestRef.current = element.scrollHeight - element.clientHeight - element.scrollTop < 64;
494
+ } },
495
+ h('div', { style: styles.thread },
496
+ messages.length === 0 ? h('div', { style: { ...styles.status, paddingTop: 40, textAlign: 'center' } },
497
+ phase === 'error' ? h('div', { style: { ...styles.centeredInner, margin: '0 auto' } },
498
+ h('strong', null, '暂时连不上个人助理'), h('span', null, error || '请稍后重试'),
499
+ h('button', { style: styles.ghost, onClick: () => void bootstrap() }, '重试'))
500
+ : phase === 'loading' ? '正在连接个人助理…' : EMPTY_HINT) : null,
501
+ messages.map(message => {
502
+ if (message.role === 'step') return h('div', { key: message.id, style: styles.step }, `· ${message.text}`);
503
+ const mine = message.role === 'user';
504
+ return h('div', { key: message.id, style: mine ? styles.rowUser : styles.row },
505
+ h('span', { style: styles.who }, mine ? '你' : '助理'),
506
+ h('div', { style: mine ? styles.bubbleUser : styles.bubble },
507
+ mine ? (message.text || '…') : (renderMarkdown(h, message.text || '', styles) || '…')),
508
+ message.status === 'failed' ? h('button', { onClick: () => void submit(outboxRef.current.get(message.clientTurnId)), disabled: sending }, `${message.failure} · 重试发送`) : null);
509
+ }),
510
+ awaiting ? h('div', { key: 'awaiting', style: styles.row },
511
+ h('span', { style: styles.who }, '助理'),
512
+ h('div', { style: { ...styles.bubble, color: 'var(--dsw-alias-label-secondary, #536174)' } },
513
+ h('span', { style: styles.pulse }, progressText || '正在处理…'))) : null,
514
+ queue.map(item => h('div', { key: `queued-${item.clientTurnId}`, style: styles.rowUser },
515
+ h('span', { style: styles.who }, '你 · 排队中'),
516
+ h('div', { style: styles.queueBubble }, `${item.text}${item.uploads?.length ? `(${item.uploads.length} 个附件待发)` : ''}`),
517
+ h('div', { style: styles.queueRow },
518
+ h('span', { style: styles.queueHint }, item.attempts ? '云电脑仍在处理上一轮,稍后自动重发' : '已排队,本轮结束后发送'),
519
+ h('button', {
520
+ type: 'button', 'aria-label': `撤回排队消息:${item.text.slice(0, 24)}`, title: '撤回这条排队消息',
521
+ style: styles.queueCancel, onClick: () => cancelQueued(item.clientTurnId),
522
+ }, closeGlyph(h), '撤回')))))),
523
+ h('div', { 'data-personal-assistant-composer': 'true', style: styles.composer },
524
+ queueNotice ? h('div', { role: 'status', style: styles.queueNotice }, queueNotice) : null,
525
+ h('div', { style: styles.composerInner },
526
+ attachments.length > 0 ? h('div', { style: styles.attachRow }, attachments.map(item => h('span', { key: item.id, style: styles.attach },
527
+ item.mimeType.startsWith('image/') ? h('img', { src: item.dataUrl, alt: '', style: styles.attachThumb }) : null,
528
+ h('span', { style: styles.attachName }, item.name),
529
+ h('button', {
530
+ 'aria-label': `移除 ${item.name}`,
531
+ style: { ...styles.iconButton, width: 16, height: 16, border: 'none' },
532
+ onClick: () => setAttachments(previous => previous.filter(entry => entry.id !== item.id)),
533
+ }, closeGlyph(h))))) : null,
534
+ h('textarea', {
535
+ 'aria-label': '给个人助理发消息', value: draft, rows: 1, style: styles.textarea,
536
+ placeholder: phase !== 'ready' ? '云端暂未连接,可先写下消息'
537
+ : (sending || awaiting ? '本轮进行中:现在发送会排队,本轮结束后自动发出' : '给个人助理发消息,Enter 发送,Shift+Enter 换行'),
538
+ onChange: event => setDraft(event.target.value),
539
+ onKeyDown: event => {
540
+ if (phase === 'ready' && event.key === 'Enter' && !event.shiftKey && !event.nativeEvent?.isComposing) { event.preventDefault(); void submit(); }
541
+ },
542
+ }),
543
+ h('div', { style: styles.composerBar },
544
+ h('button', { 'aria-label': '添加附件', style: styles.iconButton, disabled: phase !== 'ready' || sending, title: '上传到云电脑:最多 4 个文件,每个 512 KiB', onClick: () => fileRef.current?.click() }, plusGlyph(h)),
545
+ h('input', { ref: fileRef, type: 'file', multiple: true, accept: 'image/*,.txt,.md,.json,.pdf', style: { display: 'none' }, onChange: pickFiles }),
546
+ h('span', { style: styles.spacer }),
547
+ queue.length > 0 ? h('span', { role: 'status', style: styles.queueBadge }, `排队中 ${queue.length}`) : null,
548
+ h('button', {
549
+ // 排队非空时保持可点,并明确告诉用户"这是在排队",不能做成禁用到以为点不动的样子。
550
+ 'aria-label': queue.length > 0 ? `发送消息(排队中 ${queue.length} 条)` : '发送消息',
551
+ title: sending || awaiting ? '本轮进行中:发送后会排队,本轮结束后自动发出' : '发送消息',
552
+ disabled: phase !== 'ready' || (!draft.trim() && attachments.length === 0),
553
+ style: phase !== 'ready' || (!draft.trim() && attachments.length === 0)
554
+ ? { ...styles.send, ...styles.sendDisabled }
555
+ : (sending || awaiting ? styles.sendQueued : styles.send),
556
+ onClick: () => void submit(),
557
+ }, sendGlyph(h))))));
558
+ }
@@ -0,0 +1,70 @@
1
+ /** 同一画面通道内交接控制权;租约确认前只缓冲输入,绝不假装接管成功。 */
2
+ export function createDesktopControl({ send, changed = () => {}, failed = () => {}, setTimer = setTimeout, clearTimer = clearTimeout } = {}) {
3
+ let available = false, epoch = 0, phase = 'observe', wanted = false, queue = [], text = '', textTimer;
4
+ const notify = () => changed({ phase, readOnly: phase !== 'controlling', controlEpoch: epoch, available });
5
+ const flushText = () => {
6
+ clearTimer(textTimer); textTimer = undefined;
7
+ const value = text; text = '';
8
+ if (value && phase === 'controlling') send({ type: 'input', controlEpoch: epoch, event: { type: 'text', text: value } });
9
+ };
10
+ const transmit = event => {
11
+ if (event.type === 'text') {
12
+ if (text.length + event.text.length > 16384) flushText();
13
+ text += event.text;
14
+ if (!textTimer) textTimer = setTimer(flushText, 30);
15
+ } else { flushText(); send({ type: 'input', controlEpoch: epoch, event }); }
16
+ };
17
+ const start = () => { phase = 'acquiring'; notify(); send({ type: 'control.acquire', controlEpoch: epoch }); };
18
+ const buffer = event => {
19
+ if (event.type === 'pointermove' && queue.at(-1)?.type === 'pointermove') queue[queue.length - 1] = event;
20
+ else if (queue.length < 32) queue.push(event);
21
+ else { api.release(); failed('接管期间输入过快,请重新点击云电脑'); return false; }
22
+ return true;
23
+ };
24
+ const api = {
25
+ get snapshot() { return { phase, readOnly: phase !== 'controlling', controlEpoch: epoch, available }; },
26
+ reset(value = {}) {
27
+ clearTimer(textTimer); textTimer = undefined; text = ''; queue = [];
28
+ available = value.controlAvailable === true; epoch = Number.isSafeInteger(value.controlEpoch) ? value.controlEpoch : 0;
29
+ phase = value.readOnly === false ? 'controlling' : 'observe'; wanted = phase === 'controlling'; notify();
30
+ },
31
+ input(event) {
32
+ if (phase === 'controlling') { transmit(event); return true; }
33
+ if ((!wanted || phase === 'observe') && (event.type === 'pointermove' || (['button', 'key'].includes(event.type) && event.action === 'up'))) return false;
34
+ if (event.type === 'pointermove' && phase === 'observe') return false;
35
+ if (!available) { if (event.type !== 'pointermove') failed('当前为只读旁观,鼠标和键盘不会发送到云电脑'); return false; }
36
+ wanted = true;
37
+ if (!buffer(event)) return false;
38
+ if (phase === 'observe') start();
39
+ return true;
40
+ },
41
+ acquire() { if (!available) return false; wanted = true; if (phase === 'observe') start(); return true; },
42
+ release() {
43
+ wanted = false; queue = [];
44
+ if (phase === 'controlling') { flushText(); phase = 'releasing'; notify(); send({ type: 'control.release', controlEpoch: epoch }); }
45
+ // acquire 尚在路上时等待回执,收到后立刻释放,绝不重放已经取消的首击。
46
+ },
47
+ accept(value) {
48
+ if (!Number.isSafeInteger(value.controlEpoch) || value.controlEpoch < epoch) return;
49
+ if (value.controlEpoch !== epoch) { clearTimer(textTimer); textTimer = undefined; text = ''; }
50
+ epoch = value.controlEpoch; available = value.controlAvailable === true;
51
+ if (value.readOnly) {
52
+ if (phase !== 'releasing') { wanted = false; queue = []; }
53
+ phase = 'observe'; notify();
54
+ if (wanted && available) start();
55
+ return;
56
+ }
57
+ if (!wanted) { queue = []; phase = 'releasing'; notify(); send({ type: 'control.release', controlEpoch: epoch }); return; }
58
+ phase = 'controlling'; notify();
59
+ const pending = queue; queue = []; for (const event of pending) transmit(event);
60
+ },
61
+ reject(value) {
62
+ if (Number.isSafeInteger(value.controlEpoch) && value.controlEpoch < epoch) return;
63
+ wanted = false; queue = []; clearTimer(textTimer); textTimer = undefined; text = '';
64
+ if (Number.isSafeInteger(value.controlEpoch) && value.controlEpoch >= epoch) epoch = value.controlEpoch;
65
+ phase = value.readOnly === false ? 'controlling' : 'observe'; notify(); failed(value.message || '控制权暂不可用,仍可继续旁观');
66
+ },
67
+ dispose() { api.release(); clearTimer(textTimer); textTimer = undefined; text = ''; queue = []; },
68
+ };
69
+ return api;
70
+ }