@leeoohoo/ui-apps-devkit 0.1.11 → 0.1.12

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leeoohoo/ui-apps-devkit",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "description": "ChatOS UI Apps DevKit (CLI + templates + sandbox) for building installable ChatOS UI Apps plugins.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -65,11 +65,58 @@ function resolveSandboxConfigPath({ primaryRoot, legacyRoot }) {
65
65
  }
66
66
 
67
67
  const DEFAULT_LLM_BASE_URL = 'https://api.openai.com/v1';
68
+ const UI_PROMPTS_FILENAME = 'ui-prompts.jsonl';
68
69
 
69
70
  function normalizeText(value) {
70
71
  return typeof value === 'string' ? value.trim() : '';
71
72
  }
72
73
 
74
+ function ensureUiPromptsFile(filePath) {
75
+ const normalized = typeof filePath === 'string' ? filePath.trim() : '';
76
+ if (!normalized) return '';
77
+ try {
78
+ ensureDir(path.dirname(normalized));
79
+ if (!fs.existsSync(normalized)) {
80
+ fs.writeFileSync(normalized, '', 'utf8');
81
+ }
82
+ } catch {
83
+ // ignore
84
+ }
85
+ return normalized;
86
+ }
87
+
88
+ function appendUiPromptsEntry(filePath, entry) {
89
+ const target = ensureUiPromptsFile(filePath);
90
+ if (!target) return;
91
+ try {
92
+ const payload = entry && typeof entry === 'object' ? entry : { value: entry };
93
+ fs.appendFileSync(target, `${JSON.stringify(payload)}\n`, 'utf8');
94
+ } catch {
95
+ // ignore
96
+ }
97
+ }
98
+
99
+ function readUiPromptsEntries(filePath) {
100
+ const target = ensureUiPromptsFile(filePath);
101
+ if (!target) return [];
102
+ try {
103
+ const raw = fs.readFileSync(target, 'utf8');
104
+ if (!raw) return [];
105
+ const lines = raw.split(/\r?\n/).filter((line) => line.trim());
106
+ const entries = [];
107
+ for (const line of lines) {
108
+ try {
109
+ entries.push(JSON.parse(line));
110
+ } catch {
111
+ // ignore parse errors
112
+ }
113
+ }
114
+ return entries;
115
+ } catch {
116
+ return [];
117
+ }
118
+ }
119
+
73
120
  function isPlainObject(value) {
74
121
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
75
122
  }
@@ -925,7 +972,10 @@ function htmlPage() {
925
972
  <input id="mcpDisableTools" type="checkbox" style="width:auto;" />
926
973
  Disable tools
927
974
  </label>
928
- <button id="btnMcpSend" class="btn" type="button">Send</button>
975
+ <div class="row">
976
+ <button id="btnMcpAsync" class="btn" type="button">AsyncTask Test</button>
977
+ <button id="btnMcpSend" class="btn" type="button">Send</button>
978
+ </div>
929
979
  </div>
930
980
  <div id="mcpStatus" class="muted"></div>
931
981
  </div>
@@ -1003,6 +1053,7 @@ const mcpPanel = $('#mcpPanel');
1003
1053
  const btnMcpClose = $('#btnMcpClose');
1004
1054
  const btnMcpClear = $('#btnMcpClear');
1005
1055
  const btnMcpSend = $('#btnMcpSend');
1056
+ const btnMcpAsync = $('#btnMcpAsync');
1006
1057
  const mcpPaths = $('#mcpPaths');
1007
1058
  const mcpWorkdir = $('#mcpWorkdir');
1008
1059
  const mcpModelId = $('#mcpModelId');
@@ -1049,7 +1100,10 @@ const markMcpStreamStatus = (payload) => {
1049
1100
  }
1050
1101
  };
1051
1102
 
1052
- const setPanelOpen = (open) => { panel.style.display = open ? 'flex' : 'none'; };
1103
+ const setPanelOpen = (open) => {
1104
+ panel.style.display = open ? 'flex' : 'none';
1105
+ if (open) refreshUiPromptsFromFile();
1106
+ };
1053
1107
  fab.addEventListener('click', () => setPanelOpen(panel.style.display !== 'flex'));
