Files
opsxapi/packages/ui/src/ai.ts
T
logan 1bcd7be2ac AI 大模型接入 + 自动化测试(手动/AI/定时三种触发)
- 自动化测试核心(packages/core/src/testing.ts):
  - JSONPath 求值($.a.b[0].c)、断言评估(状态码/耗时/Header/Body × 10 种操作)
  - 变量提取(响应 → 当前环境,{{var}} 链式传值)、Markdown 报告导出
- 用例模型:场景用例 = 有序步骤(引用 HTTP 接口 + 断言 + 提取),失败后继续可选
- 执行器 testRunner + 定时调度器 scheduler(应用运行期间按间隔自动执行)
- TestingView:用例列表 / 步骤编排 / 断言与提取编辑器 / 报告(统计卡+断言明细+过滤+再次运行+导出)
- AI 助手:OpenAI 兼容客户端(DeepSeek/智谱/Kimi/OpenAI/Ollama 预设)
  - 设置 → AI 助手:供应商 CRUD + 测试连接 + 当前使用切换
  - AI 生成场景用例:读取集合接口清单 → 编排步骤/断言/提取 → 预览保存
- 入口:侧栏底部「自动化测试」+ ⋯ 菜单;文档新增 /docs/testing /docs/ai
- 新增 11 个引擎单测(总 48 前端用例全绿);本地 E2E:运行通过+断言通过+petId 提取验证
2026-09-18 01:57:57 +08:00

229 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string> {
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<string> {
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<AiCaseDraft> {
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;
}