virlen-remote 0.5.0 → 0.7.0

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 (61) hide show
  1. package/CHANGELOG.md +373 -169
  2. package/README.md +389 -201
  3. package/dist/index.d.ts +10 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +10 -0
  6. package/dist/index.js.map +1 -1
  7. package/dist/protocol/agents.d.ts +14 -0
  8. package/dist/protocol/agents.d.ts.map +1 -0
  9. package/dist/protocol/agents.js +14 -0
  10. package/dist/protocol/agents.js.map +1 -0
  11. package/dist/protocol/api.d.ts +370 -1
  12. package/dist/protocol/api.d.ts.map +1 -1
  13. package/dist/protocol/api.js.map +1 -1
  14. package/dist/protocol/compress.d.ts +55 -0
  15. package/dist/protocol/compress.d.ts.map +1 -0
  16. package/dist/protocol/compress.js +60 -0
  17. package/dist/protocol/compress.js.map +1 -0
  18. package/dist/protocol/files.d.ts +261 -0
  19. package/dist/protocol/files.d.ts.map +1 -0
  20. package/dist/protocol/files.js +506 -0
  21. package/dist/protocol/files.js.map +1 -0
  22. package/dist/protocol/host.d.ts +24 -1
  23. package/dist/protocol/host.d.ts.map +1 -1
  24. package/dist/protocol/host.js +9 -1
  25. package/dist/protocol/host.js.map +1 -1
  26. package/dist/protocol/message-files.d.ts +79 -0
  27. package/dist/protocol/message-files.d.ts.map +1 -0
  28. package/dist/protocol/message-files.js +118 -0
  29. package/dist/protocol/message-files.js.map +1 -0
  30. package/dist/protocol/tool-args.d.ts +95 -0
  31. package/dist/protocol/tool-args.d.ts.map +1 -0
  32. package/dist/protocol/tool-args.js +439 -0
  33. package/dist/protocol/tool-args.js.map +1 -0
  34. package/dist/testing/mock-host.d.ts +44 -1
  35. package/dist/testing/mock-host.d.ts.map +1 -1
  36. package/dist/testing/mock-host.js +439 -10
  37. package/dist/testing/mock-host.js.map +1 -1
  38. package/dist/transport/rtc.d.ts +17 -0
  39. package/dist/transport/rtc.d.ts.map +1 -1
  40. package/dist/transport/rtc.js +49 -12
  41. package/dist/transport/rtc.js.map +1 -1
  42. package/dist/transport/signaling.d.ts +12 -1
  43. package/dist/transport/signaling.d.ts.map +1 -1
  44. package/dist/transport/signaling.js +17 -3
  45. package/dist/transport/signaling.js.map +1 -1
  46. package/package.json +69 -69
  47. package/src/index.ts +270 -184
  48. package/src/protocol/agents.ts +13 -0
  49. package/src/protocol/api.ts +974 -615
  50. package/src/protocol/compress.ts +65 -0
  51. package/src/protocol/files.ts +544 -0
  52. package/src/protocol/host.ts +205 -165
  53. package/src/protocol/message-actions.ts +60 -60
  54. package/src/protocol/message-files.ts +161 -0
  55. package/src/protocol/pairing.ts +254 -254
  56. package/src/protocol/tool-args.ts +478 -0
  57. package/src/testing/mock-host.ts +1285 -740
  58. package/src/transport/link-kind.ts +222 -222
  59. package/src/transport/rtc.ts +381 -344
  60. package/src/transport/signaling.ts +331 -317
  61. package/src/transport/transfer-tier.ts +64 -64
@@ -23,7 +23,12 @@
23
23
  import { BridgeError } from '../protocol/errors.js';
24
24
  import { answerActionError, normalizeChoiceAnswer } from '../protocol/answer.js';
25
25
  import { COMPRESS_MIN_RATIO, DEFAULT_CONTEXT_WINDOW_TOKENS } from '../protocol/api.js';
26
+ import { COMPRESS_MODE_CAPABILITY, DEFAULT_COMPRESS_MODE, compressModeOf, } from '../protocol/compress.js';
26
27
  import { MESSAGE_DELETE_CAPABILITY, MESSAGE_QUOTE_CAPABILITY } from '../protocol/message-actions.js';
