@leterhong/multichat 1.6.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 (214) hide show
  1. package/.agents/plugins/marketplace.json +32 -0
  2. package/.agents/plugins/plugins/developer-toolkit/.codex-plugin/plugin.json +24 -0
  3. package/.agents/plugins/plugins/developer-toolkit/skills/code-review/SKILL.md +9 -0
  4. package/.agents/plugins/plugins/developer-toolkit/skills/code-review/agents/openai.yaml +3 -0
  5. package/.agents/plugins/plugins/developer-toolkit/skills/release-notes/SKILL.md +9 -0
  6. package/.agents/plugins/plugins/developer-toolkit/skills/release-notes/agents/openai.yaml +3 -0
  7. package/.agents/plugins/plugins/developer-toolkit/skills/security-review/SKILL.md +9 -0
  8. package/.agents/plugins/plugins/developer-toolkit/skills/security-review/agents/openai.yaml +3 -0
  9. package/.agents/plugins/plugins/developer-toolkit/skills/test-plan/SKILL.md +9 -0
  10. package/.agents/plugins/plugins/developer-toolkit/skills/test-plan/agents/openai.yaml +3 -0
  11. package/.agents/plugins/plugins/writing-toolkit/.codex-plugin/plugin.json +24 -0
  12. package/.agents/plugins/plugins/writing-toolkit/skills/meeting-notes/SKILL.md +9 -0
  13. package/.agents/plugins/plugins/writing-toolkit/skills/meeting-notes/agents/openai.yaml +3 -0
  14. package/.agents/plugins/plugins/writing-toolkit/skills/resume-tune/SKILL.md +9 -0
  15. package/.agents/plugins/plugins/writing-toolkit/skills/resume-tune/agents/openai.yaml +3 -0
  16. package/.agents/plugins/plugins/writing-toolkit/skills/weekly-report/SKILL.md +9 -0
  17. package/.agents/plugins/plugins/writing-toolkit/skills/weekly-report/agents/openai.yaml +3 -0
  18. package/.agents/skills/.gitkeep +1 -0
  19. package/.agents/skills/wanghong-handwritten-ppt/SKILL.md +172 -0
  20. package/.agents/skills/wanghong-handwritten-ppt/assets/HTML-PPT-LICENSE +21 -0
  21. package/.agents/skills/wanghong-handwritten-ppt/assets/NEAT-ANNOTATIONS-LICENSE +21 -0
  22. package/.agents/skills/wanghong-handwritten-ppt/assets/animations.css +138 -0
  23. package/.agents/skills/wanghong-handwritten-ppt/assets/base.css +150 -0
  24. package/.agents/skills/wanghong-handwritten-ppt/assets/deepseek-example.css +893 -0
  25. package/.agents/skills/wanghong-handwritten-ppt/assets/neat-annotations.css +152 -0
  26. package/.agents/skills/wanghong-handwritten-ppt/assets/preview-cover.png +0 -0
  27. package/.agents/skills/wanghong-handwritten-ppt/assets/preview-diagram.png +0 -0
  28. package/.agents/skills/wanghong-handwritten-ppt/assets/preview-summary.png +0 -0
  29. package/.agents/skills/wanghong-handwritten-ppt/assets/preview-table.png +0 -0
  30. package/.agents/skills/wanghong-handwritten-ppt/assets/runtime.js +960 -0
  31. package/.agents/skills/wanghong-handwritten-ppt/assets/template.css +198 -0
  32. package/.agents/skills/wanghong-handwritten-ppt/evals/evals.json +20 -0
  33. package/.agents/skills/wanghong-handwritten-ppt/examples/deepseek-v4-flash/index.html +414 -0
  34. package/.agents/skills/wanghong-handwritten-ppt/examples/deepseek-v4-flash/style.css +897 -0
  35. package/.agents/skills/wanghong-handwritten-ppt/references/prompts.md +170 -0
  36. package/.agents/skills/wanghong-handwritten-ppt/references/style-guide.md +133 -0
  37. package/.agents/skills/wanghong-handwritten-ppt/scripts/check_deck.py +41 -0
  38. package/.agents/skills/wanghong-handwritten-ppt/scripts/prepare_render_html.py +137 -0
  39. package/.agents/skills/wanghong-handwritten-ppt/scripts/render.sh +110 -0
  40. package/.agents/skills/wanghong-handwritten-ppt/templates/deck.html +94 -0
  41. package/.agents/skills/wanghong-handwritten-ppt/tests/test_font_contract.py +73 -0
  42. package/LICENSE +21 -0
  43. package/README.md +186 -0
  44. package/backend/adapters/README.md +77 -0
  45. package/backend/adapters/anthropic.ts +115 -0
  46. package/backend/adapters/base.ts +47 -0
  47. package/backend/adapters/index.ts +41 -0
  48. package/backend/adapters/lmstudio.ts +7 -0
  49. package/backend/adapters/moonshot.ts +7 -0
  50. package/backend/adapters/ollama.ts +7 -0
  51. package/backend/adapters/openai-compatible.ts +51 -0
  52. package/backend/adapters/openai.ts +7 -0
  53. package/backend/adapters/qwen.ts +112 -0
  54. package/backend/adapters/wenxin.ts +136 -0
  55. package/backend/adapters/zhipu.ts +93 -0
  56. package/backend/dist/adapters/anthropic.js +130 -0
  57. package/backend/dist/adapters/anthropic.js.map +1 -0
  58. package/backend/dist/adapters/base.js +38 -0
  59. package/backend/dist/adapters/base.js.map +1 -0
  60. package/backend/dist/adapters/index.js +39 -0
  61. package/backend/dist/adapters/index.js.map +1 -0
  62. package/backend/dist/adapters/lmstudio.js +8 -0
  63. package/backend/dist/adapters/lmstudio.js.map +1 -0
  64. package/backend/dist/adapters/moonshot.js +8 -0
  65. package/backend/dist/adapters/moonshot.js.map +1 -0
  66. package/backend/dist/adapters/ollama.js +8 -0
  67. package/backend/dist/adapters/ollama.js.map +1 -0
  68. package/backend/dist/adapters/openai-compatible.js +47 -0
  69. package/backend/dist/adapters/openai-compatible.js.map +1 -0
  70. package/backend/dist/adapters/openai.js +8 -0
  71. package/backend/dist/adapters/openai.js.map +1 -0
  72. package/backend/dist/adapters/qwen.js +103 -0
  73. package/backend/dist/adapters/qwen.js.map +1 -0
  74. package/backend/dist/adapters/wenxin.js +114 -0
  75. package/backend/dist/adapters/wenxin.js.map +1 -0
  76. package/backend/dist/adapters/zhipu.js +86 -0
  77. package/backend/dist/adapters/zhipu.js.map +1 -0
  78. package/backend/dist/extensions/demo-mcp-server.js +75 -0
  79. package/backend/dist/extensions/demo-mcp-server.js.map +1 -0
  80. package/backend/dist/extensions/importer.js +828 -0
  81. package/backend/dist/extensions/importer.js.map +1 -0
  82. package/backend/dist/extensions/manager.js +1154 -0
  83. package/backend/dist/extensions/manager.js.map +1 -0
  84. package/backend/dist/lib/catalog.js +30 -0
  85. package/backend/dist/lib/catalog.js.map +1 -0
  86. package/backend/dist/lib/context.js +59 -0
  87. package/backend/dist/lib/context.js.map +1 -0
  88. package/backend/dist/lib/errors.js +75 -0
  89. package/backend/dist/lib/errors.js.map +1 -0
  90. package/backend/dist/lib/provider-store.js +116 -0
  91. package/backend/dist/lib/provider-store.js.map +1 -0
  92. package/backend/dist/lib/redact.js +11 -0
  93. package/backend/dist/lib/redact.js.map +1 -0
  94. package/backend/dist/lib/runtime-store.js +28 -0
  95. package/backend/dist/lib/runtime-store.js.map +1 -0
  96. package/backend/dist/lib/sqlite-store.js +218 -0
  97. package/backend/dist/lib/sqlite-store.js.map +1 -0
  98. package/backend/dist/lib/sse.js +46 -0
  99. package/backend/dist/lib/sse.js.map +1 -0
  100. package/backend/dist/lib/ssrf.js +131 -0
  101. package/backend/dist/lib/ssrf.js.map +1 -0
  102. package/backend/dist/lib/store.js +84 -0
  103. package/backend/dist/lib/store.js.map +1 -0
  104. package/backend/dist/lib/usage.js +219 -0
  105. package/backend/dist/lib/usage.js.map +1 -0
  106. package/backend/dist/lib/util.js +38 -0
  107. package/backend/dist/lib/util.js.map +1 -0
  108. package/backend/dist/lib/workspace-store.js +152 -0
  109. package/backend/dist/lib/workspace-store.js.map +1 -0
  110. package/backend/dist/mcp.js +429 -0
  111. package/backend/dist/mcp.js.map +1 -0
  112. package/backend/dist/routes/agents.js +51 -0
  113. package/backend/dist/routes/agents.js.map +1 -0
  114. package/backend/dist/routes/assistants.js +32 -0
  115. package/backend/dist/routes/assistants.js.map +1 -0
  116. package/backend/dist/routes/chat.js +697 -0
  117. package/backend/dist/routes/chat.js.map +1 -0
  118. package/backend/dist/routes/control-plane.js +200 -0
  119. package/backend/dist/routes/control-plane.js.map +1 -0
  120. package/backend/dist/routes/conversations.js +66 -0
  121. package/backend/dist/routes/conversations.js.map +1 -0
  122. package/backend/dist/routes/extensions-import.js +22 -0
  123. package/backend/dist/routes/extensions-import.js.map +1 -0
  124. package/backend/dist/routes/import.js +85 -0
  125. package/backend/dist/routes/import.js.map +1 -0
  126. package/backend/dist/routes/mcp-servers.js +56 -0
  127. package/backend/dist/routes/mcp-servers.js.map +1 -0
  128. package/backend/dist/routes/meta.js +37 -0
  129. package/backend/dist/routes/meta.js.map +1 -0
  130. package/backend/dist/routes/mock.js +28 -0
  131. package/backend/dist/routes/mock.js.map +1 -0
  132. package/backend/dist/routes/plugins.js +41 -0
  133. package/backend/dist/routes/plugins.js.map +1 -0
  134. package/backend/dist/routes/providers.js +197 -0
  135. package/backend/dist/routes/providers.js.map +1 -0
  136. package/backend/dist/routes/runs.js +57 -0
  137. package/backend/dist/routes/runs.js.map +1 -0
  138. package/backend/dist/routes/skills.js +73 -0
  139. package/backend/dist/routes/skills.js.map +1 -0
  140. package/backend/dist/routes/tools.js +40 -0
  141. package/backend/dist/routes/tools.js.map +1 -0
  142. package/backend/dist/routes/usage.js +14 -0
  143. package/backend/dist/routes/usage.js.map +1 -0
  144. package/backend/dist/routes/workspaces.js +158 -0
  145. package/backend/dist/routes/workspaces.js.map +1 -0
  146. package/backend/dist/runtime/agent.js +518 -0
  147. package/backend/dist/runtime/agent.js.map +1 -0
  148. package/backend/dist/server.js +103 -0
  149. package/backend/dist/server.js.map +1 -0
  150. package/backend/dist/types.js +3 -0
  151. package/backend/dist/types.js.map +1 -0
  152. package/backend/extensions/demo-mcp-server.js +70 -0
  153. package/backend/extensions/importer.ts +709 -0
  154. package/backend/extensions/manager.ts +1055 -0
  155. package/backend/lib/catalog.ts +28 -0
  156. package/backend/lib/context.ts +61 -0
  157. package/backend/lib/errors.ts +78 -0
  158. package/backend/lib/provider-store.ts +116 -0
  159. package/backend/lib/redact.ts +9 -0
  160. package/backend/lib/runtime-store.ts +38 -0
  161. package/backend/lib/sqlite-store.ts +220 -0
  162. package/backend/lib/sse.ts +42 -0
  163. package/backend/lib/ssrf.ts +120 -0
  164. package/backend/lib/store.ts +79 -0
  165. package/backend/lib/usage.ts +212 -0
  166. package/backend/lib/util.ts +31 -0
  167. package/backend/lib/workspace-store.ts +150 -0
  168. package/backend/mcp.ts +417 -0
  169. package/backend/package.json +34 -0
  170. package/backend/prompts.json +82 -0
  171. package/backend/routes/agents.ts +55 -0
  172. package/backend/routes/assistants.ts +30 -0
  173. package/backend/routes/chat.ts +660 -0
  174. package/backend/routes/control-plane.ts +165 -0
  175. package/backend/routes/conversations.ts +62 -0
  176. package/backend/routes/extensions-import.ts +15 -0
  177. package/backend/routes/import.ts +72 -0
  178. package/backend/routes/mcp-servers.ts +36 -0
  179. package/backend/routes/meta.ts +38 -0
  180. package/backend/routes/mock.ts +25 -0
  181. package/backend/routes/plugins.ts +27 -0
  182. package/backend/routes/providers.ts +166 -0
  183. package/backend/routes/runs.ts +55 -0
  184. package/backend/routes/skills.ts +52 -0
  185. package/backend/routes/tools.ts +40 -0
  186. package/backend/routes/usage.ts +13 -0
  187. package/backend/routes/workspaces.ts +124 -0
  188. package/backend/runtime/agent.ts +474 -0
  189. package/backend/server.ts +106 -0
  190. package/backend/types.ts +126 -0
  191. package/bin/multichat.mjs +535 -0
  192. package/frontend/dist/assets/agentPicker-C39VzmYa.js +13 -0
  193. package/frontend/dist/assets/compare-BpaGD32J.js +1 -0
  194. package/frontend/dist/assets/conversations-COuLmg99.js +7 -0
  195. package/frontend/dist/assets/data-ZH7Dux1K.js +1 -0
  196. package/frontend/dist/assets/extensionImport-CnTWKtWm.js +28 -0
  197. package/frontend/dist/assets/importExport-B3mB-9zs.js +70 -0
  198. package/frontend/dist/assets/index-CASVCOUO.js +34 -0
  199. package/frontend/dist/assets/index-DSvbjNLc.css +1 -0
  200. package/frontend/dist/assets/init-ZPK483vB.js +1 -0
  201. package/frontend/dist/assets/jsx-runtime-ZSsbr0oW.js +9 -0
  202. package/frontend/dist/assets/modal-CgHocv3n.js +2 -0
  203. package/frontend/dist/assets/modelPicker-EUmmVnbj.js +14 -0
  204. package/frontend/dist/assets/pluginsUI-B6jGUQkx.js +16 -0
  205. package/frontend/dist/assets/render-DgAg1K8A.js +1 -0
  206. package/frontend/dist/assets/send-BJhZ_5YA.js +14 -0
  207. package/frontend/dist/assets/settings-DdnYwZl-.js +2 -0
  208. package/frontend/dist/assets/shell-DFV2khQl.js +1 -0
  209. package/frontend/dist/assets/workbench-BY2J4vWC.js +41 -0
  210. package/frontend/dist/icon-maskable.svg +4 -0
  211. package/frontend/dist/icon.svg +4 -0
  212. package/frontend/dist/index.html +21 -0
  213. package/frontend/dist/manifest.webmanifest +26 -0
  214. package/package.json +58 -0
