@kingsoftcloud/agentkit-personal-assistant 0.1.10 → 0.1.13

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.
@@ -57,15 +57,62 @@ export function pasteShortcutEvents() {
57
57
  ]);
58
58
  }
59
59
 
60
+ /**
61
+ * 云端桌面里的复制快捷键也始终是 Linux 的 Control+C。
62
+ * 宿主是 macOS 时,页面收到的是 ⌘C,但不能把 Super_L 发给 X11;
63
+ * Super_L+C 不会触发大多数 Linux 应用的复制动作。
64
+ */
65
+ export function copyShortcutEvents() {
66
+ return Object.freeze([
67
+ Object.freeze({ type: 'key', action: 'down', key: 'Control_L' }),
68
+ Object.freeze({ type: 'key', action: 'down', key: 'c' }),
69
+ Object.freeze({ type: 'key', action: 'up', key: 'c' }),
70
+ Object.freeze({ type: 'key', action: 'up', key: 'Control_L' }),
71
+ ]);
72
+ }
73
+
74
+ /**
75
+ * 等待云桌面处理完 Ctrl+C 再读取 selection。
76
+ *
77
+ * X11 selection 是异步的:应用收到按键后才会把内容交给 clipboard owner,
78
+ * 立即 read 很容易拿到上一次内容,中文通常因此看起来像“乱码/没复制成功”。
79
+ * 若新内容与旧内容相同,仍在最短等待窗口后接受它,支持用户重复复制同一段文字。
80
+ */
81
+ export async function readCloudClipboardAfterCopy({ readCloud, beforeText = '', timeoutMs = 1200, settleMs = 180, pollMs = 50, sleep = delay => new Promise(resolve => setTimeout(resolve, delay)), now = Date.now } = {}) {
82
+ if (typeof readCloud !== 'function') throw clipError('CLIPBOARD_READ_FAILED', '读取云电脑剪贴板失败,请重试');
83
+ const timeout = Number.isFinite(timeoutMs) && timeoutMs >= 0 ? timeoutMs : 1200;
84
+ const settle = Number.isFinite(settleMs) && settleMs >= 0 ? settleMs : 180;
85
+ const poll = Number.isFinite(pollMs) && pollMs > 0 ? pollMs : 50;
86
+ const clock = typeof now === 'function' ? now : Date.now;
87
+ const started = Number(clock());
88
+ let latest = '';
89
+ while (Number(clock()) - started <= timeout) {
90
+ const value = await readCloud();
91
+ latest = clipboardTextOf(value);
92
+ const elapsed = Number(clock()) - started;
93
+ if (latest && (latest !== String(beforeText ?? '') || elapsed >= settle)) return { text: latest, changed: latest !== String(beforeText ?? '') };
94
+ if (elapsed >= timeout) break;
95
+ await sleep(Math.min(poll, timeout - elapsed));
96
+ }
97
+ return { text: latest, changed: latest !== String(beforeText ?? '') };
98
+ }
99
+
60
100
  /** 把云端的错误码翻成用户能照做的中文;英文原文只作为兜底。 */
