zen-gitsync 2.17.5 → 2.17.6
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/README.md +13 -0
- package/package.json +1 -1
- package/src/aiCommit.js +6 -3
- package/src/cli/ai/agent.js +23 -8
- package/src/cli/ai/modelSetup.js +6 -8
- package/src/config.js +57 -4
- package/src/ui/public/assets/{AgentView-DJOdvJEV.js → AgentView-BC3F0F6m.js} +3 -3
- package/src/ui/public/assets/AgentView-CzUeyQfR.css +1 -0
- package/src/ui/public/assets/{AppVersionBadge-D2Li5_R-.js → AppVersionBadge-DV6KuLqb.js} +2 -2
- package/src/ui/public/assets/{BranchSelector-CSd5uaVm.js → BranchSelector-DGXDAOkR.js} +1 -1
- package/src/ui/public/assets/{CommitForm-Ckg9-zay.js → CommitForm-BYBj7UUa.js} +1 -1
- package/src/ui/public/assets/{CommonDialog-B8XOXtz_.js → CommonDialog-DWMCeujE.js} +1 -1
- package/src/ui/public/assets/{EditorView-Crj8S2TI.js → EditorView-D-TEQZso.js} +1 -1
- package/src/ui/public/assets/{FileDiffViewer-DL07IMz5.js → FileDiffViewer-C4ZANajI.js} +1 -1
- package/src/ui/public/assets/{FlowExecutionViewer-DvVT19Qv.js → FlowExecutionViewer-DUv7mBuG.js} +1 -1
- package/src/ui/public/assets/{FlowOrchestrationWorkspace-kRhK9IyL.js → FlowOrchestrationWorkspace-BiSuvzL7.js} +1 -1
- package/src/ui/public/assets/{IconButton-pqSYKRT8.js → IconButton-BltPKVbV.js} +1 -1
- package/src/ui/public/assets/{LogList-BrN6qtFb.js → LogList-BY1Haq9r.js} +1 -1
- package/src/ui/public/assets/{MindmapView-CSsT0NDZ.js → MindmapView-BKsvCnYv.js} +1 -1
- package/src/ui/public/assets/{MonitorView-edRyBybQ.js → MonitorView-B3_NaJRy.js} +1 -1
- package/src/ui/public/assets/{ProjectStartupButton-DZ8VgU79.js → ProjectStartupButton-DP3eDVRp.js} +1 -1
- package/src/ui/public/assets/{RemoteManagerDialog-pu9szuYB.js → RemoteManagerDialog-Bl9b_iRv.js} +1 -1
- package/src/ui/public/assets/{RemoteRepoCard-BSFehgY5.js → RemoteRepoCard-TQ3DxDnq.js} +1 -1
- package/src/ui/public/assets/{SourceMapView-DbEydLSU.js → SourceMapView-BF-Mzv3V.js} +1 -1
- package/src/ui/public/assets/{SvgIcon-BVCbrPws.js → SvgIcon-DxazW7dI.js} +1 -1
- package/src/ui/public/assets/{TemplateManager-DPyYaGCE.js → TemplateManager-BN6gWFWX.js} +1 -1
- package/src/ui/public/assets/{UserInputNode-DXHeCjzG.js → UserInputNode-CPwvIfP6.js} +1 -1
- package/src/ui/public/assets/WorkbenchView-CDu61WWs.js +13 -0
- package/src/ui/public/assets/{WorkbenchView-CKXtZAJn.css → WorkbenchView-DEejub9Z.css} +1 -1
- package/src/ui/public/assets/{_plugin-vue_export-helper-C-YBnnP5.js → _plugin-vue_export-helper-B7A-NgBp.js} +5 -5
- package/src/ui/public/assets/configStore-Dx84lvTX.js +1 -0
- package/src/ui/public/assets/{gitStore-BJbNtjxA.js → gitStore-CYrdjXPU.js} +1 -1
- package/src/ui/public/assets/index-DNHOYMCg.js +24 -0
- package/src/ui/public/assets/index-Dsfmp6w8.css +1 -0
- package/src/ui/public/assets/{previewSandbox-BZV6Eqhv.js → previewSandbox-D_j5PWX1.js} +0 -0
- package/src/ui/public/assets/{toolsStore-Bu8Ujw_A.js → toolsStore-BiC2mmC3.js} +1 -1
- package/src/ui/public/assets/{vendor-CwZs6S_i.js → vendor-92fKgtnz.js} +7 -7
- package/src/ui/public/assets/{vendor-_6pgW9Z2.css → vendor-B1UrVTTI.css} +1 -1
- package/src/ui/public/index.html +14 -14
- package/src/ui/server/routes/codeAnalysis.js +6 -3
- package/src/ui/server/routes/config.js +32 -6
- package/src/ui/server/routes/workbench/agentChat.js +34 -8
- package/src/ui/server/routes/workbench/llmClient.js +15 -8
- package/src/utils/aiEndpoint.js +231 -0
- package/src/utils/aiEndpoint.test.js +175 -0
- package/src/ui/public/assets/AgentView-BsN9y4Kg.css +0 -1
- package/src/ui/public/assets/WorkbenchView-DGcirla3.js +0 -13
- package/src/ui/public/assets/configStore-DoobmT7N.js +0 -1
- package/src/ui/public/assets/index-ClqvC4kJ.css +0 -1
- package/src/ui/public/assets/index-ndDaGoRO.js +0 -24
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// Copyright 2026 xz333221
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
//
|
|
15
|
+
// ─────────────────────────────────────────────────────────────
|
|
16
|
+
// AI 请求的地址 + 请求头统一构造层
|
|
17
|
+
//
|
|
18
|
+
// 为什么要单独抽一层(以前这些逻辑散在 7 个调用点,只改一处等于没改):
|
|
19
|
+
//
|
|
20
|
+
// 1) 客户端身份 + 会话 ID。OpenCode 的网关(opencode.ai/zen/**,含 $10/月的 Go 订阅)
|
|
21
|
+
// 要求客户端「用自己的名字当 User-Agent」(不能是 node-fetch/undici 这类通用库名),
|
|
22
|
+
// 并且每次对话在 `x-opencode-session` 里带一个稳定会话 ID,用于路由与提示缓存。
|
|
23
|
+
// 缺了会被网关直接拒掉:
|
|
24
|
+
// Request is missing x-opencode-session and cannot be routed efficiently.
|
|
25
|
+
// Please see https://opencode.ai/docs/go/#where-can-i-use-it
|
|
26
|
+
// 用 node-fetch 默认 UA 也会被当成"通用 HTTP 库"而命中滥用风控。
|
|
27
|
+
//
|
|
28
|
+
// 2) 协议族选择。同一个 baseURL 后面挂着三种协议,path 与鉴权方式都不同:
|
|
29
|
+
// chat → POST /chat/completions (Authorization: Bearer)
|
|
30
|
+
// messages → POST /messages (x-api-key + anthropic-version)
|
|
31
|
+
// responses → POST /responses (Authorization: Bearer)
|
|
32
|
+
// 本仓库的 AI 调用全部只实现了 OpenAI 兼容的 chat 形态,所以碰到后两种协议族
|
|
33
|
+
// 要**明确报错**(提示换模型),而不是把 OpenAI body 打到 Anthropic 路由上等一个
|
|
34
|
+
// 看不懂的 4xx。
|
|
35
|
+
//
|
|
36
|
+
// 参考实现:ai-model-form 包的 server/middleware.js(同一套判断,那边只做连通性测试)。
|
|
37
|
+
// ─────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
import { randomUUID } from 'node:crypto'
|
|
40
|
+
import { readFileSync } from 'node:fs'
|
|
41
|
+
|
|
42
|
+
/** 本包版本,用于 User-Agent(从模块自身位置往上找 package.json,不受 cwd 影响) */
|
|
43
|
+
const PKG_VERSION = (() => {
|
|
44
|
+
try {
|
|
45
|
+
const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'))
|
|
46
|
+
return pkg.version || '0.0.0'
|
|
47
|
+
} catch {
|
|
48
|
+
return '0.0.0'
|
|
49
|
+
}
|
|
50
|
+
})()
|
|
51
|
+
|
|
52
|
+
/** 网关/风控认的客户端标识:得是"自己的名字",不能是通用 HTTP 库 */
|
|
53
|
+
export const AI_USER_AGENT = `zen-gitsync/${PKG_VERSION}`
|
|
54
|
+
|
|
55
|
+
/** OpenCode 网关的 Go 订阅入口(ai-model-form 里叫 OPENCODE_GO_URL) */
|
|
56
|
+
export const OPENCODE_GO_URL = 'https://opencode.ai/zen/go/v1'
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* OpenCode Go 上走 Anthropic /messages 协议的模型(鉴权是 x-api-key,不是 Bearer)。
|
|
60
|
+
* 只列**非 chat** 的模型:没列到的一律按 chat 处理,这样网关以后新增 chat 模型
|
|
61
|
+
* 不需要同步改这里(新增非 chat 模型时才会退化回"上游报错")。
|
|
62
|
+
*/
|
|
63
|
+
const OPENCODE_GO_MESSAGES_MODELS = new Set([
|
|
64
|
+
'minimax-m3', 'minimax-m2.7', 'minimax-m2.5',
|
|
65
|
+
'qwen3.8-max', 'qwen3.8-flash', 'qwen3.7-max', 'qwen3.7-plus',
|
|
66
|
+
'qwen3.6-plus', 'qwen3.5-plus', 'union-alpha',
|
|
67
|
+
])
|
|
68
|
+
|
|
69
|
+
/** OpenCode Go 上走 OpenAI Responses 协议(/responses)的模型 */
|
|
70
|
+
const OPENCODE_GO_RESPONSES_MODELS = new Set([
|
|
71
|
+
'grok-4.6', 'grok-4.5', 'gpt-5.6-luna',
|
|
72
|
+
'muse-spark-1.3-contributor', 'muse-spark-1.2-contributor',
|
|
73
|
+
])
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 归一化 baseURL:去掉尾部斜杠(兼容用户填 .../v1/ 的写法)
|
|
77
|
+
* @param {string} baseURL
|
|
78
|
+
* @returns {string}
|
|
79
|
+
*/
|
|
80
|
+
export function normalizeBaseURL(baseURL) {
|
|
81
|
+
return String(baseURL || '').trim().replace(/\/+$/, '')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 是否为 OpenCode 网关(opencode.ai 及其子域)。
|
|
86
|
+
* 只有这类端点才需要客户端身份 + 会话 ID;其它 provider 不必多带这几个头。
|
|
87
|
+
* @param {string} baseURL
|
|
88
|
+
* @returns {boolean}
|
|
89
|
+
*/
|
|
90
|
+
export function isOpenCodeGateway(baseURL) {
|
|
91
|
+
try {
|
|
92
|
+
const host = new URL(normalizeBaseURL(baseURL)).hostname
|
|
93
|
+
return /(^|\.)opencode\.ai$/i.test(host)
|
|
94
|
+
} catch {
|
|
95
|
+
return false
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 判断模型在 OpenCode Go 上走哪种协议族。
|
|
101
|
+
* @param {string} modelId
|
|
102
|
+
* @returns {'chat'|'messages'|'responses'}
|
|
103
|
+
*/
|
|
104
|
+
export function openCodeGoFamily(modelId) {
|
|
105
|
+
const id = String(modelId || '').trim().toLowerCase()
|
|
106
|
+
if (OPENCODE_GO_MESSAGES_MODELS.has(id)) return 'messages'
|
|
107
|
+
if (OPENCODE_GO_RESPONSES_MODELS.has(id)) return 'responses'
|
|
108
|
+
return 'chat'
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let _processSessionId = null
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 进程级稳定会话 ID(懒创建)。
|
|
115
|
+
* 一次性调用(生成提交信息 / 分支名 / 代码分析…)没有"对话"概念,用同一个值把
|
|
116
|
+
* 同一进程里的这类请求归到一条会话上——重复点"重新生成"还能吃到网关的提示缓存。
|
|
117
|
+
* @returns {string}
|
|
118
|
+
*/
|
|
119
|
+
export function processAiSessionId() {
|
|
120
|
+
if (!_processSessionId) _processSessionId = randomUUID()
|
|
121
|
+
return _processSessionId
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 请求的公共头:客户端身份(+ OpenCode 网关的会话 ID)。
|
|
126
|
+
* GET /models、连通性探测这类非 chat 请求也应该带上,否则同样会被风控挡。
|
|
127
|
+
*
|
|
128
|
+
* @param {object} opts
|
|
129
|
+
* @param {string} opts.baseURL
|
|
130
|
+
* @param {string} [opts.apiKey]
|
|
131
|
+
* @param {string} [opts.sessionId] - 对话级 ID;不传则用进程级兜底
|
|
132
|
+
* @returns {Record<string,string>}
|
|
133
|
+
*/
|
|
134
|
+
export function buildAiBaseHeaders({ baseURL, apiKey, sessionId } = {}) {
|
|
135
|
+
const headers = { 'User-Agent': AI_USER_AGENT }
|
|
136
|
+
if (isOpenCodeGateway(baseURL)) {
|
|
137
|
+
headers['x-opencode-session'] = sessionId || processAiSessionId()
|
|
138
|
+
}
|
|
139
|
+
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
|
|
140
|
+
return headers
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 构造「OpenAI 兼容 chat 请求」的 URL 与完整请求头。
|
|
145
|
+
*
|
|
146
|
+
* @param {object} opts
|
|
147
|
+
* @param {string} opts.baseURL
|
|
148
|
+
* @param {string} [opts.model] - 用于识别 OpenCode Go 的协议族
|
|
149
|
+
* @param {string} [opts.apiKey]
|
|
150
|
+
* @param {string} [opts.sessionId] - 对话级 ID(多轮对话请传,一次性调用可省略)
|
|
151
|
+
* @param {Record<string,string>} [opts.extraHeaders]
|
|
152
|
+
* @returns {{ url: string, headers: Record<string,string> }}
|
|
153
|
+
* @throws {Error} baseURL 缺失,或所选模型在 OpenCode 网关上不是 chat/completions 协议
|
|
154
|
+
*/
|
|
155
|
+
export function buildAiChatRequest({ baseURL, model, apiKey, sessionId, extraHeaders } = {}) {
|
|
156
|
+
const base = normalizeBaseURL(baseURL)
|
|
157
|
+
if (!base) throw new Error('AI 模型未配置接口地址(baseURL)')
|
|
158
|
+
|
|
159
|
+
if (isOpenCodeGateway(base)) {
|
|
160
|
+
const family = openCodeGoFamily(model)
|
|
161
|
+
if (family !== 'chat') {
|
|
162
|
+
const proto = family === 'messages'
|
|
163
|
+
? 'Anthropic 的 /messages(鉴权走 x-api-key)'
|
|
164
|
+
: 'OpenAI 的 /responses'
|
|
165
|
+
throw new Error(
|
|
166
|
+
`模型「${model}」在 OpenCode 网关上走 ${proto} 协议,本工具的 AI 调用只实现了 ` +
|
|
167
|
+
'OpenAI 兼容的 /chat/completions。请在设置里换一个走 chat/completions 的模型' +
|
|
168
|
+
'(如 deepseek-v4.1-flash、glm-5.3、kimi-k3)。'
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
url: `${base}/chat/completions`,
|
|
175
|
+
headers: {
|
|
176
|
+
'Content-Type': 'application/json',
|
|
177
|
+
...buildAiBaseHeaders({ baseURL: base, apiKey, sessionId }),
|
|
178
|
+
...extraHeaders,
|
|
179
|
+
},
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 把上游返回的错误体整理成一句人能读的话。
|
|
185
|
+
*
|
|
186
|
+
* 各家 provider 的错误体形态不一(OpenAI 兼容是 {error:{message}},Anthropic 是
|
|
187
|
+
* {error:{message}} / {message},还有些直接返回纯文本或 HTML),以前直接把整个响应体
|
|
188
|
+
* 丢进 Error 里,界面上就是一大坨 JSON。这里统一抽 message,抽不到才退回原文(截断)。
|
|
189
|
+
*
|
|
190
|
+
* @param {string} bodyText - 响应体原文
|
|
191
|
+
* @param {number} [status]
|
|
192
|
+
* @returns {string}
|
|
193
|
+
*/
|
|
194
|
+
export function describeAiHttpError(bodyText, status) {
|
|
195
|
+
const raw = String(bodyText || '').trim()
|
|
196
|
+
let message = ''
|
|
197
|
+
try {
|
|
198
|
+
const data = JSON.parse(raw)
|
|
199
|
+
message = data?.error?.message
|
|
200
|
+
|| (typeof data?.error === 'string' ? data.error : '')
|
|
201
|
+
|| data?.message
|
|
202
|
+
|| ''
|
|
203
|
+
} catch {
|
|
204
|
+
message = ''
|
|
205
|
+
}
|
|
206
|
+
if (message) return message
|
|
207
|
+
const fallback = raw.length > 300 ? `${raw.slice(0, 300)}…` : raw
|
|
208
|
+
return fallback || `HTTP ${status}`
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* 构造非 chat 请求(GET /models 等)的 URL 与请求头。
|
|
213
|
+
* @param {object} opts
|
|
214
|
+
* @param {string} opts.baseURL
|
|
215
|
+
* @param {string} opts.path - 以 / 开头的路径,如 '/models'
|
|
216
|
+
* @param {string} [opts.apiKey]
|
|
217
|
+
* @param {string} [opts.sessionId]
|
|
218
|
+
* @param {Record<string,string>} [opts.extraHeaders]
|
|
219
|
+
* @returns {{ url: string, headers: Record<string,string> }}
|
|
220
|
+
*/
|
|
221
|
+
export function buildAiApiRequest({ baseURL, path, apiKey, sessionId, extraHeaders } = {}) {
|
|
222
|
+
const base = normalizeBaseURL(baseURL)
|
|
223
|
+
if (!base) throw new Error('AI 模型未配置接口地址(baseURL)')
|
|
224
|
+
return {
|
|
225
|
+
url: `${base}${path}`,
|
|
226
|
+
headers: {
|
|
227
|
+
...buildAiBaseHeaders({ baseURL: base, apiKey, sessionId }),
|
|
228
|
+
...extraHeaders,
|
|
229
|
+
},
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Copyright 2026 xz333221
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
//
|
|
15
|
+
// src/utils/aiEndpoint.js 单元测试(node:test 内置)。
|
|
16
|
+
//
|
|
17
|
+
// 这一层是为了修「生成提交信息报 Request is missing x-opencode-session」而抽出来的,
|
|
18
|
+
// 所以用测试把三件事钉死:
|
|
19
|
+
// 1) 只对 OpenCode 网关加 x-opencode-session,别的 provider 不加(避免污染第三方请求)
|
|
20
|
+
// 2) 请求头里必须有自己的客户端名字(User-Agent 不能是 node-fetch/undici)
|
|
21
|
+
// 3) OpenCode 网关上非 chat 协议族的模型要**明确报错**,不能把 OpenAI body 打错路由
|
|
22
|
+
import { test } from 'node:test'
|
|
23
|
+
import assert from 'node:assert/strict'
|
|
24
|
+
import {
|
|
25
|
+
AI_USER_AGENT,
|
|
26
|
+
OPENCODE_GO_URL,
|
|
27
|
+
buildAiApiRequest,
|
|
28
|
+
buildAiChatRequest,
|
|
29
|
+
describeAiHttpError,
|
|
30
|
+
isOpenCodeGateway,
|
|
31
|
+
normalizeBaseURL,
|
|
32
|
+
openCodeGoFamily,
|
|
33
|
+
processAiSessionId,
|
|
34
|
+
} from './aiEndpoint.js'
|
|
35
|
+
|
|
36
|
+
const GO = 'https://opencode.ai/zen/go/v1'
|
|
37
|
+
|
|
38
|
+
// ========== normalizeBaseURL ==========
|
|
39
|
+
|
|
40
|
+
test('normalizeBaseURL: 去掉尾部斜杠并 trim', () => {
|
|
41
|
+
assert.equal(normalizeBaseURL('https://api.deepseek.com/v1/'), 'https://api.deepseek.com/v1')
|
|
42
|
+
assert.equal(normalizeBaseURL(' https://api.deepseek.com/v1// '), 'https://api.deepseek.com/v1')
|
|
43
|
+
assert.equal(normalizeBaseURL(''), '')
|
|
44
|
+
assert.equal(normalizeBaseURL(undefined), '')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// ========== isOpenCodeGateway ==========
|
|
48
|
+
|
|
49
|
+
test('isOpenCodeGateway: 识别 opencode.ai 及其子域', () => {
|
|
50
|
+
assert.equal(isOpenCodeGateway(GO), true)
|
|
51
|
+
assert.equal(isOpenCodeGateway('https://opencode.ai/zen/go/v1/'), true)
|
|
52
|
+
assert.equal(isOpenCodeGateway('https://api.opencode.ai/v1'), true)
|
|
53
|
+
assert.equal(isOpenCodeGateway('https://api.deepseek.com/v1'), false)
|
|
54
|
+
// 不能被"看起来像"的域名骗到(endsWith 判断容易踩)
|
|
55
|
+
assert.equal(isOpenCodeGateway('https://evil-opencode.ai.attacker.com/v1'), false)
|
|
56
|
+
assert.equal(isOpenCodeGateway('not-a-url'), false)
|
|
57
|
+
assert.equal(isOpenCodeGateway(''), false)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
// ========== openCodeGoFamily ==========
|
|
61
|
+
|
|
62
|
+
test('openCodeGoFamily: 三种协议族与默认 chat', () => {
|
|
63
|
+
assert.equal(openCodeGoFamily('deepseek-v4.1-flash'), 'chat')
|
|
64
|
+
assert.equal(openCodeGoFamily('glm-5.3'), 'chat')
|
|
65
|
+
assert.equal(openCodeGoFamily('minimax-m3'), 'messages')
|
|
66
|
+
assert.equal(openCodeGoFamily('qwen3.8-max'), 'messages')
|
|
67
|
+
assert.equal(openCodeGoFamily('grok-4.6'), 'responses')
|
|
68
|
+
assert.equal(openCodeGoFamily('gpt-5.6-luna'), 'responses')
|
|
69
|
+
// 大小写/空格容错
|
|
70
|
+
assert.equal(openCodeGoFamily(' MiniMax-M3 '), 'messages')
|
|
71
|
+
// 未知/新增模型一律按 chat:网关以后加 chat 模型不需要同步改代码
|
|
72
|
+
assert.equal(openCodeGoFamily('brand-new-model'), 'chat')
|
|
73
|
+
assert.equal(openCodeGoFamily(''), 'chat')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
// ========== buildAiChatRequest ==========
|
|
77
|
+
|
|
78
|
+
test('buildAiChatRequest: 普通 provider 拼 url + Bearer,不带 opencode 会话头', () => {
|
|
79
|
+
const { url, headers } = buildAiChatRequest({
|
|
80
|
+
baseURL: 'https://api.deepseek.com/v1/',
|
|
81
|
+
model: 'deepseek-chat',
|
|
82
|
+
apiKey: 'sk-abc',
|
|
83
|
+
})
|
|
84
|
+
assert.equal(url, 'https://api.deepseek.com/v1/chat/completions')
|
|
85
|
+
assert.equal(headers['Content-Type'], 'application/json')
|
|
86
|
+
assert.equal(headers['Authorization'], 'Bearer sk-abc')
|
|
87
|
+
assert.equal(headers['User-Agent'], AI_USER_AGENT)
|
|
88
|
+
assert.ok(!('x-opencode-session' in headers), '非 OpenCode 网关不应带 x-opencode-session')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('buildAiChatRequest: OpenCode Go + chat 模型带上客户端身份与会话 ID', () => {
|
|
92
|
+
const { url, headers } = buildAiChatRequest({
|
|
93
|
+
baseURL: GO,
|
|
94
|
+
model: 'deepseek-v4.1-flash',
|
|
95
|
+
apiKey: 'sk-go',
|
|
96
|
+
})
|
|
97
|
+
assert.equal(url, `${OPENCODE_GO_URL}/chat/completions`)
|
|
98
|
+
assert.equal(headers['Authorization'], 'Bearer sk-go')
|
|
99
|
+
assert.ok(headers['x-opencode-session'], '缺少 x-opencode-session 会被网关拒绝')
|
|
100
|
+
// 网关要求"用自己的名字"当 UA,通用库名(node-fetch/undici)会命中风控
|
|
101
|
+
assert.match(headers['User-Agent'], /^zen-gitsync\/\d+\.\d+\.\d+$/)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('buildAiChatRequest: 省略 sessionId 时同进程内保持稳定(吃提示缓存)', () => {
|
|
105
|
+
const a = buildAiChatRequest({ baseURL: GO, model: 'deepseek-v4.1-flash', apiKey: 'k' })
|
|
106
|
+
const b = buildAiChatRequest({ baseURL: GO, model: 'deepseek-v4.1-flash', apiKey: 'k' })
|
|
107
|
+
assert.equal(a.headers['x-opencode-session'], b.headers['x-opencode-session'])
|
|
108
|
+
assert.equal(a.headers['x-opencode-session'], processAiSessionId())
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('buildAiChatRequest: 显式 sessionId 覆盖进程级兜底(对话级复用)', () => {
|
|
112
|
+
const { headers } = buildAiChatRequest({
|
|
113
|
+
baseURL: GO,
|
|
114
|
+
model: 'deepseek-v4.1-flash',
|
|
115
|
+
apiKey: 'k',
|
|
116
|
+
sessionId: 'session-42',
|
|
117
|
+
})
|
|
118
|
+
assert.equal(headers['x-opencode-session'], 'session-42')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('buildAiChatRequest: OpenCode Go 上非 chat 协议族的模型直接给可读报错', () => {
|
|
122
|
+
assert.throws(
|
|
123
|
+
() => buildAiChatRequest({ baseURL: GO, model: 'minimax-m3', apiKey: 'k' }),
|
|
124
|
+
/minimax-m3.*Anthropic.*\/messages.*换一个/
|
|
125
|
+
)
|
|
126
|
+
assert.throws(
|
|
127
|
+
() => buildAiChatRequest({ baseURL: GO, model: 'grok-4.6', apiKey: 'k' }),
|
|
128
|
+
/grok-4\.6.*\/responses/
|
|
129
|
+
)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('buildAiChatRequest: 无 apiKey 不发 Authorization,extraHeaders 可覆盖默认头', () => {
|
|
133
|
+
const { headers } = buildAiChatRequest({
|
|
134
|
+
baseURL: 'https://api.deepseek.com/v1',
|
|
135
|
+
model: 'deepseek-chat',
|
|
136
|
+
extraHeaders: { 'X-Trace': '1' },
|
|
137
|
+
})
|
|
138
|
+
assert.ok(!('Authorization' in headers))
|
|
139
|
+
assert.equal(headers['X-Trace'], '1')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('buildAiChatRequest: 缺 baseURL 时明确报错(而不是拼出 /chat/completions)', () => {
|
|
143
|
+
assert.throws(() => buildAiChatRequest({ model: 'm' }), /baseURL/)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
// ========== buildAiApiRequest ==========
|
|
147
|
+
|
|
148
|
+
test('buildAiApiRequest: 拼 /models 并同样带身份头', () => {
|
|
149
|
+
const { url, headers } = buildAiApiRequest({ baseURL: GO + '/', path: '/models', apiKey: 'k' })
|
|
150
|
+
assert.equal(url, `${OPENCODE_GO_URL}/models`)
|
|
151
|
+
assert.equal(headers['Authorization'], 'Bearer k')
|
|
152
|
+
assert.ok(headers['x-opencode-session'])
|
|
153
|
+
assert.ok(headers['User-Agent'])
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
// ========== describeAiHttpError ==========
|
|
157
|
+
|
|
158
|
+
test('describeAiHttpError: 抽 error.message,抽不到才退回原文', () => {
|
|
159
|
+
assert.equal(
|
|
160
|
+
describeAiHttpError(JSON.stringify({
|
|
161
|
+
error: {
|
|
162
|
+
message: 'Request is missing x-opencode-session and cannot be routed efficiently.',
|
|
163
|
+
code: 'HTTP_ERR',
|
|
164
|
+
},
|
|
165
|
+
}), 400),
|
|
166
|
+
'Request is missing x-opencode-session and cannot be routed efficiently.'
|
|
167
|
+
)
|
|
168
|
+
assert.equal(describeAiHttpError('{"error":"boom"}', 500), 'boom')
|
|
169
|
+
assert.equal(describeAiHttpError('{"message":"boom"}', 500), 'boom')
|
|
170
|
+
assert.equal(describeAiHttpError('plain failure', 500), 'plain failure')
|
|
171
|
+
assert.equal(describeAiHttpError('', 502), 'HTTP 502')
|
|
172
|
+
// 超长原文截断,不把整页 HTML 甩到界面上
|
|
173
|
+
const long = 'x'.repeat(500)
|
|
174
|
+
assert.equal(describeAiHttpError(long, 500).length, 301)
|
|
175
|
+
})
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
.agent-view[data-v-773baa74]{background:var(--bg-container);height:100%;min-height:0;display:flex}.agent-sidebar[data-v-773baa74]{background:var(--bg-panel);border-right:1px solid var(--border-color);flex-direction:column;flex-shrink:0;display:flex;overflow:hidden}.sidebar-header[data-v-773baa74]{flex-shrink:0;padding:10px 12px}.new-session-btn[data-v-773baa74]{border:1px dashed var(--border-color);border-radius:var(--radius-md);width:100%;color:var(--text-secondary);cursor:pointer;background:0 0;justify-content:center;align-items:center;gap:6px;padding:8px 12px;font-family:inherit;font-size:13px;transition:all .15s;display:flex}.new-session-btn[data-v-773baa74]:hover{border-color:var(--color-primary);color:var(--color-primary);background:color-mix(in srgb, var(--color-primary) 6%, transparent)}.sidebar-search[data-v-773baa74]{flex-shrink:0;align-items:center;gap:6px;padding:0 12px 8px;display:flex}.sidebar-search .search-icon[data-v-773baa74]{color:var(--text-tertiary);flex-shrink:0}.sidebar-search .search-input[data-v-773baa74]{color:var(--text-primary);background:0 0;border:none;outline:none;flex:1;padding:4px 0;font-family:inherit;font-size:13px}.sidebar-search .search-input[data-v-773baa74]::placeholder{color:var(--text-tertiary)}.session-list[data-v-773baa74]{flex:1;min-height:0;padding:0 6px 8px;overflow-y:auto}.empty-state[data-v-773baa74]{text-align:center;color:var(--text-tertiary);flex-direction:column;justify-content:center;align-items:center;padding:48px 16px;display:flex}.empty-state .empty-text[data-v-773baa74]{color:var(--text-secondary);margin-top:10px;font-size:14px}.empty-state .empty-hint[data-v-773baa74]{margin-top:4px;font-size:12px}.session-item[data-v-773baa74]{border-radius:var(--radius-sm);cursor:pointer;align-items:flex-start;gap:4px;padding:8px 10px;transition:background .12s;display:flex;position:relative}.session-item[data-v-773baa74]:hover{background:var(--bg-hover)}.session-item:hover .session-item-actions[data-v-773baa74]{opacity:1}.session-item.active[data-v-773baa74]{background:color-mix(in srgb, var(--color-primary) 10%, transparent)}.session-item.active .session-item-title[data-v-773baa74]{color:var(--color-primary)}.session-item.active[data-v-773baa74]:before{content:"";background:var(--color-primary);border-radius:0 2px 2px 0;width:3px;height:20px;position:absolute;top:50%;left:0;transform:translateY(-50%)}.session-item-main[data-v-773baa74]{flex:1;min-width:0}.session-item-title[data-v-773baa74]{color:var(--text-primary);white-space:nowrap;text-overflow:ellipsis;margin-bottom:2px;font-size:13px;font-weight:500;overflow:hidden}.session-item-meta[data-v-773baa74]{color:var(--text-tertiary);align-items:center;gap:4px;font-size:11px;display:flex}.session-item-meta .meta-dot[data-v-773baa74]{opacity:.5}.session-item-meta .meta-source[data-v-773baa74]{border-radius:3px;padding:0 4px;font-size:10px;font-weight:600}.session-item-meta .meta-source.cli[data-v-773baa74]{background:color-mix(in srgb, var(--color-warning) 20%, transparent);color:var(--color-warning)}.session-item-actions[data-v-773baa74]{opacity:0;flex-shrink:0;gap:2px;transition:opacity .12s;display:flex}.item-action-btn[data-v-773baa74]{width:24px;height:24px;color:var(--text-tertiary);cursor:pointer;border-radius:var(--radius-xs);background:0 0;border:none;justify-content:center;align-items:center;padding:0;transition:all .12s;display:flex}.item-action-btn[data-v-773baa74]:hover{background:var(--bg-hover);color:var(--text-secondary)}.item-action-btn.danger[data-v-773baa74]:hover{color:var(--color-danger);background:color-mix(in srgb, var(--color-danger) 10%, transparent)}.sidebar-resizer[data-v-773baa74]{cursor:col-resize;background:0 0;flex-shrink:0;width:4px;transition:background .15s}.sidebar-resizer[data-v-773baa74]:hover,.sidebar-resizer.active[data-v-773baa74]{background:var(--color-primary);opacity:.3}.agent-chat-area[data-v-773baa74]{flex-direction:column;flex:1;min-width:0;display:flex;position:relative;overflow:hidden}.chat-loading[data-v-773baa74]{color:var(--text-tertiary);flex-direction:column;flex:1;justify-content:center;align-items:center;gap:12px;font-size:14px;display:flex}.stop-button-bar[data-v-773baa74]{z-index:10;position:absolute;bottom:80px;left:50%;transform:translate(-50%)}.stop-button[data-v-773baa74]{border:1px solid var(--color-danger);background:var(--bg-container);color:var(--color-danger);cursor:pointer;border-radius:20px;align-items:center;gap:6px;padding:6px 16px;font-family:inherit;font-size:13px;transition:all .15s;display:flex;box-shadow:0 2px 8px #0000001f}.stop-button[data-v-773baa74]:hover{background:var(--color-danger);color:#fff}.fade-enter-active[data-v-773baa74],.fade-leave-active[data-v-773baa74]{transition:opacity .2s}.fade-enter-from[data-v-773baa74],.fade-leave-to[data-v-773baa74]{opacity:0}[data-theme=dark]{background:var(--bg-panel)}
|