dsh-gufa-code 0.0.0-stage → 1.0.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 (158) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +96 -2
  3. package/client.js +11548 -0
  4. package/cordis.patch.yml +8 -0
  5. package/docs/icon-180.png +0 -0
  6. package/docs/icon.svg +18 -0
  7. package/docs/market-submission.md +32 -0
  8. package/docs/screenshot-editor.jpg +0 -0
  9. package/docs/screenshot-ide.jpg +0 -0
  10. package/host/assets.js +31 -0
  11. package/host/chat.js +523 -0
  12. package/host/context.js +438 -0
  13. package/host/dev.js +35 -0
  14. package/host/fs.js +306 -0
  15. package/host/git.js +532 -0
  16. package/host/project.js +305 -0
  17. package/host/ssh.js +54 -0
  18. package/host/terminal-pty.js +237 -0
  19. package/host/terminal.js +947 -0
  20. package/icon.svg +18 -0
  21. package/index.js +63 -0
  22. package/locale/en.json +121 -0
  23. package/locale/zh.json +121 -0
  24. package/package.json +66 -4
  25. package/scripts/build-client.mjs +38 -0
  26. package/scripts/selftest-client.mjs +457 -0
  27. package/scripts/selftest-host.mjs +197 -0
  28. package/scripts/selftest-http.mjs +284 -0
  29. package/scripts/selftest-router.mjs +101 -0
  30. package/scripts/selftest-terminal-client.mjs +314 -0
  31. package/scripts/selftest-terminal-pty.mjs +190 -0
  32. package/scripts/selftest-terminal.mjs +351 -0
  33. package/scripts/test-chat-render.mjs +702 -0
  34. package/scripts/test-chat.mjs +761 -0
  35. package/scripts/test-editor.mjs +516 -0
  36. package/scripts/verify-workspace-sessions.mjs +135 -0
  37. package/src/client/00-core.js +1686 -0
  38. package/src/client/10-icons.js +205 -0
  39. package/src/client/20-editor.js +1318 -0
  40. package/src/client/30-terminal.js +1510 -0
  41. package/src/client/40-chat.js +1184 -0
  42. package/src/client/46-gitgraph.js +278 -0
  43. package/src/client/50-panels.js +916 -0
  44. package/src/client/55-backdrop.js +508 -0
  45. package/src/client/60-dock.js +662 -0
  46. package/src/client/85-shortcuts.js +516 -0
  47. package/src/client/90-app.js +2272 -0
  48. package/src/client/99-main.js +449 -0
  49. package/vendor/monaco/LICENSE +21 -0
  50. package/vendor/monaco/ThirdPartyNotices.txt +448 -0
  51. package/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
  52. package/vendor/monaco/vs/base/worker/workerMain.js +31 -0
  53. package/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
  54. package/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
  55. package/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
  56. package/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
  57. package/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
  58. package/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
  59. package/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
  60. package/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
  61. package/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
  62. package/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
  63. package/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
  64. package/vendor/monaco/vs/basic-languages/css/css.js +12 -0
  65. package/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
  66. package/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
  67. package/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
  68. package/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
  69. package/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
  70. package/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
  71. package/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
  72. package/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
  73. package/vendor/monaco/vs/basic-languages/go/go.js +10 -0
  74. package/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
  75. package/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
  76. package/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
  77. package/vendor/monaco/vs/basic-languages/html/html.js +10 -0
  78. package/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
  79. package/vendor/monaco/vs/basic-languages/java/java.js +10 -0
  80. package/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
  81. package/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
  82. package/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
  83. package/vendor/monaco/vs/basic-languages/less/less.js +11 -0
  84. package/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
  85. package/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
  86. package/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
  87. package/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
  88. package/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
  89. package/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
  90. package/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
  91. package/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
  92. package/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
  93. package/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
  94. package/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
  95. package/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
  96. package/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
  97. package/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
  98. package/vendor/monaco/vs/basic-languages/php/php.js +10 -0
  99. package/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
  100. package/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
  101. package/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
  102. package/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
  103. package/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
  104. package/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
  105. package/vendor/monaco/vs/basic-languages/python/python.js +10 -0
  106. package/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
  107. package/vendor/monaco/vs/basic-languages/r/r.js +10 -0
  108. package/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
  109. package/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
  110. package/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
  111. package/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
  112. package/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
  113. package/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
  114. package/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
  115. package/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
  116. package/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
  117. package/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
  118. package/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
  119. package/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
  120. package/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
  121. package/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
  122. package/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
  123. package/vendor/monaco/vs/basic-languages/st/st.js +10 -0
  124. package/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
  125. package/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
  126. package/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
  127. package/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
  128. package/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
  129. package/vendor/monaco/vs/basic-languages/typespec/typespec.js +10 -0
  130. package/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
  131. package/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
  132. package/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
  133. package/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
  134. package/vendor/monaco/vs/editor/editor.main.css +8 -0
  135. package/vendor/monaco/vs/editor/editor.main.js +798 -0
  136. package/vendor/monaco/vs/language/css/cssMode.js +13 -0
  137. package/vendor/monaco/vs/language/css/cssWorker.js +77 -0
  138. package/vendor/monaco/vs/language/html/htmlMode.js +13 -0
  139. package/vendor/monaco/vs/language/html/htmlWorker.js +454 -0
  140. package/vendor/monaco/vs/language/json/jsonMode.js +19 -0
  141. package/vendor/monaco/vs/language/json/jsonWorker.js +42 -0
  142. package/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
  143. package/vendor/monaco/vs/language/typescript/tsWorker.js +51328 -0
  144. package/vendor/monaco/vs/loader.js +11 -0
  145. package/vendor/monaco/vs/nls.messages.de.js +21 -0
  146. package/vendor/monaco/vs/nls.messages.es.js +21 -0
  147. package/vendor/monaco/vs/nls.messages.fr.js +19 -0
  148. package/vendor/monaco/vs/nls.messages.it.js +19 -0
  149. package/vendor/monaco/vs/nls.messages.ja.js +21 -0
  150. package/vendor/monaco/vs/nls.messages.ko.js +19 -0
  151. package/vendor/monaco/vs/nls.messages.ru.js +21 -0
  152. package/vendor/monaco/vs/nls.messages.zh-cn.js +21 -0
  153. package/vendor/monaco/vs/nls.messages.zh-tw.js +19 -0
  154. package/vendor/xterm/LICENSE +21 -0
  155. package/vendor/xterm/LICENSE.addon-fit +19 -0
  156. package/vendor/xterm/addon-fit.js +2 -0
  157. package/vendor/xterm/xterm.css +285 -0
  158. package/vendor/xterm/xterm.js +2 -0
