@leeoohoo/ui-apps-devkit 0.1.10 → 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.10",
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');
@@ -1013,7 +1064,46 @@ const mcpStatus = $('#mcpStatus');
1013
1064
  const mcpOutput = $('#mcpOutput');
1014
1065
  const mcpConfigHint = $('#mcpConfigHint');
1015
1066
 
1016
- const setPanelOpen = (open) => { panel.style.display = open ? 'flex' : 'none'; };
1067
+ const mcpStreamState = {
1068
+ active: false,
1069
+ seen: false,
1070
+ runId: '',
1071
+ };
1072
+
1073
+ const resetMcpStreamState = () => {
1074
+ mcpStreamState.active = false;
1075
+ mcpStreamState.seen = false;
1076
+ mcpStreamState.runId = '';
1077
+ };
1078
+
1079
+ const markMcpStreamStatus = (payload) => {
1080
+ const method = payload && typeof payload.method === 'string' ? payload.method : '';
1081
+ if (!method.startsWith('codex_app.window_run.')) return;
1082
+ const params = payload && typeof payload.params === 'object' ? payload.params : null;
1083
+ const runId = params && typeof params.runId === 'string' ? params.runId : '';
1084
+ if (runId && (!mcpStreamState.runId || mcpStreamState.runId === runId)) {
1085
+ mcpStreamState.runId = runId;
1086
+ }
1087
+ mcpStreamState.seen = true;
1088
+ const status = params && typeof params.status === 'string' ? params.status.toLowerCase() : '';
1089
+ const done =
1090
+ method === 'codex_app.window_run.done' ||
1091
+ method === 'codex_app.window_run.completed' ||
1092
+ params?.done === true ||
1093
+ ['completed', 'failed', 'aborted', 'cancelled'].includes(status);
1094
+ if (done) {
1095
+ mcpStreamState.active = false;
1096
+ setMcpStatus('Done');
1097
+ } else {
1098
+ mcpStreamState.active = true;
1099
+ setMcpStatus('Streaming...');
1100
+ }
1101
+ };
1102
+
1103
+ const setPanelOpen = (open) => {
1104
+ panel.style.display = open ? 'flex' : 'none';
1105
+ if (open) refreshUiPromptsFromFile();
1106
+ };
1017
1107
  fab.addEventListener('click', () => setPanelOpen(panel.style.display !== 'flex'));
1018
1108
  panelClose.addEventListener('click', () => setPanelOpen(false));
1019
1109
  window.addEventListener('chatos:uiPrompts:open', () => setPanelOpen(true));
@@ -1221,6 +1311,108 @@ const setMcpPanelOpen = (open) => {
1221
1311
  }
1222
1312
  };
