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.
- package/LICENSE +21 -0
- package/README.md +96 -2
- package/client.js +11548 -0
- package/cordis.patch.yml +8 -0
- package/docs/icon-180.png +0 -0
- package/docs/icon.svg +18 -0
- package/docs/market-submission.md +32 -0
- package/docs/screenshot-editor.jpg +0 -0
- package/docs/screenshot-ide.jpg +0 -0
- package/host/assets.js +31 -0
- package/host/chat.js +523 -0
- package/host/context.js +438 -0
- package/host/dev.js +35 -0
- package/host/fs.js +306 -0
- package/host/git.js +532 -0
- package/host/project.js +305 -0
- package/host/ssh.js +54 -0
- package/host/terminal-pty.js +237 -0
- package/host/terminal.js +947 -0
- package/icon.svg +18 -0
- package/index.js +63 -0
- package/locale/en.json +121 -0
- package/locale/zh.json +121 -0
- package/package.json +66 -4
- package/scripts/build-client.mjs +38 -0
- package/scripts/selftest-client.mjs +457 -0
- package/scripts/selftest-host.mjs +197 -0
- package/scripts/selftest-http.mjs +284 -0
- package/scripts/selftest-router.mjs +101 -0
- package/scripts/selftest-terminal-client.mjs +314 -0
- package/scripts/selftest-terminal-pty.mjs +190 -0
- package/scripts/selftest-terminal.mjs +351 -0
- package/scripts/test-chat-render.mjs +702 -0
- package/scripts/test-chat.mjs +761 -0
- package/scripts/test-editor.mjs +516 -0
- package/scripts/verify-workspace-sessions.mjs +135 -0
- package/src/client/00-core.js +1686 -0
- package/src/client/10-icons.js +205 -0
- package/src/client/20-editor.js +1318 -0
- package/src/client/30-terminal.js +1510 -0
- package/src/client/40-chat.js +1184 -0
- package/src/client/46-gitgraph.js +278 -0
- package/src/client/50-panels.js +916 -0
- package/src/client/55-backdrop.js +508 -0
- package/src/client/60-dock.js +662 -0
- package/src/client/85-shortcuts.js +516 -0
- package/src/client/90-app.js +2272 -0
- package/src/client/99-main.js +449 -0
- package/vendor/monaco/LICENSE +21 -0
- package/vendor/monaco/ThirdPartyNotices.txt +448 -0
- package/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
- package/vendor/monaco/vs/base/worker/workerMain.js +31 -0
- package/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
- package/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
- package/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
- package/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
- package/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
- package/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
- package/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
- package/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
- package/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
- package/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
- package/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
- package/vendor/monaco/vs/basic-languages/css/css.js +12 -0
- package/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
- package/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
- package/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
- package/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
- package/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
- package/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
- package/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
- package/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
- package/vendor/monaco/vs/basic-languages/go/go.js +10 -0
- package/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
- package/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
- package/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
- package/vendor/monaco/vs/basic-languages/html/html.js +10 -0
- package/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
- package/vendor/monaco/vs/basic-languages/java/java.js +10 -0
- package/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
- package/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
- package/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
- package/vendor/monaco/vs/basic-languages/less/less.js +11 -0
- package/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
- package/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
- package/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
- package/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
- package/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
- package/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
- package/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
- package/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
- package/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
- package/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
- package/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
- package/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
- package/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
- package/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
- package/vendor/monaco/vs/basic-languages/php/php.js +10 -0
- package/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
- package/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
- package/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
- package/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
- package/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
- package/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
- package/vendor/monaco/vs/basic-languages/python/python.js +10 -0
- package/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
- package/vendor/monaco/vs/basic-languages/r/r.js +10 -0
- package/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
- package/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
- package/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
- package/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
- package/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
- package/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
- package/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
- package/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
- package/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
- package/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
- package/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
- package/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
- package/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
- package/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
- package/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
- package/vendor/monaco/vs/basic-languages/st/st.js +10 -0
- package/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
- package/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
- package/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
- package/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
- package/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
- package/vendor/monaco/vs/basic-languages/typespec/typespec.js +10 -0
- package/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
- package/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
- package/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
- package/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
- package/vendor/monaco/vs/editor/editor.main.css +8 -0
- package/vendor/monaco/vs/editor/editor.main.js +798 -0
- package/vendor/monaco/vs/language/css/cssMode.js +13 -0
- package/vendor/monaco/vs/language/css/cssWorker.js +77 -0
- package/vendor/monaco/vs/language/html/htmlMode.js +13 -0
- package/vendor/monaco/vs/language/html/htmlWorker.js +454 -0
- package/vendor/monaco/vs/language/json/jsonMode.js +19 -0
- package/vendor/monaco/vs/language/json/jsonWorker.js +42 -0
- package/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
- package/vendor/monaco/vs/language/typescript/tsWorker.js +51328 -0
- package/vendor/monaco/vs/loader.js +11 -0
- package/vendor/monaco/vs/nls.messages.de.js +21 -0
- package/vendor/monaco/vs/nls.messages.es.js +21 -0
- package/vendor/monaco/vs/nls.messages.fr.js +19 -0
- package/vendor/monaco/vs/nls.messages.it.js +19 -0
- package/vendor/monaco/vs/nls.messages.ja.js +21 -0
- package/vendor/monaco/vs/nls.messages.ko.js +19 -0
- package/vendor/monaco/vs/nls.messages.ru.js +21 -0
- package/vendor/monaco/vs/nls.messages.zh-cn.js +21 -0
- package/vendor/monaco/vs/nls.messages.zh-tw.js +19 -0
- package/vendor/xterm/LICENSE +21 -0
- package/vendor/xterm/LICENSE.addon-fit +19 -0
- package/vendor/xterm/addon-fit.js +2 -0
- package/vendor/xterm/xterm.css +285 -0
- package/vendor/xterm/xterm.js +2 -0
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# dsh-gufa-code bundle patch:标准 bundle 自挂载。
|
|
2
|
+
#
|
|
3
|
+
# 行 name 必须是包名原样(Host 半的 Node 导入说明符,也是 dsh-client-modules 扫描
|
|
4
|
+
# 浏览器半的键,经 exports["./client"] 解析到 client.js),Host/Client 两个半共享这一行。
|
|
5
|
+
# id 是稳定身份:profile 用户层可用该 id 做定向 config 覆盖。
|
|
6
|
+
- insert:
|
|
7
|
+
- id: dsh-gufa-code
|
|
8
|
+
name: 'dsh-gufa-code'
|
|
Binary file
|
package/docs/icon.svg
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="古法编程 Gufa Code">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="gufaSeal" x1="0" y1="0" x2="1" y2="1">
|
|
4
|
+
<stop offset="0" stop-color="#d0573f"/>
|
|
5
|
+
<stop offset="0.55" stop-color="#b33a2c"/>
|
|
6
|
+
<stop offset="1" stop-color="#8c211b"/>
|
|
7
|
+
</linearGradient>
|
|
8
|
+
</defs>
|
|
9
|
+
<rect x="6" y="6" width="116" height="116" rx="28" fill="url(#gufaSeal)"/>
|
|
10
|
+
<rect x="15" y="15" width="98" height="98" rx="21" fill="none" stroke="#f7e9d8" stroke-opacity="0.78" stroke-width="3.5"/>
|
|
11
|
+
<g fill="#fbeee0">
|
|
12
|
+
<rect x="32" y="32" width="64" height="9.5" rx="2.5"/>
|
|
13
|
+
<rect x="58.5" y="32" width="11" height="30" rx="2"/>
|
|
14
|
+
<path fill-rule="evenodd" d="M40 62 h48 a4 4 0 0 1 4 4 v27 a4 4 0 0 1 -4 4 h-48 a4 4 0 0 1 -4 -4 v-27 a4 4 0 0 1 4 -4 z m8 10 h32 v19 h-32 z"/>
|
|
15
|
+
</g>
|
|
16
|
+
<path d="M27 101 l-7 -6 7 -6" fill="none" stroke="#f7e9d8" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.8"/>
|
|
17
|
+
<path d="M101 101 l7 -6 -7 -6" fill="none" stroke="#f7e9d8" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.8"/>
|
|
18
|
+
</svg>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# 上架到 DSH 插件市场(awesome-dsh-plugin)
|
|
2
|
+
|
|
3
|
+
市场(dsh-market / Discover / dsh-plugin-catalog)的目录来自
|
|
4
|
+
[awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin)。
|
|
5
|
+
|
|
6
|
+
> 2026-11 起,该仓库改为 **一个插件一个 YAML 文件**:`data/plugins/<owner>__<repo>.yml`,
|
|
7
|
+
> 不要再手改 README(README 由脚本生成)。
|
|
8
|
+
|
|
9
|
+
## 提交文件(唯一一步)
|
|
10
|
+
|
|
11
|
+
路径:`data/plugins/liujiangnanchenjbguo__dsh-gufa-code.yml`
|
|
12
|
+
|
|
13
|
+
```yaml
|
|
14
|
+
url: https://github.com/liujiangnanchenjbguo/dsh-gufa-code
|
|
15
|
+
name: liujiangnanchenjbguo/dsh-gufa-code
|
|
16
|
+
category: ui
|
|
17
|
+
description:
|
|
18
|
+
en: Cursor-style IDE mode for DeepSeek Harness — one click on a workspace menu turns the main panel into a VS Code-like workbench with a Monaco editor, file tree, Git panel (commit graph, push/pull, per-file revert), SSH hosts and a real PTY terminal on the left, while the official DSH conversation stays docked on the right so tool calls, file edits, approvals and session history keep working as you code.
|
|
19
|
+
zh: 给 DeepSeek Harness 的 IDE 模式:点工作区「…」菜单一键把主面板变成 VS Code 风格工作台——左侧 Monaco 编辑器、文件树、Git 面板(提交图/推送/拉取/逐文件还原)、SSH 主机与真实 PTY 终端,右侧原样停靠官方 DSH 会话,写代码时工具调用、自动改文件、审批与会话历史照常可用。
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## 硬性要求(当前状态)
|
|
23
|
+
|
|
24
|
+
- ✅ `package.json` 声明了 `dsh.bundle`(`cordis.patch.yml` 在仓库根);
|
|
25
|
+
- ✅ 真实可用代码(Host/Client 全套,已实装验证);
|
|
26
|
+
- ⏳ **仓库创建满 1 天**——本仓库创建于 2026-11-05,最早 2026-11-06 可提交;
|
|
27
|
+
- ✅ 通过 `dsh plugin add` 安装验证(临时 profile 实测通过)。
|
|
28
|
+
|
|
29
|
+
## 可选
|
|
30
|
+
|
|
31
|
+
- `icon:` 字段可指向 GitHub 上的位图(不写则回退作者头像);
|
|
32
|
+
- 发布到 npm(`npm publish --access public`)后,Discover 索引会带版本号,安装与自动更新更快。
|
|
Binary file
|
|
Binary file
|
package/host/assets.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 静态资产:Monaco AMD 版(vendor/monaco/vs/**)与 xterm(vendor/xterm/**)。
|
|
3
|
+
* 均为 MIT 许可的离线资产,随插件目录分发;Host 按前缀原样提供。
|
|
4
|
+
*/
|
|
5
|
+
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { mimeOf } from './context.js'
|
|
8
|
+
|
|
9
|
+
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
10
|
+
|
|
11
|
+
function inside(root, target) {
|
|
12
|
+
const rel = relative(root, target)
|
|
13
|
+
return rel === '' || (!rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function assetResolver(baseDir) {
|
|
17
|
+
return async (raw) => {
|
|
18
|
+
const target = resolve(baseDir, raw)
|
|
19
|
+
if (!inside(baseDir, target)) return undefined
|
|
20
|
+
return { path: target, type: mimeOf(target) }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function register(router) {
|
|
25
|
+
router.asset('monaco', assetResolver(resolve(packageRoot, 'vendor', 'monaco', 'vs')))
|
|
26
|
+
router.asset('xterm', assetResolver(resolve(packageRoot, 'vendor', 'xterm')))
|
|
27
|
+
router.asset('icon', async (raw) => {
|
|
28
|
+
if (raw !== 'icon.svg') return undefined
|
|
29
|
+
return { path: resolve(packageRoot, 'icon.svg'), type: 'image/svg+xml', cache: 'public, max-age=3600' }
|
|
30
|
+
})
|
|
31
|
+
}
|
package/host/chat.js
ADDED
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI 聊天能力:默认模型信息 + SSE 流式对话。
|
|
3
|
+
*
|
|
4
|
+
* 路由(挂在 /gufa-code/api 下,鉴权、SSE 帧格式见 host/context.js):
|
|
5
|
+
* POST chat.info → { provider, model, ready }
|
|
6
|
+
* POST chat.stream → text/event-stream,每帧 data: <json>:
|
|
7
|
+
* { type: 'thinking' } 模型开始思考(收到第一段 reasoning 时发一次)
|
|
8
|
+
* { type: 'delta', text } 正文增量
|
|
9
|
+
* { type: 'done' } 正常结束
|
|
10
|
+
* { type: 'error', message, code? } 失败 / 超时 / 服务不可用
|
|
11
|
+
*
|
|
12
|
+
* 请求体:
|
|
13
|
+
* {
|
|
14
|
+
* messages: [{ role: 'user' | 'assistant', content: string }], // 由旧到新,最后一条是本次提问
|
|
15
|
+
* context: { root?, path?, content?, language?, selection?, selectionPath?, selectionLine? },
|
|
16
|
+
* provider?, model? // 可选覆盖默认模型
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* 实现要点:
|
|
20
|
+
* - 默认模型取 Host 的 agentDefaultModel.currentSelection()(profile 里为
|
|
21
|
+
* deepseek-official / deepseek-flash),取不到时回落同一对值。
|
|
22
|
+
* - llm 的 messages 只接受 durable Message / 请求态 user 输入,因此把「系统提示 + 代码上下文 +
|
|
23
|
+
* 最近对话 + 当前问题」压成单条 user 文本(content: [{ type: 'text', text }]),最稳妥。
|
|
24
|
+
* - 单次请求 120 秒超时;客户端取消(SSE 断开 / req aborted)时静默结束,不报错。
|
|
25
|
+
* - 所有异常都转成 error 事件,保证客户端一定能收到终止事件。
|
|
26
|
+
*
|
|
27
|
+
* 注意:本模块不 import host/context.js,便于在 Node 里直接单测纯函数与 register 行为。
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** 默认模型的兜底值:与本机 profile 的 agent-default-model 配置保持一致。 */
|
|
31
|
+
const FALLBACK_PROVIDER = 'deepseek-official'
|
|
32
|
+
const FALLBACK_MODEL = 'deepseek-flash'
|
|
33
|
+
|
|
34
|
+
/** 单次请求超时(毫秒)。env.chatTimeoutMs 可覆盖(仅用于测试/自部署调参)。 */
|
|
35
|
+
export const CHAT_TIMEOUT_MS = 120_000
|
|
36
|
+
|
|
37
|
+
/** 代码上下文截断上限(字符)。选中代码优先,有选中时代码正文收紧。 */
|
|
38
|
+
const CONTEXT_SELECTION_LIMIT = 20_000
|
|
39
|
+
const CONTEXT_FILE_LIMIT = 20_000
|
|
40
|
+
const CONTEXT_FILE_LIMIT_WITH_SELECTION = 12_000
|
|
41
|
+
/** 极端输入先硬截断,避免在巨型字符串上做额外处理。 */
|
|
42
|
+
const CONTEXT_HARD_LIMIT = 200_000
|
|
43
|
+
|
|
44
|
+
/** 对话历史预算:最多携带最近多少条、总字符上限、单条字符上限。 */
|
|
45
|
+
const HISTORY_MESSAGE_LIMIT = 24
|
|
46
|
+
const HISTORY_CHAR_LIMIT = 40_000
|
|
47
|
+
const MESSAGE_CHAR_LIMIT = 8_000
|
|
48
|
+
/** 当前问题的截断上限。 */
|
|
49
|
+
const QUESTION_LIMIT = 20_000
|
|
50
|
+
/** 请求体里最多接受的原始消息条数(只取最后这些条)。 */
|
|
51
|
+
const MAX_INPUT_MESSAGES = 200
|
|
52
|
+
/** 最终 prompt 的硬上限(正常预算下不会触发)。 */
|
|
53
|
+
const PROMPT_HARD_LIMIT = 120_000
|
|
54
|
+
|
|
55
|
+
/** 系统提示:中文,说明身份与回答格式。 */
|
|
56
|
+
const SYSTEM_PROMPT = [
|
|
57
|
+
'你是「古法编程」(GUFA CODE)内置的 AI 编程助手,运行在用户的本地 IDE 侧边栏里。',
|
|
58
|
+
'你的职责是辅助用户手写代码:解释代码、定位问题、补充注释、给出重构思路,或直接给出可用的代码片段。',
|
|
59
|
+
'请遵守以下要求:',
|
|
60
|
+
'1. 始终用简体中文回答,先给结论,再给必要解释;不要客套、不要重复用户的问题。',
|
|
61
|
+
'2. 代码一律放在 markdown 代码块中,并标注语言;除非用户只要片段,否则给出完整可用的代码。',
|
|
62
|
+
'3. 只依据用户提供的代码与上下文作答;信息不足时明确说明你的假设,不要编造未提供的文件内容。',
|
|
63
|
+
'4. 如果用户要求修改代码,给出修改后的完整代码,并简要说明改了哪些地方。',
|
|
64
|
+
'5. 回答保持简洁,避免与编程无关的内容。',
|
|
65
|
+
].join('\n')
|
|
66
|
+
|
|
67
|
+
/* ── 小工具 ───────────────────────────────────────────────────────────── */
|
|
68
|
+
|
|
69
|
+
/** 转字符串(非字符串一律视为空)。 */
|
|
70
|
+
function textOf(value) {
|
|
71
|
+
return typeof value === 'string' ? value : ''
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 从任意错误里取可读文本。 */
|
|
75
|
+
function errorText(error) {
|
|
76
|
+
if (error !== null && typeof error === 'object' && typeof error.message === 'string' && error.message !== '') return error.message
|
|
77
|
+
if (typeof error === 'string') return error
|
|
78
|
+
return '未知错误'
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 语言标识白名单:只有安全短标识才放进 markdown 代码围栏。 */
|
|
82
|
+
function sanitizeLanguage(value) {
|
|
83
|
+
return /^[A-Za-z0-9_+.#-]{1,24}$/.test(value) ? value : ''
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 从模型失败对象里取稳定 code(失败路由用,不要依赖 message 文本)。 */
|
|
87
|
+
function failureCode(failure) {
|
|
88
|
+
return failure !== null && typeof failure === 'object' && typeof failure.code === 'string' ? failure.code : ''
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* ── 默认模型与目标模型 ───────────────────────────────────────────────── */
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 读取 Host 的默认模型选择。
|
|
95
|
+
* @param {any} ctx Host 插件 context(Cordis Context)。
|
|
96
|
+
* @returns {{ provider: string, model: string, reasoningEffort?: string }}
|
|
97
|
+
*/
|
|
98
|
+
export function readDefaultSelection(ctx) {
|
|
99
|
+
try {
|
|
100
|
+
if (ctx === undefined || ctx === null || typeof ctx.get !== 'function') throw new Error('no context')
|
|
101
|
+
const service = ctx.get('agentDefaultModel')
|
|
102
|
+
if (service === undefined || service === null || typeof service.currentSelection !== 'function') throw new Error('no service')
|
|
103
|
+
const value = service.currentSelection()
|
|
104
|
+
if (value === null || typeof value !== 'object') throw new Error('bad selection')
|
|
105
|
+
const provider = textOf(value.provider).trim()
|
|
106
|
+
const model = textOf(value.model).trim()
|
|
107
|
+
const reasoningEffort = textOf(value.reasoningEffort).trim()
|
|
108
|
+
if (provider !== '' && model !== '') {
|
|
109
|
+
return reasoningEffort === '' ? { provider, model } : { provider, model, reasoningEffort }
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
/* 服务未挂载或读取失败:回落固定默认值 */
|
|
113
|
+
}
|
|
114
|
+
return { provider: FALLBACK_PROVIDER, model: FALLBACK_MODEL }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 超时配置:默认 120 秒,env.chatTimeoutMs 可覆盖。 */
|
|
118
|
+
function chatTimeoutOf(env) {
|
|
119
|
+
const value = env !== null && typeof env === 'object' ? env.chatTimeoutMs : undefined
|
|
120
|
+
if (typeof value === 'number' && Number.isFinite(value) && value > 0) return Math.min(Math.trunc(value), 600_000)
|
|
121
|
+
return CHAT_TIMEOUT_MS
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 目标模型:body 覆盖优先,否则用 profile 默认;只有完全没覆盖时才沿用默认推理档位。 */
|
|
125
|
+
export function resolveChatTarget(body, ctx) {
|
|
126
|
+
const source = body !== null && typeof body === 'object' ? body : {}
|
|
127
|
+
const requestedProvider = textOf(source.provider).trim()
|
|
128
|
+
const requestedModel = textOf(source.model).trim()
|
|
129
|
+
const fallback = readDefaultSelection(ctx)
|
|
130
|
+
const overridden = requestedProvider !== '' || requestedModel !== ''
|
|
131
|
+
return {
|
|
132
|
+
provider: requestedProvider !== '' ? requestedProvider : fallback.provider,
|
|
133
|
+
model: requestedModel !== '' ? requestedModel : fallback.model,
|
|
134
|
+
reasoningEffort: overridden ? undefined : fallback.reasoningEffort,
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/* ── 失败原因 → 用户可读文案 ──────────────────────────────────────────── */
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 把 llm finish 的 failure(稳定 code + message)翻成中文提示。
|
|
142
|
+
* 路由只看 code,message 仅作补充。
|
|
143
|
+
* @param {any} failure
|
|
144
|
+
* @returns {string}
|
|
145
|
+
*/
|
|
146
|
+
export function describeLlmFailure(failure) {
|
|
147
|
+
const code = failureCode(failure)
|
|
148
|
+
const raw = failure !== null && typeof failure === 'object' ? textOf(failure.message).trim() : ''
|
|
149
|
+
switch (code) {
|
|
150
|
+
case 'NO_ADAPTER':
|
|
151
|
+
return '当前没有可用的模型服务,请在 DSH 设置中启用模型后再试。'
|
|
152
|
+
case 'MISSING_CREDENTIAL':
|
|
153
|
+
return '尚未配置模型 API Key,请到 DSH 设置中填写后再试。'
|
|
154
|
+
case 'INVALID_CREDENTIAL':
|
|
155
|
+
return '模型凭据格式无效,请检查 DSH 设置中的 API Key。'
|
|
156
|
+
case 'AUTH':
|
|
157
|
+
return '模型认证失败(API Key 无效或已过期),请检查 DSH 设置。'
|
|
158
|
+
case 'RATE_LIMIT':
|
|
159
|
+
return '请求过于频繁,请稍后再试。'
|
|
160
|
+
case 'QUOTA':
|
|
161
|
+
case 'ACCOUNT_QUOTA':
|
|
162
|
+
return '模型额度不足,请检查账户余额后再试。'
|
|
163
|
+
case 'CONTEXT_WINDOW_EXCEEDED':
|
|
164
|
+
return '对话内容超出模型上下文窗口,请新建对话或减少附带的上下文。'
|
|
165
|
+
case 'SERVER':
|
|
166
|
+
return '模型服务暂时不可用,请稍后再试。'
|
|
167
|
+
case 'EMPTY_RESPONSE':
|
|
168
|
+
return '模型返回了空内容,请重试。'
|
|
169
|
+
default:
|
|
170
|
+
break
|
|
171
|
+
}
|
|
172
|
+
if (raw !== '') return code === '' ? raw : `${raw}(${code})`
|
|
173
|
+
return code === '' ? '模型请求失败,请重试。' : `模型请求失败:${code}`
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** 超时提示事件。 */
|
|
177
|
+
function timeoutEvent(timeoutMs) {
|
|
178
|
+
return {
|
|
179
|
+
type: 'error',
|
|
180
|
+
message: `请求超时(${Math.max(1, Math.round(timeoutMs / 1000))} 秒),请稍后重试或新建对话。`,
|
|
181
|
+
code: 'TIMEOUT',
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/* ── 请求内容归一化与 prompt 拼装 ─────────────────────────────────────── */
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 归一化消息数组:只保留 user/assistant 的非空文本,截断超长内容。
|
|
189
|
+
* @param {any} raw
|
|
190
|
+
* @returns {Array<{ role: 'user' | 'assistant', text: string }>}
|
|
191
|
+
*/
|
|
192
|
+
export function normalizeMessages(raw) {
|
|
193
|
+
if (!Array.isArray(raw) || raw.length === 0) return []
|
|
194
|
+
const source = raw.length > MAX_INPUT_MESSAGES ? raw.slice(-MAX_INPUT_MESSAGES) : raw
|
|
195
|
+
const messages = []
|
|
196
|
+
for (const item of source) {
|
|
197
|
+
if (item === null || typeof item !== 'object') continue
|
|
198
|
+
const role = item.role === 'assistant' ? 'assistant' : item.role === 'user' ? 'user' : ''
|
|
199
|
+
if (role === '') continue
|
|
200
|
+
let text = textOf(item.content).trim()
|
|
201
|
+
if (text === '') continue
|
|
202
|
+
if (text.length > MESSAGE_CHAR_LIMIT) text = `${text.slice(0, MESSAGE_CHAR_LIMIT)}\n…(消息过长,已截断)`
|
|
203
|
+
messages.push({ role, text })
|
|
204
|
+
}
|
|
205
|
+
return messages
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** 归一化代码上下文:root/path/language + 选中的代码 + 当前文件内容。 */
|
|
209
|
+
export function normalizeContext(raw) {
|
|
210
|
+
const source = raw !== null && typeof raw === 'object' ? raw : {}
|
|
211
|
+
const root = textOf(source.root).trim()
|
|
212
|
+
const path = textOf(source.path).trim()
|
|
213
|
+
const language = sanitizeLanguage(textOf(source.language).trim())
|
|
214
|
+
const selectionPath = textOf(source.selectionPath).trim()
|
|
215
|
+
const lineValue = source.selectionLine
|
|
216
|
+
const selectionLine = typeof lineValue === 'number' && Number.isFinite(lineValue) && lineValue > 0 ? Math.trunc(lineValue) : 0
|
|
217
|
+
|
|
218
|
+
let selection = textOf(source.selection)
|
|
219
|
+
let content = textOf(source.content)
|
|
220
|
+
if (selection.length > CONTEXT_HARD_LIMIT) selection = selection.slice(0, CONTEXT_HARD_LIMIT)
|
|
221
|
+
if (content.length > CONTEXT_HARD_LIMIT) content = content.slice(0, CONTEXT_HARD_LIMIT)
|
|
222
|
+
|
|
223
|
+
const selectionLimit = CONTEXT_SELECTION_LIMIT
|
|
224
|
+
const fileLimit = selection !== '' ? CONTEXT_FILE_LIMIT_WITH_SELECTION : CONTEXT_FILE_LIMIT
|
|
225
|
+
const selectionTruncated = selection.length > selectionLimit
|
|
226
|
+
if (selectionTruncated) selection = selection.slice(0, selectionLimit)
|
|
227
|
+
const contentTruncated = content.length > fileLimit
|
|
228
|
+
if (contentTruncated) content = content.slice(0, fileLimit)
|
|
229
|
+
|
|
230
|
+
return { root, path, language, content, contentTruncated, selection, selectionTruncated, selectionPath, selectionLine }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** 代码围栏:语言合法才追加。 */
|
|
234
|
+
function fence(language) {
|
|
235
|
+
return '```' + (language === '' ? '' : language)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** 渲染「代码上下文」段;无上下文时返回空串。 */
|
|
239
|
+
function buildContextSection(context) {
|
|
240
|
+
const hasFile = context.path !== '' || context.content !== ''
|
|
241
|
+
if (!hasFile && context.selection === '' && context.root === '') return ''
|
|
242
|
+
const lines = ['【代码上下文】']
|
|
243
|
+
if (context.root !== '') lines.push(`项目根目录:${context.root}`)
|
|
244
|
+
const filePath = context.path !== '' ? context.path : context.selectionPath
|
|
245
|
+
if (filePath !== '') {
|
|
246
|
+
lines.push(`当前文件:${filePath}${context.language === '' ? '' : `(语言:${context.language})`}`)
|
|
247
|
+
}
|
|
248
|
+
if (context.selection !== '') {
|
|
249
|
+
const details = []
|
|
250
|
+
if (context.selectionPath !== '' && context.selectionPath !== filePath) details.push(`来自 ${context.selectionPath}`)
|
|
251
|
+
if (context.selectionLine > 0) details.push(`从第 ${context.selectionLine} 行开始`)
|
|
252
|
+
lines.push(`用户选中的代码${details.length === 0 ? '' : `(${details.join(',')})`}:`)
|
|
253
|
+
lines.push(fence(context.language))
|
|
254
|
+
lines.push(context.selection)
|
|
255
|
+
lines.push('```')
|
|
256
|
+
if (context.selectionTruncated) lines.push(`(选中代码过长,已截断到 ${CONTEXT_SELECTION_LIMIT} 字符)`)
|
|
257
|
+
}
|
|
258
|
+
if (context.content !== '') {
|
|
259
|
+
const fileLimit = context.selection !== '' ? CONTEXT_FILE_LIMIT_WITH_SELECTION : CONTEXT_FILE_LIMIT
|
|
260
|
+
lines.push(`当前文件内容${context.contentTruncated ? `(过长,已截断到 ${fileLimit} 字符)` : ''}:`)
|
|
261
|
+
lines.push(fence(context.language))
|
|
262
|
+
lines.push(context.content)
|
|
263
|
+
lines.push('```')
|
|
264
|
+
}
|
|
265
|
+
return lines.join('\n')
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** 渲染「对话历史」段;从新到旧取,超出预算时省略较早内容。 */
|
|
269
|
+
function buildHistorySection(history) {
|
|
270
|
+
if (history.length === 0) return ''
|
|
271
|
+
const picked = []
|
|
272
|
+
let used = 0
|
|
273
|
+
let omitted = false
|
|
274
|
+
for (let index = history.length - 1; index >= 0; index -= 1) {
|
|
275
|
+
const message = history[index]
|
|
276
|
+
const body = `${message.role === 'user' ? '用户' : '助手'}:${message.text}`
|
|
277
|
+
if (picked.length >= HISTORY_MESSAGE_LIMIT || used + body.length > HISTORY_CHAR_LIMIT) {
|
|
278
|
+
omitted = true
|
|
279
|
+
break
|
|
280
|
+
}
|
|
281
|
+
picked.push(body)
|
|
282
|
+
used += body.length
|
|
283
|
+
}
|
|
284
|
+
picked.reverse()
|
|
285
|
+
const lines = ['【对话历史】']
|
|
286
|
+
if (omitted) lines.push('(较早的对话已省略)')
|
|
287
|
+
for (const body of picked) lines.push(body)
|
|
288
|
+
return lines.join('\n\n')
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** 拆分「历史 + 当前问题」:最后一条 user 消息是本次提问。 */
|
|
292
|
+
function splitMessages(messages) {
|
|
293
|
+
if (messages.length === 0) return { history: [], question: '' }
|
|
294
|
+
const last = messages[messages.length - 1]
|
|
295
|
+
if (last.role !== 'user') return { history: messages, question: '' }
|
|
296
|
+
let question = last.text
|
|
297
|
+
if (question.length > QUESTION_LIMIT) question = `${question.slice(0, QUESTION_LIMIT)}\n…(问题过长,已截断)`
|
|
298
|
+
return { history: messages.slice(0, -1), question }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* 把请求体拼成单条 user 文本:系统提示 + 代码上下文 + 最近对话 + 当前问题。
|
|
303
|
+
* 导出以便纯函数单测。
|
|
304
|
+
* @param {any} body chat.stream 请求体
|
|
305
|
+
* @returns {string}
|
|
306
|
+
*/
|
|
307
|
+
export function buildChatPrompt(body) {
|
|
308
|
+
const source = body !== null && typeof body === 'object' ? body : {}
|
|
309
|
+
const messages = normalizeMessages(source.messages)
|
|
310
|
+
const context = normalizeContext(source.context)
|
|
311
|
+
const { history, question } = splitMessages(messages)
|
|
312
|
+
const parts = [SYSTEM_PROMPT]
|
|
313
|
+
const contextSection = buildContextSection(context)
|
|
314
|
+
if (contextSection !== '') parts.push(contextSection)
|
|
315
|
+
const historySection = buildHistorySection(history)
|
|
316
|
+
if (historySection !== '') parts.push(historySection)
|
|
317
|
+
parts.push(`【当前问题】\n${question === '' ? '(用户没有输入具体问题,请根据上面的上下文给出有用的分析与建议)' : question}`)
|
|
318
|
+
const prompt = parts.join('\n\n')
|
|
319
|
+
if (prompt.length <= PROMPT_HARD_LIMIT) return prompt
|
|
320
|
+
return `${prompt.slice(0, PROMPT_HARD_LIMIT)}\n…(提示过长,已截断)`
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/* ── 路由注册 ─────────────────────────────────────────────────────────── */
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* 注册聊天能力。
|
|
327
|
+
* @param {{ method(name: string, fn: Function): void, stream(name: string, fn: Function): void }} router
|
|
328
|
+
* @param {{ ctx: any, chatTimeoutMs?: number }} env
|
|
329
|
+
*/
|
|
330
|
+
export function register(router, env) {
|
|
331
|
+
const ctx = env !== null && typeof env === 'object' ? env.ctx : undefined
|
|
332
|
+
const timeoutMs = chatTimeoutOf(env)
|
|
333
|
+
|
|
334
|
+
/** 读取 Host 的 llm 服务(每个请求实时取,避免服务晚于插件挂载)。 */
|
|
335
|
+
const readLlm = () => {
|
|
336
|
+
try {
|
|
337
|
+
if (ctx === undefined || ctx === null || typeof ctx.get !== 'function') return undefined
|
|
338
|
+
const service = ctx.get('llm')
|
|
339
|
+
return service === undefined || service === null ? undefined : service
|
|
340
|
+
} catch {
|
|
341
|
+
return undefined
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
router.method('chat.info', async () => {
|
|
346
|
+
const target = readDefaultSelection(ctx)
|
|
347
|
+
const llm = readLlm()
|
|
348
|
+
let ready = false
|
|
349
|
+
if (llm !== undefined && typeof llm.stream === 'function') {
|
|
350
|
+
ready = true
|
|
351
|
+
try {
|
|
352
|
+
const providers = typeof llm.listProviders === 'function' ? llm.listProviders() : []
|
|
353
|
+
if (Array.isArray(providers) && providers.length > 0) {
|
|
354
|
+
ready = providers.some((entry) => entry !== null && typeof entry === 'object' && entry.id === target.provider)
|
|
355
|
+
}
|
|
356
|
+
} catch {
|
|
357
|
+
/* 目录不可读时保留「llm 服务存在即可用」的宽松判断 */
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return { provider: target.provider, model: target.model, ready }
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
router.stream('chat.stream', async (body, { res, signal, send, close }) => {
|
|
364
|
+
/** 合并「客户端断开」与「超时」的取消源。 */
|
|
365
|
+
const controller = new AbortController()
|
|
366
|
+
let timedOut = false
|
|
367
|
+
let thinkingSent = false
|
|
368
|
+
let terminalSent = false
|
|
369
|
+
let closed = false
|
|
370
|
+
let timer
|
|
371
|
+
|
|
372
|
+
const sendEvent = (event) => {
|
|
373
|
+
try {
|
|
374
|
+
send(event)
|
|
375
|
+
} catch {
|
|
376
|
+
/* 客户端已断开:忽略写失败 */
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
const sendTerminal = (event) => {
|
|
380
|
+
if (terminalSent) return
|
|
381
|
+
terminalSent = true
|
|
382
|
+
sendEvent(event)
|
|
383
|
+
}
|
|
384
|
+
const abortForCancel = () => {
|
|
385
|
+
controller.abort()
|
|
386
|
+
}
|
|
387
|
+
const onResponseClose = () => {
|
|
388
|
+
if (res === undefined || res === null || res.writableEnded !== true) abortForCancel()
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (signal !== undefined && signal !== null) {
|
|
392
|
+
if (signal.aborted === true) controller.abort()
|
|
393
|
+
else if (typeof signal.addEventListener === 'function') signal.addEventListener('abort', abortForCancel, { once: true })
|
|
394
|
+
}
|
|
395
|
+
if (res !== undefined && res !== null && typeof res.once === 'function') res.once('close', onResponseClose)
|
|
396
|
+
if (typeof setTimeout === 'function') {
|
|
397
|
+
timer = setTimeout(() => {
|
|
398
|
+
timedOut = true
|
|
399
|
+
controller.abort()
|
|
400
|
+
}, timeoutMs)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
try {
|
|
404
|
+
/* SSE 头:必须显式设置 text/event-stream,Host 的 gzip 中间件依据它跳过压缩/缓冲。 */
|
|
405
|
+
if (res !== undefined && res !== null && res.headersSent !== true) {
|
|
406
|
+
try {
|
|
407
|
+
res.writeHead(200, {
|
|
408
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
409
|
+
'cache-control': 'no-cache, no-transform',
|
|
410
|
+
connection: 'keep-alive',
|
|
411
|
+
'x-accel-buffering': 'no',
|
|
412
|
+
})
|
|
413
|
+
if (typeof res.flushHeaders === 'function') res.flushHeaders()
|
|
414
|
+
} catch {
|
|
415
|
+
/* 替代实现(单测 mock)可能没有完整 Node 响应对象 */
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const llm = readLlm()
|
|
420
|
+
if (llm === undefined || typeof llm.stream !== 'function') {
|
|
421
|
+
sendTerminal({ type: 'error', message: 'AI 服务不可用:Host 没有挂载 llm 服务。', code: 'NO_LLM' })
|
|
422
|
+
return
|
|
423
|
+
}
|
|
424
|
+
const messages = normalizeMessages(body !== null && typeof body === 'object' ? body.messages : undefined)
|
|
425
|
+
if (messages.length === 0) {
|
|
426
|
+
sendTerminal({ type: 'error', message: '消息不能为空。', code: 'EMPTY_MESSAGES' })
|
|
427
|
+
return
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const target = resolveChatTarget(body, ctx)
|
|
431
|
+
const options = {
|
|
432
|
+
provider: target.provider,
|
|
433
|
+
model: target.model,
|
|
434
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: buildChatPrompt(body) }] }],
|
|
435
|
+
signal: controller.signal,
|
|
436
|
+
}
|
|
437
|
+
if (target.reasoningEffort !== undefined) options.reasoningEffort = target.reasoningEffort
|
|
438
|
+
|
|
439
|
+
/* 硬性取消:超时或客户端断开时,即使适配器不响应 signal,也要从 for-await 中退出。 */
|
|
440
|
+
const ABORTED = { aborted: true }
|
|
441
|
+
let onAbortResolve
|
|
442
|
+
const abortedPromise = new Promise((resolve) => {
|
|
443
|
+
onAbortResolve = resolve
|
|
444
|
+
if (controller.signal.aborted) {
|
|
445
|
+
resolve(ABORTED)
|
|
446
|
+
return
|
|
447
|
+
}
|
|
448
|
+
controller.signal.addEventListener('abort', () => resolve(ABORTED), { once: true })
|
|
449
|
+
})
|
|
450
|
+
let iterator
|
|
451
|
+
try {
|
|
452
|
+
iterator = llm.stream(options)[Symbol.asyncIterator]()
|
|
453
|
+
for (;;) {
|
|
454
|
+
const nextPromise = iterator.next()
|
|
455
|
+
/* 输掉 race 的那一侧稍后可能 reject:提前挂上处理,避免 unhandledRejection。 */
|
|
456
|
+
nextPromise.catch(() => {})
|
|
457
|
+
const next = await Promise.race([nextPromise, abortedPromise])
|
|
458
|
+
if (next === ABORTED) break
|
|
459
|
+
if (next.done) break
|
|
460
|
+
const chunk = next.value
|
|
461
|
+
if (chunk === null || typeof chunk !== 'object') continue
|
|
462
|
+
if (chunk.type === 'text-delta') {
|
|
463
|
+
const text = textOf(chunk.text)
|
|
464
|
+
if (text !== '') sendEvent({ type: 'delta', text })
|
|
465
|
+
} else if (chunk.type === 'reasoning-delta') {
|
|
466
|
+
if (!thinkingSent) {
|
|
467
|
+
thinkingSent = true
|
|
468
|
+
sendEvent({ type: 'thinking' })
|
|
469
|
+
}
|
|
470
|
+
} else if (chunk.type === 'finish') {
|
|
471
|
+
const reason = chunk.reason !== null && typeof chunk.reason === 'object' ? chunk.reason : { kind: 'stop' }
|
|
472
|
+
if (reason.kind === 'error') {
|
|
473
|
+
const code = failureCode(reason.failure)
|
|
474
|
+
sendTerminal({
|
|
475
|
+
type: 'error',
|
|
476
|
+
message: describeLlmFailure(reason.failure),
|
|
477
|
+
code: code === '' ? 'LLM_ERROR' : code,
|
|
478
|
+
})
|
|
479
|
+
} else if (reason.kind === 'aborted') {
|
|
480
|
+
if (timedOut) sendTerminal(timeoutEvent(timeoutMs))
|
|
481
|
+
else if (!controller.signal.aborted) sendTerminal({ type: 'error', message: '模型请求已中止,请重试。', code: 'ABORTED' })
|
|
482
|
+
} else {
|
|
483
|
+
sendTerminal({ type: 'done' })
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
} catch (error) {
|
|
488
|
+
if (timedOut) sendTerminal(timeoutEvent(timeoutMs))
|
|
489
|
+
else if (!controller.signal.aborted) sendTerminal({ type: 'error', message: `模型请求失败:${errorText(error)}`, code: 'LLM_ERROR' })
|
|
490
|
+
} finally {
|
|
491
|
+
if (onAbortResolve !== undefined) onAbortResolve(ABORTED)
|
|
492
|
+
/* 取消后尽量让适配器释放底层连接(不等待,避免清理本身卡住)。 */
|
|
493
|
+
if (controller.signal.aborted && iterator !== undefined && typeof iterator.return === 'function') {
|
|
494
|
+
try {
|
|
495
|
+
Promise.resolve(iterator.return()).catch(() => {})
|
|
496
|
+
} catch {
|
|
497
|
+
/* ignore */
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (!terminalSent) {
|
|
503
|
+
if (timedOut) sendTerminal(timeoutEvent(timeoutMs))
|
|
504
|
+
else if (!controller.signal.aborted) sendTerminal({ type: 'done' })
|
|
505
|
+
}
|
|
506
|
+
} catch (error) {
|
|
507
|
+
if (timedOut) sendTerminal(timeoutEvent(timeoutMs))
|
|
508
|
+
else if (!controller.signal.aborted) sendTerminal({ type: 'error', message: `AI 聊天请求失败:${errorText(error)}`, code: 'CHAT_ERROR' })
|
|
509
|
+
} finally {
|
|
510
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
511
|
+
if (signal !== undefined && signal !== null && typeof signal.removeEventListener === 'function') signal.removeEventListener('abort', abortForCancel)
|
|
512
|
+
if (res !== undefined && res !== null && typeof res.removeListener === 'function') res.removeListener('close', onResponseClose)
|
|
513
|
+
if (!closed) {
|
|
514
|
+
closed = true
|
|
515
|
+
try {
|
|
516
|
+
close()
|
|
517
|
+
} catch {
|
|
518
|
+
/* 忽略关闭失败 */
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
})
|
|
523
|
+
}
|