1054
1108
  panelClose.addEventListener('click', () => setPanelOpen(false));
1055
1109
  window.addEventListener('chatos:uiPrompts:open', () => setPanelOpen(true));
@@ -1257,6 +1311,108 @@ const setMcpPanelOpen = (open) => {
1257
1311
  }
1258
1312
  };
1259
1313
 
1314
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1315
+
1316
+ const normalizeRequestId = (value) => (typeof value === 'string' ? value.trim() : '');
1317
+
1318
+ const buildAsyncRequestIds = (taskId) => {
1319
+ const id = normalizeRequestId(taskId);
1320
+ if (!id) return [];
1321
+ return [id, `mcp-task:${id}`];
1322
+ };
1323
+
1324
+ const extractAsyncResult = (list, requestIds) => {
1325
+ const ids = new Set((Array.isArray(requestIds) ? requestIds : []).filter(Boolean));
1326
+ if (ids.size === 0) return null;
1327
+ const entriesList = Array.isArray(list) ? list : [];
1328
+ for (let i = entriesList.length - 1; i >= 0; i -= 1) {
1329
+ const entry = entriesList[i];
1330
+ if (!entry || typeof entry !== 'object') continue;
1331
+ if (entry.type !== 'ui_prompt') continue;
1332
+ if (entry.action !== 'request') continue;
1333
+ const requestId = normalizeRequestId(entry.requestId);
1334
+ if (!requestId || !ids.has(requestId)) continue;
1335
+ const prompt = entry.prompt && typeof entry.prompt === 'object' ? entry.prompt : null;
1336
+ if (!prompt || typeof prompt.kind !== 'string' || prompt.kind.trim() !== 'result') continue;
1337
+ if (typeof prompt.markdown === 'string') return prompt.markdown;
1338
+ if (typeof prompt.result === 'string') return prompt.result;
1339
+ if (typeof prompt.content === 'string') return prompt.content;
1340
+ return '';
1341
+ }
1342
+ return null;
1343
+ };
1344
+
1345
+ const pollAsyncResult = async ({ taskId, timeoutMs = 6000, intervalMs = 500 } = {}) => {
1346
+ const requestIds = buildAsyncRequestIds(taskId);
1347
+ const deadline = Date.now() + Math.max(500, timeoutMs);
1348
+ while (Date.now() < deadline) {
1349
+ const fileEntries = await readUiPromptsFromFile();
1350
+ const text = extractAsyncResult(fileEntries, requestIds);
1351
+ if (text !== null) return { found: true, text };
1352
+ await sleep(Math.max(200, intervalMs));
1353
+ }
1354
+ return { found: false, text: '' };
1355
+ };
1356
+
1357
+ const buildAsyncTaskCallMeta = (taskId) => ({
1358
+ asyncTask: {
1359
+ tools: ['codex_app_window_run'],
1360
+ taskIdKey: 'taskId',
1361
+ resultSource: 'ui_prompts',
1362
+ uiPromptFile: 'ui-prompts.jsonl',
1363
+ },
1364
+ taskId,
1365
+ });
1366
+
1367
+ const runMcpAsyncTest = async () => {
1368
+ const sendBtn = btnMcpAsync;
1369
+ try {
1370
+ const message = mcpMessage ? String(mcpMessage.value || '').trim() : '';
1371
+ if (!message) {
1372
+ setMcpStatus('Message is required.', true);
1373
+ return;
1374
+ }
1375
+ resetMcpStreamState();
1376
+ if (sendBtn) sendBtn.disabled = true;
1377
+
1378
+ const taskId = 'task_' + uuid();
1379
+ const callMeta = buildAsyncTaskCallMeta(taskId);
1380
+ const ack = { status: 'accepted', taskId };
1381
+ const resultText = `AsyncTask result for ${taskId}`;
1382
+
1383
+ appendMcpOutput('asyncTask.request', { message, callMeta });
1384
+ appendMcpOutput('asyncTask.ack', ack);
1385
+ setMcpStatus(`ACK: ${taskId} (waiting for uiPrompts result)...`);
1386
+
1387
+ const pollPromise = pollAsyncResult({ taskId, timeoutMs: 8000, intervalMs: 400 });
1388
+
1389
+ setTimeout(async () => {
1390
+ const prompt = { kind: 'result', markdown: resultText, source: 'sandbox-async-test' };
1391
+ try {
1392
+ await host.uiPrompts.request({ requestId: taskId, prompt });
1393
+ appendMcpOutput('asyncTask.result', { requestId: taskId, prompt });
1394
+ setMcpStatus(`Result stored in uiPrompts (taskId=${taskId})`);
1395
+ } catch (err) {
1396
+ appendMcpOutput('asyncTask.error', err?.message || String(err));
1397
+ setMcpStatus(err?.message || String(err), true);
1398
+ }
1399
+ }, 800);
1400
+
1401
+ const pollResult = await pollPromise;
1402
+ if (pollResult.found) {
1403
+ appendMcpOutput('asyncTask.polled', pollResult.text);
1404
+ setMcpStatus(`Polled result (taskId=${taskId})`);
1405
+ } else {
1406
+ appendMcpOutput('asyncTask.timeout', { taskId });
1407
+ setMcpStatus(`Polling timeout (taskId=${taskId})`, true);
1408
+ }
1409
+ } catch (err) {
1410
+ setMcpStatus(err?.message || String(err), true);
1411
+ } finally {
1412
+ if (sendBtn) sendBtn.disabled = false;
1413
+ }
1414
+ };
1415
+
1260
1416
  const runMcpTest = async () => {
1261
1417
  const sendBtn = btnMcpSend;
1262
1418
  try {
@@ -1419,6 +1575,10 @@ if (btnMcpClear)
1419
1575
  if (mcpOutput) mcpOutput.textContent = '';
1420
1576
  setMcpStatus('');
1421
1577
  });
