tt-help-cli-ycl 1.4.21 → 1.4.22

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.
@@ -0,0 +1,269 @@
1
+ /**
2
+ * LLM 配置持久化模块
3
+ *
4
+ * 运行时动态读写 LLM 配置(Provider、Model、URL、APIKey),
5
+ * 优先从 SQLite 读取,fallback 到环境变量。
6
+ * 通过 API 修改后立即生效,无需重启。
7
+ *
8
+ * 结构:Provider(服务商) → Models(模型列表)
9
+ * 选择 Provider 自动填入对应 URL,选择 Model 使用对应 model id。
10
+ */
11
+
12
+ import { getDb } from "./db-schema.js";
13
+
14
+ // ====== Provider + Model 定义 ======
15
+
16
+ export const LLM_PROVIDERS = [
17
+ {
18
+ id: "legacy",
19
+ name: "旧 LLM (zc-fast)",
20
+ url: "http://82.156.52.214:18000/v1/chat/completions",
21
+ models: [{ id: "zc-fast", label: "zc-fast" }],
22
+ },
23
+ {
24
+ id: "dashscope",
25
+ name: "阿里云 DashScope",
26
+ url: "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions",
27
+ models: [
28
+ { id: "auto", label: "🤖 自动(程序选择)" },
29
+ { id: "qwen3.7-max", label: "Qwen3.7-Max" },
30
+ { id: "qwen3.7-max-2026-06-08", label: "Qwen3.7-Max-2026-06-08" },
31
+ { id: "qwen3.7-max-2026-05-20", label: "Qwen3.7-Max-2026-05-20" },
32
+ { id: "qwen3.7-max-2026-05-17", label: "Qwen3.7-Max-2026-05-17" },
33
+ { id: "qwen3.7-max-preview", label: "Qwen3.7-Max-Preview" },
34
+ { id: "qwen3.7-plus", label: "Qwen3.7-Plus" },
35
+ { id: "qwen3.7-plus-2026-05-26", label: "Qwen3.7-Plus-2026-05-26" },
36
+ { id: "qwen3.6-plus", label: "Qwen3.6-Plus" },
37
+ { id: "qwen3.6-plus-2026-04-02", label: "Qwen3.6-Plus-2026-04-02" },
38
+ { id: "qwen3.6-max-preview", label: "Qwen3.6-Max-Preview" },
39
+ { id: "qwen3.6-flash", label: "Qwen3.6-Flash" },
40
+ { id: "qwen3.6-flash-2026-04-16", label: "Qwen3.6-Flash-2026-04-16" },
41
+ { id: "qwen3.6-35b-a3b", label: "Qwen3.6-35B-A3B" },
42
+ { id: "qwen3.6-27b", label: "Qwen3.6-27B" },
43
+ { id: "qwen3.5-plus", label: "Qwen3.5-Plus" },
44
+ { id: "qwen3.5-plus-2026-04-20", label: "Qwen3.5-Plus-2026-04-20" },
45
+ { id: "qwen3.5-ocr", label: "Qwen3.5-OCR" },
46
+ { id: "qwen-plus", label: "Qwen-Plus" },
47
+ { id: "qwen-turbo", label: "Qwen-Turbo(即将下线)" },
48
+ { id: "qwen-max", label: "Qwen-Max" },
49
+ { id: "deepseek-v4-flash", label: "DeepSeek-V4-Flash" },
50
+ { id: "deepseek-v4-pro", label: "DeepSeek-V4-Pro" },
51
+ { id: "glm-5.2", label: "GLM-5.2" },
52
+ { id: "kimi-k2.7-code", label: "Kimi-K2.7-Code" },
53
+ { id: "kimi-k2.6", label: "Kimi-K2.6" },
54
+ ],
55
+ },
56
+ ];
57
+
58
+ /** 根据 providerId 查找 provider */
59
+ export function findProvider(providerId) {
60
+ return LLM_PROVIDERS.find((p) => p.id === providerId) || LLM_PROVIDERS[0];
61
+ }
62
+
63
+ /** 根据 providerId + modelId 查找模型 */
64
+ export function findModel(providerId, modelId) {
65
+ const provider = findProvider(providerId);
66
+ return provider.models.find((m) => m.id === modelId) || provider.models[0];
67
+ }
68
+
69
+ // 兼容旧接口:扁平模型选项列表
70
+ export const LLM_MODEL_OPTIONS = LLM_PROVIDERS.flatMap((p) =>
71
+ p.models.map((m) => ({
72
+ id: m.id,
73
+ label: `${m.label} (${p.name})`,
74
+ url: p.url,
75
+ })),
76
+ );
77
+
78
+ // ====== 内存缓存 ======
79
+
80
+ let _cachedProvider = null;
81
+ let _cachedModel = null;
82
+ let _cachedUrl = null;
83
+ let _cachedApiKey = null;
84
+ let _loaded = false;
85
+
86
+ function ensureTable() {
87
+ const db = getDb();
88
+ if (!db) return;
89
+ db.prepare(
90
+ `CREATE TABLE IF NOT EXISTS _llm_config (
91
+ key TEXT PRIMARY KEY,
92
+ value TEXT NOT NULL
93
+ )`,
94
+ ).run();
95
+ }
96
+
97
+ function loadFromDb() {
98
+ const db = getDb();
99
+ if (!db) return {};
100
+ ensureTable();
101
+ try {
102
+ const rows = db.prepare("SELECT key, value FROM _llm_config").all();
103
+ const config = {};
104
+ for (const row of rows) {
105
+ config[row.key] = row.value;
106
+ }
107
+ return config;
108
+ } catch {
109
+ return {};
110
+ }
111
+ }
112
+
113
+ function saveToDb(key, value) {
114
+ const db = getDb();
115
+ if (!db) return;
116
+ ensureTable();
117
+ db.prepare(
118
+ `INSERT INTO _llm_config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
119
+ ).run(key, String(value));
120
+ }
121
+
122
+ /**
123
+ * 加载配置(服务启动时调用一次)
124
+ * 优先级:SQLite > 环境变量 > 默认值
125
+ */
126
+ export function loadLlmConfig() {
127
+ if (_loaded) return;
128
+ const dbConfig = loadFromDb();
129
+
130
+ _cachedUrl = dbConfig.llm_url || process.env.LLM_URL || LLM_PROVIDERS[0].url;
131
+ _cachedModel =
132
+ dbConfig.llm_model ||
133
+ process.env.LLM_MODEL ||
134
+ LLM_PROVIDERS[0].models[0].id;
135
+ _cachedProvider = dbConfig.llm_provider || LLM_PROVIDERS[0].id;
136
+ _cachedApiKey = dbConfig.llm_apikey || process.env.APIKEY || "";
137
+ _loaded = true;
138
+
139
+ console.error(
140
+ `[llm-config] 加载完成: provider=${_cachedProvider}, model=${_cachedModel}, url=${_cachedUrl}, hasKey=${!!_cachedApiKey}`,
141
+ );
142
+ }
143
+
144
+ /**
145
+ * 获取当前 LLM 配置(供前端 API 使用)
146
+ */
147
+ export function getLlmConfig() {
148
+ if (!_loaded) loadLlmConfig();
149
+ return {
150
+ provider: _cachedProvider,
151
+ model: _cachedModel,
152
+ url: _cachedUrl,
153
+ hasApiKey: !!_cachedApiKey,
154
+ apiKeyHint: _cachedApiKey
155
+ ? _cachedApiKey.slice(0, 6) + "..." + _cachedApiKey.slice(-4)
156
+ : "",
157
+ };
158
+ }
159
+
160
+ /** 获取当前 LLM URL(内部调用) */
161
+ export function getLlmUrl() {
162
+ if (!_loaded) loadLlmConfig();
163
+ return _cachedUrl;
164
+ }
165
+
166
+ // Auto 模式的候选模型池(按优先级排列,优先用快的)
167
+ const AUTO_POOL = [
168
+ "qwen-turbo",
169
+ "qwen-plus",
170
+ "qwen3.6-flash",
171
+ "qwen3.6-27b",
172
+ "qwen3.5-plus",
173
+ "qwen3.6-plus",
174
+ ];
175
+ let _lastAutoSuccess = null; // 上次成功的模型,下次优先用它
176
+
177
+ /** 是否处于 auto 模式 */
178
+ export function isAutoMode() {
179
+ if (!_loaded) loadLlmConfig();
180
+ return _cachedModel === "auto";
181
+ }
182
+
183
+ /** 记录 auto 模式下成功调用的模型 */
184
+ export function recordAutoSuccess(model) {
185
+ _lastAutoSuccess = model;
186
+ }
187
+
188
+ /** 获取 auto 模式候选池(上次成功的排最前,失败时依次尝试) */
189
+ export function getAutoPool() {
190
+ const pool = [...AUTO_POOL];
191
+ if (_lastAutoSuccess) {
192
+ const idx = pool.indexOf(_lastAutoSuccess);
193
+ if (idx > 0) {
194
+ // 把上次成功的移到第一位,其余保持相对顺序
195
+ pool.splice(idx, 1);
196
+ pool.unshift(_lastAutoSuccess);
197
+ }
198
+ }
199
+ return pool;
200
+ }
201
+
202
+ /** 解析当前实际使用的 model(auto 模式下轮询选一个) */
203
+ function resolveModel() {
204
+ if (_cachedModel !== "auto") return _cachedModel;
205
+ const idx = _autoRoundIdx % AUTO_POOL.length;
206
+ _autoRoundIdx++;
207
+ return AUTO_POOL[idx];
208
+ }
209
+
210
+ /** 获取当前 LLM Model(内部调用,auto 模式自动轮询) */
211
+ export function getLlmModel() {
212
+ if (!_loaded) loadLlmConfig();
213
+ return resolveModel();
214
+ }
215
+
216
+ /** 获取当前 API Key(内部调用) */
217
+ export function getLlmApiKey() {
218
+ if (!_loaded) loadLlmConfig();
219
+ return _cachedApiKey;
220
+ }
221
+
222
+ /**
223
+ * 更新 LLM 配置(通过 API 调用)
224
+ * @param {{ provider?: string, model?: string, url?: string, apiKey?: string }} updates
225
+ */
226
+ export function updateLlmConfig(updates = {}) {
227
+ if (!_loaded) loadLlmConfig();
228
+
229
+ // Provider 变更:切换 URL,Model 用指定的或默认第一个
230
+ if (updates.provider !== undefined) {
231
+ const provider = findProvider(updates.provider);
232
+ _cachedProvider = provider.id;
233
+ _cachedUrl = provider.url;
234
+ // 如果同时传了 model 且在 provider 中存在,用指定的;否则用第一个
235
+ const specifiedModel =
236
+ updates.model !== undefined
237
+ ? provider.models.find((m) => m.id === updates.model)
238
+ : null;
239
+ _cachedModel = specifiedModel ? specifiedModel.id : provider.models[0].id;
240
+ saveToDb("llm_provider", provider.id);
241
+ saveToDb("llm_url", provider.url);
242
+ saveToDb("llm_model", _cachedModel);
243
+ } else if (updates.model !== undefined) {
244
+ // Model 单独变更(同 provider 内切换)
245
+ _cachedModel = updates.model;
246
+ saveToDb("llm_model", updates.model);
247
+ }
248
+
249
+ // URL 手动覆盖
250
+ if (updates.url !== undefined) {
251
+ _cachedUrl = updates.url;
252
+ saveToDb("llm_url", updates.url);
253
+ }
254
+
255
+ // API Key(跳过提示字符串:含 "..." 且长度 < 20 的视为 hint,不覆盖)
256
+ if (updates.apiKey !== undefined && updates.apiKey !== "") {
257
+ const isHint = updates.apiKey.includes("...") && updates.apiKey.length < 20;
258
+ if (!isHint) {
259
+ _cachedApiKey = updates.apiKey;
260
+ saveToDb("llm_apikey", updates.apiKey);
261
+ }
262
+ }
263
+
264
+ console.error(
265
+ `[llm-config] 配置已更新: provider=${_cachedProvider}, model=${_cachedModel}, url=${_cachedUrl}, hasKey=${!!_cachedApiKey}`,
266
+ );
267
+
268
+ return getLlmConfig();
269
+ }
@@ -42,104 +42,144 @@ Standards: 90-100=clear match, 70-89=likely, 50-69=possible, 20-49=low, 0-19=unl
42
42
  `;
43
43
 
44
44
  try {
45
- const apiKey = process.env.APIKEY || "";
46
- const response = await undiciFetch(
47
- "http://82.156.52.214:18000/v1/chat/completions",
48
- {
45
+ const { getLlmUrl, getLlmApiKey, isAutoMode, getAutoPool } =
46
+ await import("./llm-config.js");
47
+ const llmUrl = getLlmUrl();
48
+ const apiKey = getLlmApiKey();
49
+
50
+ // auto 模式:依次尝试候选池中的模型
51
+ const models = isAutoMode() ? getAutoPool() : [null];
52
+
53
+ let lastError = "";
54
+ for (let i = 0; i < models.length; i++) {
55
+ const llmModel =
56
+ models[i] !== null
57
+ ? models[i]
58
+ : (await import("./llm-config.js")).getLlmModel();
59
+
60
+ const response = await undiciFetch(llmUrl, {
49
61
  method: "POST",
50
62
  headers: {
51
63
  "Content-Type": "application/json",
52
64
  Authorization: `Bearer ${apiKey}`,
53
65
  },
54
66
  body: JSON.stringify({
55
- model: "zc-fast",
67
+ model: llmModel,
56
68
  messages: [{ role: "user", content: prompt }],
57
69
  max_tokens: 512,
58
70
  temperature: 0.1,
71
+ enable_thinking: false,
59
72
  }),
60
- },
61
- );
73
+ });
74
+
75
+ const result = await response.json().catch(() => null);
76
+ const content = result?.choices?.[0]?.message?.content || "";
77
+
78
+ if (!content) {
79
+ const errMsg = result?.error?.message || `HTTP ${response.status}`;
80
+ lastError = errMsg;
81
+ console.error(
82
+ `[scoreJobLocation] ${llmModel} 失败: ${errMsg},尝试下一个`,
83
+ );
84
+ continue;
85
+ }
86
+
87
+ if (i > 0) {
88
+ console.error(`[scoreJobLocation] ✅ auto 切换成功: ${llmModel}`);
89
+ }
90
+
91
+ let parsed = null;
62
92
 
63
- const result = await response.json();
64
- const content = result.choices?.[0]?.message?.content || "";
65
-
66
- let parsed = null;
67
-
68
- // 尝试 1: 直接解析
69
- try {
70
- parsed = JSON.parse(content);
71
- } catch {
72
- // 尝试 2: 提取 {} 包裹的内容
73
- const match = content.match(/\{[\s\S]*\}/);
74
- if (match) {
75
- try {
76
- parsed = JSON.parse(match[0]);
77
- } catch {
78
- // 尝试 3: 清理常见问题后解析
79
- const cleaned = match[0]
80
- .replace(/"/g, '"')
81
- .replace(/\s+/g, " ")
82
- .trim();
93
+ // 尝试 1: 直接解析
94
+ try {
95
+ parsed = JSON.parse(content);
96
+ } catch {
97
+ // 尝试 2: 提取 {} 包裹的内容
98
+ const match = content.match(/\{[\s\S]*\}/);
99
+ if (match) {
83
100
  try {
84
- parsed = JSON.parse(cleaned);
101
+ parsed = JSON.parse(match[0]);
85
102
  } catch {
86
- // 尝试 4: 从文本中提取 score 和 reason
87
- const scoreMatch = content.match(/"?score"?\s*:\s*(\d+)/i);
88
- if (scoreMatch) {
89
- let reason = "解析降级";
90
- const reasonKeyPos = content.search(/"?reason"?\s*:\s*"/i);
91
- if (reasonKeyPos !== -1) {
92
- const afterKey = content.substring(reasonKeyPos);
93
- const colonPos = afterKey.indexOf(":");
94
- const valueStart = afterKey.indexOf('"', colonPos + 1) + 1;
95
- const rawValue = afterKey.substring(valueStart);
96
- const lastBrace = content.lastIndexOf("}");
97
- const reasonEnd = lastBrace - reasonKeyPos - valueStart;
98
- if (reasonEnd > 0) {
99
- reason = rawValue.substring(0, reasonEnd).trim();
100
- if (reason.startsWith('"')) reason = reason.substring(1);
101
- if (reason.endsWith('"'))
102
- reason = reason.substring(0, reason.length - 1);
103
+ // 尝试 3: 清理常见问题后解析
104
+ const cleaned = match[0]
105
+ .replace(/"/g, '"')
106
+ .replace(/\s+/g, " ")
107
+ .trim();
108
+ try {
109
+ parsed = JSON.parse(cleaned);
110
+ } catch {
111
+ // 尝试 4: 从文本中提取 score 和 reason
112
+ const scoreMatch = content.match(/"?score"?\s*:\s*(\d+)/i);
113
+ if (scoreMatch) {
114
+ let reason = "解析降级";
115
+ const reasonKeyPos = content.search(/"?reason"?\s*:\s*"/i);
116
+ if (reasonKeyPos !== -1) {
117
+ const afterKey = content.substring(reasonKeyPos);
118
+ const colonPos = afterKey.indexOf(":");
119
+ const valueStart = afterKey.indexOf('"', colonPos + 1) + 1;
120
+ const rawValue = afterKey.substring(valueStart);
121
+ const lastBrace = content.lastIndexOf("}");
122
+ const reasonEnd = lastBrace - reasonKeyPos - valueStart;
123
+ if (reasonEnd > 0) {
124
+ reason = rawValue.substring(0, reasonEnd).trim();
125
+ if (reason.startsWith('"')) reason = reason.substring(1);
126
+ if (reason.endsWith('"'))
127
+ reason = reason.substring(0, reason.length - 1);
128
+ }
103
129
  }
130
+ parsed = { score: parseInt(scoreMatch[1]) || 50, reason };
104
131
  }
105
- parsed = { score: parseInt(scoreMatch[1]) || 50, reason };
106
132
  }
107
133
  }
108
134
  }
135
+
136
+ // 尝试 5: 更宽松的正则提取
137
+ if (!parsed) {
138
+ const scoreMatch = content.match(/"score"\s*:\s*(\d+)/);
139
+ const reasonMatch = content.match(/"reason"\s*:\s*"([^"]*)"/);
140
+ if (scoreMatch) {
141
+ parsed = {
142
+ score: parseInt(scoreMatch[1]) || 50,
143
+ reason: reasonMatch ? reasonMatch[1] : "解析降级 - 宽松模式",
144
+ };
145
+ }
146
+ }
109
147
  }
110
148
 
111
- // 尝试 5: 更宽松的正则提取
112
- if (!parsed) {
113
- const scoreMatch = content.match(/"score"\s*:\s*(\d+)/);
114
- const reasonMatch = content.match(/"reason"\s*:\s*"([^"]*)"/);
115
- if (scoreMatch) {
116
- parsed = {
117
- score: parseInt(scoreMatch[1]) || 50,
118
- reason: reasonMatch ? reasonMatch[1] : "解析降级 - 宽松模式",
119
- };
149
+ if (parsed && typeof parsed.score === "number") {
150
+ if (isAutoMode()) {
151
+ const { recordAutoSuccess } = await import("./llm-config.js");
152
+ recordAutoSuccess(llmModel);
120
153
  }
154
+ return {
155
+ uniqueId: job.unique_id,
156
+ score: Math.max(0, Math.min(100, parsed.score)),
157
+ reason: parsed.reason || "",
158
+ };
121
159
  }
122
- }
123
160
 
124
- if (parsed && typeof parsed.score === "number") {
161
+ console.error(
162
+ `[scoreJobLocation] JSON 解析失败 (${job.unique_id}): ${content.substring(0, 100)}`,
163
+ );
125
164
  return {
126
165
  uniqueId: job.unique_id,
127
- score: Math.max(0, Math.min(100, parsed.score)),
128
- reason: parsed.reason || "",
166
+ score: 50,
167
+ reason: "LLM 响应解析失败,使用默认分",
129
168
  };
130
- }
169
+ } // end for
131
170
 
171
+ // 全部模型均失败
132
172
  console.error(
133
- `[scoreJobLocation] JSON 解析失败 (${job.unique_id}): ${content.substring(0, 100)}`,
173
+ `[scoreJobLocation] ❌ ${models.length} 个模型均失败 (${job.unique_id}): ${lastError}`,
134
174
  );
135
175
  return {
136
176
  uniqueId: job.unique_id,
137
177
  score: 50,
138
- reason: "LLM 响应解析失败,使用默认分",
178
+ reason: `全部模型失败: ${lastError}`,
139
179
  };
140
180
  } catch (e) {
141
181
  console.error(
142
- `[scoreJobLocation] LLM 调用失败 (${job.unique_id}): ${e.message}`,
182
+ `[scoreJobLocation] LLM 调用异常 (${job.unique_id}): ${e.message}`,
143
183
  );
144
184
  return {
145
185
  uniqueId: job.unique_id,