@@ -0,0 +1,761 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 聊天面板单测(纯函数 + mock 路由)。
4
+ *
5
+ * - host/chat.js:默认模型读取、prompt 拼装、失败文案、register 的 chat.info / chat.stream 行为
6
+ * (用假 llm.stream 覆盖成功、模型报错、客户端取消、120s 超时、服务缺失等路径)。
7
+ * - src/client/40-chat.js:localStorage 会话存取、markdown-lite 解析、上下文拼装等纯函数。
8
+ *
9
+ * 运行:node scripts/test-chat.mjs
10
+ */
11
+ import assert from 'node:assert/strict'
12
+ import { readFileSync } from 'node:fs'
13
+ import { dirname, join, resolve } from 'node:path'
14
+ import { fileURLToPath } from 'node:url'
15
+
16
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
17
+
18
+ let passed = 0
19
+ const failures = []
20
+
21
+ function test(name, fn) {
22
+ try {
23
+ const result = fn()
24
+ if (result instanceof Promise) {
25
+ throw new Error('use a synchronous test or await it explicitly')
26
+ }
27
+ passed += 1
28
+ console.log(` ✓ ${name}`)
29
+ } catch (error) {
30
+ failures.push({ name, error })
31
+ console.error(` ✗ ${name}`)
32
+ console.error(` ${error instanceof Error ? error.message : String(error)}`)
33
+ }
34
+ }
35
+
36
+ async function testAsync(name, fn) {
37
+ try {
38
+ await fn()
39
+ passed += 1
40
+ console.log(` ✓ ${name}`)
41
+ } catch (error) {
42
+ failures.push({ name, error })
43
+ console.error(` ✗ ${name}`)
44
+ console.error(` ${error instanceof Error ? error.message : String(error)}`)
45
+ }
46
+ }
47
+
48
+ /** 简易 mock:Cordis router(只记录注册的回调)。 */
49
+ function mockRouter() {
50
+ const methods = new Map()
51
+ const streams = new Map()
52
+ return {
53
+ methods,
54
+ streams,
55
+ method(name, fn) {
56
+ if (methods.has(name)) throw new Error(`duplicate ${name}`)
57
+ methods.set(name, fn)
58
+ },
59
+ stream(name, fn) {
60
+ if (streams.has(name)) throw new Error(`duplicate ${name}`)
61
+ streams.set(name, fn)
62
+ },
63
+ }
64
+ }
65
+
66
+ /** 简易 mock:Node SSE 响应对象。 */
67
+ function mockResponse() {
68
+ const listeners = new Map()
69
+ return {
70
+ headersSent: false,
71
+ writableEnded: false,
72
+ status: 0,
73
+ headers: null,
74
+ writeHead(status, headers) {
75
+ this.status = status
76
+ this.headers = headers
77
+ this.headersSent = true
78
+ return this
79
+ },
80
+ flushHeaders() {},
81
+ once(event, fn) {
82
+ if (!listeners.has(event)) listeners.set(event, [])
83
+ listeners.get(event).push(fn)
84
+ },
85
+ removeListener(event, fn) {
86
+ const list = listeners.get(event)
87
+ if (list === undefined) return
88
+ const index = list.indexOf(fn)
89
+ if (index >= 0) list.splice(index, 1)
90
+ },
91
+ emit(event) {
92
+ for (const fn of (listeners.get(event) ?? []).slice()) fn()
93
+ },
94
+ end() {
95
+ this.writableEnded = true
96
+ },
97
+ }
98
+ }
99
+
100
+ /** 执行一次 chat.stream,返回收集到的事件与相关上下文。 */
101
+ async function runStream(streamFn, body, options = {}) {
102
+ const res = mockResponse()
103
+ const events = []
104
+ const controller = new AbortController()
105
+ let closed = 0
106
+ const handler = streamFn(body, {
107
+ req: {},
108
+ res,
109
+ signal: controller.signal,
110
+ send(event) {
111
+ events.push(event)
112
+ },
113
+ close() {
114
+ closed += 1
115
+ res.end()
116
+ },
117
+ })
118
+ const { autoAbortMs, externalAbort } = options
119
+ if (typeof autoAbortMs === 'number') {
120
+ setTimeout(() => externalAbort?.(), autoAbortMs)
121
+ }
122
+ await handler
123
+ return { res, events, closed: () => closed, controller }
124
+ }
125
+
126
+ function sleep(ms) {
127
+ return new Promise((done) => setTimeout(done, ms))
128
+ }
129
+
130
+ /* ══════════════════════════════════════════════════════════════════════ */
131
+ /* 1. Host:纯函数 */
132
+ /* ══════════════════════════════════════════════════════════════════════ */
133
+ console.log('\n[host/chat.js] 纯函数')
134
+
135
+ const host = await import(join(root, 'host/chat.js'))
136
+
137
+ test('readDefaultSelection:无 context → 固定回落', () => {
138
+ assert.deepEqual(host.readDefaultSelection(undefined), { provider: 'deepseek-official', model: 'deepseek-flash' })
139
+ assert.deepEqual(host.readDefaultSelection(null), { provider: 'deepseek-official', model: 'deepseek-flash' })
140
+ assert.deepEqual(host.readDefaultSelection({ get() { throw new Error('boom') } }), { provider: 'deepseek-official', model: 'deepseek-flash' })
141
+ })
142
+
143
+ test('readDefaultSelection:读 agentDefaultModel 并带 reasoningEffort', () => {
144
+ const ctx = {
145
+ get(name) {
146
+ assert.equal(name, 'agentDefaultModel')
147
+ return { currentSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-flash', reasoningEffort: 'max' }) }
148
+ },
149
+ }
150
+ assert.deepEqual(host.readDefaultSelection(ctx), { provider: 'deepseek-official', model: 'deepseek-flash', reasoningEffort: 'max' })
151
+ })
152
+
153
+ test('readDefaultSelection:字段不完整 → 回落', () => {
154
+ const ctx = { get: () => ({ currentSelection: () => ({ provider: '', model: 'x' }) }) }
155
+ assert.deepEqual(host.readDefaultSelection(ctx), { provider: 'deepseek-official', model: 'deepseek-flash' })
156
+ })
157
+
158
+ test('resolveChatTarget:body 覆盖时使用覆盖值且不带默认推理档位', () => {
159
+ const ctx = { get: () => ({ currentSelection: () => ({ provider: 'a', model: 'b', reasoningEffort: 'max' }) }) }
160
+ assert.deepEqual(host.resolveChatTarget({ provider: 'p', model: 'm' }, ctx), { provider: 'p', model: 'm', reasoningEffort: undefined })
161
+ assert.deepEqual(host.resolveChatTarget({}, ctx), { provider: 'a', model: 'b', reasoningEffort: 'max' })
162
+ assert.deepEqual(host.resolveChatTarget({ model: 'm2' }, ctx), { provider: 'a', model: 'm2', reasoningEffort: undefined })
163
+ })
164
+
165
+ test('describeLlmFailure:稳定 code → 中文提示', () => {
166
+ assert.match(host.describeLlmFailure({ code: 'NO_ADAPTER', message: 'x' }), /没有可用的模型服务/)
167
+ assert.match(host.describeLlmFailure({ code: 'MISSING_CREDENTIAL' }), /API Key/)
168
+ assert.match(host.describeLlmFailure({ code: 'RATE_LIMIT' }), /频繁/)
169
+ assert.match(host.describeLlmFailure({ code: 'CONTEXT_WINDOW_EXCEEDED' }), /上下文窗口/)
170
+ assert.match(host.describeLlmFailure({ code: 'SOMETHING_NEW', message: 'raw detail' }), /raw detail(SOMETHING_NEW)/)
171
+ assert.equal(host.describeLlmFailure(undefined), '模型请求失败,请重试。')
172
+ })
173
+
174
+ test('normalizeMessages:过滤非法/空消息并截断超长内容', () => {
175
+ const long = 'x'.repeat(9000)
176
+ const messages = host.normalizeMessages([
177
+ null,
178
+ { role: 'system', content: 'ignored' },
179
+ { role: 'user', content: ' 你好 ' },
180
+ { role: 'assistant', content: '' },
181
+ { role: 'assistant', content: long },
182
+ 42,
183
+ ])
184
+ assert.equal(messages.length, 2)
185
+ assert.deepEqual(messages[0], { role: 'user', text: '你好' })
186
+ assert.equal(messages[1].role, 'assistant')
187
+ assert.ok(messages[1].text.length < long.length)
188
+ assert.match(messages[1].text, /已截断/)
189
+ })
190
+
191
+ test('buildChatPrompt:系统提示 + 上下文 + 历史 + 当前问题,选中代码优先', () => {
192
+ const prompt = host.buildChatPrompt({
193
+ messages: [
194
+ { role: 'user', content: '帮我看看排序' },
195
+ { role: 'assistant', content: '好的,请给我代码' },
196
+ { role: 'user', content: '这段代码有什么问题?' },
197
+ ],
198
+ context: {
199
+ root: '/tmp/demo',
200
+ path: 'src/sort.js',
201
+ language: 'javascript',
202
+ content: 'function sort(a){ return a }',
203
+ selection: 'return a',
204
+ selectionPath: 'src/sort.js',
205
+ selectionLine: 3,
206
+ },
207
+ })
208
+ assert.match(prompt, /古法编程/)
209
+ assert.match(prompt, /项目根目录:\/tmp\/demo/)
210
+ assert.match(prompt, /当前文件:src\/sort\.js(语言:javascript)/)
211
+ assert.ok(prompt.indexOf('用户选中的代码') > 0)
212
+ assert.ok(prompt.indexOf('用户选中的代码') < prompt.indexOf('当前文件内容'), '选中代码应排在文件内容前')
213
+ assert.match(prompt, /```javascript\nreturn a\n```/)
214
+ assert.match(prompt, /从第 3 行开始/)
215
+ assert.match(prompt, /【对话历史】[\s\S]*用户:帮我看看排序[\s\S]*助手:好的,请给我代码/)
216
+ assert.match(prompt, /【当前问题】\n这段代码有什么问题?$/)
217
+ })
218
+
219
+ test('buildChatPrompt:无上下文/无消息时给出兜底问题', () => {
220
+ const prompt = host.buildChatPrompt({})
221
+ assert.match(prompt, /【当前问题】/)
222
+ assert.ok(!prompt.includes('【代码上下文】'))
223
+ assert.ok(!prompt.includes('【对话历史】'))
224
+ })
225
+
226
+ test('buildChatPrompt:上下文超长被截断且提示语出现', () => {
227
+ const prompt = host.buildChatPrompt({
228
+ messages: [{ role: 'user', content: '解释' }],
229
+ context: { path: 'a.js', content: 'a'.repeat(50000), selection: 'b'.repeat(50000) },
230
+ })
231
+ assert.match(prompt, /选中代码过长,已截断到 20000 字符/)
232
+ assert.match(prompt, /当前文件内容(过长,已截断到 12000 字符)/)
233
+ assert.ok(prompt.length < 120_000)
234
+ })
235
+
236
+ test('buildChatPrompt:历史只保留最近部分并省略更早内容', () => {
237
+ const messages = []
238
+ for (let index = 0; index < 60; index += 1) {
239
+ messages.push({ role: index % 2 === 0 ? 'user' : 'assistant', content: `第 ${index} 条 ${'z'.repeat(2000)}` })
240
+ }
241
+ messages.push({ role: 'user', content: '最后一个问题' })
242
+ const prompt = host.buildChatPrompt({ messages })
243
+ assert.match(prompt, /较早的对话已省略/)
244
+ assert.match(prompt, /【当前问题】\n最后一个问题$/)
245
+ assert.ok(prompt.length <= 120_000)
246
+ })
247
+
248
+ test('normalizeContext:语言白名单与行号归一化', () => {
249
+ const context = host.normalizeContext({ language: '```evil', selection: 'x', selectionLine: 2.9, path: 'a' })
250
+ assert.equal(context.language, '')
251
+ assert.equal(context.selectionLine, 2)
252
+ assert.equal(host.normalizeContext(undefined).content, '')
253
+ })
254
+
255
+ /* ══════════════════════════════════════════════════════════════════════ */
256
+ /* 2. Host:register 行为(mock llm) */
257
+ /* ══════════════════════════════════════════════════════════════════════ */
258
+ console.log('\n[host/chat.js] register / chat.stream')
259
+
260
+ /** 构造 mock env.ctx:按服务名返回服务。 */
261
+ function mockCtx(services) {
262
+ return { get: (name) => services[name] }
263
+ }
264
+
265
+ await testAsync('chat.info:默认模型 + provider 已注册 → ready=true', async () => {
266
+ const router = mockRouter()
267
+ host.register(router, {
268
+ ctx: mockCtx({
269
+ agentDefaultModel: { currentSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-flash', reasoningEffort: 'max' }) },
270
+ llm: { stream() {}, listProviders: () => [{ id: 'deepseek-official', name: 'DeepSeek' }] },
271
+ }),
272
+ })
273
+ const info = await router.methods.get('chat.info')({})
274
+ assert.deepEqual(info, { provider: 'deepseek-official', model: 'deepseek-flash', ready: true })
275
+ })
276
+
277
+ await testAsync('chat.info:没有 llm 服务 → ready=false 但仍给默认模型', async () => {
278
+ const router = mockRouter()
279
+ host.register(router, { ctx: mockCtx({}) })
280
+ const info = await router.methods.get('chat.info')({})
281
+ assert.equal(info.provider, 'deepseek-official')
282
+ assert.equal(info.model, 'deepseek-flash')
283
+ assert.equal(info.ready, false)
284
+ })
285
+
286
+ await testAsync('chat.info:llm 存在但 provider 未注册 → ready=false', async () => {
287
+ const router = mockRouter()
288
+ host.register(router, {
289
+ ctx: mockCtx({
290
+ agentDefaultModel: { currentSelection: () => ({ provider: 'p', model: 'm' }) },
291
+ llm: { stream() {}, listProviders: () => [{ id: 'q', name: 'Q' }] },
292
+ }),
293
+ })
294
+ const info = await router.methods.get('chat.info')({})
295
+ assert.equal(info.ready, false)
296
+ })
297
+
298
+ await testAsync('chat.stream:成功路径逐块转发 delta / thinking / done', async () => {
299
+ const captured = []
300
+ const router = mockRouter()
301
+ host.register(router, {
302
+ ctx: mockCtx({
303
+ agentDefaultModel: { currentSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-flash', reasoningEffort: 'max' }) },
304
+ llm: {
305
+ stream(options) {
306
+ captured.push(options)
307
+ return (async function* gen() {
308
+ yield { type: 'block-start', index: 0, blockType: 'reasoning' }
309
+ yield { type: 'reasoning-delta', index: 0, text: '想一想' }
310
+ yield { type: 'reasoning-delta', index: 0, text: '再想' }
311
+ yield { type: 'block-start', index: 1, blockType: 'text' }
312
+ yield { type: 'text-delta', index: 1, text: '你好' }
313
+ yield { type: 'text-delta', index: 1, text: ',世界' }
314
+ yield { type: 'usage', usage: { inputTokens: 1 } }
315
+ yield { type: 'finish', reason: { kind: 'stop' } }
316
+ })()
317
+ },
318
+ listProviders: () => [{ id: 'deepseek-official', name: 'DeepSeek' }],
319
+ },
320
+ }),
321
+ })
322
+ const { events, res, closed } = await runStream(router.streams.get('chat.stream'), {
323
+ messages: [{ role: 'user', content: '打个招呼' }],
324
+ context: { root: '/tmp/demo', path: 'a.js', language: 'javascript', content: 'const a = 1' },
325
+ })
326
+ assert.equal(res.status, 200)
327
+ assert.equal(res.headers['content-type'], 'text/event-stream; charset=utf-8')
328
+ assert.deepEqual(events, [
329
+ { type: 'thinking' },
330
+ { type: 'delta', text: '你好' },
331
+ { type: 'delta', text: ',世界' },
332
+ { type: 'done' },
333
+ ])
334
+ assert.equal(closed(), 1)
335
+ // 传给 llm 的请求:单条 user 文本、带上下文与问题、沿用默认模型和推理档位
336
+ assert.equal(captured.length, 1)
337
+ const options = captured[0]
338
+ assert.equal(options.provider, 'deepseek-official')
339
+ assert.equal(options.model, 'deepseek-flash')
340
+ assert.equal(options.reasoningEffort, 'max')
341
+ assert.equal(options.messages.length, 1)
342
+ assert.equal(options.messages[0].role, 'user')
343
+ assert.deepEqual(Object.keys(options.messages[0].content[0]), ['type', 'text'])
344
+ assert.match(options.messages[0].content[0].text, /const a = 1/)
345
+ assert.match(options.messages[0].content[0].text, /打个招呼/)
346
+ })
347
+
348
+ await testAsync('chat.stream:finish error → 映射中文 error 事件', async () => {
349
+ const router = mockRouter()
350
+ host.register(router, {
351
+ ctx: mockCtx({
352
+ llm: {
353
+ stream() {
354
+ return (async function* gen() {
355
+ yield { type: 'finish', reason: { kind: 'error', failure: { code: 'AUTH', message: 'bad key' } } }
356
+ })()
357
+ },
358
+ },
359
+ }),
360
+ })
361
+ const { events } = await runStream(router.streams.get('chat.stream'), { messages: [{ role: 'user', content: 'hi' }] })
362
+ assert.equal(events.length, 1)
363
+ assert.equal(events[0].type, 'error')
364
+ assert.match(events[0].message, /认证失败/)
365
+ assert.equal(events[0].code, 'AUTH')
366
+ })
367
+
368
+ await testAsync('chat.stream:llm.stream 抛错 → error 事件兜底', async () => {
369
+ const router = mockRouter()
370
+ host.register(router, {
371
+ ctx: mockCtx({
372
+ llm: {
373
+ stream() {
374
+ return (async function* gen() {
375
+ yield { type: 'text-delta', index: 0, text: '半' }
376
+ throw new Error('socket hang up')
377
+ })()
378
+ },
379
+ },
380
+ }),
381
+ })
382
+ const { events } = await runStream(router.streams.get('chat.stream'), { messages: [{ role: 'user', content: 'hi' }] })
383
+ assert.equal(events[0].type, 'delta')
384
+ assert.equal(events[1].type, 'error')
385
+ assert.match(events[1].message, /socket hang up/)
386
+ })
387
+
388
+ await testAsync('chat.stream:没有 llm 服务 / 空消息 → 明确的 error 事件', async () => {
389
+ const missingRouter = mockRouter()
390
+ host.register(missingRouter, { ctx: mockCtx({}) })
391
+ const missing = await runStream(missingRouter.streams.get('chat.stream'), { messages: [{ role: 'user', content: 'hi' }] })
392
+ assert.equal(missing.events[0].code, 'NO_LLM')
393
+
394
+ const emptyRouter = mockRouter()
395
+ host.register(emptyRouter, {
396
+ ctx: mockCtx({ llm: { stream() { return (async function* gen() { yield { type: 'finish', reason: { kind: 'stop' } } })() } } }),
397
+ })
398
+ const empty = await runStream(emptyRouter.streams.get('chat.stream'), { messages: [] })
399
+ assert.equal(empty.events[0].code, 'EMPTY_MESSAGES')
400
+ })
401
+
402
+ await testAsync('chat.stream:客户端取消 → 静默结束(不产生 done/error)', async () => {
403
+ const router = mockRouter()
404
+ host.register(router, {
405
+ ctx: mockCtx({
406
+ llm: {
407
+ stream(options) {
408
+ return (async function* gen() {
409
+ yield { type: 'text-delta', index: 0, text: '部分' }
410
+ await new Promise((done) => {
411
+ if (options.signal.aborted) done()
412
+ else options.signal.addEventListener('abort', done, { once: true })
413
+ })
414
+ yield { type: 'finish', reason: { kind: 'aborted', failure: { code: 'ABORTED', message: 'aborted' } } }
415
+ })()
416
+ },
417
+ },
418
+ }),
419
+ })
420
+ const controller = new AbortController()
421
+ const res = mockResponse()
422
+ const events = []
423
+ const pending = router.streams.get('chat.stream')(
424
+ { messages: [{ role: 'user', content: 'hi' }] },
425
+ {
426
+ req: {},
427
+ res,
428
+ signal: controller.signal,
429
+ send: (event) => events.push(event),
430
+ close: () => res.end(),
431
+ },
432
+ )
433
+ await sleep(10)
434
+ controller.abort()
435
+ await pending
436
+ assert.deepEqual(events, [{ type: 'delta', text: '部分' }])
437
+ })
438
+
439
+ await testAsync('chat.stream:超时 → TIMEOUT error 事件', async () => {
440
+ const router = mockRouter()
441
+ host.register(router, {
442
+ chatTimeoutMs: 30,
443
+ ctx: mockCtx({
444
+ llm: {
445
+ stream(options) {
446
+ return (async function* gen() {
447
+ await new Promise((done) => {
448
+ if (options.signal.aborted) done()
449
+ else options.signal.addEventListener('abort', done, { once: true })
450
+ })
451
+ yield { type: 'finish', reason: { kind: 'aborted', failure: { code: 'ABORTED', message: 'aborted' } } }
452
+ })()
453
+ },
454
+ },
455
+ }),
456
+ })
457
+ const { events } = await runStream(router.streams.get('chat.stream'), { messages: [{ role: 'user', content: 'hi' }] })
458
+ assert.equal(events.length, 1)
459
+ assert.equal(events[0].type, 'error')
460
+ assert.equal(events[0].code, 'TIMEOUT')
461
+ assert.match(events[0].message, /超时/)
462
+ })
463
+
464
+ await testAsync('chat.stream:适配器不响应取消时,超时仍能强制结束', async () => {
465
+ const router = mockRouter()
466
+ host.register(router, {
467
+ chatTimeoutMs: 30,
468
+ ctx: mockCtx({
469
+ llm: {
470
+ stream() {
471
+ /* 故意既不 yield 也不监听 signal:验证 Promise.race 的硬超时 */
472
+ return (async function* gen() {
473
+ await new Promise(() => {})
474
+ yield { type: 'finish', reason: { kind: 'stop' } }
475
+ })()
476
+ },
477
+ },
478
+ }),
479
+ })
480
+ const { events, res } = await runStream(router.streams.get('chat.stream'), { messages: [{ role: 'user', content: 'hi' }] })
481
+ assert.equal(events.length, 1)
482
+ assert.equal(events[0].code, 'TIMEOUT')
483
+ assert.equal(res.writableEnded, true, '超时后应关闭响应')
484
+ })
485
+
486
+ await testAsync('chat.stream:body.provider/model 覆盖默认目标', async () => {
487
+ const captured = []
488
+ const router = mockRouter()
489
+ host.register(router, {
490
+ ctx: mockCtx({
491
+ agentDefaultModel: { currentSelection: () => ({ provider: 'a', model: 'b', reasoningEffort: 'max' }) },
492
+ llm: {
493
+ stream(options) {
494
+ captured.push(options)
495
+ return (async function* gen() {
496
+ yield { type: 'finish', reason: { kind: 'stop' } }
497
+ })()
498
+ },
499
+ },
500
+ }),
501
+ })
502
+ await runStream(router.streams.get('chat.stream'), {
503
+ messages: [{ role: 'user', content: 'hi' }],
504
+ provider: 'other',
505
+ model: 'other-model',
506
+ })
507
+ assert.equal(captured[0].provider, 'other')
508
+ assert.equal(captured[0].model, 'other-model')
509
+ assert.equal(captured[0].reasoningEffort, undefined)
510
+ })
511
+
512
+ /* ══════════════════════════════════════════════════════════════════════ */
513
+ /* 3. Client:纯函数(在 factory 作用域等价环境里求值) */
514
+ /* ══════════════════════════════════════════════════════════════════════ */
515
+ console.log('\n[src/client/40-chat.js] 纯函数')
516
+
517
+ const clientSource = readFileSync(join(root, 'src', 'client', '40-chat.js'), 'utf8')
518
+ const clientFactory = new Function(
519
+ 'G',
520
+ 'h',
521
+ 'React',
522
+ 'useState',
523
+ 'useEffect',
524
+ 'useRef',
525
+ 'useMemo',
526
+ 'useCallback',
527
+ 'useLayoutEffect',
528
+ `${clientSource}\nreturn {
529
+ gufaChatParseMarkdown,
530
+ gufaChatInline,
531
+ gufaChatBuildContext,
532
+ gufaChatRequestMessages,
533
+ gufaChatStorageKey,
534
+ gufaChatSanitizeStore,
535
+ gufaChatTrimConversation,
536
+ gufaChatTitleOf,
537
+ gufaChatRelativeTime,
538
+ gufaChatSaveStore,
539
+ gufaChatLoadStore,
540
+ GufaChatInline,
541
+ GufaChatMarkdownText,
542
+ GufaChatCode,
543
+ GufaChatMessageView,
544
+ ChatPanel: G.ChatPanel,
545
+ }\n`,
546
+ )
547
+ const stubG = { ChatPanel: null }
548
+ const client = clientFactory(
549
+ stubG,
550
+ () => null,
551
+ { Fragment: Symbol('fragment') },
552
+ () => undefined,
553
+ () => undefined,
554
+ () => ({ current: null }),
555
+ (fn) => fn(),
556
+ (fn) => fn,
557
+ () => undefined,
558
+ )
559
+
560
+ test('G.ChatPanel 已注册为组件函数', () => {
561
+ assert.equal(typeof client.ChatPanel, 'function')
562
+ assert.equal(stubG.ChatPanel, client.ChatPanel)
563
+ })
564
+
565
+ test('gufaChatStorageKey:按项目根隔离', () => {
566
+ assert.equal(client.gufaChatStorageKey('/a/b'), 'gufa-code:chat:v1:/a/b')
567
+ assert.equal(client.gufaChatStorageKey(''), 'gufa-code:chat:v1:')
568
+ assert.equal(client.gufaChatStorageKey(null), 'gufa-code:chat:v1:')
569
+ })
570
+
571
+ test('gufaChatTitleOf:压缩空白并截断到 28 字符', () => {
572
+ assert.equal(client.gufaChatTitleOf(' 帮我 看看\n这段代码 '), '帮我 看看 这段代码')
573
+ const long = client.gufaChatTitleOf('a'.repeat(50))
574
+ assert.equal(long.length, 28)
575
+ assert.ok(long.endsWith('…'))
576
+ })
577
+
578
+ test('gufaChatRelativeTime:刚刚 / 分钟 / 小时 / 日期', () => {
579
+ const now = Date.now()
580
+ assert.equal(client.gufaChatRelativeTime(now - 5_000, now), '刚刚')
581
+ assert.equal(client.gufaChatRelativeTime(now - 5 * 60_000, now), '5 分钟前')
582
+ assert.equal(client.gufaChatRelativeTime(now - 5 * 3_600_000, now), '5 小时前')
583
+ assert.match(client.gufaChatRelativeTime(now - 3 * 86_400_000, now), /^\d{2}-\d{2} \d{2}:\d{2}$/)
584
+ assert.equal(client.gufaChatRelativeTime(0, now), '')
585
+ })
586
+
587
+ test('gufaChatParseMarkdown:普通文本 / 闭合代码块 / 多块', () => {
588
+ assert.deepEqual(client.gufaChatParseMarkdown(''), [])
589
+ assert.deepEqual(client.gufaChatParseMarkdown('你好'), [{ type: 'text', text: '你好' }])
590
+ const blocks = client.gufaChatParseMarkdown('说明:\n```js\nconst a = 1\n```\n结束')
591
+ assert.equal(blocks.length, 3)
592
+ assert.deepEqual(blocks[0], { type: 'text', text: '说明:\n' })
593
+ assert.deepEqual(blocks[1], { type: 'code', lang: 'js', text: 'const a = 1' })
594
+ assert.deepEqual(blocks[2], { type: 'text', text: '\n结束' })
595
+ })
596
+
597
+ test('gufaChatParseMarkdown:流式中未闭合围栏按代码块处理', () => {
598
+ const blocks = client.gufaChatParseMarkdown('开头\n```python\nprint(1)')
599
+ assert.equal(blocks[0].type, 'text')
600
+ assert.equal(blocks[1].type, 'code')
601
+ assert.equal(blocks[1].lang, 'python')
602
+ assert.equal(blocks[1].text, 'print(1)')
603
+ assert.equal(blocks[1].open, true)
604
+ })
605
+
606
+ test('gufaChatInline:行内代码与粗体', () => {
607
+ assert.deepEqual(client.gufaChatInline('用 `map` 和 **reduce** 实现'), [
608
+ { type: 'text', text: '用 ' },
609
+ { type: 'code', text: 'map' },
610
+ { type: 'text', text: ' 和 ' },
611
+ { type: 'bold', text: 'reduce' },
612
+ { type: 'text', text: ' 实现' },
613
+ ])
614
+ assert.deepEqual(client.gufaChatInline('纯文本'), [{ type: 'text', text: '纯文本' }])
615
+ })
616
+
617
+ test('gufaChatBuildContext:文件 + 选中代码都开启', () => {
618
+ const context = client.gufaChatBuildContext({
619
+ project: { root: '/repo' },
620
+ activeFile: { path: 'src/a.js', content: 'const a = 1', language: 'javascript' },
621
+ selection: { text: 'a = 1', path: 'src/a.js', line: 4 },
622
+ attachFile: true,
623
+ attachSelection: true,
624
+ })
625
+ assert.deepEqual(context, {
626
+ root: '/repo',
627
+ selection: 'a = 1',
628
+ selectionPath: 'src/a.js',
629
+ selectionLine: 4,
630
+ path: 'src/a.js',
631
+ content: 'const a = 1',
632
+ language: 'javascript',
633
+ })
634
+ })
635
+
636
+ test('gufaChatBuildContext:关闭文件但保留选中代码', () => {
637
+ const context = client.gufaChatBuildContext({
638
+ project: { root: '/repo' },
639
+ activeFile: { path: 'src/a.js', content: 'const a = 1' },
640
+ selection: { text: 'a = 1', path: 'src/a.js', line: 1 },
641
+ attachFile: false,
642
+ attachSelection: true,
643
+ })
644
+ assert.equal(context.content, undefined)
645
+ assert.equal(context.path, 'src/a.js')
646
+ assert.equal(context.selection, 'a = 1')
647
+ })
648
+
649
+ test('gufaChatBuildContext:关闭选中代码;无上下文返回 null', () => {
650
+ const noSelection = client.gufaChatBuildContext({
651
+ project: { root: '/repo' },
652
+ activeFile: { path: 'a.js', content: 'x' },
653
+ selection: { text: 'x' },
654
+ attachFile: true,
655
+ attachSelection: false,
656
+ })
657
+ assert.equal(noSelection.selection, undefined)
658
+ assert.equal(noSelection.content, 'x')
659
+ assert.equal(client.gufaChatBuildContext({ project: { root: '/repo' } }).root, '/repo')
660
+ assert.equal(client.gufaChatBuildContext({}), null)
661
+ })
662
+
663
+ test('gufaChatRequestMessages:过滤错误/空消息', () => {
664
+ const messages = client.gufaChatRequestMessages([
665
+ { role: 'user', content: '你好' },
666
+ { role: 'assistant', content: '', error: '请求失败' },
667
+ { role: 'assistant', content: '在的' },
668
+ { role: 'user', content: ' ' },
669
+ { role: 'system', content: 'x' },
670
+ ])
671
+ assert.deepEqual(messages, [
672
+ { role: 'user', content: '你好' },
673
+ { role: 'assistant', content: '在的' },
674
+ ])
675
+ })
676
+
677
+ test('gufaChatTrimConversation:条数/长度裁剪与字段保真', () => {
678
+ const messages = []
679
+ for (let index = 0; index < 200; index += 1) messages.push({ role: 'user', content: `m${index}` })
680
+ const trimmed = client.gufaChatTrimConversation({
681
+ id: 'c1',
682
+ title: '标题',
683
+ updatedAt: 123,
684
+ messages: [...messages, { role: 'assistant', content: 'x'.repeat(70_000), error: 'e', stopped: true }],
685
+ })
686
+ assert.equal(trimmed.id, 'c1')
687
+ assert.equal(trimmed.messages.length, 120)
688
+ assert.equal(trimmed.updatedAt, 123)
689
+ const last = trimmed.messages[trimmed.messages.length - 1]
690
+ assert.ok(last.content.length < 70_000)
691
+ assert.match(last.content, /已截断/)
692
+ assert.equal(last.error, 'e')
693
+ assert.equal(last.stopped, true)
694
+ })
695
+
696
+ test('gufaChatSanitizeStore:坏数据/重复 id/activeId 回落/上限 30', () => {
697
+ assert.deepEqual(client.gufaChatSanitizeStore(null), { conversations: [], activeId: null })
698
+ assert.deepEqual(client.gufaChatSanitizeStore({ conversations: 'x' }), { conversations: [], activeId: null })
699
+ const raw = { activeId: 'nope', conversations: [] }
700
+ for (let index = 0; index < 40; index += 1) raw.conversations.push({ id: `c${index}`, title: `t${index}`, messages: [] })
701
+ raw.conversations.push({ id: 'c0', title: 'dup', messages: [] })
702
+ const sanitized = client.gufaChatSanitizeStore(raw)
703
+ assert.equal(sanitized.conversations.length, 30)
704
+ assert.equal(sanitized.activeId, 'c0')
705
+ })
706
+
707
+ /* 假 localStorage(含一次配额失败模拟) */
708
+ function installFakeStorage() {
709
+ const store = new Map()
710
+ let failNextSet = false
711
+ globalThis.localStorage = {
712
+ getItem(key) {
713
+ return store.has(key) ? store.get(key) : null
714
+ },
715
+ setItem(key, value) {
716
+ if (failNextSet) {
717
+ failNextSet = false
718
+ throw new Error('QuotaExceededError')
719
+ }
720
+ store.set(key, String(value))
721
+ },
722
+ removeItem(key) {
723
+ store.delete(key)
724
+ },
725
+ __store: store,
726
+ __failNextSet() {
727
+ failNextSet = true
728
+ },
729
+ }
730
+ return globalThis.localStorage
731
+ }
732
+
733
+ test('gufaChatSaveStore / LoadStore:roundtrip 与配额回退', () => {
734
+ const storage = installFakeStorage()
735
+ const conversations = [
736
+ { id: 'c1', title: '第一个', messages: [{ id: 'm1', role: 'user', content: '你好', at: 1 }], updatedAt: 1 },
737
+ { id: 'c2', title: '第二个', messages: [], updatedAt: 2 },
738
+ ]
739
+ assert.equal(client.gufaChatSaveStore('/repo', conversations, 'c2'), true)
740
+ const loaded = client.gufaChatLoadStore('/repo')
741
+ assert.equal(loaded.activeId, 'c2')
742
+ assert.equal(loaded.conversations.length, 2)
743
+ assert.equal(loaded.conversations[0].messages[0].content, '你好')
744
+ // 配额失败:第二次写入(减半)成功
745
+ storage.__failNextSet()
746
+ assert.equal(client.gufaChatSaveStore('/repo', conversations, 'c1'), true)
747
+ assert.ok(storage.__store.size >= 1)
748
+ // 坏 JSON 回落
749
+ storage.setItem(client.gufaChatStorageKey('/broken'), '{not json')
750
+ assert.deepEqual(client.gufaChatLoadStore('/broken'), { conversations: [], activeId: null })
751
+ })
752
+
753
+ /* ══════════════════════════════════════════════════════════════════════ */
754
+
755
+ console.log('')
756
+ if (failures.length > 0) {
757
+ console.error(`✗ 聊天面板单测失败:${failures.length} 个失败,${passed} 个通过`)
758
+ for (const failure of failures) console.error(` - ${failure.name}: ${failure.error instanceof Error ? failure.error.message : failure.error}`)
759
+ process.exit(1)
760
+ }
761
+ console.log(`✓ 聊天面板单测全部通过:${passed} 个用例`)