/** * AI 助手:OpenAI 兼容 Chat Completions 客户端 + 用例生成。 * 供应商配置保存在工作区设置(本地 localStorage),不经任何第三方中转。 */ import type { AiProvider, Collection, HttpRequestData, RequestNode, TestAssertion, TestCase, TestStep, } from "@opsxapi/core"; import { newTestCase, suggestAssertions } from "@opsxapi/core"; /** 常见供应商预设(均为 OpenAI 兼容协议) */ export const AI_PROVIDER_PRESETS: { name: string; baseUrl: string; model: string; hint: string }[] = [ { name: "DeepSeek", baseUrl: "https://api.deepseek.com/v1", model: "deepseek-chat", hint: "性价比高,推荐" }, { name: "智谱 GLM", baseUrl: "https://open.bigmodel.cn/api/paas/v4", model: "glm-4-flash", hint: "新用户有免费额度" }, { name: "Moonshot Kimi", baseUrl: "https://api.moonshot.cn/v1", model: "moonshot-v1-8k", hint: "" }, { name: "OpenAI", baseUrl: "https://api.openai.com/v1", model: "gpt-4o-mini", hint: "" }, { name: "Ollama 本地", baseUrl: "http://127.0.0.1:11434/v1", model: "qwen2.5:7b", hint: "无需 API Key" }, ]; export function newAiProvider(preset?: (typeof AI_PROVIDER_PRESETS)[number]): AiProvider { return { id: `ai-${Math.random().toString(36).slice(2, 10)}`, name: preset?.name ?? "自定义供应商", baseUrl: preset?.baseUrl ?? "", apiKey: "", model: preset?.model ?? "", enabled: true, }; } type ChatMessage = { role: "system" | "user" | "assistant"; content: string }; /** 调用 Chat Completions(非流式),失败抛 Error */ export async function chatComplete(provider: AiProvider, messages: ChatMessage[], timeoutMs = 60000): Promise { if (!provider.baseUrl.trim()) throw new Error("未配置 Base URL"); const url = `${provider.baseUrl.trim().replace(/\/+$/, "")}/chat/completions`; const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), timeoutMs); try { const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", ...(provider.apiKey.trim() ? { Authorization: `Bearer ${provider.apiKey.trim()}` } : {}), }, body: JSON.stringify({ model: provider.model.trim() || "gpt-4o-mini", messages, temperature: 0.2, stream: false, }), signal: ctrl.signal, }); if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(`HTTP ${res.status}${text ? `:${text.slice(0, 200)}` : ""}`); } const data = (await res.json()) as { choices?: { message?: { content?: string } }[]; }; const content = data.choices?.[0]?.message?.content; if (!content) throw new Error("模型未返回内容"); return content; } catch (e) { if (e instanceof DOMException && e.name === "AbortError") throw new Error("请求超时"); throw e instanceof Error ? e : new Error(String(e)); } } /** 连接测试:问一句并要求简答 */ export async function testProviderConnection(provider: AiProvider): Promise { const reply = await chatComplete( provider, [ { role: "system", content: "你是连通性测试助手,只输出:OK" }, { role: "user", content: "ping" }, ], 20000, ); return reply.trim().slice(0, 60) || "OK"; } /* ---------------- AI 生成场景用例 ---------------- */ /** 汇总集合中的 HTTP 接口给模型看 */ function summarizeNodes(nodes: { node: RequestNode; path: string }[]): string { return nodes .map(({ node, path }) => { if (node.kind !== "http") return null; const d = node.data as HttpRequestData; const lines = [ `- id: ${node.id} | ${d.method} ${d.url} | 名称: ${node.name}${path ? ` | 目录: ${path}` : ""}`, ]; if (d.bodyMode === "json" && d.bodyRaw.trim()) { lines.push(` body 示例: ${d.bodyRaw.replace(/\s+/g, " ").slice(0, 160)}`); } return lines.join("\n"); }) .filter(Boolean) .join("\n"); } const GEN_SYSTEM_PROMPT = `你是 API 自动化测试专家。根据给定的接口清单设计一个可执行的"场景用例"(按业务顺序编排多个接口步骤)。 只输出一个 JSON 代码块(\`\`\`json ... \`\`\`),不要输出其他解释文字。JSON 结构: { "name": "用例名称(中文,简短)", "description": "一句话说明场景", "steps": [ { "nodeId": "必须使用清单中的接口 id", "reason": "为什么这一步", "assertions": [ { "target": "status|timeMs|header|body", "locator": "header名或JSONPath(其余为空)", "op": "eq|neq|lt|gt|contains|not_contains|exists|not_exists|jsonpath_eq|jsonpath_contains", "expected": "期望值" } ], "extracts": [ { "jsonpath": "$.data.id", "varName": "petId" } ] } ] } 要求:步骤顺序符合业务依赖(如 先创建 → 再查询 → 再删除);每步至少 1 条断言;跨步骤传值用 extracts 提取变量并在后续 URL/Body 用 {{变量名}}(如果原接口里已有 {{xxx}} 占位则沿用);断言 4-8 条为宜,聚焦关键业务字段。`; export type AiCaseDraft = { name: string; description?: string; steps: TestStep[]; notes: string[] }; /** 从模型回复中提取 JSON(容错:代码块 / 首尾杂文) */ export function extractJsonBlock(text: string): unknown { const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(text); const raw = fenced ? fenced[1] : text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1); return JSON.parse(raw.trim()); } export async function aiGenerateTestCase( provider: AiProvider, collection: Collection, instruction?: string, ): Promise { const nodes: { node: RequestNode; path: string }[] = []; const walk = (children: Collection["children"], path: string): void => { for (const n of children) { if (n.type === "folder") walk(n.children, path ? `${path}/${n.name}` : n.name); else if (n.kind === "http") nodes.push({ node: n, path }); } }; walk(collection.children, ""); if (nodes.length === 0) throw new Error("集合中没有 HTTP 接口可供编排"); const user = [ `接口清单(共 ${nodes.length} 个):`, summarizeNodes(nodes), instruction?.trim() ? `\n用户要求:${instruction.trim()}` : "", "\n请输出场景用例 JSON。", ] .filter(Boolean) .join("\n"); const reply = await chatComplete(provider, [ { role: "system", content: GEN_SYSTEM_PROMPT }, { role: "user", content: user }, ]); const parsed = extractJsonBlock(reply) as { name?: string; description?: string; steps?: { nodeId?: string; reason?: string; assertions?: { target?: string; locator?: string; op?: string; expected?: string }[]; extracts?: { jsonpath?: string; varName?: string }[]; }[]; }; const notes: string[] = []; const steps: TestStep[] = []; const validIds = new Set(nodes.map((n) => n.node.id)); const nodeById = new Map(nodes.map((n) => [n.node.id, n.node])); for (const s of parsed.steps ?? []) { if (!s.nodeId) continue; if (!validIds.has(s.nodeId)) { notes.push(`已跳过未知接口:${s.nodeId}`); continue; } const node = nodeById.get(s.nodeId)!; const assertions: TestAssertion[] = (s.assertions ?? []).map((a) => ({ id: `as-${Math.random().toString(36).slice(2, 10)}`, enabled: true, target: (["status", "timeMs", "header", "body"].includes(a.target ?? "") ? a.target : "status") as TestAssertion["target"], locator: a.locator ?? "", op: ([ "eq", "neq", "lt", "gt", "contains", "not_contains", "exists", "not_exists", "jsonpath_eq", "jsonpath_contains", ].includes(a.op ?? "") ? a.op : "eq") as TestAssertion["op"], expected: a.expected ?? "", })); if (assertions.length === 0) { assertions.push(...suggestAssertions(node.data as HttpRequestData)); if (s.reason) notes.push(`「${node.name}」未给断言,已补默认断言`); } steps.push({ id: `st-${Math.random().toString(36).slice(2, 10)}`, enabled: true, nodeId: s.nodeId, assertions, extracts: (s.extracts ?? []) .filter((e) => e.jsonpath && e.varName) .map((e) => ({ id: `ex-${Math.random().toString(36).slice(2, 10)}`, jsonpath: e.jsonpath!, varName: e.varName! })), }); } if (steps.length === 0) throw new Error("模型未生成有效步骤,请重试或补充要求"); return { name: parsed.name?.trim() || `${collection.name} · AI 用例`, description: parsed.description?.trim(), steps, notes, }; } /** 把 AI 草稿转成正式用例 */ export function draftToTestCase(draft: AiCaseDraft): TestCase { const tc = newTestCase(draft.name); tc.description = draft.description; tc.steps = draft.steps; return tc; }