28
+ import { MESSAGE_FILE_CAPABILITY, sanitizeFileRefs } from '../protocol/message-files.js';
29
+ import { SESSION_AGENT_CAPABILITY } from '../protocol/agents.js';
30
+ import { summarizeToolArgs, formatToolArgs } from '../protocol/tool-args.js';
31
+ import { FILE_BROWSE_CAPABILITY, FILE_CHUNK_BYTES, FILE_DOWNLOAD_CAPABILITY, FILE_EDIT_CAPABILITY, FILE_EDIT_MAX_BYTES, FILE_LIST_MAX_ENTRIES, FILE_UPLOAD_CAPABILITY, FILE_UPLOAD_MAX_BYTES, base64ToBytes, baseNameOfPath, bytesToBase64, compareFileEntries, duplicateNameCandidate, fileTransferDeniedReason, formatFileSize, isEditableFileName, isSafeEntryName, mimeTypeOf, normalizeRelPath, previewKindOf, } from '../protocol/files.js';
27
32
  /** 演示用模型服务(与 `sessions` 里的 provider 字段一致)。 */
28
33
  const DEMO_PROVIDERS = [
29
34
  { id: 'p-openai', name: 'OpenAI(演示)', models: ['gpt-4o', 'gpt-4o-mini'] },
@@ -31,6 +36,91 @@ const DEMO_PROVIDERS = [
31
36
  ];
32
37
  /** 演示用工作目录候选集(新建会话只能从这里选)。 */
33
38
  const DEMO_WORKSPACES = ['E:/code/virlen-demo', 'E:/code/another-project'];
39
+ /**
40
+ * 演示文件树的固定修改时刻 —— 不取 `Date.now()`:否则用例只能对着「大于某刻」断言,
41
+ * 而手机上要显示「3 分钟前修改」时又得再多一层换算。
42
+ */
43
+ const DEMO_FILE_MTIME = Date.UTC(2026, 9, 1, 12, 0, 0);
44
+ /**
45
+ * 标准 1×1 透明 PNG(68 字节)。
46
+ *
47
+ * 为什么放一个**真的**图片字节而不是随便一段文本叫 `logo.png`:图片预览这条路要验证的是
48
+ * 「字节 → base64 → Blob → ObjectURL → <img> 真的能显示」,而假图片会让这条路永远发绿灯。
49
+ */
50
+ const DEMO_PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
51
+ /** 演示文件树的初始内容(文本 / base64 两种写法)。 */
52
+ const DEMO_TREE_SPEC = [
53
+ {
54
+ path: 'README.md',
55
+ text: '# Virlen 演示项目\n\n这是一个用于验证**手机端文件浏览**的演示目录。\n\n- `src/` 下有 TypeScript 源码\n- `assets/logo.png` 是一张小图\n- `build/app.bin` 是未知类型(只能下载)\n',
56
+ },
57
+ {
58
+ path: 'src/index.ts',
59
+ text: [
60
+ // ⚠️ 演示内容里**不要**写相对导入说明符(构建脚本会把产物中所有形如相对导入的字符串
61
+ // 补上 `.js` 后缀,于是这段演示文本会被误报一句告警)。用路径别名即可。
62
+ "import { createApp } from '@/app'",
63
+ '',
64
+ 'const app = createApp({ port: 3000 })',
65
+ 'app.start()',
66
+ '',
67
+ ].join('\n'),
68
+ },
69
+ {
70
+ path: 'src/store/chat.ts',
71
+ text: [
72
+ 'export interface ChatState {',
73
+ ' messages: string[]',
74
+ ' working: boolean',
75
+ '}',
76
+ '',
77
+ 'export const INITIAL: ChatState = { messages: [], working: false }',
78
+ '',
79
+ ].join('\n'),
80
+ },
81
+ { path: 'src/app.ts', text: 'export function createApp(config: { port: number }) {\n return { start: () => console.log(config.port) }\n}\n' },
82
+ {
83
+ path: 'package.json',
84
+ text: '{\n "name": "virlen-demo",\n "version": "1.0.0",\n "private": true\n}\n',
85
+ },
86
+ { path: 'docs/notes.txt', text: '手机端浏览这个目录时,应该看到 src/ 与 docs/ 两个子目录。\n' },
87
+ { path: '.gitignore', text: 'node_modules/\ndist/\n' },
88
+ { path: 'assets/logo.png', base64: DEMO_PNG_BASE64 },
89
+ // 未知扩展名 → `previewKindOf` 给 binary → 手机端只给「下载」(这条路必须有用例盯着)
90
+ { path: 'build/app.bin', text: 'not-a-real-binary-but-unknown-extension' },
91
+ ];
92
+ /** 建一份演示文件树(相对工作目录的路径 → 文件)。每份工作目录各得一份,互不影响。 */
93
+ function createDemoTree() {
94
+ const encoder = new TextEncoder();
95
+ const tree = new Map();
96
+ for (const item of DEMO_TREE_SPEC) {
97
+ tree.set(item.path, {
98
+ bytes: item.base64 ? base64ToBytes(item.base64) : encoder.encode(item.text ?? ''),
99
+ mtimeMs: DEMO_FILE_MTIME,
100
+ });
101
+ }
102
+ return tree;
103
+ }
104
+ /**
105
+ * 演示用 Agent 候选集(与上面 `sessions` 里的 `agentId` / `agentName` 一致)。
106
+ *
107
+ * 两个 Agent 故意配**不同的默认模型 / 默认目录**:手机端切换 Agent 时能看出联动,
108
+ * 也能验证「不传模型 / 目录 = 用所选 Agent 的默认值」这条语义。
109
+ */
110
+ const DEMO_AGENTS = [
111
+ {
112
+ id: 'agent-virlen',
113
+ name: 'Virlen',
114
+ defaultModel: { providerConfigId: 'p-openai', modelId: 'gpt-4o' },
115
+ defaultWorkspace: DEMO_WORKSPACES[0],
116
+ },
117
+ {
118
+ id: 'agent-reviewer',
119
+ name: '代码评审员',
120
+ defaultModel: { providerConfigId: 'p-anthropic', modelId: 'claude-sonnet-4' },
121
+ defaultWorkspace: DEMO_WORKSPACES[1],
122
+ },
123
+ ];
34
124
  /**
35
125
  * 「只引用了消息、没写正文」时电脑侧补的那句话。
36
126
  *
@@ -40,6 +130,8 @@ const DEMO_WORKSPACES = ['E:/code/virlen-demo', 'E:/code/another-project'];
40
130
  * (真要与 i18n 一致,那是 virlen-app 侧的跨端用例该盯的事)。
41
131
  */
42
132
  const QUOTE_ONLY_TEXT = '请针对引用的消息回复';
133
+ /** 只带文件、不写正文时的兜底正文(与电脑侧 `buildUserContent` 的「看看这些文件」同义)。 */
134
+ const FILE_ONLY_TEXT = '看看这些文件';
43
135
  export function createMockHostDataSource(options = {}) {
44
136
  const streamSteps = options.streamSteps ?? 3;
45
137
  const streamDelayMs = options.streamDelayMs ?? 40;
@@ -106,6 +198,16 @@ export function createMockHostDataSource(options = {}) {
106
198
  text: 'src/index.ts\nsrc/store.ts\nsrc/ui/pages/Chat.tsx',
107
199
  createdAt: Date.now() - 105000,
108
200
  toolName: 'list_files',
201
+ /*
202
+ * 入参摘要与真实电脑侧**同源**(`summarizeToolArgs`):演示宿主不手写一行
203
+ * 会与真实格式化规则打架的假数据 —— 那种假数据会让联调时看到的现象失去意义。
204
+ */
205
+ toolArgs: summarizeToolArgs('list_files', { path: 'src' }),
206
+ /*
207
+ * 展开区的完整入参同样**同源**(`formatToolArgs`):演示宿主手写一段会在形态上
208
+ * 与真实电脑侧打架(缩进 / 路径缩不缩短),那会让联调时看到的现象失去意义。
209
+ */
210
+ toolArgsFull: formatToolArgs({ path: 'src' }),
109
211
  }, {
110
212
  id: 'empty-assistant',
111
213
  role: 'assistant',
@@ -212,6 +314,22 @@ export function createMockHostDataSource(options = {}) {
212
314
  runtime: { working: true, ...(progress ? { toolProgress: progress } : {}) },
213
315
  });
214
316
  }
317
+ /** 推一次「正在执行中的工具」(`RuntimeDTO.runningTools`)—— 同上,受订阅门约束。 */
318
+ function setRunningTools(sessionId, tools) {
319
+ if (tools === null) {
320
+ // 跑完了:没有执行中的工具,也不再工作(真机上这一步之后要么空闲、要么出新消息)
321
+ emitFor(sessionId, 'host.event.session.runtime.changed', {
322
+ sessionId,
323
+ runtime: { working: false },
324
+ });
325
+ return;
326
+ }
327
+ emitFor(sessionId, 'host.event.session.runtime.changed', {
328
+ sessionId,
329
+ // 工具在执行 → 一定是 working;空数组即「都执行完了,但本轮还没结束」
330
+ runtime: { working: true, ...(tools.length > 0 ? { runningTools: tools } : {}) },
331
+ });
332
+ }
215
333
  function contextOf(sessionId) {
216
334
  const tokens = contextTokens.get(sessionId);
217
335
  return {
@@ -219,6 +337,80 @@ export function createMockHostDataSource(options = {}) {
219
337
  windowTokens: DEFAULT_CONTEXT_WINDOW_TOKENS,
220
338
  };
221
339
  }
340
+ /* ───────────────────── §37:演示文件树(手机端文件浏览 / 上传) ───────────────────── */
341
+ /** 各工作目录一份演示文件树(懒建:没碰过就没有)。 */
342
+ const trees = new Map();
343
+ function treeOf(sessionId) {
344
+ const workspace = sessions.find((s) => s.id === sessionId)?.workspace;
345
+ if (!workspace)
346
+ throw new BridgeError('E_NOT_FOUND', `session not found: ${sessionId}`);
347
+ let tree = trees.get(workspace);
348
+ if (!tree) {
349
+ tree = createDemoTree();
350
+ trees.set(workspace, tree);
351
+ }
352
+ return tree;
353
+ }
354
+ /**
355
+ * 单调递增的修改时刻。
356
+ *
357
+ * 不用裸 `Date.now()`:同毫秒内的两次保存会拿到**相同**的 mtime,冲突就检不出来 ——
358
+ * 而「连续存两次」正是这条路径最常见的用法。
359
+ */
360
+ let mtimeSeq = 0;
361
+ function nowMtime() {
362
+ mtimeSeq = Math.max(Date.now(), mtimeSeq + 1);
363
+ return mtimeSeq;
364
+ }
365
+ /**
366
+ * 非中继门槛 —— **与真实电脑侧同一句文案**(`fileTransferDeniedReason`),
367
+ * 差别只在模拟开关:`MockHostOptions.fileLinkKind`。
368
+ */
369
+ function assertFileLinkUsable() {
370
+ const reason = fileTransferDeniedReason(options.fileLinkKind ?? 'direct');
371
+ if (reason)
372
+ throw new BridgeError('E_DENIED', reason);
373
+ }
374
+ /** 目录是否存在(目录由路径前缀隐式推出;根目录恒存在)。 */
375
+ function dirExists(tree, relPath) {
376
+ if (!relPath)
377
+ return true;
378
+ const prefix = `${relPath}/`;
379
+ for (const key of tree.keys())
380
+ if (key.startsWith(prefix))
381
+ return true;
382
+ return false;
383
+ }
384
+ /** 把扁平的文件表按当前层拆成条目(目录在前、按名排序,超过上限即截断)。 */
385
+ function entriesOf(tree, relPath) {
386
+ const prefix = relPath ? `${relPath}/` : '';
387
+ const byName = new Map();
388
+ for (const [path, file] of tree) {
389
+ if (!path.startsWith(prefix))
390
+ continue;
391
+ const rest = path.slice(prefix.length);
392
+ const slash = rest.indexOf('/');
393
+ if (slash < 0) {
394
+ byName.set(rest, { name: rest, isDir: false, size: file.bytes.length, mtimeMs: file.mtimeMs });
395
+ }
396
+ else {
397
+ const dir = rest.slice(0, slash);
398
+ if (!byName.has(dir))
399
+ byName.set(dir, { name: dir, isDir: true, size: 0, mtimeMs: DEMO_FILE_MTIME });
400
+ }
401
+ }
402
+ const all = [...byName.values()].sort(compareFileEntries);
403
+ return { entries: all.slice(0, FILE_LIST_MAX_ENTRIES), truncated: all.length > FILE_LIST_MAX_ENTRIES };
404
+ }
405
+ const uploads = new Map();
406
+ /** 最后一次真正执行的压缩(见 `MockHostDataSource.lastCompress`)。 */
407
+ let lastCompress = null;
408
+ function requireUpload(uploadId) {
409
+ const upload = uploads.get(uploadId);
410
+ if (!upload)
411
+ throw new BridgeError('E_NOT_FOUND', `上传任务不存在或已结束:${uploadId}`);
412
+ return upload;
413
+ }
222
414
  const host = {
223
415
  calls,
224
416
  bind(fn) {
@@ -268,10 +460,26 @@ export function createMockHostDataSource(options = {}) {
268
460
  'session.workspace',
269
461
  'session.context',
270
462
  'session.compress',
463
+ // 压缩方式(§22):mock 真的按 `CompressParams.mode` 出不同产物,故如实声明 ——
464
+ // 不声明的话手机端只会渲染一个「压缩上下文」按钮,用例就测不到选择器那条路
465
+ COMPRESS_MODE_CAPABILITY,
466
+ // Agent 选择:mock 确实按候选集校验 agentId(见 createSession),故如实声明 ——
467
+ // 不声明的话手机端会隐藏选择器,用例就测不到真实链路
468
+ SESSION_AGENT_CAPABILITY,
271
469
  // 消息级操作:mock 确实实现了这两项(引用进内容块 / 截断删除),故如实声明 ——
272
470
  // 不声明的话手机端会隐藏入口,用例就测不到真实链路
273
471
  MESSAGE_QUOTE_CAPABILITY,
274
472
  MESSAGE_DELETE_CAPABILITY,
473
+ // 文件引用(`SendParams.files`):mock 真的做两步 —— 校验归一(`sanitizeFileRefs`,
474
+ // 与真电脑侧同一份)与「文件块不进 `text`」的投影规则 —— 故如实声明
475
+ MESSAGE_FILE_CAPABILITY,
476
+ // §37:文件四档 —— mock 真的按这四档实现了(列 / 读 / 写 / 覆写),故如实声明
477
+ FILE_BROWSE_CAPABILITY,
478
+ FILE_DOWNLOAD_CAPABILITY,
479
+ FILE_UPLOAD_CAPABILITY,
480
+ // 覆写已有文件(编辑保存):mock 真的按「目标必须存在 + mtime/size 校验 + 不改名」
481
+ // 实现了,故如实声明 —— 不声明的话手机端只会给只读预览,用例就测不到编辑那条路
482
+ FILE_EDIT_CAPABILITY,
275
483
  ],
276
484
  paired: true,
277
485
  deviceName: 'Virlen 电脑(演示)',
@@ -322,14 +530,27 @@ export function createMockHostDataSource(options = {}) {
322
530
  if (!list)
323
531
  throw new BridgeError('E_NOT_FOUND', `session not found: ${params.sessionId}`);
324
532
  const quotes = params.quotes ?? [];
533
+ /*
534
+ * 文件引用(§37 的延伸):与真电脑侧**同一个校验口径**(`sanitizeFileRefs`)——
535
+ * 形状非法 / 超上限 → `E_BAD_REQUEST` 拒整条。mock 若比真机宽松(比如照单全收),
536
+ * 手机端侧的处理分支就永远测不到,真机上才表现为「发了但什么都没发生」。
537
+ */
538
+ const sanitized = sanitizeFileRefs(params.files);
539
+ if (!sanitized.ok) {
540
+ throw new BridgeError('E_BAD_REQUEST', sanitized.reason);
541
+ }
542
+ const files = sanitized.files;
325
543
  const userMessage = {
326
544
  id: nextId('u'),
327
545
  role: 'user',
328
- // 与真实电脑侧的白名单投影同一条规则:引用**不进 `text`**(而是走 `quotes`),
329
- // 否则客户端会把它显示两遍(引用条 + 正文里的 `[引用] …`)
330
- text: params.text || (quotes.length > 0 ? QUOTE_ONLY_TEXT : ''),
546
+ // 与真实电脑侧的白名单投影同一条规则:引用与文件都**不进 `text`**
547
+ // (引用走 `quotes`、文件走 `files`),否则客户端会各显示两遍
548
+ // (引用条 / 文件 chip + 正文里的 `[引用] …` / `[文件] …`)
549
+ text: params.text || (quotes.length > 0 ? QUOTE_ONLY_TEXT : files.length > 0 ? FILE_ONLY_TEXT : ''),
331
550
  createdAt: Date.now(),
332
551
  ...(quotes.length > 0 ? { quotes: quotes.map((q) => ({ ...q })) } : {}),
552
+ // 无文件则整个字段不带(与 `quotes` 同一条纪律:不为旧手机端凭空多出一个空数组)
553
+ ...(files.length > 0 ? { files: files.map((f) => ({ ...f })) } : {}),
333
554
  };
334
555
  list.push(userMessage);
335
556
  emitFor(params.sessionId, 'host.event.message.added', { sessionId: params.sessionId, message: userMessage });
@@ -459,15 +680,24 @@ export function createMockHostDataSource(options = {}) {
459
680
  }
460
681
  workspace = match;
461
682
  }
462
- const model = resolveModel(params.providerConfigId, params.modelId);
683
+ /**
684
+ * Agent:**与真实电脑侧同一条防线** —— id 必须来自 `host.agent.list` 的候选集,
685
+ * 手机不能自造(未知 id 即 `E_BAD_REQUEST`)。不传 = 默认 Agent。
686
+ */
687
+ const agent = params.agentId != null ? DEMO_AGENTS.find((a) => a.id === params.agentId) : undefined;
688
+ if (params.agentId != null && !agent) {
689
+ throw new BridgeError('E_BAD_REQUEST', `Agent 不存在:${params.agentId}`);
690
+ }
691
+ // 模型:手机没给就用**所选 Agent** 的默认模型(与真实电脑侧 createSession 同序)
692
+ const model = resolveModel(params.providerConfigId ?? agent?.defaultModel?.providerConfigId, params.modelId ?? agent?.defaultModel?.modelId);
463
693
  sessions.unshift({
464
694
  id,
465
695
  title: params.title || '手机新建的会话',
466
696
  updatedAt: Date.now(),
467
697
  working: false,
468
- agentId: 'agent-virlen',
469
- agentName: 'Virlen',
470
- workspace: workspace ?? DEMO_WORKSPACES[0],
698
+ agentId: agent?.id ?? 'agent-virlen',
699
+ agentName: agent?.name ?? 'Virlen',
700
+ workspace: workspace ?? agent?.defaultWorkspace ?? DEMO_WORKSPACES[0],
471
701
  ...model,
472
702
  });
473
703
  messages.set(id, []);
@@ -540,11 +770,18 @@ export function createMockHostDataSource(options = {}) {
540
770
  bumpContext(params.sessionId, Math.max(0, (contextTokens.get(params.sessionId) ?? 0) - 1000));
541
771
  return { ok: true };
542
772
  },
543
- // ── §22:模型 / 工作目录 / 上下文 ──
773
+ // ── §22:模型 / Agent / 工作目录 / 上下文 ──
544
774
  async listModels() {
545
775
  calls.push('host.model.list');
546
776
  return DEMO_PROVIDERS.map((p) => ({ ...p, models: [...p.models] }));
547
777
  },
778
+ async listAgents() {
779
+ calls.push('host.agent.list');
780
+ return DEMO_AGENTS.map((a) => ({
781
+ ...a,
782
+ ...(a.defaultModel ? { defaultModel: { ...a.defaultModel } } : {}),
783
+ }));
784
+ },
548
785
  async setModel(params) {
549
786
  calls.push('host.session.setModel');
550
787
  const session = sessions.find((s) => s.id === params.sessionId);
@@ -570,14 +807,22 @@ export function createMockHostDataSource(options = {}) {
570
807
  return contextOf(params.sessionId);
571
808
  },
572
809
  /**
573
- * 压缩上下文:**与真实电脑侧同判据**(confirm + 最小占用比例),
810
+ * 压缩上下文:**与真实电脑侧同判据**(confirm + 最小占用比例 + 方式可辨认),
574
811
  * 然后把消息换成一条 summary,并推 `messages.reset`(手机重拉窗口)。
812
+ *
813
+ * `mode` 不同则产物不同(`ai` 是一句摘要口吻、`raw` 是「历史已重排为文本」)——
814
+ * 两种方式的差别在真机上恰恰就是产物形态,mock 若给同一句文本,两条支路就分不开。
575
815
  */
576
816
  async compress(params) {
577
817
  calls.push('host.session.compress');
578
818
  if (params.confirm !== true) {
579
819
  throw new BridgeError('E_CONFIRM_REQUIRED', '压缩上下文需二次确认(confirm:true)');
580
820
  }
821
+ // 没传 = 用缺省(真实电脑侧是「设置里的 contextCompressMode」);传了但不认识 → 拒
822
+ const mode = params.mode == null ? DEFAULT_COMPRESS_MODE : compressModeOf(params.mode);
823
+ if (!mode) {
824
+ throw new BridgeError('E_BAD_REQUEST', `未知的压缩方式: ${String(params.mode)}`);
825
+ }
581
826
  if (!sessions.some((s) => s.id === params.sessionId)) {
582
827
  throw new BridgeError('E_NOT_FOUND', `session not found: ${params.sessionId}`);
583
828
  }
@@ -589,9 +834,12 @@ export function createMockHostDataSource(options = {}) {
589
834
  const summary = {
590
835
  id: nextId('sum'),
591
836
  role: 'system',
592
- text: '[上下文摘要] 之前的内容已压缩为摘要。',
837
+ text: mode === 'raw'
838
+ ? '[上下文摘要] 正文压缩:整段历史已本地渲染为一段文本(正文一字未删)。'
839
+ : '[上下文摘要] 之前的内容已压缩为摘要。',
593
840
  createdAt: Date.now(),
594
841
  };
842
+ lastCompress = { sessionId: params.sessionId, mode };
595
843
  messages.set(params.sessionId, [summary]);
596
844
  emitFor(params.sessionId, 'host.event.session.messages.reset', { sessionId: params.sessionId });
597
845
  emitFor(params.sessionId, 'host.event.message.added', { sessionId: params.sessionId, message: summary });
@@ -601,6 +849,187 @@ export function createMockHostDataSource(options = {}) {
601
849
  /** 手动推一次占用变化(harness 按钮 / 测试用)。 */
602
850
  bumpContext,
603
851
  setToolProgress,
852
+ setRunningTools,
853
+ // ── §37:工作目录文件(与真实电脑侧同几条纪律:非中继拒 / 分块 / 临时态不落盘)──
854
+ async listFiles(params) {
855
+ calls.push('host.file.list');
856
+ assertFileLinkUsable();
857
+ const tree = treeOf(params.sessionId);
858
+ const relPath = normalizeRelPath(params.path ?? '');
859
+ if (!dirExists(tree, relPath)) {
860
+ throw new BridgeError('E_NOT_FOUND', `目录不存在:${relPath || '/'}`);
861
+ }
862
+ const { entries, truncated } = entriesOf(tree, relPath);
863
+ const workspace = sessions.find((s) => s.id === params.sessionId)?.workspace ?? '';
864
+ return {
865
+ relPath,
866
+ absPath: relPath ? `${workspace}/${relPath}` : workspace,
867
+ entries,
868
+ ...(truncated ? { truncated: true } : {}),
869
+ };
870
+ },
871
+ async readFile(params) {
872
+ calls.push('host.file.read');
873
+ assertFileLinkUsable();
874
+ const relPath = normalizeRelPath(params.path);
875
+ const file = treeOf(params.sessionId).get(relPath);
876
+ if (!file)
877
+ throw new BridgeError('E_NOT_FOUND', `文件不存在:${relPath}`);
878
+ const bytes = file.bytes;
879
+ const offset = Math.max(0, Math.floor(params.offset ?? 0));
880
+ const want = Math.max(1, Math.floor(params.length ?? FILE_CHUNK_BYTES));
881
+ const slice = bytes.subarray(offset, offset + Math.min(want, FILE_CHUNK_BYTES));
882
+ const name = baseNameOfPath(relPath);
883
+ return {
884
+ data: bytesToBase64(slice),
885
+ offset,
886
+ size: bytes.length,
887
+ eof: offset + slice.length >= bytes.length,
888
+ kind: previewKindOf(name),
889
+ mime: mimeTypeOf(name),
890
+ // 编辑保存的冲突校验靠它(手机端打开时记下、保存时回传)
891
+ mtimeMs: file.mtimeMs,
892
+ };
893
+ },
894
+ async beginFileWrite(params) {
895
+ calls.push('host.file.write.begin');
896
+ assertFileLinkUsable();
897
+ const tree = treeOf(params.sessionId);
898
+ const dir = normalizeRelPath(params.dir ?? '');
899
+ const target = (n) => (dir ? `${dir}/${n}` : n);
900
+ if (!isSafeEntryName(params.name)) {
901
+ throw new BridgeError('E_BAD_REQUEST', `文件名不合法:${params.name}`);
902
+ }
903
+ if (!Number.isFinite(params.size) || params.size < 0) {
904
+ throw new BridgeError('E_BAD_REQUEST', '文件大小非法');
905
+ }
906
+ // 超限在**开始前**就拒(而不是传到一半才说)—— 这是 u91cf 级纪律,与真实电脑侧同一条
907
+ if (params.size > FILE_UPLOAD_MAX_BYTES) {
908
+ throw new BridgeError('E_BAD_REQUEST', `文件超过上限(${formatFileSize(FILE_UPLOAD_MAX_BYTES)})`);
909
+ }
910
+ if (!dirExists(tree, dir))
911
+ throw new BridgeError('E_NOT_FOUND', `目录不存在:${dir || '/'}`);
912
+ /*
913
+ * ── 覆写(编辑保存):与上传是**两条不同的路** ──
914
+ * 这里的每一条都与真实电脑侧逐条对齐(否则手机端用例在 mock 上跑出来的行为不算数):
915
+ * 目标必须已存在、只收可编辑的扩展名、按编辑上限夹一次、mtime/size 不符即冲突。
916
+ */
917
+ if (params.overwrite) {
918
+ const existing = tree.get(target(params.name));
919
+ if (!existing) {
920
+ throw new BridgeError('E_NOT_FOUND', `文件不存在:${target(params.name)}`);
921
+ }
922
+ if (!isEditableFileName(params.name)) {
923
+ throw new BridgeError('E_BAD_REQUEST', `这类文件不支持编辑:${params.name}`);
924
+ }
925
+ if (params.size > FILE_EDIT_MAX_BYTES) {
926
+ throw new BridgeError('E_BAD_REQUEST', `文件超过编辑上限(${formatFileSize(FILE_EDIT_MAX_BYTES)})`);
927
+ }
928
+ if (params.expectMtimeMs != null && params.expectMtimeMs !== existing.mtimeMs) {
929
+ throw new BridgeError('E_CONFLICT', '电脑上的这份文件已经变了,请重新载入');
930
+ }
931
+ if (params.expectSize != null && params.expectSize !== existing.bytes.length) {
932
+ throw new BridgeError('E_CONFLICT', '电脑上的这份文件已经变了,请重新载入');
933
+ }
934
+ const editId = nextId('up');
935
+ uploads.set(editId, {
936
+ sessionId: params.sessionId,
937
+ dir,
938
+ name: params.name,
939
+ relPath: target(params.name),
940
+ bytes: new Uint8Array(params.size),
941
+ received: 0,
942
+ overwrite: true,
943
+ });
944
+ return { uploadId: editId, name: params.name, relPath: target(params.name), received: 0 };
945
+ }
946
+ let name = params.name;
947
+ if (tree.has(target(name))) {
948
+ if ((params.onConflict ?? 'rename') === 'reject') {
949
+ throw new BridgeError('E_CONFLICT', `同名文件已存在:${name}`);
950
+ }
951
+ let index = 1;
952
+ for (; index <= 100; index++) {
953
+ const candidate = duplicateNameCandidate(params.name, index);
954
+ if (!tree.has(target(candidate))) {
955
+ name = candidate;
956
+ break;
957
+ }
958
+ }
959
+ if (index > 100)
960
+ throw new BridgeError('E_CONFLICT', '同名文件过多,无法自动改名');
961
+ }
962
+ const uploadId = nextId('up');
963
+ uploads.set(uploadId, {
964
+ sessionId: params.sessionId,
965
+ dir,
966
+ name,
967
+ relPath: target(name),
968
+ bytes: new Uint8Array(params.size),
969
+ received: 0,
970
+ });
971
+ return { uploadId, name, relPath: target(name), received: 0 };
972
+ },
973
+ async writeFileChunk(params) {
974
+ calls.push('host.file.write.chunk');
975
+ assertFileLinkUsable();
976
+ const upload = requireUpload(params.uploadId);
977
+ // 乱序即拒:拼接错位得到的是一份「看起来成功了」的坏文件,比报错难查得多
978
+ if (params.offset !== upload.received) {
979
+ throw new BridgeError('E_CONFLICT', `分块偏移不对:期望 ${upload.received},收到 ${params.offset}`);
980
+ }
981
+ const chunk = base64ToBytes(params.data);
982
+ if (upload.received + chunk.length > upload.bytes.length) {
983
+ throw new BridgeError('E_BAD_REQUEST', '写入字节数超过申报的文件大小');
984
+ }
985
+ upload.bytes.set(chunk, upload.received);
986
+ upload.received += chunk.length;
987
+ return { received: upload.received };
988
+ },
989
+ async finishFileWrite(params) {
990
+ calls.push('host.file.write.finish');
991
+ assertFileLinkUsable();
992
+ const upload = requireUpload(params.uploadId);
993
+ // 字节数不符就不落盘:申报 1MB 只收到 300KB 时,用户在电脑上得到的是一份坏文件
994
+ if (upload.received !== upload.bytes.length) {
995
+ throw new BridgeError('E_BAD_REQUEST', `文件未传完:${upload.received}/${upload.bytes.length}`);
996
+ }
997
+ uploads.delete(params.uploadId);
998
+ // 写盘即换 mtime(编辑保存的冲突校验靠它)
999
+ const mtimeMs = nowMtime();
1000
+ treeOf(upload.sessionId).set(upload.relPath, { bytes: upload.bytes, mtimeMs });
1001
+ // 覆写与上传的收尾差别只有一句注释那么大:**同一个 relPath**,不做冲突改名
1002
+ return { name: upload.name, relPath: upload.relPath, size: upload.bytes.length, mtimeMs };
1003
+ },
1004
+ /**
1005
+ * 放弃上传:**不过非中继门槛**(只删自己的临时态)。
1006
+ *
1007
+ * 为何例外:上传开始后链路可能刚好被换成中继(或断了),而那时用户/手机端恰恰最需要
1008
+ * 把临时文件清掉。在一个只做清理的动作上又加一道链路门,只会把临时文件永久留在用户项目里。
1009
+ */
1010
+ async abortFileWrite(params) {
1011
+ calls.push('host.file.write.abort');
1012
+ uploads.delete(params.uploadId);
1013
+ return { ok: true };
1014
+ },
1015
+ /** 测试 / harness:直接读演示文件树里的一个文件(`null` = 不存在)。 */
1016
+ readMockFile(sessionId, relPath) {
1017
+ const file = treeOf(sessionId).get(normalizeRelPath(relPath));
1018
+ return file ? file.bytes.slice() : null;
1019
+ },
1020
+ /** 测试 / harness:往演示文件树里放一个文件(目录隐式创建)——模拟「电脑上有人改了它」。 */
1021
+ writeMockFile(sessionId, relPath, content) {
1022
+ const data = typeof content === 'string' ? new TextEncoder().encode(content) : content;
1023
+ treeOf(sessionId).set(normalizeRelPath(relPath), { bytes: data, mtimeMs: nowMtime() });
1024
+ },
1025
+ /** 测试 / harness:当前文件树里的全部相对路径(排序后)。 */
1026
+ listMockFiles(sessionId) {
1027
+ return [...treeOf(sessionId).keys()].sort();
1028
+ },
1029
+ /** 测试 / harness:最后一次真正执行的压缩(含**实际生效的方式**)。 */
1030
+ lastCompress() {
1031
+ return lastCompress;
1032
+ },
604
1033
  };
605
1034
  return host;
606
1035
  }