61
101
  export function clipboardFriendlyError(error) {
62
102
  const code = String(error?.code || '');
103
+ if (code === 'COMPUTER_USER_CONTROLLED') return '当前有人正在操作云电脑,请等对方交还后再复制或粘贴';
104
+ if (code === 'COMPUTER_LEASE_CONFLICT') return '云电脑正在被其他任务使用,请稍后再复制或粘贴';
63
105
  if (['COMPUTER_LEASE_REQUIRED', 'DESKTOP_CONTROL_GRANT_REQUIRED', 'DESKTOP_READ_ONLY', 'DESKTOP_CONTROL_EPOCH_CHANGED'].includes(code)) {
64
106
  return '需要先点画面接管云电脑,才能读写云端剪贴板';
65
107
  }
66
108
  if (['DESKTOP_SESSION_IDLE', 'DESKTOP_SESSION_NOT_FOUND', 'CLIPBOARD_CLOSED'].includes(code)) {
67
109
  return '云电脑已空闲或断开,请先恢复画面再试';
68
110
  }
111
+ if (code === 'CLIPBOARD_UPGRADE_REQUIRED') return '当前云端版本尚不支持剪贴板,请更新云端助理后重试';
112
+ if (code === 'CLIPBOARD_CONTROL_REQUIRED') return '需要先接管云电脑,才能读写云端剪贴板';
113
+ if (code === 'CLIPBOARD_RESPONSE_INVALID') return '云端没有确认剪贴板操作成功,请重试';
114
+ if (code === 'CLIPBOARD_TIMEOUT') return '云端剪贴板响应超时,请检查连接后重试';
115
+ if (code === 'CLIPBOARD_UNAVAILABLE') return '云电脑尚未连接,请恢复画面后重试';
69
116
  if (code === 'CLIPBOARD_TEXT_TOO_LARGE') return '剪贴板内容超过 64 KiB,云电脑剪贴板放不下';
70
117
  if (['DESKTOP_UNAVAILABLE', 'DESKTOP_FRAME_FAILED', 'DESKTOP_SESSION_FAILED'].includes(code)) return '云电脑暂时不可用,请稍后重试';
71
118
  if (['CLIPBOARD_FAILED', 'CLOUD_SESSION_FAILED'].includes(code)) return '剪贴板操作失败,请重试';
@@ -86,12 +133,17 @@ const nextRequestId = () => {
86
133
  * 云端契约(runtime/desktop-ws.mjs):
87
134
  * 发 `{ type: 'clipboard.read' | 'clipboard.write', requestId, text? }`
88
135
  * 收 `{ type: 'clipboard.response', requestId, ok, value | error }`
89
- * 超时是必需的:旧版云端会直接回 DESKTOP_MESSAGE_UNSUPPORTED(不带 requestId),
90
- * 没有超时按钮就会一直转圈,所以这里超时即明确提示"云端待更新"。
136
+ * 旧版云端的 DESKTOP_MESSAGE_UNSUPPORTED 没有 requestId,收到后立即结束
137
+ * 在途请求;没有任何响应时只报告超时,不凭网络延迟猜测版本。
91
138
  */
92
139
  export function createClipboardChannel({ send, timeoutMs = 5000, controlEpoch, setTimer = setTimeout, clearTimer = clearTimeout } = {}) {
93
140
  const pending = new Map();
141
+ const epochOf = () => {
142
+ try { return typeof controlEpoch === 'function' ? controlEpoch() : undefined; }
143
+ catch { return undefined; }
144
+ };
94
145
  const settle = (entry, settleWith) => {
146
+ if (!entry || !pending.has(entry.id)) return;
95
147
  pending.delete(entry.id);
96
148
  clearTimer(entry.timer);
97
149
  settleWith();
@@ -101,33 +153,46 @@ export function createClipboardChannel({ send, timeoutMs = 5000, controlEpoch, s
101
153
  const timer = setTimer(() => {
102
154
  const entry = pending.get(id);
103
155
  if (!entry) return;
104
- settle(entry, () => reject(clipError('CLIPBOARD_TIMEOUT', '云端助理版本较旧,剪贴板通道未启用(重新部署后可用)')));
156
+ settle(entry, () => reject(clipError('CLIPBOARD_TIMEOUT', '云端剪贴板响应超时,请检查连接后重试')));
105
157
  }, timeoutMs);
158
+ const epoch = epochOf();
106
159
  pending.set(id, {
107
- id, timer, resolve,
160
+ id, timer, type, epoch, resolve,
108
161
  reject: error => reject(error),
109
162
  });
110
163
  let sent = false;
111
164
  // handoff 会话里云端要求与 input 一样带 controlEpoch;非 handoff 会话会忽略它。
112
- let epoch;
113
- try { epoch = typeof controlEpoch === 'function' ? controlEpoch() : undefined; }
114
- catch { epoch = undefined; }
115
165
  try { sent = send({ type, requestId: id, ...(Number.isSafeInteger(epoch) ? { controlEpoch: epoch } : {}), ...extra }) !== false; }
116
166
  catch (error) { settle(pending.get(id), () => reject(clipError('CLIPBOARD_UNAVAILABLE', '云电脑尚未连接,剪贴板暂不可用', error))); return; }
117
167
  // send() 返回 false 表示 socket 不在 OPEN:立刻失败,别让用户白等 5 秒。
118
168
  if (!sent) settle(pending.get(id), () => reject(clipError('CLIPBOARD_UNAVAILABLE', '云电脑尚未连接,剪贴板暂不可用')));
119
169
  });
120
- return Object.freeze({
170
+ const channel = Object.freeze({
121
171
  read: () => request('clipboard.read').then(value => ({ text: clipboardTextOf(value) })),
122
- write: text => request('clipboard.write', { text: String(text) }).then(value => ({ written: value?.written !== false })),
172
+ write: text => request('clipboard.write', { text: String(text) }).then(() => ({ written: true })),
123
173
  /** socket.onmessage 里把 clipboard.response 交给它;返回是否消费了这条消息。 */
124
174
  accept(message) {
175
+ if (message?.type === 'error' && message.code === 'DESKTOP_MESSAGE_UNSUPPORTED' && pending.size > 0) {
176
+ channel.rejectAll(clipError('CLIPBOARD_UPGRADE_REQUIRED', '当前云端版本尚不支持剪贴板,请更新云端助理后重试'));
177
+ return true;
178
+ }
125
179
  if (!message || message.type !== 'clipboard.response') return false;
126
180
  const entry = pending.get(String(message.requestId || ''));
127
181
  if (!entry) return false;
128
182
  settle(entry, () => {
129
- if (message.ok !== false) entry.resolve(message.value);
130
- else entry.reject(clipError(message.error?.code || 'CLIPBOARD_FAILED', message.error?.message || '', message.error));
183
+ if (Number.isSafeInteger(entry.epoch) && entry.epoch !== epochOf()) {
184
+ entry.reject(clipError('DESKTOP_CONTROL_EPOCH_CHANGED', '云电脑控制权已改变,本次剪贴板操作未完成')); return;
185
+ }
186
+ if (message.ok === false) {
187
+ entry.reject(clipError(message.error?.code || 'CLIPBOARD_FAILED', message.error?.message || '', message.error)); return;
188
+ }
189
+ const valid = entry.type === 'clipboard.read'
190
+ ? typeof message.value === 'string' || typeof message.value?.text === 'string'
191
+ : message.value?.written === true;
192
+ if (message.ok !== true || !valid) {
193
+ entry.reject(clipError('CLIPBOARD_RESPONSE_INVALID', '云端没有确认剪贴板操作成功,请重试')); return;
194
+ }
195
+ entry.resolve(message.value);
131
196
  });
132
197
  return true;
133
198
  },
@@ -137,6 +202,7 @@ export function createClipboardChannel({ send, timeoutMs = 5000, controlEpoch, s
137
202
  },
138
203
  get size() { return pending.size; },
139
204
  });
205
+ return channel;
140
206
  }
141
207
 
142
208
  /** 画布上的 ⌘V / Ctrl+V 是否该走"本地→云端"的粘贴流程(而不是把按键原样转给云桌面)。 */
@@ -159,14 +225,25 @@ export function isCopyShortcut(event) {
159
225
  * 粘贴:本地剪贴板 → 云电脑剪贴板 → 云桌面 Ctrl+V。
160
226
  * notify 负责把结果写进面板上的轻提示;返回结构化结果方便调用方/测试断言。
161
227
  */
162
- export async function pasteToCloud({ readLocal, writeCloud, sendKeys, notify = () => {} } = {}) {
228
+ export async function pasteToCloud({ readLocal, writeCloud, sendKeys, ensureControl, notify = () => {} } = {}) {
163
229
  const read = readLocal || (() => readLocalClipboard());
164
230
  let text = '';
165
231
  try { text = await read(); }
166
232
  catch (error) { notify(clipboardFriendlyError(error) || '浏览器不允许读取剪贴板,请按 ⌘V / Ctrl+V 再试'); return { ok: false, code: error?.code || 'CLIPBOARD_READ_FAILED' }; }
167
233
  if (!text) { notify('本地剪贴板是空的,没有可粘贴的内容'); return { ok: false, code: 'CLIPBOARD_EMPTY' }; }
168
234
  if (byteLength(text) > MAX_CLIPBOARD_BYTES) { notify('剪贴板内容超过 64 KiB,云电脑剪贴板放不下'); return { ok: false, code: 'CLIPBOARD_TOO_LARGE' }; }
169
- try { await writeCloud(text); }
235
+ if (typeof ensureControl === 'function') {
236
+ try {
237
+ if (!await ensureControl()) { notify('需要先接管云电脑,才能粘贴到云电脑'); return { ok: false, code: 'CLIPBOARD_CONTROL_REQUIRED' }; }
238
+ } catch (error) {
239
+ notify(clipboardFriendlyError(error) || '接管云电脑失败,请重试');
240
+ return { ok: false, code: error?.code || 'CLIPBOARD_CONTROL_REQUIRED' };
241
+ }
242
+ }
243
+ try {
244
+ const result = await writeCloud(text);
245
+ if (result?.written !== true) throw clipError('CLIPBOARD_RESPONSE_INVALID', '云端没有确认剪贴板写入成功,请重试');
246
+ }
170
247
  catch (error) { notify(clipboardFriendlyError(error) || '写入云电脑剪贴板失败,请重试'); return { ok: false, code: 'CLIPBOARD_WRITE_FAILED' }; }
171
248
  if (typeof sendKeys === 'function') {
172
249
  let sent = false;
@@ -180,8 +257,16 @@ export async function pasteToCloud({ readLocal, writeCloud, sendKeys, notify = (
180
257
  }
181
258
 
182
259
  /** 复制:云电脑剪贴板 → 本地剪贴板。 */
183
- export async function copyCloudClipboard({ readCloud, writeLocal, notify = () => {} } = {}) {
260
+ export async function copyCloudClipboard({ readCloud, writeLocal, ensureControl, notify = () => {} } = {}) {
184
261
  const write = writeLocal || (text => writeLocalClipboard(text));
262
+ if (typeof ensureControl === 'function') {
263
+ try {
264
+ if (!await ensureControl()) { notify('需要先接管云电脑,才能复制云电脑内容'); return { ok: false, code: 'CLIPBOARD_CONTROL_REQUIRED' }; }
265
+ } catch (error) {
266
+ notify(clipboardFriendlyError(error) || '接管云电脑失败,请重试');
267
+ return { ok: false, code: error?.code || 'CLIPBOARD_CONTROL_REQUIRED' };
268
+ }
269
+ }
185
270
  let value;
186
271
  try { value = await readCloud(); }
187
272
  catch (error) { notify(clipboardFriendlyError(error) || '读取云电脑剪贴板失败,请重试'); return { ok: false, code: 'CLIPBOARD_READ_FAILED' }; }
@@ -26,6 +26,33 @@
26
26
 
27
27
  import { renderMarkdown } from './markdown.mjs';
28
28
 
29
+ /**
30
+ * 宿主 DSH 的 MarkdownText 需要的 chrome 文案(代码块复制/折行、脚注)。
31
+ * 结构对齐 ui-chat 的 markdownLabels():缺了 code 会在遇到代码块时直接抛错。
32
+ */
33
+ const MARKDOWN_LABELS = {
34
+ code: {
35
+ copyLabel: '复制',
36
+ copiedLabel: '已复制',
37
+ toolbarLabels: { codeLabel: '代码', wrapLabel: '自动换行', unwrapLabel: '取消换行' },
38
+ },
39
+ footnotes: '脚注',
40
+ };
41
+
42
+ /**
43
+ * 渲染助理消息正文。
44
+ *
45
+ * 优先用宿主 DSH 暴露的原生 MarkdownText:micromark 解析 + DSH 原生 .markdown 样式,
46
+ * 表格、标题层级、代码高亮、链接行为与 DSH 会话一致,插件不再自己维护解析器。
47
+ * 宿主没有这个模块(老版本)时退回本目录 markdown.mjs 的极简渲染。
48
+ */
49
+ export function renderAssistantBody(h, text, styles, markdown) {
50
+ const MarkdownText = markdown?.MarkdownText;
51
+ const source = typeof text === 'string' ? text : '';
52
+ if (typeof MarkdownText === 'function') return h(MarkdownText, { text: source, labels: MARKDOWN_LABELS });
53
+ return renderMarkdown(h, source, styles) || '…';
54
+ }
55
+
29
56
  const EMPTY_HINT = '有什么可以帮你?';
30
57
  /** 这两类失败是“消息根本没开始跑”的瞬时状态:云端现在会按会话排队重试,
31
58
  * 历史会话里残留的记录不再渲染成任务失败,避免对话结束后一直显示红色失败。 */
@@ -159,6 +186,22 @@ const styles = {
159
186
 
160
187
  const text = value => (typeof value === 'string' ? value : '');
161
188
 
189
+ const SESSION_POINTER_PREFIX = 'agentkit.personal-assistant.current-session.v1';
190
+ const sessionPointerKey = scope => {
191
+ const agentId = text(scope?.agentId), botId = text(scope?.botId);
192
+ return agentId && botId ? `${SESSION_POINTER_PREFIX}:${encodeURIComponent(agentId)}:${encodeURIComponent(botId)}` : '';
193
+ };
194
+ const readSessionPointer = key => {
195
+ if (!key) return '';
196
+ try { return text(globalThis.localStorage?.getItem(key)); } catch { return ''; }
197
+ };
198
+ const writeSessionPointer = (key, sessionId) => {
199
+ const value = text(sessionId);
200
+ if (!key || !value) return;
201
+ try { globalThis.localStorage?.setItem(key, value); } catch { /* 宿主可能禁用存储,不影响云端会话 */ }
202
+ };
203
+ const sessionFailure = (code, message, status = 503) => Object.assign(new Error(message), { code, status });
204
+
162
205
  /** 与 DSH 原生对话框一致的发送箭头。 */
163
206
  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 },
164
207
  h('path', { d: 'M12 19V5M5 12l7-7 7 7' }));
@@ -167,13 +210,17 @@ const plusGlyph = h => h('svg', { width: 15, height: 15, viewBox: '0 0 24 24', f
167
210
  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 },
168
211
  h('path', { d: 'M6 6l12 12M18 6L6 18' }));
169
212
 
170
- export function PersonalAssistantConversation({ React: R, request, scope, onOpenComputer = null } = {}) {
213
+ export function PersonalAssistantConversation({ React: R, request, scope, markdown = undefined, onOpenComputer = null } = {}) {
171
214
  if (!R) return '个人助理对话尚未就绪,请重新打开';
172
215
  const { createElement: h, useCallback, useEffect, useRef, useState } = R;
216
+ // Hook 依赖稳定身份,避免父组件仅创建新的 scope 对象就重新初始化会话。
217
+ const pointerKey = sessionPointerKey(scope);
173
218
  const [phase, setPhase] = useState('loading');
174
219
  const [error, setError] = useState('');
175
220
  const [sessions, setSessions] = useState([]);
176
221
  const [sessionId, setSessionId] = useState('');
222
+ const [runtimeInstanceId, setRuntimeInstanceId] = useState('');
223
+ const [stateStorage, setStateStorage] = useState('');
177
224
  const [messages, setMessages] = useState([]);
178
225
  const [draft, setDraft] = useState('');
179
226
  const [attachments, setAttachments] = useState([]);
@@ -263,8 +310,15 @@ export function PersonalAssistantConversation({ React: R, request, scope, onOpen
263
310
  try {
264
311
  const snapshot = await call('personal-assistant.session.snapshot', { sessionId: id });
265
312
  if (!aliveRef.current || sessionRef.current !== id || generation !== generationRef.current) return;
266
- cursorRef.current = Number(snapshot?.cursor) || 0;
267
- const history = Array.isArray(snapshot?.messages) ? snapshot.messages : [];
313
+ if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)
314
+ || !Array.isArray(snapshot.messages) || !Array.isArray(snapshot.turns)
315
+ || !Number.isSafeInteger(snapshot.cursor) || snapshot.cursor < 0) {
316
+ throw sessionFailure('PERSONAL_ASSISTANT_SESSION_SNAPSHOT_INVALID', '云端会话快照格式无效,请重新同步');
317
+ }
318
+ setRuntimeInstanceId(text(snapshot?.runtimeInstanceId));
319
+ setStateStorage(text(snapshot?.stateStorage));
320
+ cursorRef.current = snapshot.cursor;
321
+ const history = snapshot.messages;
268
322
  setMessages(history.map((message, index) => ({
269
323
  id: text(message?.id) || `history-${index}`,
270
324
  role: text(message?.role) || 'assistant',
@@ -272,13 +326,37 @@ export function PersonalAssistantConversation({ React: R, request, scope, onOpen
272
326
  done: true,
273
327
  })));
274
328
  setAwaiting(snapshot.turns?.some(turn => ['queued', 'running'].includes(turn.status)) || false);
329
+ writeSessionPointer(pointerKey, id);
275
330
  setPhase('ready');
276
331
  } catch (failure) {
277
332
  if (!aliveRef.current || generation !== generationRef.current) return;
333
+ // 找不到快照也可能是重启/状态卷短暂不可见;保留指针,只由显式选择
334
+ // 或新建成功替换,避免下次初始化把历史缺失误认成首次使用。
278
335
  setPhase('error');
279
336
  setError(failure?.message || '无法读取会话内容');
280
337
  }
281
- }, [call]);
338
+ }, [call, pointerKey]);
339
+
340
+ const createSession = useCallback(async () => {
341
+ if (!aliveRef.current) return;
342
+ if (sendingRef.current || awaitingRef.current || queueRef.current.length > 0) {
343
+ setQueueNotice('当前会话还有进行中的任务,请等待完成后再新建会话');
344
+ return;
345
+ }
346
+ setPhase('loading');
347
+ setError('');
348
+ try {
349
+ const created = await call('personal-assistant.session.create', {});
350
+ const createdId = text(created?.sessionId);
351
+ if (!createdId) throw sessionFailure('PERSONAL_ASSISTANT_SESSION_CREATE_INVALID', '云端没有返回有效的新会话标识');
352
+ setSessions(previous => [...previous.filter(row => text(row?.sessionId) !== createdId), { sessionId: createdId, title: text(created?.title) || '新会话' }]);
353
+ await openSession(createdId);
354
+ } catch (failure) {
355
+ if (!aliveRef.current) return;
356
+ setPhase('error');
357
+ setError(failure?.message || '新会话创建失败,请重试');
358
+ }
359
+ }, [call, openSession]);
282
360
 
283
361
  const bootstrap = useCallback(async () => {
284
362
  setPhase('loading');
@@ -287,20 +365,39 @@ export function PersonalAssistantConversation({ React: R, request, scope, onOpen
287
365
  try {
288
366
  const listed = await call('personal-assistant.sessions.list');
289
367
  if (!aliveRef.current) return;
290
- const available = Array.isArray(listed?.sessions) ? listed.sessions : [];
368
+ if (!Array.isArray(listed?.sessions)) throw sessionFailure('PERSONAL_ASSISTANT_SESSION_LIST_INVALID', '云端会话目录格式无效,请重试');
369
+ const sessionIds = new Set();
370
+ if (listed.sessions.some(row => {
371
+ const id = text(row?.sessionId);
372
+ if (!row || typeof row !== 'object' || !id || sessionIds.has(id)) return true;
373
+ sessionIds.add(id);
374
+ return false;
375
+ })) {
376
+ throw sessionFailure('PERSONAL_ASSISTANT_SESSION_LIST_INVALID', '云端会话目录包含无效会话,请重试');
377
+ }
378
+ const available = listed.sessions;
379
+ setRuntimeInstanceId(text(listed.runtimeInstanceId));
380
+ setStateStorage(text(listed.stateStorage));
291
381
  setSessions(available);
292
- if (available.length > 0) { await openSession(text(available[0].sessionId)); return; }
293
- const created = await call('personal-assistant.session.create', {});
294
- if (!aliveRef.current) return;
295
- const createdId = text(created?.sessionId);
296
- setSessions(createdId ? [{ sessionId: createdId, title: text(created?.title) || '新会话' }] : []);
297
- await openSession(createdId);
382
+ const remembered = readSessionPointer(pointerKey);
383
+ // 即使目录里还有其他会话,也不能悄悄换过去掩盖当前会话丢失。
384
+ if (remembered && !available.some(row => text(row.sessionId) === remembered)) {
385
+ throw sessionFailure('PERSONAL_ASSISTANT_SESSION_STATE_MISSING', '云端历史会话暂时不可见,请检查实例状态后重试,或手动选择会话');
386
+ }
387
+ if (available.length > 0) {
388
+ const selected = available.find(row => text(row.sessionId) === remembered) || available[0];
389
+ await openSession(text(selected.sessionId));
390
+ return;
391
+ }
392
+ // 有过成功打开的会话却突然拿到空目录,通常是 PVC/状态目录/Bot 范围出了问题。
393
+ // 不再静默创建一个空会话;用户可以通过头部的“新任务”显式确认创建。
394
+ await createSession();
298
395
  } catch (failure) {
299
396
  if (!aliveRef.current) return;
300
397
  setPhase('error');
301
398
  setError(failure?.message || '会话通道暂时不可用');
302
399
  }
303
- }, [call, openSession]);
400
+ }, [call, createSession, openSession, pointerKey]);
304
401
 
305
402
  useEffect(() => {
306
403
  aliveRef.current = true;
@@ -320,23 +417,26 @@ export function PersonalAssistantConversation({ React: R, request, scope, onOpen
320
417
  try {
321
418
  const page = await call('personal-assistant.session.events', { sessionId, cursor: cursorRef.current });
322
419
  if (cancelled) return;
323
- cursorRef.current = Number(page?.cursor ?? cursorRef.current) || 0;
324
- mergeEvents(page?.events);
325
- if (Array.isArray(page.turns)) {
326
- setAwaiting(page.turns.some(turn => ['queued', 'running'].includes(turn.status)));
327
- const active = page.turns.findLast(turn => ['queued', 'running'].includes(turn.status));
328
- const labels = { cloud_computer: '操作云电脑', cloud_workspace: '处理云端文件', cloud_bash: '执行云端命令', cloud_run_code: '运行云端代码', assistant_schedule: '设置云端任务' };
329
- const tool = active?.progress?.tool;
330
- setProgressText(tool ? `${active.progress.state === 'running' ? '正在' : '已完成'}${labels[tool] || (tool.startsWith('cloud_browser_') ? '操作云端浏览器' : '执行工具')}` : '');
331
- const visibleFailures = page.turns
332
- .filter(turn => ['failed', 'interrupted', 'cancelled'].includes(turn.status) && !TRANSIENT_TURN_ERRORS.has(String(turn.error || '')))
333
- .map(turn => ({ type: 'step', id: `turn-status-${turn.turnId}`, text: `任务${turn.status === 'failed' ? '失败' : '已中断'}:${turnErrorText(turn.error)}` }));
334
- mergeEvents(visibleFailures);
420
+ if (!page || typeof page !== 'object' || Array.isArray(page) || !Array.isArray(page.events)
421
+ || !Array.isArray(page.turns) || !Number.isSafeInteger(page.cursor)) {
422
+ throw sessionFailure('PERSONAL_ASSISTANT_SESSION_EVENTS_INVALID', '云端会话增量格式无效,请重新同步');
335
423
  }
424
+ if (page.cursor < cursorRef.current) throw sessionFailure('SESSION_CURSOR_STALE', '会话日志已更新,请重新同步', 409);
425
+ cursorRef.current = page.cursor;
426
+ mergeEvents(page.events);
427
+ setAwaiting(page.turns.some(turn => ['queued', 'running'].includes(turn.status)));
428
+ const active = page.turns.findLast(turn => ['queued', 'running'].includes(turn.status));
429
+ const labels = { cloud_computer: '操作云电脑', cloud_workspace: '处理云端文件', cloud_bash: '执行云端命令', cloud_run_code: '运行云端代码', assistant_schedule: '设置云端任务' };
430
+ const tool = active?.progress?.tool;
431
+ setProgressText(tool ? `${active.progress.state === 'running' ? '正在' : '已完成'}${labels[tool] || (tool.startsWith('cloud_browser_') ? '操作云端浏览器' : '执行工具')}` : '');
432
+ const visibleFailures = page.turns
433
+ .filter(turn => ['failed', 'interrupted', 'cancelled'].includes(turn.status) && !TRANSIENT_TURN_ERRORS.has(String(turn.error || '')))
434
+ .map(turn => ({ type: 'step', id: `turn-status-${turn.turnId}`, text: `任务${turn.status === 'failed' ? '失败' : '已中断'}:${turnErrorText(turn.error)}` }));
435
+ mergeEvents(visibleFailures);
336
436
  setError('');
337
437
  } catch (failure) {
338
438
  if (!cancelled) {
339
- if (failure.code === 'SESSION_CURSOR_STALE') { void openSession(sessionId); return; }
439
+ if (failure.code === 'SESSION_CURSOR_STALE' || failure.code === 'PERSONAL_ASSISTANT_SESSION_EVENTS_INVALID') { void openSession(sessionId); return; }
340
440
  setError(failure.message || '云端连接中断,正在恢复;任务不会改到本机执行');
341
441
  }
342
442
  }
@@ -488,13 +588,23 @@ export function PersonalAssistantConversation({ React: R, request, scope, onOpen
488
588
  return () => window.removeEventListener('beforeunload', warn);
489
589
  }, [queue.length]);
490
590
 
491
- const header = h('header', { style: styles.header },
591
+ const sessionScopeTitle = [
592
+ sessionId ? '当前助理线程已持久化' : '',
593
+ runtimeInstanceId ? `运行实例 ${runtimeInstanceId}` : '',
594
+ stateStorage ? `状态 ${stateStorage}` : '',
595
+ ].filter(Boolean).join(' · ');
596
+
597
+ // Studio(macOS) 的拖拽区靠 data-window-drag 标记:PA 页面没有外壳 tab strip,需要自己的头部行。
598
+ const header = h('header', { style: styles.header, 'data-window-drag': '' },
492
599
  h('h1', { style: styles.title }, '个人助理'),
493
- h('span', { style: styles.status }, phase === 'ready' ? '云端常驻,关掉窗口也会继续' : phase === 'loading' ? '正在连接…' : '连接不可用'),
600
+ h('span', { style: styles.status, title: sessionScopeTitle || undefined }, phase === 'ready' ? '云端常驻,关掉窗口也会继续' : phase === 'loading' ? '正在连接…' : '连接不可用'),
494
601
  h('span', { style: styles.spacer }),
495
- 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);
602
+ // 会话目录是云端恢复/多 Bot 隔离的后台能力,个人助理主界面只保留一条连续线程。
603
+ // 不把 sessionId 下拉框暴露给用户,避免把基础设施概念变成日常管理负担。
604
+ h('button', { type: 'button', 'data-window-drag-recall': '', disabled: phase === 'loading' || sending || awaiting || queue.length > 0, onClick: () => void createSession(), 'aria-label': '开始新任务', title: queue.length > 0 ? '请等待排队消息发送后再开始新任务' : '开始一个新的助理任务线程', style: styles.ghost }, '新任务'),
605
+ onOpenComputer ? h('button', { type: 'button', 'data-window-drag-recall': '', 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);
496
606
 
497
- return h('section', { 'data-personal-assistant-conversation': 'true', style: styles.page, onClick: async event => {
607
+ return h('section', { 'data-personal-assistant-conversation': 'true', 'data-agent-id': text(scope?.agentId) || undefined, 'data-bot-id': text(scope?.botId) || undefined, 'data-session-id': sessionId || undefined, 'data-runtime-instance-id': runtimeInstanceId || undefined, 'data-state-storage': stateStorage || undefined, style: styles.page, onClick: async event => {
498
608
  const link = event.target.closest?.('a[href^="cloud-file:"]');
499
609
  if (!link) return;
500
610
  event.preventDefault();
@@ -507,7 +617,9 @@ export function PersonalAssistantConversation({ React: R, request, scope, onOpen
507
617
  } catch (failure) { setError(failure.message || '云端文件下载失败'); }
508
618
  } }, header,
509
619
  h('style', null, '@keyframes pa-awaiting { 0%, 100% { opacity: 0.35 } 50% { opacity: 1 } }'),
510
- error && messages.length > 0 ? h('div', { role: 'alert', style: { padding: '8px 16px', background: '#fff0d6' } }, error) : null,
620
+ error && messages.length > 0 ? h('div', { role: 'alert', style: { padding: '8px 16px', background: '#fff0d6', display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' } },
621
+ h('span', null, error),
622
+ sessionId ? h('button', { type: 'button', style: styles.ghost, disabled: phase === 'loading', onClick: () => void openSession(sessionId) }, '重新同步会话') : null) : null,
511
623
  h('div', { ref: scrollerRef, 'data-personal-assistant-messages': 'true', style: styles.scroller, onScroll: event => {
512
624
  const element = event.currentTarget;
513
625
  followLatestRef.current = element.scrollHeight - element.clientHeight - element.scrollTop < 64;
@@ -524,7 +636,7 @@ export function PersonalAssistantConversation({ React: R, request, scope, onOpen
524
636
  return h('div', { key: message.id, style: mine ? styles.rowUser : styles.row },
525
637
  h('span', { style: styles.who }, mine ? '你' : '助理'),
526
638
  h('div', { style: mine ? styles.bubbleUser : styles.bubble },
527
- mine ? (message.text || '…') : (renderMarkdown(h, message.text || '', styles) || '…')),
639
+ mine ? (message.text || '…') : renderAssistantBody(h, message.text, styles, markdown)),
528
640
  message.status === 'failed' ? h('button', { onClick: () => void submit(outboxRef.current.get(message.clientTurnId)), disabled: sending }, `${message.failure} · 重试发送`) : null);
529
641
  }),
530
642
  awaiting ? h('div', { key: 'awaiting', style: styles.row },
@@ -5,6 +5,12 @@ export const DESKTOP_STREAM_IDLE_MS = 10_000;
5
5
  export const DESKTOP_ACTIVE_POLL_MS = 100;
6
6
  export const DESKTOP_IDLE_POLL_MS = 1_000;
7
7
  export const DESKTOP_MOUSE_HZ = 30;
8
+ /**
9
+ * 滚轮一个"刻度"对应的像素距离:runtime 把 wheel 的 deltaY 当成刻度数转成
10
+ * `xdotool click --repeat N`,所以客户端必须先把像素累计成刻度再发。
11
+ * 鼠标滚轮一格通常 100px 左右(正好 1 次),触控板平滑滚动的碎事件靠累计凑满。
12
+ */
13
+ export const WHEEL_PIXEL_STEP = 100;
8
14
 
9
15
  export function normalizeDisplay(display = {}) {
10
16
  const width = Number(display.width) || DEFAULT_DESKTOP_DISPLAY.width;
@@ -30,6 +36,22 @@ export function mapDisplayPoint({ clientX, clientY, rect, display = DEFAULT_DESK
30
36
  return { x, y };
31
37
  }
32
38
 
39
+ /**
40
+ * 把远端桌面按 contain 方式装进可用区域:同时受可用宽、可用高与 maxWidth 约束,
41
+ * 保证整幅画面(含顶部输入条)永远完整可见,永远不会被容器或滚动位置切掉。
42
+ * 与 mapDisplayPoint 使用同一套 contain 数学,因此点击映射始终按真实 rect 计算。
43
+ */
44
+ export function fitDesktopBox({ availableWidth, availableHeight, display = DEFAULT_DESKTOP_DISPLAY, maxWidth = Number.POSITIVE_INFINITY } = {}) {
45
+ const target = normalizeDisplay(display);
46
+ const width = Number(availableWidth) > 0 ? Number(availableWidth) : 0;
47
+ const height = Number(availableHeight) > 0 ? Number(availableHeight) : 0;
48
+ const cap = Number(maxWidth) > 0 ? Number(maxWidth) : Number.POSITIVE_INFINITY;
49
+ if (!width || !height) return { width: 0, height: 0 };
50
+ const boxWidth = Math.min(width, cap, height * (target.width / target.height));
51
+ const round = value => Math.round(value * 100) / 100;
52
+ return { width: round(boxWidth), height: round(boxWidth * (target.height / target.width)) };
53
+ }
54
+
33
55
  export function framePayload(frame) {
34
56
  if (!frame || typeof frame !== 'object') return null;
35
57
  const seq = Number(frame.seq);
@@ -66,3 +88,20 @@ export function isLeaseConflict(error) {
66
88
  export function isInputEventWritable({ connected, readOnly } = {}) {
67
89
  return Boolean(connected && !readOnly);
68
90
  }
91
+
92
+ /**
93
+ * 把同一动画帧内的多个原生 wheel 事件合并成一次远端输入。
94
+ * pending 保存"还不够一格"的像素余数(跨帧保留,触控板碎事件不会丢),
95
+ * 返回的 clicks 是本次要发的刻度数(0 表示这一帧不用发,省掉一次远端 xdotool)。
96
+ */
97
+ export function mergeWheel(pending = { x: 0, y: 0 }, { deltaX = 0, deltaY = 0 } = {}, step = WHEEL_PIXEL_STEP) {
98
+ const size = Number(step) > 0 ? Number(step) : WHEEL_PIXEL_STEP;
99
+ const x = (Number(pending?.x) || 0) + (Number(deltaX) || 0);
100
+ const y = (Number(pending?.y) || 0) + (Number(deltaY) || 0);
101
+ const clicksX = Math.trunc(x / size);
102
+ const clicksY = Math.trunc(y / size);
103
+ return {
104
+ pending: Object.freeze({ x: x - clicksX * size, y: y - clicksY * size }),
105
+ clicks: Object.freeze({ deltaX: clicksX, deltaY: clicksY }),
106
+ };
107
+ }
@@ -1,8 +1,18 @@
1
- import { apply, inject, setClientReactRuntime } from './index.mjs';
1
+ import { apply, inject, setClientMarkdownRuntime, setClientReactRuntime } from './index.mjs';
2
+
2
3
  window.__ModuleLoader__.load({
3
4
  id: '@kingsoftcloud/agentkit-personal-assistant',
4
5
  factory: require => {
5
6
  setClientReactRuntime(require('react'));
7
+ // 宿主 DSH 的 ui-primitives 暴露原生 Markdown 渲染器(MarkdownText);
8
+ // 老宿主没有这个模块时传 undefined,对话视图自动退回自带极简渲染。
9
+ let primitives;
10
+ try {
11
+ primitives = require('@deepseek-ai/dsh-client-ui-primitives');
12
+ } catch {
13
+ primitives = undefined;
14
+ }
15
+ setClientMarkdownRuntime(primitives);
6
16
  return { name: 'agentkit-personal-assistant', inject, apply };
7
17
  },
8
18
  });