1578
+ if (btnMcpAsync)
1579
+ btnMcpAsync.addEventListener('click', () => {
1580
+ runMcpAsyncTest();
1581
+ });
1422
1582
  if (btnMcpSend)
1423
1583
  btnMcpSend.addEventListener('click', () => {
1424
1584
  runMcpTest();
@@ -1432,18 +1592,31 @@ updateContextStatus();
1432
1592
 
1433
1593
  const entries = [];
1434
1594
  const listeners = new Set();
1435
- const emitUpdate = () => {
1436
- const payload = { path: '(sandbox)', entries: [...entries] };
1437
- for (const fn of listeners) { try { fn(payload); } catch {} }
1438
- renderPrompts();
1595
+ const notifyUiPrompts = (list) => {
1596
+ const payload = {
1597
+ path: sandboxPaths.uiPromptsFile || '(sandbox)',
1598
+ entries: Array.isArray(list) ? [...list] : [],
1599
+ };
1600
+ for (const fn of listeners) {
1601
+ try {
1602
+ fn(payload);
1603
+ } catch {
1604
+ // ignore
1605
+ }
1606
+ }
1607
+ };
1608
+
1609
+ const emitUpdate = async () => {
1610
+ await refreshUiPromptsFromFile();
1439
1611
  };
1440
1612
 
1441
1613
  const uuid = () => (globalThis.crypto?.randomUUID ? crypto.randomUUID() : String(Date.now()) + '-' + Math.random().toString(16).slice(2));
1442
1614
 
1443
- function renderPrompts() {
1615
+ function renderPrompts(sourceEntries = entries) {
1444
1616
  panelBody.textContent = '';
1445
1617
  const pending = new Map();
1446
- for (const e of entries) {
1618
+ const list = Array.isArray(sourceEntries) ? sourceEntries : [];
1619
+ for (const e of list) {
1447
1620
  if (e?.type !== 'ui_prompt') continue;
1448
1621
  const id = String(e?.requestId || '');
1449
1622
  if (!id) continue;
@@ -1494,8 +1667,15 @@ function renderPrompts() {
1494
1667
  };
1495
1668
 
1496
1669
  const submit = async (response) => {
1497
- entries.push({ ts: new Date().toISOString(), type: 'ui_prompt', action: 'response', requestId, response });
1498
- emitUpdate();
1670
+ const entry = {
1671
+ ts: new Date().toISOString(),
1672
+ type: 'ui_prompt',
1673
+ action: 'response',
1674
+ requestId,
1675
+ response,
1676
+ };
1677
+ await appendUiPromptsToFile(entry);
1678
+ await emitUpdate();
1499
1679
  };
1500
1680
 
1501
1681
  if (kind === 'result') {
@@ -1632,6 +1812,46 @@ const callSandboxChat = async (payload, signal) => {
1632
1812
  return j;
1633
1813
  };
1634
1814
 
1815
+ const appendUiPromptsToFile = async (entry) => {
1816
+ try {
1817
+ const r = await fetch('/api/ui-prompts/append', {
1818
+ method: 'POST',
1819
+ headers: { 'content-type': 'application/json' },
1820
+ body: JSON.stringify({ entry }),
1821
+ });
1822
+ const j = await r.json();
1823
+ return Boolean(j?.ok);
1824
+ } catch {
1825
+ return false;
1826
+ }
1827
+ };
1828
+
1829
+ const readUiPromptsFromFile = async () => {
1830
+ try {
1831
+ const r = await fetch('/api/ui-prompts/read');
1832
+ const j = await r.json();
1833
+ if (j?.ok && Array.isArray(j.entries)) return j.entries;
1834
+ } catch {
1835
+ // ignore
1836
+ }
1837
+ return [];
1838
+ };
1839
+
1840
+ const replaceEntries = (list) => {
1841
+ entries.length = 0;
1842
+ if (Array.isArray(list) && list.length > 0) {
1843
+ entries.push(...list);
1844
+ }
1845
+ };
1846
+
1847
+ const refreshUiPromptsFromFile = async () => {
1848
+ const fileEntries = await readUiPromptsFromFile();
1849
+ replaceEntries(fileEntries);
1850
+ notifyUiPrompts(entries);
1851
+ renderPrompts(entries);
1852
+ return entries;
1853
+ };
1854
+
1635
1855
  const getTheme = () => currentTheme || resolveTheme();
1636
1856
 
1637
1857
  const host = {
@@ -1675,22 +1895,41 @@ const host = {
1675
1895
  },
1676
1896
  },
1677
1897
  uiPrompts: {
1678
- read: async () => ({ path: '(sandbox)', entries: [...entries] }),
1898
+ read: async () => {
1899
+ const fileEntries = await readUiPromptsFromFile();
1900
+ return { path: sandboxPaths.uiPromptsFile || '(sandbox)', entries: fileEntries };
1901
+ },
1679
1902
  onUpdate: (listener) => { listeners.add(listener); return () => listeners.delete(listener); },
1680
1903
  request: async (payload) => {
1681
1904
  const requestId = payload?.requestId ? String(payload.requestId) : uuid();
1682
1905
  const prompt = payload?.prompt && typeof payload.prompt === 'object' ? { ...payload.prompt } : null;
1683
1906
  if (prompt && !prompt.source) prompt.source = __SANDBOX__.pluginId + ':' + __SANDBOX__.appId;
1684
- entries.push({ ts: new Date().toISOString(), type: 'ui_prompt', action: 'request', requestId, runId: payload?.runId, prompt });
1685
- emitUpdate();
1907
+ const entry = {
1908
+ ts: new Date().toISOString(),
1909
+ type: 'ui_prompt',
1910
+ action: 'request',
1911
+ requestId,
1912
+ runId: payload?.runId,
1913
+ prompt,
1914
+ };
1915
+ await appendUiPromptsToFile(entry);
1916
+ await emitUpdate();
1686
1917
  return { ok: true, requestId };
1687
1918
  },
1688
1919
  respond: async (payload) => {
1689
1920
  const requestId = String(payload?.requestId || '');
1690
1921
  if (!requestId) throw new Error('requestId is required');
1691
1922
  const response = payload?.response && typeof payload.response === 'object' ? payload.response : null;
1692
- entries.push({ ts: new Date().toISOString(), type: 'ui_prompt', action: 'response', requestId, runId: payload?.runId, response });
1693
- emitUpdate();
1923
+ const entry = {
1924
+ ts: new Date().toISOString(),
1925
+ type: 'ui_prompt',
1926
+ action: 'response',
1927
+ requestId,
1928
+ runId: payload?.runId,
1929
+ response,
1930
+ };
1931
+ await appendUiPromptsToFile(entry);
1932
+ await emitUpdate();
1694
1933
  return { ok: true };
1695
1934
  },
1696
1935
  open: () => (setPanelOpen(true), { ok: true }),
@@ -2269,6 +2508,8 @@ export async function startSandboxServer({ pluginDir, port = 4399, appId = '' })
2269
2508
  ctxBase.dataDir = path.join(sandboxRoot, 'data', ctxBase.pluginId);
2270
2509
  ensureDir(ctxBase.stateDir);
2271
2510
  ensureDir(ctxBase.dataDir);
2511
+ const uiPromptsPath = path.join(ctxBase.stateDir, UI_PROMPTS_FILENAME);
2512
+ ensureUiPromptsFile(uiPromptsPath);
2272
2513
  sandboxCallMeta = buildSandboxCallMeta({
2273
2514
  rawCallMeta: app?.ai?.mcp?.callMeta,
2274
2515
  context: {
@@ -2363,6 +2604,7 @@ export async function startSandboxServer({ pluginDir, port = 4399, appId = '' })
2363
2604
  dataDir: ctxBase.dataDir || '',
2364
2605
  pluginDir: ctxBase.pluginDir || '',
2365
2606
  stateDir: ctxBase.stateDir || '',
2607
+ uiPromptsFile: uiPromptsPath || '',
2366
2608
  sessionRoot: ctxBase.sessionRoot || '',
2367
2609
  projectRoot: ctxBase.projectRoot || '',
2368
2610
  };
@@ -2388,6 +2630,24 @@ export async function startSandboxServer({ pluginDir, port = 4399, appId = '' })
2388
2630
  return sendJson(res, 200, { ok: true, manifest });
2389
2631
  }
2390
2632
 
2633
+ if (pathname === '/api/ui-prompts/read') {
2634
+ if (req.method !== 'GET') return sendJson(res, 405, { ok: false, message: 'Method not allowed' });
2635
+ const entries = readUiPromptsEntries(uiPromptsPath);
2636
+ return sendJson(res, 200, { ok: true, path: uiPromptsPath || '', entries });
2637
+ }
2638
+
2639
+ if (pathname === '/api/ui-prompts/append') {
2640
+ if (req.method !== 'POST') return sendJson(res, 405, { ok: false, message: 'Method not allowed' });
2641
+ try {
2642
+ const payload = await readJsonBody(req);
2643
+ const entry = payload?.entry && typeof payload.entry === 'object' ? payload.entry : payload;
2644
+ appendUiPromptsEntry(uiPromptsPath, entry);
2645
+ return sendJson(res, 200, { ok: true });
2646
+ } catch (err) {
2647
+ return sendJson(res, 200, { ok: false, message: err?.message || String(err) });
2648
+ }
2649
+ }
2650
+
2391
2651
  if (pathname === '/api/sandbox/llm-config') {
2392
2652
  if (req.method === 'GET') {
2393
2653
  const cfg = getSandboxLlmConfig();
@@ -0,0 +1,100 @@
1
+ # ChatOS UI Apps:AsyncTask 轮询协议(MCP 调用)
2
+
3
+ 本协议用于描述 **ACK + 交互待办轮询** 的异步 MCP 调用模式。当 `callMeta.asyncTask` 配置命中某个工具时,ChatOS 不走流式通知,而是:
4
+
5
+ 1. 立即接收 MCP ACK(包含 taskId)
6
+ 2. 后台执行任务
7
+ 3. 结果写入「交互待办」(`ui-prompts.jsonl`)
8
+ 4. ChatOS 轮询匹配结果并返回给工具调用
9
+
10
+ > 关键点:**没有流式通知**,只能通过轮询 `ui-prompts.jsonl` 获取结果。
11
+
12
+ ## 1) 触发条件(callMeta.asyncTask)
13
+
14
+ 当 MCP server 的 `callMeta` 包含 `asyncTask`,且 `tools` 覆盖当前工具名时,ChatOS 会启用轮询模式。
15
+
16
+ 示例:
17
+ ```json
18
+ {
19
+ "asyncTask": {
20
+ "tools": ["codex_app_window_run"],
21
+ "taskIdKey": "taskId",
22
+ "resultSource": "ui_prompts",
23
+ "uiPromptFile": "ui-prompts.jsonl",
24
+ "pollIntervalMs": 1000
25
+ }
26
+ }
27
+ ```
28
+
29
+ 约定字段:
30
+ - `tools`: 触发异步模式的 tool 名列表(小写匹配)
31
+ - `taskIdKey`: taskId 字段名(默认 `taskId`)
32
+ - `resultSource`: 固定 `ui_prompts`
33
+ - `uiPromptFile`: 固定 `ui-prompts.jsonl`
34
+ - `pollIntervalMs`: 轮询间隔(200-5000ms)
35
+
36
+ ## 2) ACK 规范(MCP 返回)
37
+
38
+ 工具调用应立即返回 ACK,并带上 `taskId`:
39
+
40
+ ```json
41
+ {
42
+ "status": "accepted",
43
+ "taskId": "task_123"
44
+ }
45
+ ```
46
+
47
+ > ChatOS 不会依赖 ACK 的结构完成轮询,但该 ACK 对其它客户端/调试很重要。
48
+
49
+ ## 3) 结果写入(交互待办)
50
+
51
+ 异步结果必须写入 `ui-prompts.jsonl`,格式必须满足以下条件:
52
+
53
+ - `type = "ui_prompt"`
54
+ - `action = "request"`
55
+ - `requestId = taskId` 或 `mcp-task:<taskId>`
56
+ - `prompt.kind = "result"`
57
+
58
+ 示例:
59
+ ```json
60
+ {
61
+ "ts": "2025-01-01T00:00:00.000Z",
62
+ "type": "ui_prompt",
63
+ "action": "request",
64
+ "requestId": "task_123",
65
+ "prompt": {
66
+ "kind": "result",
67
+ "markdown": "final output"
68
+ }
69
+ }
70
+ ```
71
+
72
+ `prompt` 文本字段优先级(ChatOS 解析顺序):
73
+ 1. `prompt.markdown`
74
+ 2. `prompt.result`
75
+ 3. `prompt.content`
76
+
77
+ ## 4) 轮询规则(ChatOS)
78
+
79
+ ChatOS 会在 `ui-prompts.jsonl` 中按以下规则匹配结果:
80
+
81
+ - `type === "ui_prompt"`
82
+ - `action === "request"`
83
+ - `prompt.kind === "result"`
84
+ - `requestId in [taskId, "mcp-task:" + taskId]`
85
+
86
+ 匹配成功即返回结果文本给工具调用。
87
+
88
+ ## 5) 沙箱调试(Devkit)
89
+
90
+ Devkit 沙箱已提供 `uiPrompts` 内存存储与 UI 面板,可用于调试本协议:
91
+
92
+ - 通过 **MCP Test 面板的 AsyncTask Test** 按钮生成 taskId + 写入结果,并执行轮询匹配
93
+ - 在 MCP Output 中可看到轮询命中结果,UI Prompts 面板可看到 result 类型条目
94
+ - 沙箱会同时写入 `stateDir/ui-prompts.jsonl`(可在 MCP Test 的 Paths 中查看路径)
95
+
96
+ ## 6) 常见问题
97
+
98
+ - **轮询超时**:确认 `ui-prompts.jsonl` 中是否有正确格式的 `prompt.kind=result`。
99
+ - **找不到结果**:确认 `requestId` 为 `taskId` 或 `mcp-task:<taskId>`。
100
+ - **结果为空**:确认 `prompt.markdown/result/content` 至少存在一个文本字段。
@@ -9,6 +9,7 @@
9
9
  - [`CHATOS_UI_APPS_STYLE_GUIDE.md`](./CHATOS_UI_APPS_STYLE_GUIDE.md):主题与样式约定(CSS Tokens / 主题切换)。
10
10
  - [`CHATOS_UI_APPS_TROUBLESHOOTING.md`](./CHATOS_UI_APPS_TROUBLESHOOTING.md):常见问题与排查清单。
11
11
  - [`CHATOS_UI_PROMPTS_PROTOCOL.md`](./CHATOS_UI_PROMPTS_PROTOCOL.md):右下角笑脸「交互待办(UI Prompts)」协议(表单/单选/多选/复杂确认)。
12
+ - [`CHATOS_UI_APPS_ASYNC_TASK_PROTOCOL.md`](./CHATOS_UI_APPS_ASYNC_TASK_PROTOCOL.md):AsyncTask 轮询协议(ACK + 交互待办)。
12
13
  - [`CHATOS_UI_APPS_BACKEND_PROTOCOL.md`](./CHATOS_UI_APPS_BACKEND_PROTOCOL.md):插件后端(Electron main 进程)协议与 `ctx` 运行时上下文。
13
14
  - [`CHATOS_UI_APPS_AI_CONTRIBUTIONS.md`](./CHATOS_UI_APPS_AI_CONTRIBUTIONS.md):应用如何对 Chat Agent 暴露 MCP/Prompt(含命名规则、合并规则、内置清单机制)。
14
15
 
@@ -0,0 +1,100 @@
1
+ # ChatOS UI Apps:AsyncTask 轮询协议(MCP 调用)
2
+
3
+ 本协议用于描述 **ACK + 交互待办轮询** 的异步 MCP 调用模式。当 `callMeta.asyncTask` 配置命中某个工具时,ChatOS 不走流式通知,而是:
4
+
5
+ 1. 立即接收 MCP ACK(包含 taskId)
6
+ 2. 后台执行任务
7
+ 3. 结果写入「交互待办」(`ui-prompts.jsonl`)
8
+ 4. ChatOS 轮询匹配结果并返回给工具调用
9
+
10
+ > 关键点:**没有流式通知**,只能通过轮询 `ui-prompts.jsonl` 获取结果。
11
+
12
+ ## 1) 触发条件(callMeta.asyncTask)
13
+
14
+ 当 MCP server 的 `callMeta` 包含 `asyncTask`,且 `tools` 覆盖当前工具名时,ChatOS 会启用轮询模式。
15
+
16
+ 示例:
17
+ ```json
18
+ {
19
+ "asyncTask": {
20
+ "tools": ["codex_app_window_run"],
21
+ "taskIdKey": "taskId",
22
+ "resultSource": "ui_prompts",
23
+ "uiPromptFile": "ui-prompts.jsonl",
24
+ "pollIntervalMs": 1000
25
+ }
26
+ }
27
+ ```
28
+
29
+ 约定字段:
30
+ - `tools`: 触发异步模式的 tool 名列表(小写匹配)
31
+ - `taskIdKey`: taskId 字段名(默认 `taskId`)
32
+ - `resultSource`: 固定 `ui_prompts`
33
+ - `uiPromptFile`: 固定 `ui-prompts.jsonl`
34
+ - `pollIntervalMs`: 轮询间隔(200-5000ms)
35
+
36
+ ## 2) ACK 规范(MCP 返回)
37
+
38
+ 工具调用应立即返回 ACK,并带上 `taskId`:
39
+
40
+ ```json
41
+ {
42
+ "status": "accepted",
43
+ "taskId": "task_123"
44
+ }
45
+ ```
46
+
47
+ > ChatOS 不会依赖 ACK 的结构完成轮询,但该 ACK 对其它客户端/调试很重要。
48
+
49
+ ## 3) 结果写入(交互待办)
50
+
51
+ 异步结果必须写入 `ui-prompts.jsonl`,格式必须满足以下条件:
52
+
53
+ - `type = "ui_prompt"`
54
+ - `action = "request"`
55
+ - `requestId = taskId` 或 `mcp-task:<taskId>`
56
+ - `prompt.kind = "result"`
57
+
58
+ 示例:
59
+ ```json
60
+ {
61
+ "ts": "2025-01-01T00:00:00.000Z",
62
+ "type": "ui_prompt",
63
+ "action": "request",
64
+ "requestId": "task_123",
65
+ "prompt": {
66
+ "kind": "result",
67
+ "markdown": "final output"
68
+ }
69
+ }
70
+ ```
71
+
72
+ `prompt` 文本字段优先级(ChatOS 解析顺序):
73
+ 1. `prompt.markdown`
74
+ 2. `prompt.result`
75
+ 3. `prompt.content`
76
+
77
+ ## 4) 轮询规则(ChatOS)
78
+
79
+ ChatOS 会在 `ui-prompts.jsonl` 中按以下规则匹配结果:
80
+
81
+ - `type === "ui_prompt"`
82
+ - `action === "request"`
83
+ - `prompt.kind === "result"`
84
+ - `requestId in [taskId, "mcp-task:" + taskId]`
85
+
86
+ 匹配成功即返回结果文本给工具调用。
87
+
88
+ ## 5) 沙箱调试(Devkit)
89
+
90
+ Devkit 沙箱已提供 `uiPrompts` 内存存储与 UI 面板,可用于调试本协议:
91
+
92
+ - 通过 **MCP Test 面板的 AsyncTask Test** 按钮生成 taskId + 写入结果,并执行轮询匹配
93
+ - 在 MCP Output 中可看到轮询命中结果,UI Prompts 面板可看到 result 类型条目
94
+ - 沙箱会同时写入 `stateDir/ui-prompts.jsonl`(可在 MCP Test 的 Paths 中查看路径)
95
+
96
+ ## 6) 常见问题
97
+
98
+ - **轮询超时**:确认 `ui-prompts.jsonl` 中是否有正确格式的 `prompt.kind=result`。
99
+ - **找不到结果**:确认 `requestId` 为 `taskId` 或 `mcp-task:<taskId>`。
100
+ - **结果为空**:确认 `prompt.markdown/result/content` 至少存在一个文本字段。
@@ -9,6 +9,7 @@
9
9
  - [`CHATOS_UI_APPS_STYLE_GUIDE.md`](./CHATOS_UI_APPS_STYLE_GUIDE.md):主题与样式约定(CSS Tokens / 主题切换)。
10
10
  - [`CHATOS_UI_APPS_TROUBLESHOOTING.md`](./CHATOS_UI_APPS_TROUBLESHOOTING.md):常见问题与排查清单。
11
11
  - [`CHATOS_UI_PROMPTS_PROTOCOL.md`](./CHATOS_UI_PROMPTS_PROTOCOL.md):右下角笑脸「交互待办(UI Prompts)」协议(表单/单选/多选/复杂确认)。
12
+ - [`CHATOS_UI_APPS_ASYNC_TASK_PROTOCOL.md`](./CHATOS_UI_APPS_ASYNC_TASK_PROTOCOL.md):AsyncTask 轮询协议(ACK + 交互待办)。
12
13
  - [`CHATOS_UI_APPS_BACKEND_PROTOCOL.md`](./CHATOS_UI_APPS_BACKEND_PROTOCOL.md):插件后端(Electron main 进程)协议与 `ctx` 运行时上下文。
13
14
  - [`CHATOS_UI_APPS_AI_CONTRIBUTIONS.md`](./CHATOS_UI_APPS_AI_CONTRIBUTIONS.md):应用如何对 Chat Agent 暴露 MCP/Prompt(含命名规则、合并规则、内置清单机制)。
14
15