@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.
@@ -2,7 +2,7 @@
2
2
  * 极简 Markdown 渲染:只覆盖模型回答里真正常见的语法,不引入第三方依赖。
3
3
  *
4
4
  * 支持:围栏代码块、行内代码、粗体、斜体、链接、无序/有序列表、引用、标题、
5
- * 分隔线、段落内换行。已知限制:不解析表格、嵌套列表、HTML(一律按文本转义显示)。
5
+ * GFM 表格、分隔线、段落内换行。已知限制:不解析嵌套列表、HTML(一律按文本转义显示)。
6
6
  */
7
7
 
8
8
  const FENCE = /^```\s*([\w+#.-]*)\s*$/u;
@@ -11,6 +11,39 @@ const UL = /^\s*[-*+]\s+(.*)$/u;
11
11
  const OL = /^\s*(\d{1,3})[.)]\s+(.*)$/u;
12
12
  const QUOTE = /^\s*>\s?(.*)$/u;
13
13
  const RULE = /^\s*([-*_])\s*(?:\1\s*){2,}$/u;
14
+ const TABLE_SEPARATOR = /^:?-{1,}:?$/u;
15
+
16
+ /** 拆分一行表格,支持首尾管道符和转义的 `\\|`。 */
17
+ function splitTableRow(line) {
18
+ let value = typeof line === 'string' ? line.trim() : '';
19
+ if (value.startsWith('|')) value = value.slice(1);
20
+ if (value.endsWith('|') && !value.endsWith('\\|')) value = value.slice(0, -1);
21
+ const cells = [];
22
+ let cell = '';
23
+ for (let index = 0; index < value.length; index += 1) {
24
+ const character = value[index];
25
+ if (character === '\\' && value[index + 1] === '|') { cell += '|'; index += 1; continue; }
26
+ if (character === '|') { cells.push(cell.trim()); cell = ''; continue; }
27
+ cell += character;
28
+ }
29
+ cells.push(cell.trim());
30
+ return cells;
31
+ }
32
+
33
+ function tableAlignment(cell) {
34
+ const value = String(cell || '').trim();
35
+ if (value.startsWith(':') && value.endsWith(':')) return 'center';
36
+ if (value.endsWith(':')) return 'right';
37
+ return 'left';
38
+ }
39
+
40
+ function tableHeaderAndSeparator(headerLine, separatorLine) {
41
+ if (!String(headerLine).includes('|') || !String(separatorLine).includes('|')) return null;
42
+ const headers = splitTableRow(headerLine);
43
+ const separators = splitTableRow(separatorLine);
44
+ if (headers.length < 2 || separators.length !== headers.length || !separators.every(cell => TABLE_SEPARATOR.test(cell))) return null;
45
+ return { headers, alignments: separators.map(tableAlignment) };
46
+ }
14
47
 
15
48
  /** 把 Markdown 源码切成块;纯函数,方便单测。 */
16
49
  export function parseMarkdown(source) {
@@ -37,6 +70,21 @@ export function parseMarkdown(source) {
37
70
  continue;
38
71
  }
39
72
  if (line.trim() === '') { flushParagraph(paragraph); index += 1; continue; }
73
+ const table = index + 1 < lines.length ? tableHeaderAndSeparator(line, lines[index + 1]) : null;
74
+ if (table) {
75
+ flushParagraph(paragraph);
76
+ index += 2;
77
+ const rows = [];
78
+ while (index < lines.length && lines[index].trim() !== '') {
79
+ if (!lines[index].includes('|')) break;
80
+ const row = splitTableRow(lines[index]);
81
+ if (row.length !== table.headers.length) break;
82
+ rows.push(row);
83
+ index += 1;
84
+ }
85
+ blocks.push({ type: 'table', ...table, rows });
86
+ continue;
87
+ }
40
88
  const heading = HEADING.exec(line);
41
89
  if (heading) {
42
90
  flushParagraph(paragraph);
@@ -125,6 +173,25 @@ export function renderMarkdown(h, source, styles = {}) {
125
173
  return h(tag, { key, style: styles.list, start: block.type === 'ordered' && block.start !== 1 ? block.start : undefined },
126
174
  block.items.map((item, position) => h('li', { key: `${key}-l${position}` }, inline(item, `${key}-l${position}`))));
127
175
  }
176
+ if (block.type === 'table') {
177
+ const tableCell = (tag, text, column, cellKey) => h(tag, {
178
+ key: cellKey,
179
+ style: {
180
+ border: '1px solid var(--dsw-alias-border-default, #d8dee8)',
181
+ padding: '6px 8px',
182
+ verticalAlign: 'top',
183
+ ...(tag === 'th' ? { background: 'var(--dsw-alias-bg-layer-2, #f7f9fc)', fontWeight: 600 } : null),
184
+ ...(styles.tableCell || {}),
185
+ textAlign: block.alignments[column] || 'left',
186
+ },
187
+ }, inline(text, cellKey));
188
+ const header = h('thead', { key: `${key}-head` }, h('tr', { key: `${key}-head-row` },
189
+ block.headers.map((text, column) => tableCell('th', text, column, `${key}-h${column}`))));
190
+ const body = h('tbody', { key: `${key}-body` }, block.rows.map((row, rowIndex) => h('tr', { key: `${key}-r${rowIndex}` },
191
+ row.map((text, column) => tableCell('td', text, column, `${key}-r${rowIndex}-c${column}`)))));
192
+ return h('div', { key, style: { overflowX: 'auto', ...styles.tableWrap } },
193
+ h('table', { style: { borderCollapse: 'collapse', width: '100%', ...styles.table } }, header, body));
194
+ }
128
195
  return h('p', { key, style: styles.paragraph }, inline(block.text, key));
129
196
  });
130
197
  }
@@ -93,30 +93,43 @@ export async function publishDshWebUrl({ brokerUrl, url, fetchImpl = globalThis.
93
93
 
94
94
  /**
95
95
  * 后台把地址推给云电脑:日志可能比宿主就绪晚出现,所以隔一会儿重试几次;
96
- * 地址不变就只推一次。任何失败都只记日志,不影响宿主启动。
96
+ * 找到地址后还要周期性重推 —— 云电脑容器(agentd)可以单独重启,重启后
97
+ * 内存里的 dshWebUrl 会丢,只推一次会让欢迎页的「云助理」一直 404。
98
+ * 任何失败都只记日志,不影响宿主启动。
97
99
  */
98
- export function startDshWebBridge({ env = process.env, brokerUrl, intervalMs = 5000, attempts = 24, timers = { setTimeout, clearTimeout }, logger = console, findUrl = findDshWebUrl, publish = publishDshWebUrl } = {}) {
100
+ export function startDshWebBridge({ env = process.env, brokerUrl, intervalMs = 5000, attempts = 24, republishMs = 30000, timers = { setTimeout, clearTimeout }, logger = console, findUrl = findDshWebUrl, publish = publishDshWebUrl } = {}) {
99
101
  const endpoint = brokerUrl || env.COMPUTER_BROKER_URL || env.AGENTKIT_COMPUTER_URL;
100
102
  const dshHome = env.DSH_HOME;
101
103
  let stopped = false;
102
104
  let timer = null;
103
105
  let lastPublished = null;
106
+ let lastPublishedAt = 0;
104
107
  let remaining = attempts;
105
108
  const run = async () => {
106
109
  if (stopped) return;
110
+ let url = null;
107
111
  try {
108
- const url = await readDshWebUrlFile({ file: env.AGENTKIT_DSH_WEB_URL_FILE })
112
+ url = await readDshWebUrlFile({ file: env.AGENTKIT_DSH_WEB_URL_FILE })
109
113
  || await findUrl({ dshHome, readdir: (await import('node:fs/promises')).readdir, stat: (await import('node:fs/promises')).stat, readFile: (await import('node:fs/promises')).readFile });
110
- if (url && url !== lastPublished) {
111
- await publish({ brokerUrl: endpoint, url });
112
- lastPublished = url;
113
- logger.info(JSON.stringify({ event: 'personal-assistant.dsh-web.registered', host: new URL(url).hostname, port: new URL(url).port }));
114
- return;
115
- }
116
- if (url && url === lastPublished) return;
117
114
  } catch (error) {
118
115
  logger.warn(JSON.stringify({ event: 'personal-assistant.dsh-web.unavailable', code: error?.code || 'DSH_WEB_LOOKUP_FAILED' }));
119
116
  }
117
+ if (url) {
118
+ const changed = url !== lastPublished;
119
+ if (changed || Date.now() - lastPublishedAt >= republishMs) {
120
+ try {
121
+ await publish({ brokerUrl: endpoint, url });
122
+ lastPublished = url; lastPublishedAt = Date.now();
123
+ remaining = attempts;
124
+ if (changed) logger.info(JSON.stringify({ event: 'personal-assistant.dsh-web.registered', host: new URL(url).hostname, port: new URL(url).port }));
125
+ } catch (error) {
126
+ logger.warn(JSON.stringify({ event: 'personal-assistant.dsh-web.publish-retry', code: error?.code || 'DSH_WEB_PUBLISH_FAILED' }));
127
+ }
128
+ }
129
+ timer = timers.setTimeout(() => { void run(); }, intervalMs);
130
+ timer?.unref?.();
131
+ return;
132
+ }
120
133
  remaining -= 1;
121
134
  if (remaining <= 0 || stopped) return;
122
135
  timer = timers.setTimeout(() => { void run(); }, intervalMs);
@@ -1,5 +1,7 @@
1
1
  /** 通过 DSH 已鉴权 Connection RPC 转接到固定云网关;凭据始终留在 Host。 */
2
2
  import { createRequire } from 'node:module';
3
+ import { execFile, spawn } from 'node:child_process';
4
+ import { promisify } from 'node:util';
3
5
  import { DIRECTORY_BOT_ID, publicBotDirectory } from './client/bot-directory.mjs';
4
6
  import { createCloudSessionTransport } from './cloud-session-transport.mjs';
5
7
  /**
@@ -58,9 +60,69 @@ const BINDING_METHODS = Object.freeze({
58
60
  'personal-assistant.settings.versions': 'settingsVersions',
59
61
  });
60
62
  const AGENT_ID = /^ar-[A-Za-z0-9-]{4,120}$/u;
61
- const OPERATIONS = new Set(['personal-assistant.configuration', 'personal-assistant.bots.list', 'personal-assistant.desktop.ticket', ...BINDING_OPERATIONS, ...SESSION_OPERATIONS]);
63
+ const OPERATIONS = new Set(['personal-assistant.configuration', 'personal-assistant.bots.list', 'personal-assistant.desktop.ticket',
64
+ // 本机剪贴板只能由 Host 进程读写:Studio 的 Electron 渲染器没有 clipboard-read/
65
+ // clipboard-write 权限,navigator.clipboard 在面板里一律被拒,⌘C/⌘V 就成了空操作。
66
+ 'personal-assistant.clipboard.read', 'personal-assistant.clipboard.write',
67
+ ...BINDING_OPERATIONS, ...SESSION_OPERATIONS]);
62
68
  const fail = (code, message, status = 400) => Object.assign(new Error(message), { code, status });
63
69
  const plain = value => value && typeof value === 'object' && !Array.isArray(value);
70
+ const CLIPBOARD_MAX_BYTES = 64 * 1024;
71
+ const CLIPBOARD_TIMEOUT_MS = 2000;
72
+ const runClipboardReader = promisify(execFile);
73
+ /** Host 是普通 Node 进程:直接调系统剪贴板命令,不依赖浏览器权限。 */
74
+ async function readHostClipboard({ execFileImpl = runClipboardReader } = {}) {
75
+ const options = { timeout: CLIPBOARD_TIMEOUT_MS, maxBuffer: CLIPBOARD_MAX_BYTES, windowsHide: true };
76
+ try {
77
+ if (process.platform === 'darwin') {
78
+ const { stdout } = await execFileImpl('/usr/bin/pbpaste', [], options);
79
+ return { text: String(stdout ?? '') };
80
+ }
81
+ if (process.platform === 'win32') {
82
+ const { stdout } = await execFileImpl('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', 'Get-Clipboard -Raw'], options);
83
+ return { text: String(stdout ?? '').replace(/\r\n$/u, '') };
84
+ }
85
+ try {
86
+ const { stdout } = await execFileImpl('wl-paste', ['--no-newline'], options);
87
+ return { text: String(stdout ?? '') };
88
+ } catch {
89
+ const { stdout } = await execFileImpl('xclip', ['-selection', 'clipboard', '-o'], options);
90
+ return { text: String(stdout ?? '') };
91
+ }
92
+ } catch {
93
+ throw fail('PERSONAL_ASSISTANT_CLIPBOARD_UNAVAILABLE', '本机剪贴板暂时不可用', 503);
94
+ }
95
+ }
96
+ function pipeToClipboardWriter(command, args, value) {
97
+ return new Promise((resolve, reject) => {
98
+ let settled = false;
99
+ const finish = error => { if (settled) return; settled = true; clearTimeout(timer); if (error) reject(error); else resolve(); };
100
+ const timer = setTimeout(() => { child.kill?.('SIGKILL'); finish(new Error('clipboard write timeout')); }, CLIPBOARD_TIMEOUT_MS);
101
+ const child = spawn(command, args, { stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true });
102
+ child.once('error', error => finish(error));
103
+ child.once('exit', code => finish(code === 0 ? null : new Error(`clipboard writer exited ${code}`)));
104
+ child.stdin?.end(value);
105
+ });
106
+ }
107
+ async function writeHostClipboard(text, { execFileImpl = runClipboardReader } = {}) {
108
+ const value = String(text ?? '');
109
+ if (Buffer.byteLength(value, 'utf8') > CLIPBOARD_MAX_BYTES) throw fail('PERSONAL_ASSISTANT_CLIPBOARD_TOO_LARGE', '剪贴板内容超过 64 KiB', 413);
110
+ try {
111
+ if (process.platform === 'darwin') return await pipeToClipboardWriter('/usr/bin/pbcopy', [], value);
112
+ if (process.platform === 'win32') {
113
+ await execFileImpl('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', `Set-Clipboard -Value ${JSON.stringify(value)}`],
114
+ { timeout: CLIPBOARD_TIMEOUT_MS, windowsHide: true });
115
+ return;
116
+ }
117
+ try {
118
+ return await pipeToClipboardWriter('wl-copy', [], value);
119
+ } catch {
120
+ return await pipeToClipboardWriter('xclip', ['-selection', 'clipboard'], value);
121
+ }
122
+ } catch {
123
+ throw fail('PERSONAL_ASSISTANT_CLIPBOARD_UNAVAILABLE', '本机剪贴板暂时不可用', 503);
124
+ }
125
+ }
64
126
  const safeCloseCode = value => Number.isInteger(value) && value >= 1000 && value <= 4999 ? value : undefined;
65
127
  const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u;
66
128
  const requireSessionId = value => {
@@ -187,6 +249,13 @@ export function createPersonalAssistantHostGateway(config = {}, { env = process.
187
249
  async function request(operation, payload = {}, signal) {
188
250
  if (!plain(payload)) throw fail('PERSONAL_ASSISTANT_REQUEST_INVALID', '请求参数格式错误');
189
251
  if (!OPERATIONS.has(operation)) throw fail('PERSONAL_ASSISTANT_OPERATION_UNAVAILABLE', '当前面板不支持此操作', 404);
252
+ // 本机剪贴板不依赖云端绑定,先于 binding() 处理:面板刚打开、账号还没绑定时也能用。
253
+ if (operation === 'personal-assistant.clipboard.read') return readHostClipboard();
254
+ if (operation === 'personal-assistant.clipboard.write') {
255
+ if (typeof payload.text !== 'string') throw fail('PERSONAL_ASSISTANT_REQUEST_INVALID', '剪贴板内容格式错误');
256
+ await writeHostClipboard(payload.text);
257
+ return { written: true };
258
+ }
190
259
  const current = await binding(signal);
191
260
  const bindingMethod = BINDING_METHODS[operation];
192
261
  if (bindingMethod) {
@@ -47,7 +47,9 @@ export class PersonalAssistantStateStore {
47
47
  async read(name) {
48
48
  try {
49
49
  const parsed = JSON.parse(await readFile(this.file(name), 'utf8'));
50
- return Array.isArray(parsed) ? parsed : [];
50
+ // 非数组表示状态损坏;不能当空库返回,否则下一次写会覆盖原历史。
51
+ if (!Array.isArray(parsed)) throw new TypeError('state collection must be an array');
52
+ return parsed;
51
53
  } catch (error) {
52
54
  if (error?.code === 'ENOENT') return [];
53
55
  throw Object.assign(new Error(`state file ${name} is invalid`), { code: 'STATE_CORRUPT', cause: error });
@@ -0,0 +1,285 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>欢迎回来</title>
7
+ <style>
8
+ :root{
9
+ --bg:#f7f7f5; --ink:#1f1f1f; --ink-2:#6b6b6b; --line:#e6e6e2;
10
+ --card:#ffffff; --shadow:0 2px 10px rgba(15,15,15,.07);
11
+ --accent:#2f6bff;
12
+ }
13
+ *{box-sizing:border-box;margin:0;padding:0}
14
+ html,body{height:100%}
15
+ body{
16
+ background:var(--bg); color:var(--ink);
17
+ font-family:"Noto Sans CJK SC","Noto Sans SC","PingFang SC",system-ui,-apple-system,sans-serif;
18
+ display:flex; align-items:center; justify-content:center;
19
+ overflow:hidden; position:relative;
20
+ -webkit-font-smoothing:antialiased;
21
+ }
22
+ /* 背景涂鸦:低对比线稿,对应 dot 的装饰 */
23
+ .doodles{position:fixed; inset:0; pointer-events:none; opacity:.35}
24
+ .doodles svg{position:absolute; fill:none; stroke:#c9c9c2; stroke-width:1.4; stroke-linecap:round; stroke-linejoin:round}
25
+
26
+ .stage{position:relative; width:100%; height:100%; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:0}
27
+
28
+ /* 顶部引导气泡 */
29
+ .hint{
30
+ position:absolute; top:18px; left:18px; max-width:330px;
31
+ display:flex; align-items:flex-start; gap:10px;
32
+ background:var(--card); border:1px solid var(--line); border-radius:12px;
33
+ box-shadow:var(--shadow); padding:11px 12px;
34
+ font-size:13px; line-height:1.55; color:#333;
35
+ transition:opacity .18s ease, transform .18s ease;
36
+ }
37
+ .hint[hidden]{display:none}
38
+ .hint.is-hidden{opacity:0; transform:translateY(-4px); pointer-events:none}
39
+ .hint__close{
40
+ flex:0 0 auto; width:18px; height:18px; border:0; border-radius:6px;
41
+ background:transparent; color:#9a9a95; cursor:pointer; font-size:13px; line-height:1;
42
+ display:flex; align-items:center; justify-content:center;
43
+ }
44
+ .hint__close:hover{background:#f0f0ec; color:#555}
45
+
46
+ /* 吉祥物 */
47
+ .mascot{width:74px; height:74px; margin-bottom:14px}
48
+ .mascot svg{width:100%; height:100%; display:block}
49
+
50
+ /* 时钟 */
51
+ .clock{
52
+ font-size:58px; line-height:1; font-weight:600; letter-spacing:.5px;
53
+ font-variant-numeric:tabular-nums; color:#2b2b2b;
54
+ }
55
+ .welcome{margin-top:10px; font-size:15px; color:var(--ink-2)}
56
+
57
+ /* 应用栅格 */
58
+ .grid{
59
+ margin-top:34px; display:grid; grid-template-columns:repeat(5,84px);
60
+ gap:22px 18px; justify-items:center;
61
+ }
62
+ .app{
63
+ width:84px; background:none; border:0; padding:0; cursor:pointer;
64
+ display:flex; flex-direction:column; align-items:center; gap:7px;
65
+ font-family:inherit; color:inherit;
66
+ }
67
+ .app__icon{
68
+ width:54px; height:54px; border-radius:14px; display:flex; align-items:center; justify-content:center;
69
+ background:var(--card); border:1px solid var(--line); box-shadow:var(--shadow);
70
+ transition:transform .14s ease, box-shadow .14s ease, border-color .14s ease;
71
+ }
72
+ .app:hover .app__icon{transform:translateY(-2px); box-shadow:0 6px 16px rgba(15,15,15,.12); border-color:#dcdcd6}
73
+ .app:active .app__icon{transform:translateY(0) scale(.97)}
74
+ .app:focus-visible .app__icon{outline:2px solid var(--accent); outline-offset:2px}
75
+ .app__icon svg{width:26px; height:26px}
76
+ .app__label{font-size:11.5px; color:#555; letter-spacing:.2px}
77
+
78
+ /* 提示条 */
79
+ .toast{
80
+ position:absolute; bottom:80px; left:50%; transform:translate(-50%,6px);
81
+ background:#1f1f1f; color:#fff; font-size:12.5px; padding:8px 14px; border-radius:999px;
82
+ opacity:0; transition:opacity .18s ease, transform .18s ease; pointer-events:none;
83
+ }
84
+ .toast.is-on{opacity:1; transform:translate(-50%,0)}
85
+ </style>
86
+ </head>
87
+ <body>
88
+ <div class="doodles" aria-hidden="true">
89
+ <svg style="left:8%;top:16%;width:52px;height:52px" viewBox="0 0 52 52"><path d="M14 26c0-6 4-10 10-10s10 4 10 10c0 5-3 8-6 9v5h-3v-4h-2v4h-3v-5c-3-1-6-4-6-9z"/><circle cx="20" cy="24" r="1.4"/><circle cx="28" cy="24" r="1.4"/><path d="M23 29c1.4 1.2 4.6 1.2 6 0"/></svg>
90
+ <svg style="right:12%;top:14%;width:44px;height:44px" viewBox="0 0 44 44"><path d="M22 12c7 0 12 5 12 11s-5 9-12 9-12-3-12-9 5-11 12-11z"/><path d="M22 12v-4"/><path d="M14 24h16"/></svg>
91
+ <svg style="left:14%;bottom:26%;width:40px;height:40px" viewBox="0 0 40 40"><path d="M20 8c4 6 9 9 9 15a9 9 0 1 1-18 0c0-6 5-9 9-15z"/></svg>
92
+ <svg style="right:16%;bottom:30%;width:46px;height:46px" viewBox="0 0 46 46"><path d="M12 30c4-8 10-12 16-12 4 0 7 2 7 6 0 5-6 8-13 8-4 0-7-1-10-2z"/><path d="M18 18c0-4 3-6 6-6"/></svg>
93
+ <svg style="left:44%;top:9%;width:34px;height:34px" viewBox="0 0 34 34"><circle cx="17" cy="17" r="7"/><circle cx="17" cy="17" r="2" fill="#c9c9c2"/></svg>
94
+ <svg style="right:40%;top:11%;width:32px;height:32px" viewBox="0 0 32 32"><path d="M16 6c3 0 5 2 5 5s-2 5-5 5-5-2-5-5 2-5 5-5z"/><path d="M16 16v10"/><path d="M11 26c2-3 8-3 10 0"/></svg>
95
+ </div>
96
+
97
+ <div class="stage">
98
+ <div class="hint" id="hint" role="status">
99
+ <div>这是我的电脑。你可以看我操作,也可以在需要时接管。</div>
100
+ <button class="hint__close" id="hint-close" aria-label="关闭提示">✕</button>
101
+ </div>
102
+
103
+ <div class="mascot" aria-hidden="true">
104
+ <svg viewBox="0 0 84 84">
105
+ <!-- 天线 -->
106
+ <path d="M42 4v7" stroke="#2f5fd6" stroke-width="3" stroke-linecap="round"/>
107
+ <circle cx="42" cy="4" r="3.2" fill="#7fa6ff"/>
108
+ <!-- 头部 -->
109
+ <rect x="12" y="13" width="60" height="52" rx="17" fill="#4a7bf7"/>
110
+ <rect x="12" y="13" width="60" height="52" rx="17" fill="url(#g1)" opacity=".55"/>
111
+ <defs>
112
+ <linearGradient id="g1" x1="0" y1="0" x2="0" y2="1">
113
+ <stop offset="0" stop-color="#7fa6ff"/><stop offset="1" stop-color="#4a7bf7" stop-opacity="0"/>
114
+ </linearGradient>
115
+ </defs>
116
+ <!-- 表情 >_ -->
117
+ <path d="M29 31l7 9-7 9" fill="none" stroke="#fff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
118
+ <path d="M46 49h11" fill="none" stroke="#fff" stroke-width="4" stroke-linecap="round"/>
119
+ <!-- 两侧耳块 -->
120
+ <rect x="6" y="31" width="7" height="16" rx="3.5" fill="#3d6ae0"/>
121
+ <rect x="71" y="31" width="7" height="16" rx="3.5" fill="#3d6ae0"/>
122
+ </svg>
123
+ </div>
124
+
125
+ <div class="clock" id="clock">--:--</div>
126
+ <div class="welcome">欢迎回来,个人助理。</div>
127
+
128
+ <div class="grid" role="list">
129
+ <button class="app" role="listitem" data-action="terminal" title="打开终端">
130
+ <span class="app__icon">
131
+ <svg viewBox="0 0 24 24" fill="none" stroke="#2b2b2b" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="3"/><path d="M7 10l3 2-3 2"/><path d="M12.5 14h4"/></svg>
132
+ </span>
133
+ <span class="app__label">终端</span>
134
+ </button>
135
+ <button class="app" role="listitem" data-action="browser" title="打开浏览器">
136
+ <span class="app__icon">
137
+ <svg viewBox="0 0 24 24" fill="none" stroke="#2f6bff" stroke-width="1.8" stroke-linecap="round"><circle cx="12" cy="12" r="8.2"/><path d="M3.8 12h16.4"/><path d="M12 3.8c2.4 2.3 3.6 5 3.6 8.2S14.4 17.9 12 20.2c-2.4-2.3-3.6-5-3.6-8.2S9.6 6.1 12 3.8z"/></svg>
138
+ </span>
139
+ <span class="app__label">浏览器</span>
140
+ </button>
141
+ <button class="app" role="listitem" data-action="files" title="打开文件管理器">
142
+ <span class="app__icon">
143
+ <svg viewBox="0 0 24 24" fill="none" stroke="#e39a1f" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 7.5a2 2 0 0 1 2-2h4l2 2.4h7a2 2 0 0 1 2 2v7.6a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2z"/></svg>
144
+ </span>
145
+ <span class="app__label">文件</span>
146
+ </button>
147
+ <button class="app" role="listitem" data-action="dsh" title="打开云端助理网页">
148
+ <span class="app__icon">
149
+ <svg viewBox="0 0 24 24" fill="none" stroke="#1f9d6b" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3.2" y="5" width="17.6" height="12.4" rx="2.6"/><path d="M8 21h8"/><path d="M12 17.4V21"/><path d="M7.6 10.4l2.2 1.6-2.2 1.6"/><path d="M12.4 13.6h3.4"/></svg>
150
+ </span>
151
+ <span class="app__label">云助理</span>
152
+ </button>
153
+ <button class="app" role="listitem" data-action="workspace" title="工作区说明">
154
+ <span class="app__icon">
155
+ <svg viewBox="0 0 24 24" fill="none" stroke="#7b61ff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5.5A1.5 1.5 0 0 1 5.5 4H10v16H5.5A1.5 1.5 0 0 1 4 18.5z"/><path d="M20 5.5A1.5 1.5 0 0 0 18.5 4H14v16h4.5A1.5 1.5 0 0 0 20 18.5z"/></svg>
156
+ </span>
157
+ <span class="app__label">工作区</span>
158
+ </button>
159
+ </div>
160
+ </div>
161
+
162
+ <div class="toast" id="toast"></div>
163
+
164
+ <script>
165
+ (function () {
166
+ 'use strict';
167
+
168
+ // ── 时钟:按桌面时区显示(容器时钟是 UTC,用户按北京时间读表)──
169
+ var clockEl = document.getElementById('clock');
170
+ var clockFormat = null;
171
+ function tick() {
172
+ var d = new Date();
173
+ var text;
174
+ try {
175
+ text = clockFormat ? clockFormat.format(d)
176
+ : (d.getHours() < 10 ? '0' : '') + d.getHours() + ':' + (d.getMinutes() < 10 ? '0' : '') + d.getMinutes();
177
+ } catch (e) { text = clockEl.textContent; }
178
+ if (clockEl.textContent !== text) clockEl.textContent = text;
179
+ }
180
+ // 时区由本机桥 /config.json 提供;拿不到就退回浏览器本地时区。
181
+ try {
182
+ fetch('/config.json', { cache: 'no-store' })
183
+ .then(function (r) { return r.ok ? r.json() : null; })
184
+ .then(function (cfg) {
185
+ if (!cfg || !cfg.timeZone) return;
186
+ clockFormat = new Intl.DateTimeFormat('zh-CN', { timeZone: cfg.timeZone, hour: '2-digit', minute: '2-digit', hour12: false });
187
+ tick();
188
+ })
189
+ .catch(function () {});
190
+ } catch (e) {}
191
+ tick();
192
+ setInterval(tick, 1000);
193
+
194
+ // ── 引导气泡:关闭状态存 localStorage ──
195
+ var hint = document.getElementById('hint');
196
+ try {
197
+ if (localStorage.getItem('pa.welcome.hint.dismissed') === '1') hint.hidden = true;
198
+ } catch (e) {}
199
+ document.getElementById('hint-close').addEventListener('click', function () {
200
+ hint.classList.add('is-hidden');
201
+ setTimeout(function () { hint.hidden = true; }, 200);
202
+ try { localStorage.setItem('pa.welcome.hint.dismissed', '1'); } catch (e) {}
203
+ });
204
+
205
+ // ── 提示条 ──
206
+ var toastEl = document.getElementById('toast'), toastTimer = 0;
207
+ function toast(text) {
208
+ toastEl.textContent = text;
209
+ toastEl.classList.add('is-on');
210
+ clearTimeout(toastTimer);
211
+ toastTimer = setTimeout(function () { toastEl.classList.remove('is-on'); }, 2200);
212
+ }
213
+
214
+ var WORKSPACE_HINT = '工作区在 /workspace,产出的文件会出现在左侧「文件」里。';
215
+
216
+ // ── 通过本机 launcher 的 HTTP 桥启动固定应用 ──
217
+ // 桥只接受 {action: "browser"|"terminal"|"files"},服务端会再用 launcher.sock 校验一遍。
218
+ function launch(action) {
219
+ return fetch('/launch', {
220
+ method: 'POST',
221
+ headers: { 'content-type': 'application/json' },
222
+ body: JSON.stringify({ action: action })
223
+ }).then(function (r) { return r.json(); });
224
+ }
225
+
226
+ // 云端 DSH 网页由同一 Pod 内的 127.0.0.1:8081 提供,地址由本机桥转发给桌面重新导航。
227
+ // runtime 可能比欢迎页晚就绪(云电脑容器先重启时尤其明显),/dsh-web.json 会先 404
228
+ // 后 200,因此这里轮询探测,点击时再重试若干次,避免永远停在「还在启动」。
229
+ var dshWebUrl = null;
230
+ function refreshDshWeb() {
231
+ return fetch('/dsh-web.json', { cache: 'no-store' }).then(function (r) { return r.ok ? r.json() : null; }).then(function (info) {
232
+ dshWebUrl = info && info.url ? info.url : null;
233
+ return dshWebUrl;
234
+ }).catch(function () { return dshWebUrl; });
235
+ }
236
+ refreshDshWeb();
237
+ setInterval(refreshDshWeb, 2000);
238
+
239
+ function openDsh(btn, attempt) {
240
+ if (btn) btn.disabled = true;
241
+ var tries = attempt || 0;
242
+ fetch('/dsh-web.json', { cache: 'no-store' }).then(function (r) { return r.ok ? r.json() : null; }).then(function (info) {
243
+ if (info && info.url) {
244
+ dshWebUrl = info.url;
245
+ return launch('dsh').then(function (res) {
246
+ if (!res || res.ok !== true) toast('打开失败:' + ((res && res.code) || '未知错误'));
247
+ });
248
+ }
249
+ if (tries < 10) return new Promise(function (resolve) { setTimeout(resolve, 800); }).then(function () { return openDsh(btn, tries + 1); });
250
+ toast('云端助理网页还在启动,请稍后再试');
251
+ }).catch(function () {
252
+ toast('云端助理网页暂时不可用');
253
+ }).then(function () {
254
+ if (btn) btn.disabled = false;
255
+ });
256
+ }
257
+
258
+ function handle(action, btn) {
259
+ if (action === 'workspace') { toast(WORKSPACE_HINT); return; }
260
+ if (action === 'dsh') { openDsh(btn); return; }
261
+ if (btn) btn.disabled = true;
262
+ launch(action).then(function (res) {
263
+ if (!res || res.ok !== true) toast('打开失败:' + ((res && res.code) || '未知错误'));
264
+ }).catch(function () {
265
+ toast('桌面入口暂时不可用');
266
+ }).then(function () {
267
+ if (btn) btn.disabled = false;
268
+ });
269
+ }
270
+
271
+ document.querySelectorAll('[data-action]').forEach(function (el) {
272
+ el.addEventListener('click', function () { handle(el.getAttribute('data-action'), el); });
273
+ });
274
+
275
+ // ── 键盘:1/2/3 快捷启动(可选,不抢焦点) ──
276
+ var keys = { '1': 'terminal', '2': 'browser', '3': 'files' };
277
+ document.addEventListener('keydown', function (e) {
278
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
279
+ var action = keys[e.key];
280
+ if (action) { e.preventDefault(); handle(action, null); }
281
+ });
282
+ })();
283
+ </script>
284
+ </body>
285
+ </html>