1223
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
+
1224
1416
  const runMcpTest = async () => {
1225
1417
  const sendBtn = btnMcpSend;
1226
1418
  try {
@@ -1229,6 +1421,7 @@ const runMcpTest = async () => {
1229
1421
  setMcpStatus('Message is required.', true);
1230
1422
  return;
1231
1423
  }
1424
+ resetMcpStreamState();
1232
1425
  if (sendBtn) sendBtn.disabled = true;
1233
1426
  const payload = {
1234
1427
  messages: [{ role: 'user', text: message }],
@@ -1255,7 +1448,11 @@ const runMcpTest = async () => {
1255
1448
  if (Array.isArray(j?.toolTrace) && j.toolTrace.length > 0) {
1256
1449
  appendMcpOutput('toolTrace', j.toolTrace);
1257
1450
  }
1258
- setMcpStatus('Done');
1451
+ if (mcpStreamState.seen || mcpStreamState.active) {
1452
+ setMcpStatus('Waiting for MCP stream...');
1453
+ } else {
1454
+ setMcpStatus('Done');
1455
+ }
1259
1456
  } catch (err) {
1260
1457
  setMcpStatus(err?.message || String(err), true);
1261
1458
  } finally {
@@ -1378,6 +1575,10 @@ if (btnMcpClear)
1378
1575
  if (mcpOutput) mcpOutput.textContent = '';
1379
1576
  setMcpStatus('');
1380
1577
  });
1578
+ if (btnMcpAsync)
1579
+ btnMcpAsync.addEventListener('click', () => {
1580
+ runMcpAsyncTest();
1581
+ });
1381
1582
  if (btnMcpSend)
1382
1583
  btnMcpSend.addEventListener('click', () => {
1383
1584
  runMcpTest();
@@ -1391,18 +1592,31 @@ updateContextStatus();
1391
1592
 
1392
1593
  const entries = [];
1393
1594
  const listeners = new Set();
1394
- const emitUpdate = () => {
1395
- const payload = { path: '(sandbox)', entries: [...entries] };
1396
- for (const fn of listeners) { try { fn(payload); } catch {} }
1397
- 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();
1398
1611
  };
1399
1612
 
1400
1613
  const uuid = () => (globalThis.crypto?.randomUUID ? crypto.randomUUID() : String(Date.now()) + '-' + Math.random().toString(16).slice(2));
1401
1614
 
1402
- function renderPrompts() {
1615
+ function renderPrompts(sourceEntries = entries) {
1403
1616
  panelBody.textContent = '';
1404
1617
  const pending = new Map();
1405
- for (const e of entries) {
1618
+ const list = Array.isArray(sourceEntries) ? sourceEntries : [];
1619
+ for (const e of list) {
1406
1620
  if (e?.type !== 'ui_prompt') continue;
1407
1621
  const id = String(e?.requestId || '');
1408
1622
  if (!id) continue;
@@ -1453,8 +1667,15 @@ function renderPrompts() {
1453
1667
  };
1454
1668
 
1455
1669
  const submit = async (response) => {
1456
- entries.push({ ts: new Date().toISOString(), type: 'ui_prompt', action: 'response', requestId, response });
1457
- 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();
1458
1679
  };
1459
1680
 
1460
1681
  if (kind === 'result') {
@@ -1591,6 +1812,46 @@ const callSandboxChat = async (payload, signal) => {
1591
1812
  return j;
1592
1813
  };
1593
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
+
1594
1855
  const getTheme = () => currentTheme || resolveTheme();
1595
1856
 
1596
1857
  const host = {
@@ -1634,22 +1895,41 @@ const host = {
1634
1895
  },
1635
1896
  },
1636
1897
  uiPrompts: {
1637
- read: async () => ({ path: '(sandbox)', entries: [...entries] }),
1898
+ read: async () => {
1899
+ const fileEntries = await readUiPromptsFromFile();
1900
+ return { path: sandboxPaths.uiPromptsFile || '(sandbox)', entries: fileEntries };
1901
+ },
1638
1902
  onUpdate: (listener) => { listeners.add(listener); return () => listeners.delete(listener); },
1639
1903
  request: async (payload) => {
1640
1904
  const requestId = payload?.requestId ? String(payload.requestId) : uuid();
1641
1905
  const prompt = payload?.prompt && typeof payload.prompt === 'object' ? { ...payload.prompt } : null;
1642
1906
  if (prompt && !prompt.source) prompt.source = __SANDBOX__.pluginId + ':' + __SANDBOX__.appId;
1643
- entries.push({ ts: new Date().toISOString(), type: 'ui_prompt', action: 'request', requestId, runId: payload?.runId, prompt });
1644
- 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();
1645
1917
  return { ok: true, requestId };
1646
1918
  },
1647
1919
  respond: async (payload) => {
1648
1920
  const requestId = String(payload?.requestId || '');
1649
1921
  if (!requestId) throw new Error('requestId is required');
1650
1922
  const response = payload?.response && typeof payload.response === 'object' ? payload.response : null;
1651
- entries.push({ ts: new Date().toISOString(), type: 'ui_prompt', action: 'response', requestId, runId: payload?.runId, response });
1652
- 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();
1653
1933
  return { ok: true };
1654
1934
  },
1655
1935
  open: () => (setPanelOpen(true), { ok: true }),
@@ -1964,6 +2244,7 @@ try {
1964
2244
  if (!payload) return;
1965
2245
  const server = payload?.serverName ? String(payload.serverName) : 'mcp';
1966
2246
  const method = payload?.method ? String(payload.method) : 'notification';
2247
+ markMcpStreamStatus(payload);
1967
2248
  appendMcpOutput(server + ' ' + method, payload?.params?.text || payload);
1968
2249
  });
1969
2250
  } catch {
@@ -2227,6 +2508,8 @@ export async function startSandboxServer({ pluginDir, port = 4399, appId = '' })
2227
2508
  ctxBase.dataDir = path.join(sandboxRoot, 'data', ctxBase.pluginId);
2228
2509
  ensureDir(ctxBase.stateDir);
2229
2510
  ensureDir(ctxBase.dataDir);
2511
+ const uiPromptsPath = path.join(ctxBase.stateDir, UI_PROMPTS_FILENAME);
2512
+ ensureUiPromptsFile(uiPromptsPath);
2230
2513
  sandboxCallMeta = buildSandboxCallMeta({
2231
2514
  rawCallMeta: app?.ai?.mcp?.callMeta,
2232
2515
  context: {
@@ -2321,6 +2604,7 @@ export async function startSandboxServer({ pluginDir, port = 4399, appId = '' })
2321
2604
  dataDir: ctxBase.dataDir || '',
2322
2605
  pluginDir: ctxBase.pluginDir || '',
2323
2606
  stateDir: ctxBase.stateDir || '',
2607
+ uiPromptsFile: uiPromptsPath || '',
2324
2608
  sessionRoot: ctxBase.sessionRoot || '',
2325
2609
  projectRoot: ctxBase.projectRoot || '',
2326
2610
  };
@@ -2346,6 +2630,24 @@ export async function startSandboxServer({ pluginDir, port = 4399, appId = '' })
2346
2630
  return sendJson(res, 200, { ok: true, manifest });
2347
2631
  }
2348
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
+
2349
2651
  if (pathname === '/api/sandbox/llm-config') {
2350
2652
  if (req.method === 'GET') {
2351
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