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

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 +733 -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 +281 -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,157 @@
1
+ /**
2
+ * Multi-Bot registry for one personal-assistant profile.
3
+ *
4
+ * The registry owns Bot definitions. The DSH bridge installs each Bot prompt;
5
+ * its scoped tool guard enforces this registry's current policy on every call.
6
+ * Native session execution and approval decisions remain owned by DSH.
7
+ */
8
+
9
+ const BOT_ID_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/u;
10
+ const PROFILE_PATTERN = /^[a-zA-Z0-9._:-]{1,128}$/u;
11
+ const POLICY_MODES = new Set(['allowlist', 'denylist']);
12
+
13
+ function requiredString(value, field) {
14
+ const text = String(value ?? '').trim();
15
+ if (!text) throw new TypeError(`${field} must be a non-empty string`);
16
+ return text;
17
+ }
18
+
19
+ function identifier(value, field, pattern) {
20
+ const text = requiredString(value, field);
21
+ if (!pattern.test(text)) throw new TypeError(`${field} has an invalid format`);
22
+ return text;
23
+ }
24
+
25
+ function uniqueStrings(value, field) {
26
+ if (value === undefined) return [];
27
+ if (!Array.isArray(value)) throw new TypeError(`${field} must be an array of strings`);
28
+ const result = [];
29
+ const seen = new Set();
30
+ for (const item of value) {
31
+ const text = requiredString(item, `${field} item`);
32
+ if (!seen.has(text)) {
33
+ seen.add(text);
34
+ result.push(text);
35
+ }
36
+ }
37
+ return result;
38
+ }
39
+
40
+ function normalizeToolPolicy(policy = {}) {
41
+ if (!policy || typeof policy !== 'object' || Array.isArray(policy)) {
42
+ throw new TypeError('toolPolicy must be an object');
43
+ }
44
+ const mode = policy.mode === undefined ? 'allowlist' : requiredString(policy.mode, 'toolPolicy.mode');
45
+ if (!POLICY_MODES.has(mode)) throw new TypeError('toolPolicy.mode must be allowlist or denylist');
46
+ return Object.freeze({
47
+ mode,
48
+ allow: Object.freeze(uniqueStrings(policy.allow, 'toolPolicy.allow')),
49
+ deny: Object.freeze(uniqueStrings(policy.deny, 'toolPolicy.deny')),
50
+ requireApproval: Object.freeze(uniqueStrings(policy.requireApproval, 'toolPolicy.requireApproval')),
51
+ });
52
+ }
53
+
54
+ function freezeDefinition(definition) {
55
+ return Object.freeze({
56
+ ...definition,
57
+ toolPolicy: normalizeToolPolicy(definition.toolPolicy),
58
+ });
59
+ }
60
+
61
+ /**
62
+ * @param {object} input
63
+ * @param {string} [profileId]
64
+ * @returns {Readonly<{botId:string,displayName:string,systemPromptRevision:string,systemPrompt?:string,memoryNamespace:string,toolPolicy:object,metadata:object}>}
65
+ */
66
+ export function normalizeBotDefinition(input = {}, profileId = 'default') {
67
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw new TypeError('Bot definition must be an object');
68
+ const normalizedProfile = identifier(profileId, 'profileId', PROFILE_PATTERN);
69
+ const botId = identifier(input.botId ?? input.id, 'botId', BOT_ID_PATTERN);
70
+ const displayName = requiredString(input.displayName ?? input.name ?? botId, 'displayName');
71
+ const systemPromptRevision = requiredString(input.systemPromptRevision, 'systemPromptRevision');
72
+ const systemPrompt = input.systemPrompt === undefined ? undefined : requiredString(input.systemPrompt, 'systemPrompt');
73
+ const agentPreset = input.agentPreset === undefined ? undefined : requiredString(input.agentPreset, 'agentPreset');
74
+ const memoryNamespace = input.memoryNamespace === undefined
75
+ ? `assistant:${normalizedProfile}:bot:${botId}`
76
+ : requiredString(input.memoryNamespace, 'memoryNamespace');
77
+ const metadata = input.metadata === undefined ? {} : input.metadata;
78
+ if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) throw new TypeError('metadata must be an object');
79
+ return freezeDefinition({
80
+ botId,
81
+ displayName,
82
+ systemPromptRevision,
83
+ ...(systemPrompt === undefined ? {} : { systemPrompt }),
84
+ ...(agentPreset === undefined ? {} : { agentPreset }),
85
+ memoryNamespace,
86
+ toolPolicy: input.toolPolicy ?? {},
87
+ metadata: Object.freeze({ ...metadata }),
88
+ });
89
+ }
90
+
91
+ export class BotRegistry {
92
+ constructor({ profileId = 'default', bots = [] } = {}) {
93
+ this.profileId = identifier(profileId, 'profileId', PROFILE_PATTERN);
94
+ this._bots = new Map();
95
+ for (const definition of bots) this.register(definition);
96
+ }
97
+
98
+ register(definition) {
99
+ const bot = normalizeBotDefinition(definition, this.profileId);
100
+ if (this._bots.has(bot.botId)) throw new Error(`BOT_ALREADY_EXISTS: ${bot.botId}`);
101
+ this._bots.set(bot.botId, bot);
102
+ return bot;
103
+ }
104
+
105
+ upsert(definition) {
106
+ const bot = normalizeBotDefinition(definition, this.profileId);
107
+ this._bots.set(bot.botId, bot);
108
+ return bot;
109
+ }
110
+
111
+ get(botId) {
112
+ const id = identifier(botId, 'botId', BOT_ID_PATTERN);
113
+ return this._bots.get(id);
114
+ }
115
+
116
+ require(botId) {
117
+ const bot = this.get(botId);
118
+ if (!bot) throw new Error(`BOT_NOT_FOUND: ${botId}`);
119
+ return bot;
120
+ }
121
+
122
+ has(botId) {
123
+ return this._bots.has(String(botId ?? '').trim());
124
+ }
125
+
126
+ remove(botId) {
127
+ const id = identifier(botId, 'botId', BOT_ID_PATTERN);
128
+ return this._bots.delete(id);
129
+ }
130
+
131
+ list() {
132
+ return [...this._bots.values()];
133
+ }
134
+
135
+ resolve(botId) {
136
+ const bot = this.require(botId);
137
+ return {
138
+ botId: bot.botId,
139
+ systemPromptRevision: bot.systemPromptRevision,
140
+ ...(bot.systemPrompt === undefined ? {} : { systemPrompt: bot.systemPrompt }),
141
+ ...(bot.agentPreset === undefined ? {} : { agentPreset: bot.agentPreset }),
142
+ memoryNamespace: bot.memoryNamespace,
143
+ toolPolicy: bot.toolPolicy,
144
+ };
145
+ }
146
+
147
+ snapshot() {
148
+ return Object.freeze({
149
+ profileId: this.profileId,
150
+ bots: Object.freeze(this.list().map(bot => ({ ...bot, toolPolicy: { ...bot.toolPolicy } }))),
151
+ });
152
+ }
153
+ }
154
+
155
+ export function createBotRegistry(options) {
156
+ return new BotRegistry(options);
157
+ }
@@ -0,0 +1,48 @@
1
+ import { CLOUD_BROWSER_TOOL_NAMES, CLOUD_EXECUTION_TOOL_NAMES, CLOUD_DESKTOP_TOOL_NAMES } from './cloud-computer-tools.mjs';
2
+
3
+ export const PA_SAFE_TOOL_NAMES = Object.freeze([...CLOUD_BROWSER_TOOL_NAMES, ...CLOUD_EXECUTION_TOOL_NAMES, ...CLOUD_DESKTOP_TOOL_NAMES, 'assistant_schedule', 'bot_memory_search', 'bot_memory_save', 'bot_plan_update']);
4
+ const safe = new Set(PA_SAFE_TOOL_NAMES);
5
+
6
+ /** deny 优先;空 allowlist 是零权限。任何 Bot 配置都不能扩大固定 PA 安全面。 */
7
+ export function personalAssistantToolDecision(policy, name) {
8
+ if (!safe.has(name)) return { allowed: false, code: 'PERSONAL_ASSISTANT_HOST_TOOL_FORBIDDEN' };
9
+ if (!policy || !['allowlist', 'denylist'].includes(policy.mode) || !['allow', 'deny', 'requireApproval'].every(key => Array.isArray(policy[key]) && policy[key].every(value => typeof value === 'string'))) {
10
+ return { allowed: false, code: 'BOT_TOOL_POLICY_INVALID' };
11
+ }
12
+ if (policy.deny.includes(name) || (policy.mode === 'allowlist' && !policy.allow.includes(name))) return { allowed: false, code: 'BOT_TOOL_POLICY_DENIED' };
13
+ return { allowed: true, requireApproval: policy.requireApproval.includes(name) };
14
+ }
15
+
16
+ const guidance = {
17
+ PERSONAL_ASSISTANT_HOST_TOOL_FORBIDDEN: '此工具不属于云电脑或 Bot 私有能力,禁止调用 Host 工具。',
18
+ BOT_TOOL_POLICY_INVALID: 'Bot 工具策略无效;请由用户检查 mode、allow、deny 和 requireApproval。',
19
+ BOT_TOOL_POLICY_DENIED: '当前 Bot 的工具策略禁止此调用;请由用户检查该 Bot 的允许/拒绝名单。',
20
+ BOT_TOOL_APPROVAL_REQUIRED: '当前执行没有一次性宿主批准;请通过宿主批准通道重新发起调用。',
21
+ BOT_TOOL_APPROVAL_UNAVAILABLE: '宿主批准通道不可用,工具未执行;请配置能够答复此云端会话的真实批准通道后重试。',
22
+ BOT_TOOL_APPROVAL_REJECTED: '宿主拒绝了本次调用,工具未执行;需要新的明确批准才能重试。',
23
+ BOT_TOOL_APPROVAL_CANCELLED: '批准请求或工具调用已取消,工具未执行;需要重新发起调用。',
24
+ BOT_TOOL_APPROVAL_TIMEOUT: '批准请求在 30 秒内没有完成,工具未执行;请连接批准通道后重新发起调用。',
25
+ };
26
+ export const policyReason = code => `${code}: ${guidance[code] || '工具未获授权。'}`;
27
+ export const policyDenial = code => ({ kind: 'deny', reason: policyReason(code), info: { name: 'PersonalAssistantPolicyError', code, reason: guidance[code] } });
28
+
29
+ /** 只消费真实 DSH approval.request 的闭集结果,超时与取消不能产生授权。 */
30
+ export async function requestBotToolApproval({ agent, execution, approval, timeoutMs = 30_000 }) {
31
+ if (typeof approval?.request !== 'function') return { granted: false, code: 'BOT_TOOL_APPROVAL_UNAVAILABLE' };
32
+ if (execution.signal?.aborted) return { granted: false, code: 'BOT_TOOL_APPROVAL_CANCELLED' };
33
+ const deadline = new AbortController();
34
+ const signal = execution.signal ? AbortSignal.any([execution.signal, deadline.signal]) : deadline.signal;
35
+ let timer;
36
+ const timeout = new Promise(resolve => { timer = setTimeout(() => { deadline.abort(); resolve('timeout'); }, timeoutMs); });
37
+ let onAbort;
38
+ const cancelled = execution.signal ? new Promise(resolve => { onAbort = () => resolve('cancelled'); execution.signal.addEventListener('abort', onAbort, { once: true }); }) : undefined;
39
+ const request = Promise.resolve().then(() => approval.request({ agent: execution.agent || agent, toolName: execution.name,
40
+ callId: execution.callId, reason: '此 Bot 的 requireApproval 要求当前工具调用获得一次性批准。', signal })).catch(() => 'unavailable');
41
+ let outcome;
42
+ try { outcome = await Promise.race([request, timeout, ...(cancelled ? [cancelled] : [])]); }
43
+ finally { clearTimeout(timer); if (onAbort) execution.signal.removeEventListener('abort', onAbort); }
44
+ if (deadline.signal.aborted) return { granted: false, code: 'BOT_TOOL_APPROVAL_TIMEOUT' };
45
+ if (signal.aborted && outcome !== 'timeout') return { granted: false, code: 'BOT_TOOL_APPROVAL_CANCELLED' };
46
+ if (outcome === 'allowed-once') return { granted: true };
47
+ return { granted: false, code: ({ rejected: 'BOT_TOOL_APPROVAL_REJECTED', cancelled: 'BOT_TOOL_APPROVAL_CANCELLED', timeout: 'BOT_TOOL_APPROVAL_TIMEOUT' })[outcome] || 'BOT_TOOL_APPROVAL_UNAVAILABLE' };
48
+ }
@@ -0,0 +1,40 @@
1
+ import { personalAssistantToolDecision, policyDenial, policyReason, requestBotToolApproval } from './bot-tool-policy.mjs';
2
+
3
+ /** PA 的唯一工具边界:继承层过滤 + own-layer 服务端守卫 + scoped 模型呈现过滤。 */
4
+ export function restrictPersonalAssistantToolScope(agent, botId, { getPolicy = () => ({ mode: 'allowlist', allow: [], deny: [], requireApproval: [] }), approvalTimeoutMs = 30_000 } = {}) {
5
+ const tools = agent?.ctx?.get?.('tools');
6
+ if (typeof tools?.restrict !== 'function' || typeof tools?.guard !== 'function' || typeof agent.ctx.on !== 'function') {
7
+ throw Object.assign(new Error('Personal Assistant requires scoped tool restrictions and guards'), { code: 'PERSONAL_ASSISTANT_TOOL_SCOPE_UNAVAILABLE', status: 503 });
8
+ }
9
+ const granted = new Set();
10
+ const decide = name => { try { return personalAssistantToolDecision(getPolicy(), name); } catch { return { allowed: false, code: 'BOT_TOOL_POLICY_INVALID' }; } };
11
+ tools.restrict({ allow: [] });
12
+ agent.ctx.on('tools/pre-execute', async (execution, next) => {
13
+ const decision = decide(execution.name);
14
+ if (!decision.allowed) return policyDenial(decision.code);
15
+ const upstream = await next();
16
+ if (upstream.kind === 'deny' || upstream.kind === 'cancel' || !decision.requireApproval) return upstream;
17
+ if (!['allow', 'ask'].includes(upstream.kind)) return policyDenial('BOT_TOOL_POLICY_INVALID');
18
+ if (typeof execution.token !== 'symbol') return policyDenial('BOT_TOOL_APPROVAL_REQUIRED');
19
+ const approval = agent.ctx.get('approval');
20
+ const result = await requestBotToolApproval({ agent, execution, approval, timeoutMs: approvalTimeoutMs });
21
+ console.info(JSON.stringify({ event: 'personal-assistant.tool.approval', botId, tool: execution.name, outcome: result.granted ? 'allowed-once' : result.code }));
22
+ if (!result.granted) return policyDenial(result.code);
23
+ granted.add(execution.token);
24
+ // 同一真实批准同时满足已有 ask;不把尚未答复的 ask 转成允许。
25
+ return { kind: 'allow' };
26
+ });
27
+ tools.guard(execution => {
28
+ const decision = decide(execution.name);
29
+ const approvedOnce = granted.delete(execution.token);
30
+ const code = !decision.allowed ? decision.code : decision.requireApproval && !approvedOnce ? 'BOT_TOOL_APPROVAL_REQUIRED' : undefined;
31
+ if (!code) return undefined;
32
+ console.warn(JSON.stringify({ event: 'personal-assistant.tool.denied', botId, tool: execution.name, code }));
33
+ return policyReason(code);
34
+ });
35
+ agent.ctx.on('tools/result', execution => { granted.delete(execution.token); });
36
+ agent.ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
37
+ const result = await next();
38
+ return { ...result, tools: result.tools.filter(tool => decide(tool.name).allowed) };
39
+ });
40
+ }
@@ -0,0 +1,23 @@
1
+ /** 仅传输目录展示字段;系统提示词、工具策略与凭据不进入浏览器。 */
2
+ export const DIRECTORY_BOT_ID = '__directory__';
3
+ export function publicBotDirectory(rows) {
4
+ if (!Array.isArray(rows) || rows.length > 128) throw new TypeError('助理目录格式无效');
5
+ const seen = new Set();
6
+ return rows.map(row => {
7
+ const botId = String(row?.botId || '').trim();
8
+ const displayName = String(row?.displayName || botId).trim();
9
+ if (!botId || botId.length > 64 || /[\u0000-\u001f]/u.test(botId) || botId === DIRECTORY_BOT_ID || seen.has(botId) || !displayName || displayName.length > 256) throw new TypeError('助理目录格式无效');
10
+ seen.add(botId);
11
+ return { botId, displayName: botId === 'default' && displayName === 'Personal Assistant' ? '默认助理' : botId === 'researcher' && displayName === 'Researcher' ? '研究助理' : displayName };
12
+ });
13
+ }
14
+
15
+ /** 系统确定唯一绑定;不把内部逻辑 Bot 变成用户必须选择的产品分类。 */
16
+ export function resolveAssistantBotId(rows, boundBotId = '') {
17
+ const bots = publicBotDirectory(rows);
18
+ const bound = String(boundBotId || '').trim();
19
+ const selected = bound ? bots.find(bot => bot.botId === bound)
20
+ : bots.find(bot => bot.botId === 'default') || (bots.length === 1 ? bots[0] : undefined);
21
+ if (!selected) throw Object.assign(new Error('云端助理绑定尚未就绪,请稍后重试'), { code: 'PERSONAL_ASSISTANT_BINDING_UNAVAILABLE' });
22
+ return selected.botId;
23
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * 云电脑剪贴板:本地 ⇄ 云端的两个可见入口。
3
+ *
4
+ * 云端桌面是 Linux(Xvfb + fluxbox),宿主可能是 macOS,所以「粘贴」永远是两步:
5
+ * 1) 读**本地**剪贴板(navigator.clipboard.readText,浏览器可能直接拒绝);
6
+ * 2) 写**云电脑**剪贴板(computer.action / clipboard.write),再向云桌面合成一次
7
+ * **Ctrl+V**(不是 ⌘V)——云桌面不认识 macOS 的 Command 键。
8
+ * 「复制」方向相反:读云电脑剪贴板 → 写本地剪贴板。
9
+ *
10
+ * 所有外部能力都从 props 注入(readCloud / writeCloud / sendKeys),因此这个模块既能被
11
+ * 面板直接渲染,也能在 Node 测试里跑真实的编排逻辑,不需要浏览器环境。
12
+ */
13
+
14
+ /** 云端 desktop-session 的剪贴板上限是 64 KiB,客户端先拦一次,避免发一半才 413。 */
15
+ export const MAX_CLIPBOARD_BYTES = 64 * 1024;
16
+
17
+ const clipError = (code, message, cause) => Object.assign(new Error(message), { code, cause });
18
+
19
+ const byteLength = value => {
20
+ try { return new TextEncoder().encode(value).length; }
21
+ catch { return value.length; }
22
+ };
23
+
24
+ /** clipboardRead() 兼容 `{ text }` 与裸字符串两种返回。 */
25
+ export const clipboardTextOf = value => (typeof value === 'string' ? value : String(value?.text ?? ''));
26
+
27
+ /** 读本地剪贴板:非安全上下文 / 未授权时浏览器会抛错,必须换成用户能懂的提示。 */
28
+ export async function readLocalClipboard({ clipboard } = {}) {
29
+ const api = clipboard || globalThis.navigator?.clipboard;
30
+ if (!api || typeof api.readText !== 'function') throw clipError('CLIPBOARD_UNAVAILABLE', '当前环境不允许读取剪贴板,请点「粘贴」按钮再试');
31
+ try {
32
+ const text = await api.readText();
33
+ return typeof text === 'string' ? text : '';
34
+ } catch (error) {
35
+ throw clipError('CLIPBOARD_READ_DENIED', '浏览器不允许读取剪贴板,请按 ⌘V / Ctrl+V 再试', error);
36
+ }
37
+ }
38
+
39
+ /** 写本地剪贴板:同样要兜住浏览器拒绝(页面失焦、权限被拒都会走到这里)。 */
40
+ export async function writeLocalClipboard(text, { clipboard } = {}) {
41
+ const api = clipboard || globalThis.navigator?.clipboard;
42
+ if (!api || typeof api.writeText !== 'function') throw clipError('CLIPBOARD_UNAVAILABLE', '当前环境不允许写入剪贴板,请手动复制');
43
+ try { await api.writeText(String(text ?? '')); }
44
+ catch (error) { throw clipError('CLIPBOARD_WRITE_DENIED', '浏览器不允许写入剪贴板,请重试', error); }
45
+ }
46
+
47
+ /**
48
+ * 云桌面里的粘贴快捷键:Linux 桌面要 Control_L + v。
49
+ * 无论宿主是 macOS 还是 Windows,最终发给云端的都是这组 X11 keysym。
50
+ */
51
+ export function pasteShortcutEvents() {
52
+ return Object.freeze([
53
+ Object.freeze({ type: 'key', action: 'down', key: 'Control_L' }),
54
+ Object.freeze({ type: 'key', action: 'down', key: 'v' }),
55
+ Object.freeze({ type: 'key', action: 'up', key: 'v' }),
56
+ Object.freeze({ type: 'key', action: 'up', key: 'Control_L' }),
57
+ ]);
58
+ }
59
+
60
+ /** 把云端的错误码翻成用户能照做的中文;英文原文只作为兜底。 */
61
+ export function clipboardFriendlyError(error) {
62
+ const code = String(error?.code || '');
63
+ if (['COMPUTER_LEASE_REQUIRED', 'DESKTOP_CONTROL_GRANT_REQUIRED', 'DESKTOP_READ_ONLY', 'DESKTOP_CONTROL_EPOCH_CHANGED'].includes(code)) {
64
+ return '需要先点画面接管云电脑,才能读写云端剪贴板';
65
+ }
66
+ if (['DESKTOP_SESSION_IDLE', 'DESKTOP_SESSION_NOT_FOUND', 'CLIPBOARD_CLOSED'].includes(code)) {
67
+ return '云电脑已空闲或断开,请先恢复画面再试';
68
+ }
69
+ if (code === 'CLIPBOARD_TEXT_TOO_LARGE') return '剪贴板内容超过 64 KiB,云电脑剪贴板放不下';
70
+ if (['DESKTOP_UNAVAILABLE', 'DESKTOP_FRAME_FAILED', 'DESKTOP_SESSION_FAILED'].includes(code)) return '云电脑暂时不可用,请稍后重试';
71
+ if (['CLIPBOARD_FAILED', 'CLOUD_SESSION_FAILED'].includes(code)) return '剪贴板操作失败,请重试';
72
+ if (code === 'CLIPBOARD_TEXT_INVALID') return '剪贴板内容格式不被云端接受,请重试';
73
+ if (error?.message) return String(error.message);
74
+ return code ? `剪贴板操作失败(${code})` : '剪贴板操作失败,请重试';
75
+ }
76
+
77
+ let requestSequence = 0;
78
+ const nextRequestId = () => {
79
+ const random = globalThis.crypto?.randomUUID?.();
80
+ return random ? `clip-${random}` : `clip-${Date.now().toString(36)}-${(requestSequence += 1)}`;
81
+ };
82
+
83
+ /**
84
+ * 云电脑桌面 WS 上的剪贴板请求通道(一个连接一个实例)。
85
+ *
86
+ * 云端契约(runtime/desktop-ws.mjs):
87
+ * 发 `{ type: 'clipboard.read' | 'clipboard.write', requestId, text? }`
88
+ * 收 `{ type: 'clipboard.response', requestId, ok, value | error }`
89
+ * 超时是必需的:旧版云端会直接回 DESKTOP_MESSAGE_UNSUPPORTED(不带 requestId),
90
+ * 没有超时按钮就会一直转圈,所以这里超时即明确提示"云端待更新"。
91
+ */
92
+ export function createClipboardChannel({ send, timeoutMs = 5000, controlEpoch, setTimer = setTimeout, clearTimer = clearTimeout } = {}) {
93
+ const pending = new Map();
94
+ const settle = (entry, settleWith) => {
95
+ pending.delete(entry.id);
96
+ clearTimer(entry.timer);
97
+ settleWith();
98
+ };
99
+ const request = (type, extra) => new Promise((resolve, reject) => {
100
+ const id = nextRequestId();
101
+ const timer = setTimer(() => {
102
+ const entry = pending.get(id);
103
+ if (!entry) return;
104
+ settle(entry, () => reject(clipError('CLIPBOARD_TIMEOUT', '云端助理版本较旧,剪贴板通道未启用(重新部署后可用)')));
105
+ }, timeoutMs);
106
+ pending.set(id, {
107
+ id, timer, resolve,
108
+ reject: error => reject(error),
109
+ });
110
+ let sent = false;
111
+ // handoff 会话里云端要求与 input 一样带 controlEpoch;非 handoff 会话会忽略它。
112
+ let epoch;
113
+ try { epoch = typeof controlEpoch === 'function' ? controlEpoch() : undefined; }
114
+ catch { epoch = undefined; }
115
+ try { sent = send({ type, requestId: id, ...(Number.isSafeInteger(epoch) ? { controlEpoch: epoch } : {}), ...extra }) !== false; }
116
+ catch (error) { settle(pending.get(id), () => reject(clipError('CLIPBOARD_UNAVAILABLE', '云电脑尚未连接,剪贴板暂不可用', error))); return; }
117
+ // send() 返回 false 表示 socket 不在 OPEN:立刻失败,别让用户白等 5 秒。
118
+ if (!sent) settle(pending.get(id), () => reject(clipError('CLIPBOARD_UNAVAILABLE', '云电脑尚未连接,剪贴板暂不可用')));
119
+ });
120
+ return Object.freeze({
121
+ read: () => request('clipboard.read').then(value => ({ text: clipboardTextOf(value) })),
122
+ write: text => request('clipboard.write', { text: String(text) }).then(value => ({ written: value?.written !== false })),
123
+ /** socket.onmessage 里把 clipboard.response 交给它;返回是否消费了这条消息。 */
124
+ accept(message) {
125
+ if (!message || message.type !== 'clipboard.response') return false;
126
+ const entry = pending.get(String(message.requestId || ''));
127
+ if (!entry) return false;
128
+ 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));
131
+ });
132
+ return true;
133
+ },
134
+ /** 连接断开/关闭时把在途请求全部失败掉,避免按钮永远转圈。 */
135
+ rejectAll(error = clipError('CLIPBOARD_CLOSED', '云电脑连接已断开,剪贴板操作未完成')) {
136
+ for (const entry of [...pending.values()]) settle(entry, () => entry.reject(error));
137
+ },
138
+ get size() { return pending.size; },
139
+ });
140
+ }
141
+
142
+ /** 画布上的 ⌘V / Ctrl+V 是否该走"本地→云端"的粘贴流程(而不是把按键原样转给云桌面)。 */
143
+ export function isPasteShortcut(event) {
144
+ if (!event || String(event.key ?? '').toLowerCase() !== 'v') return false;
145
+ if (event.altKey || event.shiftKey) return false;
146
+ if (event.isComposing || event.nativeEvent?.isComposing) return false;
147
+ return event.metaKey === true || event.ctrlKey === true;
148
+ }
149
+
150
+ /** ⌘C / Ctrl+C:把云电脑剪贴板内容取回本地(面板不再放按钮,只用快捷键)。 */
151
+ export function isCopyShortcut(event) {
152
+ if (!event || String(event.key ?? '').toLowerCase() !== 'c') return false;
153
+ if (event.altKey || event.shiftKey) return false;
154
+ if (event.isComposing || event.nativeEvent?.isComposing) return false;
155
+ return event.metaKey === true || event.ctrlKey === true;
156
+ }
157
+
158
+ /**
159
+ * 粘贴:本地剪贴板 → 云电脑剪贴板 → 云桌面 Ctrl+V。
160
+ * notify 负责把结果写进面板上的轻提示;返回结构化结果方便调用方/测试断言。
161
+ */
162
+ export async function pasteToCloud({ readLocal, writeCloud, sendKeys, notify = () => {} } = {}) {
163
+ const read = readLocal || (() => readLocalClipboard());
164
+ let text = '';
165
+ try { text = await read(); }
166
+ catch (error) { notify(clipboardFriendlyError(error) || '浏览器不允许读取剪贴板,请按 ⌘V / Ctrl+V 再试'); return { ok: false, code: error?.code || 'CLIPBOARD_READ_FAILED' }; }
167
+ if (!text) { notify('本地剪贴板是空的,没有可粘贴的内容'); return { ok: false, code: 'CLIPBOARD_EMPTY' }; }
168
+ if (byteLength(text) > MAX_CLIPBOARD_BYTES) { notify('剪贴板内容超过 64 KiB,云电脑剪贴板放不下'); return { ok: false, code: 'CLIPBOARD_TOO_LARGE' }; }
169
+ try { await writeCloud(text); }
170
+ catch (error) { notify(clipboardFriendlyError(error) || '写入云电脑剪贴板失败,请重试'); return { ok: false, code: 'CLIPBOARD_WRITE_FAILED' }; }
171
+ if (typeof sendKeys === 'function') {
172
+ let sent = false;
173
+ try { sent = sendKeys(pasteShortcutEvents()) !== false; }
174
+ catch (error) { return { ok: true, code: 'CLIPBOARD_KEY_FAILED', error }; }
175
+ // 只读旁观时输入通道拒发按键:内容已到云端剪贴板,但要明确告诉用户还差接管这一步。
176
+ if (!sent) { notify('已写入云电脑剪贴板,但当前没有控制权;接管云电脑后再按 Ctrl+V'); return { ok: true, code: 'CLIPBOARD_KEY_NOT_SENT' }; }
177
+ }
178
+ notify('已粘贴到云电脑');
179
+ return { ok: true };
180
+ }
181
+
182
+ /** 复制:云电脑剪贴板 → 本地剪贴板。 */
183
+ export async function copyCloudClipboard({ readCloud, writeLocal, notify = () => {} } = {}) {
184
+ const write = writeLocal || (text => writeLocalClipboard(text));
185
+ let value;
186
+ try { value = await readCloud(); }
187
+ catch (error) { notify(clipboardFriendlyError(error) || '读取云电脑剪贴板失败,请重试'); return { ok: false, code: 'CLIPBOARD_READ_FAILED' }; }
188
+ const text = clipboardTextOf(value);
189
+ if (!text) { notify('云电脑剪贴板是空的,没有可复制的内容'); return { ok: false, code: 'CLIPBOARD_EMPTY' }; }
190
+ try { await write(text); }
191
+ catch (error) { notify(clipboardFriendlyError(error) || '浏览器不允许写入剪贴板,请重试'); return { ok: false, code: 'CLIPBOARD_WRITE_FAILED' }; }
192
+ notify('已复制到本地');
193
+ return { ok: true, text };
194
+ }