settings-model-test.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. import { fetchEmbedding } from "@/lib/embedding"
  2. import { streamChat } from "@/lib/llm-client"
  3. import { isDirectRerankEndpoint, requestDirectRerank } from "@/lib/rerank-api"
  4. import { fetchLlmModelList } from "@/lib/settings-model-list"
  5. import type { EmbeddingConfig, LlmConfig, RerankConfig } from "@/stores/wiki-store"
  6. const TEST_TIMEOUT_MS = 30_000
  7. interface LlmModelTestResult {
  8. model: string
  9. content: string
  10. }
  11. interface EmbeddingModelTestResult {
  12. model: string
  13. dimensions: number
  14. }
  15. interface RerankModelTestResult {
  16. model: string
  17. content: string
  18. usedMainLlm: boolean
  19. }
  20. function ensureModel(model: string, emptyMessage: string): string {
  21. const trimmed = model.trim()
  22. if (!trimmed) {
  23. throw new Error(emptyMessage)
  24. }
  25. return trimmed
  26. }
  27. export function normalizeModelTestError(error: Error): Error {
  28. const message = error.message
  29. if (message === "Load failed" || /failed to fetch|networkerror|load failed/i.test(message)) {
  30. return new Error(
  31. "无法连接模型接口(Load failed)。若使用 Cursor CLI,请先在设置中重新检查 CLI 状态,确认 proxy 已拉起后再测。",
  32. )
  33. }
  34. if (/insufficient account balance/i.test(message)) {
  35. return new Error("当前中转站账户余额不足,或该模型没有可用额度,请先充值或切换可用模型。")
  36. }
  37. if (/client not allowed/i.test(message)) {
  38. return new Error("当前中转站限制了客户端来源,拒绝了桌面端、浏览器或常见 SDK 请求。请联系中转站放开通用 OpenAI 兼容 API,或切换可直连的中转站。")
  39. }
  40. const unsupportedModel = extractUnsupportedModel(message)
  41. if (unsupportedModel || (/HTTP 404/i.test(message) && /模型|model/i.test(message))) {
  42. return new Error(
  43. `当前接口不支持所选模型${unsupportedModel ? ` ${unsupportedModel}` : ""}。请从模型下拉框选择已拉取到的模型,或向中转站确认正确模型 ID。`,
  44. )
  45. }
  46. return error
  47. }
  48. function extractUnsupportedModel(message: string): string | null {
  49. const patterns = [
  50. /不支持所选模型\s*["“]?([^"”\s,,]+)/i,
  51. /unsupported(?: selected)? model\s*["']?([^"'\s,}]+)/i,
  52. /model\s+["']?([^"'\s,}]+)["']?\s+(?:is\s+)?(?:not found|not supported)/i,
  53. ]
  54. for (const pattern of patterns) {
  55. const matched = message.match(pattern)?.[1]?.trim()
  56. if (matched) return matched
  57. }
  58. return null
  59. }
  60. async function resolveChatModelConfig(config: LlmConfig): Promise<{ config: LlmConfig; model: string }> {
  61. const explicitModel = config.model.trim()
  62. if (explicitModel) {
  63. return { config, model: explicitModel }
  64. }
  65. if (config.provider === "claude-code" || config.provider === "codex-cli") {
  66. const result = await fetchLlmModelList(config)
  67. const model = ensureModel(
  68. result.models[0] ?? "",
  69. "请先在本地 CLI 中设置默认模型,或在软件里手动填写模型后再测试。",
  70. )
  71. return {
  72. config: { ...config, model },
  73. model,
  74. }
  75. }
  76. return {
  77. config,
  78. model: ensureModel(explicitModel, "请先填写模型名称后再测试。"),
  79. }
  80. }
  81. async function runChatModelTest(config: LlmConfig, prompt: string): Promise<LlmModelTestResult> {
  82. const resolved = await resolveChatModelConfig(config)
  83. let content = ""
  84. let streamError: Error | null = null
  85. await streamChat(
  86. resolved.config,
  87. [{ role: "user", content: prompt }],
  88. {
  89. onToken: (token) => {
  90. content += token
  91. },
  92. onDone: () => undefined,
  93. onError: (error) => {
  94. streamError = error
  95. },
  96. },
  97. AbortSignal.timeout(TEST_TIMEOUT_MS),
  98. {
  99. temperature: 0,
  100. max_tokens: 80,
  101. },
  102. )
  103. if (streamError) {
  104. throw normalizeModelTestError(streamError)
  105. }
  106. const trimmed = content.trim()
  107. if (!trimmed) {
  108. throw new Error("模型已连接,但没有返回可用内容。")
  109. }
  110. return {
  111. model: resolved.model,
  112. content: trimmed,
  113. }
  114. }
  115. function extractJsonObject(raw: string): string {
  116. const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1]
  117. const candidate = fenced?.match(/\{[\s\S]*\}/)?.[0] ?? raw.match(/\{[\s\S]*\}/)?.[0]
  118. if (!candidate) {
  119. throw new Error("模型返回了内容,但不是可用的 JSON 结果。")
  120. }
  121. return candidate
  122. }
  123. function resolveRerankTestConfig(llmConfig: LlmConfig, rerankConfig: RerankConfig): {
  124. config: LlmConfig
  125. model: string
  126. usedMainLlm: boolean
  127. } {
  128. if (rerankConfig.useMainLlm) {
  129. const model = ensureModel(llmConfig.model, "请先配置主模型后再测试重排模型。")
  130. return {
  131. config: { ...llmConfig, reasoning: { mode: "off" } },
  132. model,
  133. usedMainLlm: true,
  134. }
  135. }
  136. const model = ensureModel(rerankConfig.model, "请先填写重排模型名称后再测试。")
  137. if (/embedding/i.test(model)) {
  138. throw new Error("当前填写的更像是嵌入模型。重排模型需要可生成 JSON 的聊天模型,不能使用嵌入模型。")
  139. }
  140. return {
  141. config: {
  142. provider: rerankConfig.provider,
  143. apiKey: rerankConfig.apiKey,
  144. model,
  145. ollamaUrl: rerankConfig.ollamaUrl,
  146. customEndpoint: rerankConfig.customEndpoint,
  147. apiMode: rerankConfig.provider === "custom" ? rerankConfig.apiMode : undefined,
  148. maxContextSize: Math.min(llmConfig.maxContextSize ?? 65_536, 65_536),
  149. reasoning: { mode: "off" },
  150. },
  151. model,
  152. usedMainLlm: false,
  153. }
  154. }
  155. export async function testSettingsLlmModel(config: LlmConfig): Promise<LlmModelTestResult> {
  156. return runChatModelTest(
  157. config,
  158. "你正在执行模型连通性测试。请只回答“模型测试成功”。",
  159. )
  160. }
  161. export async function testSettingsEmbeddingModel(config: EmbeddingConfig): Promise<EmbeddingModelTestResult> {
  162. const model = ensureModel(config.model, "请先填写嵌入模型名称后再测试。")
  163. if (!config.endpoint.trim()) {
  164. throw new Error("请先填写嵌入接口地址后再测试。")
  165. }
  166. const vector = await fetchEmbedding(
  167. "这是一段用于测试嵌入模型可用性的短文本。",
  168. config,
  169. 1,
  170. )
  171. if (!vector || vector.length === 0) {
  172. throw new Error("嵌入模型没有返回有效向量,请检查接口、密钥和模型名称。")
  173. }
  174. return {
  175. model,
  176. dimensions: vector.length,
  177. }
  178. }
  179. export async function testSettingsRerankModel(
  180. llmConfig: LlmConfig,
  181. rerankConfig: RerankConfig,
  182. ): Promise<RerankModelTestResult> {
  183. const { config, model, usedMainLlm } = resolveRerankTestConfig(llmConfig, rerankConfig)
  184. if (isDirectRerankEndpoint(config)) {
  185. const directResults = await requestDirectRerank(
  186. config,
  187. "主角寻找关键线索",
  188. [
  189. "主角在旧仓库翻到了旧地图,并确认线索来源。",
  190. "配角讨论午饭吃什么,与寻找线索无关。",
  191. ],
  192. AbortSignal.timeout(TEST_TIMEOUT_MS),
  193. )
  194. if (!Array.isArray(directResults) || directResults.length === 0 || directResults[0]?.index === undefined) {
  195. throw new Error("重排模型已返回内容,但结果格式不正确。")
  196. }
  197. return {
  198. model,
  199. content: JSON.stringify(directResults),
  200. usedMainLlm,
  201. }
  202. }
  203. const result = await runChatModelTest(
  204. config,
  205. [
  206. "你正在执行重排模型测试。",
  207. "请根据查询将候选结果按相关性排序,只返回 JSON。",
  208. '返回格式必须是:{"order":[{"id":"a","score":1},{"id":"b","score":0.5}]}',
  209. "查询:主角寻找关键线索",
  210. "候选:",
  211. JSON.stringify([
  212. { id: "a", title: "主角在旧仓库找到线索", snippet: "主角在旧仓库翻到了旧地图,并确认线索来源。" },
  213. { id: "b", title: "配角午饭安排", snippet: "配角讨论午饭吃什么,与查找线索无关。" },
  214. ], null, 2),
  215. ].join("\n"),
  216. )
  217. const jsonText = extractJsonObject(result.content)
  218. const parsed = JSON.parse(jsonText) as { order?: Array<{ id?: string }> }
  219. if (!Array.isArray(parsed.order) || parsed.order.length === 0 || !parsed.order[0]?.id) {
  220. throw new Error("重排模型返回了内容,但结果格式不正确。")
  221. }
  222. return {
  223. model,
  224. content: result.content,
  225. usedMainLlm,
  226. }
  227. }