@@ -0,0 +1,120 @@
1
+ // SSRF 防护:拦截指向私有/内网地址的导入与远程资产请求。
2
+ // 注意:本地模型发现(/api/fetch-local-models)故意不在此拦截,因为它本就需要访问用户自己的 localhost 服务。
3
+ const { promises: dns } = require('dns');
4
+ const net = require('net');
5
+
6
+ function ipv4ToInt(ip: string): number {
7
+ const parts = ip.split('.').map(Number);
8
+ return (((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3]) >>> 0;
9
+ }
10
+
11
+ function inCidr(ipInt: number, base: number, bits: number): boolean {
12
+ if (bits === 0) return ipInt === (base >>> 0);
13
+ const mask = (0xffffffff << (32 - bits)) >>> 0;
14
+ return (ipInt & mask) === (base & mask);
15
+ }
16
+
17
+ const PRIVATE_V4: Array<[number, number]> = [
18
+ [0, 8], // 0.0.0.0/8
19
+ [ipv4ToInt('10.0.0.0'), 8],
20
+ [ipv4ToInt('100.64.0.0'), 10], // CGNAT
21
+ [ipv4ToInt('127.0.0.0'), 8], // loopback
22
+ [ipv4ToInt('169.254.0.0'), 16], // link-local
23
+ [ipv4ToInt('172.16.0.0'), 12],
24
+ [ipv4ToInt('192.0.0.0'), 24],
25
+ [ipv4ToInt('192.168.0.0'), 16],
26
+ [ipv4ToInt('198.18.0.0'), 15],
27
+ ];
28
+
29
+ function isPrivateV4(ip: string): boolean {
30
+ const n = ipv4ToInt(ip);
31
+ if (n === 0) return true;
32
+ return PRIVATE_V4.some(([base, bits]) => inCidr(n, base, bits));
33
+ }
34
+
35
+ function isPrivateV6(ip: string): boolean {
36
+ const a = ip.toLowerCase();
37
+ if (a === '::1' || a === '::') return true;
38
+ if (a.startsWith('fe80:')) return true; // link-local
39
+ if (a.startsWith('fc') || a.startsWith('fd')) return true; // unique local fc00::/7
40
+ const mapped = mappedV4(a);
41
+ if (mapped) return isPrivateV4(mapped);
42
+ return false;
43
+ }
44
+
45
+ function mappedV4(ip: string): string | null {
46
+ const dotted = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
47
+ if (dotted) return dotted[1];
48
+ const hex = ip.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
49
+ if (!hex) return null;
50
+ const high = Number.parseInt(hex[1], 16), low = Number.parseInt(hex[2], 16);
51
+ return `${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`;
52
+ }
53
+
54
+ function isMetadataAddress(ip: string): boolean {
55
+ const value = ip.toLowerCase();
56
+ const mapped = mappedV4(value);
57
+ const v4 = mapped || (net.isIP(value) === 4 ? value : null);
58
+ return Boolean(
59
+ v4 && (v4.startsWith('0.') || v4.startsWith('169.254.') || v4 === '100.100.100.200')
60
+ ) || value === '::' || value === 'fd00:ec2::254' || value.startsWith('fe80:');
61
+ }
62
+
63
+ async function assertSafeUrl(input: string, { allowPrivate = false }: { allowPrivate?: boolean } = {}): Promise<URL> {
64
+ let url: URL;
65
+ try {
66
+ url = new URL(input);
67
+ } catch {
68
+ throw new Error('非法 URL');
69
+ }
70
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
71
+ throw new Error('仅支持 http/https 链接');
72
+ }
73
+ // Node keeps square brackets around IPv6 URL hostnames; net.isIP expects the
74
+ // bare address. Normalize before applying private/metadata checks.
75
+ const host = url.hostname.replace(/^\[|\]$/g, '');
76
+ if (net.isIP(host) === 4) {
77
+ if (isMetadataAddress(host)) throw new Error('SSRF: 目标为保留、元数据或链路本地地址 ' + host);
78
+ if (!allowPrivate && isPrivateV4(host)) throw new Error('SSRF: 目标为内网地址 ' + host);
79
+ } else if (net.isIP(host) === 6) {
80
+ if (isMetadataAddress(host)) throw new Error('SSRF: 目标为保留、元数据或链路本地地址 ' + host);
81
+ if (!allowPrivate && isPrivateV6(host)) throw new Error('SSRF: 目标为内网地址 ' + host);
82
+ } else {
83
+ const addrs = await dns.lookup(host, { all: true });
84
+ for (const { address } of addrs) {
85
+ const v = net.isIP(address);
86
+ if (isMetadataAddress(address)) throw new Error('SSRF: 解析到保留、元数据或链路本地地址 ' + address);
87
+ if (v === 4 && !allowPrivate && isPrivateV4(address)) throw new Error('SSRF: 解析到内网地址 ' + address);
88
+ if (v === 6 && !allowPrivate && isPrivateV6(address)) throw new Error('SSRF: 解析到内网地址 ' + address);
89
+ }
90
+ }
91
+ return url;
92
+ }
93
+
94
+ // 带 SSRF 校验的 fetch:手动处理重定向,每次跳转都重新校验目标地址。
95
+ async function safeFetch(input: string, options: RequestInit = {}, allowPrivate = false, depth = 0): Promise<Response> {
96
+ const url = await assertSafeUrl(input, { allowPrivate });
97
+ const resp = await fetch(url, { ...options, redirect: 'manual' });
98
+ if ([301, 302, 307, 308].includes(resp.status) && depth < 3) {
99
+ const loc = resp.headers.get('location');
100
+ if (!loc) throw new Error('重定向缺少 Location');
101
+ const nextUrl = new URL(loc, url);
102
+ let nextOptions = options;
103
+ if (nextUrl.origin !== url.origin && options.headers) {
104
+ // Never forward credentials, cookies, MCP sessions or arbitrary custom
105
+ // headers to another origin. Only protocol/content negotiation headers
106
+ // are safe to retain across the redirect boundary.
107
+ const current = new Headers(options.headers);
108
+ const forwarded = new Headers();
109
+ for (const name of ['accept', 'accept-language', 'content-type', 'mcp-protocol-version']) {
110
+ const value = current.get(name);
111
+ if (value !== null) forwarded.set(name, value);
112
+ }
113
+ nextOptions = { ...options, headers: forwarded };
114
+ }
115
+ return safeFetch(nextUrl.toString(), nextOptions, allowPrivate, depth + 1);
116
+ }
117
+ return resp;
118
+ }
119
+
120
+ module.exports = { isPrivateV4, isPrivateV6, assertSafeUrl, safeFetch };
@@ -0,0 +1,79 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ import type { JsonStore } from '../types';
4
+
5
+ function createJsonStore(rootDir: string): JsonStore {
6
+ // per-file 串行化队列:防止多个并发请求对同一 JSON 做 read-modify-write 时互相覆盖
7
+ const chains = new Map<string, Promise<unknown>>();
8
+
9
+ function resolve(name: string) {
10
+ if (typeof name !== 'string' || !name || path.basename(name) !== name) {
11
+ throw new Error('invalid data file name');
12
+ }
13
+ return path.join(rootDir, name);
14
+ }
15
+
16
+ function read<T>(name: string, fallback: T): T {
17
+ const file = resolve(name);
18
+ if (!fs.existsSync(file)) return fallback;
19
+ try {
20
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
21
+ } catch {
22
+ // A partially written/corrupted JSON file should not silently erase the
23
+ // user's view of their data. Every successful write keeps one local
24
+ // recovery copy, so prefer that before falling back to an empty value.
25
+ const backup = `${file}.bak`;
26
+ try {
27
+ if (fs.existsSync(backup)) return JSON.parse(fs.readFileSync(backup, 'utf8'));
28
+ } catch {}
29
+ return fallback;
30
+ }
31
+ }
32
+
33
+ function write(name: string, value: unknown) {
34
+ const file = resolve(name);
35
+ fs.mkdirSync(rootDir, { recursive: true });
36
+ const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
37
+ fs.writeFileSync(temp, JSON.stringify(value, null, 2), 'utf8');
38
+ // Keep one known-good recovery point. On Windows rename-over-existing can
39
+ // fail with EPERM, so use a guarded replacement fallback while retaining
40
+ // the backup. The temporary file is always cleaned up.
41
+ try {
42
+ if (fs.existsSync(file)) fs.copyFileSync(file, `${file}.bak`);
43
+ try {
44
+ fs.renameSync(temp, file);
45
+ } catch (error) {
46
+ if (!['EEXIST', 'EPERM'].includes(error?.code)) throw error;
47
+ fs.rmSync(file, { force: true });
48
+ fs.renameSync(temp, file);
49
+ }
50
+ } finally {
51
+ if (fs.existsSync(temp)) fs.rmSync(temp, { force: true });
52
+ }
53
+ }
54
+
55
+ function remove(name: string) {
56
+ const file = resolve(name);
57
+ if (fs.existsSync(file)) fs.unlinkSync(file);
58
+ }
59
+
60
+ // 串行化 read-modify-write:fn(currentValue) => newValue(或 undefined 表示不写回)。
61
+ // 同一 name 的多次 mutate 会严格按调用顺序排队执行,杜绝并发覆盖。
62
+ function mutate<T>(name: string, fn: (current: T) => T | undefined | Promise<T | undefined>, fallback: T) {
63
+ const prev = chains.get(name) || Promise.resolve();
64
+ const run = prev
65
+ .then(() => fn(read(name, fallback)))
66
+ .then((result) => {
67
+ if (result === undefined) return result;
68
+ write(name, result);
69
+ return result;
70
+ });
71
+ // 存入"已处理 rejection"的链,避免后续排队因前序失败而中断,也避免 unhandled rejection
72
+ chains.set(name, run.then(() => {}, () => {}));
73
+ return run;
74
+ }
75
+
76
+ return { kind: 'json', read, write, remove, resolve, mutate };
77
+ }
78
+
79
+ module.exports = { createJsonStore };
@@ -0,0 +1,212 @@
1
+ const crypto = require('crypto');
2
+ import type { JsonRecord, JsonStore } from '../types';
3
+
4
+ const USAGE_FILE = 'usage.json';
5
+ const SCHEMA_VERSION = 1;
6
+ const MAX_RECORDS = 20_000;
7
+ const RETENTION_MS = 400 * 24 * 60 * 60 * 1000;
8
+
9
+ function finite(value: unknown): number {
10
+ const number = Number(value);
11
+ return Number.isFinite(number) && number > 0 ? Math.round(number) : 0;
12
+ }
13
+
14
+ function tokenFields(raw: JsonRecord = {}) {
15
+ const input = finite(raw.prompt_tokens ?? raw.input_tokens);
16
+ const output = finite(raw.completion_tokens ?? raw.output_tokens);
17
+ const total = finite(raw.total_tokens) || input + output;
18
+ const cached = finite(raw.cached_tokens ?? raw.prompt_tokens_details?.cached_tokens ?? raw.input_tokens_details?.cache_read_tokens);
19
+ const reasoning = finite(raw.reasoning_tokens ?? raw.completion_tokens_details?.reasoning_tokens);
20
+ return { input, output, total, cached, reasoning };
21
+ }
22
+
23
+ function estimateText(value: unknown): number {
24
+ if (value == null) return 0;
25
+ const text = typeof value === 'string' ? value : JSON.stringify(value);
26
+ // A deterministic local estimate for providers that do not report usage.
27
+ // It is intentionally labelled as estimated in every API/UI surface.
28
+ return Math.max(0, Math.ceil(text.length / 4));
29
+ }
30
+
31
+ function mergeUsage(base: JsonRecord = {}, next: JsonRecord = {}) {
32
+ const left = tokenFields(base);
33
+ const right = tokenFields(next);
34
+ return {
35
+ prompt_tokens: Math.max(left.input, right.input),
36
+ completion_tokens: Math.max(left.output, right.output),
37
+ total_tokens: Math.max(left.total, right.total, Math.max(left.input, right.input) + Math.max(left.output, right.output)),
38
+ cached_tokens: Math.max(left.cached, right.cached),
39
+ reasoning_tokens: Math.max(left.reasoning, right.reasoning),
40
+ };
41
+ }
42
+
43
+ function normalizeUsage(raw: JsonRecord | null | undefined, messages: JsonRecord[] = [], output = '') {
44
+ const exact = tokenFields(raw || {});
45
+ if (exact.total > 0) return { ...exact, source: 'reported' };
46
+ const input = messages.reduce((sum, message) => sum + estimateText(message.content) + 4, 0);
47
+ const outputTokens = estimateText(output);
48
+ return { input, output: outputTokens, total: input + outputTokens, cached: 0, reasoning: 0, source: 'estimated' };
49
+ }
50
+
51
+ interface UsageLedger { version: number; records: JsonRecord[]; }
52
+
53
+ function ledger(store: JsonStore): UsageLedger {
54
+ const value = store.read<JsonRecord | JsonRecord[]>(USAGE_FILE, { version: SCHEMA_VERSION, records: [] });
55
+ if (Array.isArray(value)) return { version: SCHEMA_VERSION, records: value };
56
+ return { version: SCHEMA_VERSION, records: Array.isArray(value.records) ? value.records : [] };
57
+ }
58
+
59
+ async function recordUsage(store: JsonStore, input: JsonRecord) {
60
+ const now = new Date().toISOString();
61
+ const normalized = input.usage || normalizeUsage(input.rawUsage, input.messages, input.output);
62
+ const record = {
63
+ id: `use_${Date.now().toString(36)}_${crypto.randomBytes(3).toString('hex')}`,
64
+ timestamp: input.timestamp || now,
65
+ finishedAt: input.finishedAt || now,
66
+ providerId: String(input.providerId || 'unknown'),
67
+ providerName: String(input.providerName || input.providerId || 'Unknown'),
68
+ model: String(input.model || 'unknown'),
69
+ mode: input.mode === 'agent' ? 'agent' : 'chat',
70
+ status: input.status === 'error' || input.status === 'cancelled' ? input.status : 'success',
71
+ errorCode: input.errorCode ? String(input.errorCode).slice(0, 80) : null,
72
+ workspaceId: input.workspaceId || null,
73
+ projectId: input.projectId || null,
74
+ conversationId: input.conversationId || null,
75
+ runId: input.runId || null,
76
+ interactionId: input.interactionId || input.runId || input.conversationId || null,
77
+ step: finite(input.step),
78
+ durationMs: finite(input.durationMs),
79
+ inputTokens: finite(normalized.input),
80
+ outputTokens: finite(normalized.output),
81
+ totalTokens: finite(normalized.total) || finite(normalized.input) + finite(normalized.output),
82
+ cachedTokens: finite(normalized.cached),
83
+ reasoningTokens: finite(normalized.reasoning),
84
+ source: normalized.source === 'reported' ? 'reported' : 'estimated',
85
+ };
86
+ await store.mutate<UsageLedger | JsonRecord[]>(USAGE_FILE, current => {
87
+ const state = Array.isArray(current) ? { version: SCHEMA_VERSION, records: current } : current;
88
+ const cutoff = Date.now() - RETENTION_MS;
89
+ state.version = SCHEMA_VERSION;
90
+ state.records = [record, ...(state.records || [])]
91
+ .filter(item => Date.parse(item.timestamp || '') >= cutoff)
92
+ .slice(0, MAX_RECORDS);
93
+ return state;
94
+ }, { version: SCHEMA_VERSION, records: [] });
95
+ return record;
96
+ }
97
+
98
+ function localParts(timestamp: string, offsetMinutes: number) {
99
+ const parsed = Date.parse(timestamp);
100
+ if (!Number.isFinite(parsed)) return { date: '0000-00-00', hour: 0 };
101
+ const local = new Date(parsed - offsetMinutes * 60_000);
102
+ return {
103
+ date: local.toISOString().slice(0, 10),
104
+ hour: local.getUTCHours(),
105
+ };
106
+ }
107
+
108
+ function dateRange(days: number, offsetMinutes: number) {
109
+ const end = new Date(Date.now() - offsetMinutes * 60_000);
110
+ end.setUTCHours(0, 0, 0, 0);
111
+ const out = [];
112
+ for (let index = days - 1; index >= 0; index -= 1) {
113
+ out.push(new Date(end.getTime() - index * 86_400_000).toISOString().slice(0, 10));
114
+ }
115
+ return out;
116
+ }
117
+
118
+ function summarizeRows(rows: JsonRecord[], days: number, offsetMinutes: number) {
119
+ const dailyMap = new Map<string, JsonRecord>();
120
+ const modelMap = new Map<string, JsonRecord>();
121
+ const providerMap = new Map<string, JsonRecord>();
122
+ const hours = Array.from({ length: 24 }, () => 0);
123
+ const interactionIds = new Set<string>();
124
+ let inputTokens = 0, outputTokens = 0, totalTokens = 0, cachedTokens = 0, reasoningTokens = 0;
125
+ let reportedTokens = 0, estimatedTokens = 0, errors = 0, durationMs = 0, durationCount = 0;
126
+ for (const row of rows) {
127
+ const { date, hour } = localParts(row.timestamp, offsetMinutes);
128
+ const daily = dailyMap.get(date) || { date, inputTokens: 0, outputTokens: 0, totalTokens: 0, requests: 0, errors: 0, models: {} };
129
+ daily.inputTokens += finite(row.inputTokens);
130
+ daily.outputTokens += finite(row.outputTokens);
131
+ daily.totalTokens += finite(row.totalTokens);
132
+ daily.requests += 1;
133
+ if (row.status !== 'success') daily.errors += 1;
134
+ daily.models[row.model] = finite(daily.models[row.model]) + finite(row.totalTokens);
135
+ dailyMap.set(date, daily);
136
+
137
+ const modelKey = String(row.model || 'unknown');
138
+ const model = modelMap.get(modelKey) || { name: modelKey, totalTokens: 0, inputTokens: 0, outputTokens: 0, requests: 0 };
139
+ model.totalTokens += finite(row.totalTokens); model.inputTokens += finite(row.inputTokens); model.outputTokens += finite(row.outputTokens); model.requests += 1;
140
+ modelMap.set(modelKey, model);
141
+
142
+ const providerKey = String(row.providerId || 'unknown');
143
+ const provider = providerMap.get(providerKey) || { id: providerKey, name: row.providerName || providerKey, totalTokens: 0, requests: 0, errors: 0 };
144
+ provider.totalTokens += finite(row.totalTokens); provider.requests += 1; if (row.status !== 'success') provider.errors += 1;
145
+ providerMap.set(providerKey, provider);
146
+
147
+ inputTokens += finite(row.inputTokens); outputTokens += finite(row.outputTokens); totalTokens += finite(row.totalTokens);
148
+ cachedTokens += finite(row.cachedTokens); reasoningTokens += finite(row.reasoningTokens);
149
+ if (row.source === 'reported') reportedTokens += finite(row.totalTokens); else estimatedTokens += finite(row.totalTokens);
150
+ if (row.status !== 'success') errors += 1;
151
+ if (row.durationMs) { durationMs += finite(row.durationMs); durationCount += 1; }
152
+ hours[hour] += finite(row.totalTokens) || 1;
153
+ interactionIds.add(String(row.interactionId || row.runId || row.conversationId || row.id));
154
+ }
155
+
156
+ const dailyDates = dateRange(days, offsetMinutes);
157
+ const daily = dailyDates.map(date => dailyMap.get(date) || { date, inputTokens: 0, outputTokens: 0, totalTokens: 0, requests: 0, errors: 0, models: {} });
158
+ const models = [...modelMap.values()].filter(item => item.totalTokens > 0).sort((a, b) => b.totalTokens - a.totalTokens).map(item => ({ ...item, share: totalTokens ? item.totalTokens / totalTokens : 0 }));
159
+ const providers = [...providerMap.values()].sort((a, b) => b.totalTokens - a.totalTokens).map(item => ({ ...item, share: totalTokens ? item.totalTokens / totalTokens : 0 }));
160
+ const activeDates = new Set(rows.map(row => localParts(row.timestamp, offsetMinutes).date));
161
+ const today = dateRange(1, offsetMinutes)[0];
162
+ let currentStreak = 0;
163
+ for (let cursor = new Date(`${today}T00:00:00.000Z`); activeDates.has(cursor.toISOString().slice(0, 10)); cursor = new Date(cursor.getTime() - 86_400_000)) currentStreak += 1;
164
+ let longestStreak = 0, running = 0, previous = '';
165
+ for (const date of [...activeDates].sort()) {
166
+ const adjacent = previous && Date.parse(`${date}T00:00:00Z`) - Date.parse(`${previous}T00:00:00Z`) === 86_400_000;
167
+ running = adjacent ? running + 1 : 1; longestStreak = Math.max(longestStreak, running); previous = date;
168
+ }
169
+ const peakHour = hours.reduce((best, value, index) => value > hours[best] ? index : best, 0);
170
+ return {
171
+ totals: {
172
+ inputTokens, outputTokens, totalTokens, cachedTokens, reasoningTokens,
173
+ requests: rows.length, messages: interactionIds.size, sessions: interactionIds.size,
174
+ activeDays: activeDates.size, errors, successRate: rows.length ? (rows.length - errors) / rows.length : 1,
175
+ averagePerActiveDay: activeDates.size ? Math.round(totalTokens / activeDates.size) : 0,
176
+ averageDurationMs: durationCount ? Math.round(durationMs / durationCount) : 0,
177
+ reportedTokens, estimatedTokens, reportedShare: totalTokens ? reportedTokens / totalTokens : 0,
178
+ currentStreak, longestStreak, peakHour,
179
+ },
180
+ daily, models, providers,
181
+ };
182
+ }
183
+
184
+ function usageSummary(store: JsonStore, options: JsonRecord = {}) {
185
+ const all = ledger(store).records;
186
+ const offsetMinutes = Math.max(-840, Math.min(840, Number(options.offsetMinutes) || 0));
187
+ const requestedRange = String(options.range || '30');
188
+ const days = requestedRange === 'all'
189
+ ? Math.max(1, Math.min(400, all.length ? Math.ceil((Date.now() - Date.parse(all[all.length - 1].timestamp)) / 86_400_000) + 1 : 30))
190
+ : Math.max(1, Math.min(365, Number.parseInt(requestedRange, 10) || 30));
191
+ const startDate = dateRange(days, offsetMinutes)[0];
192
+ const rows = all.filter(row => localParts(row.timestamp, offsetMinutes).date >= startDate)
193
+ .filter(row => !options.projectId || row.projectId === options.projectId);
194
+ const summary = summarizeRows(rows, days, offsetMinutes);
195
+ const heatmapStart = dateRange(182, offsetMinutes)[0];
196
+ const heatmapRows = all
197
+ .filter(row => localParts(row.timestamp, offsetMinutes).date >= heatmapStart)
198
+ .filter(row => !options.projectId || row.projectId === options.projectId);
199
+ const heatmap = summarizeRows(heatmapRows, 182, offsetMinutes).daily.map(day => ({ date: day.date, totalTokens: day.totalTokens, requests: day.requests }));
200
+ return {
201
+ version: SCHEMA_VERSION,
202
+ range: requestedRange,
203
+ days,
204
+ timezoneOffset: offsetMinutes,
205
+ generatedAt: new Date().toISOString(),
206
+ ...summary,
207
+ heatmap,
208
+ recent: rows.slice(0, 30),
209
+ };
210
+ }
211
+
212
+ module.exports = { USAGE_FILE, mergeUsage, normalizeUsage, recordUsage, usageSummary };
@@ -0,0 +1,31 @@
1
+ // ── 跨路由共享的纯函数工具 ──────────────────────────────────────────────
2
+ // 从资产对象中剔除 content 字段,仅保留元数据(避免列表接口返回大正文)
3
+ function assetMeta<T extends { content?: unknown }>(asset: T | null | undefined) {
4
+ if (!asset) return asset;
5
+ const { content: _content, ...meta } = asset;
6
+ return meta;
7
+ }
8
+
9
+ async function readResponseText(response: Response, maxBytes = 2_000_000): Promise<string> {
10
+ const declared = Number(response.headers.get('content-length') || 0);
11
+ if (declared > maxBytes) throw new Error(`response exceeds ${maxBytes} bytes`);
12
+ if (!response.body) return '';
13
+ const reader = response.body.getReader();
14
+ const decoder = new TextDecoder();
15
+ let bytes = 0, output = '';
16
+ while (true) {
17
+ const { done, value } = await reader.read();
18
+ if (done) break;
19
+ bytes += value.byteLength;
20
+ if (bytes > maxBytes) { await reader.cancel(); throw new Error(`response exceeds ${maxBytes} bytes`); }
21
+ output += decoder.decode(value, { stream: true });
22
+ }
23
+ return output + decoder.decode();
24
+ }
25
+
26
+ async function readResponseJson(response: Response, maxBytes = 2_000_000) {
27
+ const text = await readResponseText(response, maxBytes);
28
+ return JSON.parse(text);
29
+ }
30
+
31
+ module.exports = { assetMeta, readResponseText, readResponseJson };
@@ -0,0 +1,150 @@
1
+ const { safeId } = require('./catalog');
2
+ import type { AssetRecord, JsonRecord, JsonStore, ProjectRecord, WorkspaceRecord } from '../types';
3
+
4
+ const WORKSPACE_FILE = 'workspaces.json';
5
+ const PROJECT_FILE = 'projects.json';
6
+ const ASSET_FILE = 'assets.json';
7
+
8
+ function now() { return new Date().toISOString(); }
9
+
10
+ function createWorkspaceStore(store: JsonStore) {
11
+ function workspaces(): WorkspaceRecord[] { return store.read(WORKSPACE_FILE, [] as WorkspaceRecord[]); }
12
+ function projects(): ProjectRecord[] { return store.read(PROJECT_FILE, [] as ProjectRecord[]); }
13
+ function assets(): AssetRecord[] { return store.read(ASSET_FILE, [] as AssetRecord[]); }
14
+
15
+ function ensureSeed() {
16
+ let rows = workspaces();
17
+ if (!rows.length) {
18
+ const timestamp = now();
19
+ rows = [{ id: 'ws_default', name: '默认工作区', description: 'MultiChat 本地 Agent 工作区', createdAt: timestamp, updatedAt: timestamp, defaultProjectId: 'pr_inbox' }];
20
+ store.write(WORKSPACE_FILE, rows);
21
+ }
22
+ let projectRows = projects();
23
+ if (!projectRows.length) {
24
+ const timestamp = now();
25
+ projectRows = [{ id: 'pr_inbox', workspaceId: rows[0].id, name: '收件箱', description: '未分类的对话和文件', createdAt: timestamp, updatedAt: timestamp }];
26
+ store.write(PROJECT_FILE, projectRows);
27
+ }
28
+ if (!store.read(ASSET_FILE, null)) store.write(ASSET_FILE, []);
29
+ }
30
+
31
+ function getWorkspace(id: string) { return workspaces().find(x => x.id === id) || null; }
32
+ function getProject(id: string) { return projects().find(x => x.id === id) || null; }
33
+ function getAsset(id: string) { return assets().find(x => x.id === id) || null; }
34
+ function requireWorkspace(id: string) {
35
+ const value = getWorkspace(id);
36
+ if (!value) throw new Error('workspace not found');
37
+ return value;
38
+ }
39
+ function requireProject(id: string) {
40
+ const value = getProject(id);
41
+ if (!value) throw new Error('project not found');
42
+ return value;
43
+ }
44
+
45
+ function createWorkspace(input: JsonRecord = {}) {
46
+ const timestamp = now();
47
+ const workspaceId = 'ws_' + Date.now().toString(36);
48
+ const projectId = 'pr_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
49
+ const row = { id: workspaceId, name: String(input.name || '新工作区').trim(), description: String(input.description || '').trim(), createdAt: timestamp, updatedAt: timestamp, defaultProjectId: projectId };
50
+ if (!row.name) throw new Error('workspace name is required');
51
+ const project = { id: projectId, workspaceId, name: '收件箱', description: '未分类的对话和文件', createdAt: timestamp, updatedAt: timestamp };
52
+ const rows = workspaces();
53
+ rows.unshift(row);
54
+ store.write(WORKSPACE_FILE, rows);
55
+ const projectRows = projects();
56
+ projectRows.unshift(project);
57
+ store.write(PROJECT_FILE, projectRows);
58
+ return row;
59
+ }
60
+
61
+ function updateWorkspace(id: string, input: JsonRecord = {}) {
62
+ const rows = workspaces();
63
+ const index = rows.findIndex(x => x.id === id);
64
+ if (index < 0) throw new Error('workspace not found');
65
+ const current = rows[index];
66
+ rows[index] = { ...current, ...(input.name !== undefined ? { name: String(input.name).trim() } : {}), ...(input.description !== undefined ? { description: String(input.description).trim() } : {}), updatedAt: now(), id: current.id };
67
+ if (!rows[index].name) throw new Error('workspace name is required');
68
+ store.write(WORKSPACE_FILE, rows);
69
+ return rows[index];
70
+ }
71
+
72
+ function removeWorkspace(id: string) {
73
+ const rows = workspaces();
74
+ if (rows.length <= 1) throw new Error('at least one workspace is required');
75
+ const workspace = requireWorkspace(id);
76
+ const remaining = rows.filter(x => x.id !== id);
77
+ const projectRows = projects().filter(x => x.workspaceId !== id);
78
+ const projectIds = new Set(projects().filter(x => x.workspaceId === id).map(x => x.id));
79
+ const assetRows = assets().filter(x => x.workspaceId !== id && !projectIds.has(x.projectId));
80
+ store.write(WORKSPACE_FILE, remaining);
81
+ store.write(PROJECT_FILE, projectRows);
82
+ store.write(ASSET_FILE, assetRows);
83
+ return workspace;
84
+ }
85
+
86
+ function createProject(input: JsonRecord = {}) {
87
+ const workspaceId = safeId(input.workspaceId || '', 'workspace id');
88
+ requireWorkspace(workspaceId);
89
+ const timestamp = now();
90
+ const row = { id: 'pr_' + Date.now().toString(36), workspaceId, name: String(input.name || '新项目').trim(), description: String(input.description || '').trim(), createdAt: timestamp, updatedAt: timestamp };
91
+ if (!row.name) throw new Error('project name is required');
92
+ const rows = projects();
93
+ rows.unshift(row);
94
+ store.write(PROJECT_FILE, rows);
95
+ return row;
96
+ }
97
+
98
+ function updateProject(id: string, input: JsonRecord = {}) {
99
+ const rows = projects();
100
+ const index = rows.findIndex(x => x.id === id);
101
+ if (index < 0) throw new Error('project not found');
102
+ const current = rows[index];
103
+ const nullableId = (value, label) => value == null || value === '' ? null : safeId(value, label);
104
+ rows[index] = {
105
+ ...current,
106
+ ...(input.name !== undefined ? { name: String(input.name).trim() } : {}),
107
+ ...(input.description !== undefined ? { description: String(input.description).trim() } : {}),
108
+ ...(input.defaultAgentId !== undefined ? { defaultAgentId: nullableId(input.defaultAgentId, 'agent id') } : {}),
109
+ ...(input.defaultProviderId !== undefined ? { defaultProviderId: nullableId(input.defaultProviderId, 'provider id') } : {}),
110
+ ...(input.defaultModel !== undefined ? { defaultModel: input.defaultModel == null || input.defaultModel === '' ? null : String(input.defaultModel).trim().slice(0, 200) } : {}),
111
+ updatedAt: now(), id: current.id, workspaceId: current.workspaceId,
112
+ };
113
+ if (!rows[index].name) throw new Error('project name is required');
114
+ store.write(PROJECT_FILE, rows);
115
+ return rows[index];
116
+ }
117
+
118
+ function removeProject(id: string) {
119
+ const project = requireProject(id);
120
+ if (projects().filter(x => x.workspaceId === project.workspaceId).length <= 1) throw new Error('a workspace must keep one project');
121
+ store.write(PROJECT_FILE, projects().filter(x => x.id !== id));
122
+ store.write(ASSET_FILE, assets().filter(x => x.projectId !== id));
123
+ return project;
124
+ }
125
+
126
+ function createAsset(input: JsonRecord = {}) {
127
+ const project = requireProject(safeId(input.projectId || '', 'project id'));
128
+ const content = String(input.content || '');
129
+ if (!content) throw new Error('asset content is required');
130
+ if (Buffer.byteLength(content, 'utf8') > 2_000_000) throw new Error('asset content exceeds 2 MB');
131
+ const timestamp = now();
132
+ const row: AssetRecord = { id: 'asset_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6), workspaceId: project.workspaceId, projectId: project.id, name: String(input.name || '未命名文件').trim(), mimeType: String(input.mimeType || 'text/plain'), size: Buffer.byteLength(content, 'utf8'), source: input.source === 'url' ? 'url' : 'local', url: input.url ? String(input.url) : null, content, createdAt: timestamp, updatedAt: timestamp };
133
+ if (!row.name) throw new Error('asset name is required');
134
+ const rows = assets();
135
+ rows.unshift(row);
136
+ store.write(ASSET_FILE, rows);
137
+ return row;
138
+ }
139
+
140
+ function removeAsset(id: string) {
141
+ const asset = getAsset(id);
142
+ if (!asset) throw new Error('asset not found');
143
+ store.write(ASSET_FILE, assets().filter(x => x.id !== id));
144
+ return asset;
145
+ }
146
+
147
+ return { ensureSeed, workspaces, projects, assets, getWorkspace, getProject, getAsset, requireWorkspace, requireProject, createWorkspace, updateWorkspace, removeWorkspace, createProject, updateProject, removeProject, createAsset, removeAsset };
148
+ }
149
+
150
+ module.exports = { createWorkspaceStore, WORKSPACE_FILE, PROJECT_FILE, ASSET_FILE };