Skip to content

Commit 70ca99f

Browse files
authored
Merge pull request #4 from alicesainta-sketch/codex/agent-http-adapter-v2
feat(agent): 增加可切换HTTP adapter并完善联调面板
2 parents cce61ec + 37eb91a commit 70ca99f

9 files changed

Lines changed: 479 additions & 58 deletions

File tree

docs/agent-spec.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,19 @@
7373

7474
- 前端只依赖 adapter 接口。
7575
- 后续切换到 Gin 后端时,仅替换 adapter 实现,不改状态机与测试用例语义。
76+
77+
## 8. Adapter 模式切换(v0.2)
78+
79+
- `mock`:本地演示模式,保留成功/超时/重试失败三条路径。
80+
- `http`:联调模式,调用后端 `POST /v1/mcp/tools/{tool_name}/invoke`
81+
82+
相关环境变量:
83+
84+
- `NEXT_PUBLIC_AGENT_ADAPTER=mock|http`
85+
- `NEXT_PUBLIC_AGENT_API_BASE_URL`(http 模式必填)
86+
- `NEXT_PUBLIC_AGENT_TOOL_NAME`(可选,默认 `deepscan.search`
87+
88+
约束:
89+
90+
- 状态机语义不随 adapter 模式变化。
91+
- 错误收敛仍由 runner 统一处理(`TOOL_TIMEOUT / UPSTREAM_ERROR / TOOL_RETRY_EXHAUSTED`)。

docs/configuration.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,14 @@
77
环境变量:
88
- `DEEPSEEK_API_KEY`:模型 API Key
99
- `BASE_URL`:OpenAI-compatible 接口地址(例如 `https://api.deepseek.com/v1`
10+
- `NEXT_PUBLIC_AGENT_ADAPTER`:Agent adapter 模式(`mock``http`,默认 `mock`
11+
- `NEXT_PUBLIC_AGENT_API_BASE_URL`:当 `NEXT_PUBLIC_AGENT_ADAPTER=http` 时使用的后端地址
12+
- `NEXT_PUBLIC_AGENT_TOOL_NAME`:HTTP 模式下默认工具名(默认 `deepscan.search`
1013

1114
注意:
1215
- 前端不直接暴露模型 Key。
1316
- 当前不依赖登录即可运行。
17+
- Agent 面板联调前至少配置 `NEXT_PUBLIC_AGENT_ADAPTER=http``NEXT_PUBLIC_AGENT_API_BASE_URL`
1418

1519
## 规划中的分离后端配置(Go Gin)
1620

src/app/components/AgentMvpPanel.tsx

Lines changed: 134 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,24 @@
11
"use client";
22

33
import { useMemo, useState } from "react";
4-
import { MockAgentAdapter } from "@/lib/agent/mockAdapter";
4+
import { createAgentAdapter } from "@/lib/agent/createAdapter";
5+
import { getAgentAdapterMode, getAgentApiBaseUrl, getAgentToolName } from "@/lib/agent/config";
56
import { runAgent } from "@/lib/agent/runner";
67
import type { AgentRunState } from "@/lib/agent/types";
78

8-
type AgentScenario = "success" | "timeout" | "retry_exhausted";
9+
type AgentScenario = "success" | "timeout" | "retry_exhausted" | "remote_call";
910

1011
type ScenarioConfig = {
1112
label: string;
1213
description: string;
13-
run: () => Promise<AgentRunState>;
14+
maxRetries: number;
15+
timeoutMs: number;
16+
retryDelayMs: number;
17+
mockMode?: "success" | "timeout" | "fail";
1418
};
1519

20+
type ScenarioMap = Partial<Record<AgentScenario, ScenarioConfig>>;
21+
1622
/**
1723
* 将状态值映射为统一徽标样式,避免页面里散落大量条件类名判断。
1824
*/
@@ -33,93 +39,156 @@ const getStatusBadgeClass = (status: string) => {
3339
* Agent MVP 演示面板:展示本地状态机、mock adapter 和三条核心测试路径。
3440
*/
3541
export default function AgentMvpPanel() {
36-
const [activeScenario, setActiveScenario] = useState<AgentScenario>("success");
42+
const adapterMode = useMemo(() => getAgentAdapterMode(), []);
43+
const configuredApiBaseUrl = useMemo(() => getAgentApiBaseUrl(), []);
44+
const configuredToolName = useMemo(() => getAgentToolName(), []);
45+
const [activeScenario, setActiveScenario] = useState<AgentScenario>(
46+
adapterMode === "http" ? "remote_call" : "success"
47+
);
3748
const [isRunning, setIsRunning] = useState(false);
3849
const [runState, setRunState] = useState<AgentRunState | null>(null);
50+
const [runDurationMs, setRunDurationMs] = useState<number | null>(null);
3951
const [actionError, setActionError] = useState("");
52+
const [runtimeMetadata, setRuntimeMetadata] = useState<{
53+
mode: "mock" | "http";
54+
baseUrl?: string;
55+
toolName?: string;
56+
} | null>(null);
4057

41-
const scenarioConfig = useMemo<Record<AgentScenario, ScenarioConfig>>(
42-
() => ({
43-
success: {
44-
label: "成功路径",
45-
description: "工具调用一次成功,run 最终为 succeeded。",
46-
run: () =>
47-
runAgent({
48-
runId: `run_success_${Date.now()}`,
49-
sessionId: "ui_demo",
50-
input: "演示成功路径",
51-
adapter: new MockAgentAdapter({ mode: "success", delayMs: 0 }),
52-
maxRetries: 2,
53-
timeoutMs: 120,
54-
retryDelayMs: 0,
55-
}),
56-
},
57-
timeout: {
58-
label: "工具超时",
59-
description: "工具调用超过超时阈值,run 直接失败并返回 TOOL_TIMEOUT。",
60-
run: () =>
61-
runAgent({
62-
runId: `run_timeout_${Date.now()}`,
63-
sessionId: "ui_demo",
64-
input: "演示超时路径",
65-
adapter: new MockAgentAdapter({ mode: "timeout" }),
66-
maxRetries: 0,
67-
timeoutMs: 20,
68-
retryDelayMs: 0,
69-
}),
70-
},
71-
retry_exhausted: {
72-
label: "重试失败",
73-
description: "连续上游失败直至重试耗尽,run 返回 TOOL_RETRY_EXHAUSTED。",
74-
run: () =>
75-
runAgent({
76-
runId: `run_retry_${Date.now()}`,
77-
sessionId: "ui_demo",
78-
input: "演示重试耗尽路径",
79-
adapter: new MockAgentAdapter({ mode: "fail", failMessage: "mock upstream error" }),
80-
maxRetries: 2,
81-
timeoutMs: 120,
82-
retryDelayMs: 0,
83-
}),
84-
},
85-
}),
86-
[]
58+
const scenarioConfig = useMemo<ScenarioMap>(
59+
() =>
60+
adapterMode === "http"
61+
? {
62+
remote_call: {
63+
label: "远端联调",
64+
description: "调用后端 MCP 工具接口,验证真实链路可用性。",
65+
maxRetries: 1,
66+
timeoutMs: 10_000,
67+
retryDelayMs: 0,
68+
},
69+
}
70+
: {
71+
success: {
72+
label: "成功路径",
73+
description: "工具调用一次成功,run 最终为 succeeded。",
74+
maxRetries: 2,
75+
timeoutMs: 120,
76+
retryDelayMs: 0,
77+
mockMode: "success",
78+
},
79+
timeout: {
80+
label: "工具超时",
81+
description: "工具调用超过超时阈值,run 直接失败并返回 TOOL_TIMEOUT。",
82+
maxRetries: 0,
83+
timeoutMs: 20,
84+
retryDelayMs: 0,
85+
mockMode: "timeout",
86+
},
87+
retry_exhausted: {
88+
label: "重试失败",
89+
description: "连续上游失败直至重试耗尽,run 返回 TOOL_RETRY_EXHAUSTED。",
90+
maxRetries: 2,
91+
timeoutMs: 120,
92+
retryDelayMs: 0,
93+
mockMode: "fail",
94+
},
95+
},
96+
[adapterMode]
8797
);
8898

8999
const runScenario = async (scenario: AgentScenario) => {
100+
const selectedScenario = scenarioConfig[scenario];
101+
if (!selectedScenario) return;
102+
90103
setActionError("");
91104
setActiveScenario(scenario);
105+
setRunDurationMs(null);
92106
setIsRunning(true);
107+
const startedAt = performance.now();
93108
try {
94-
const result = await scenarioConfig[scenario].run();
109+
const resolvedAdapter =
110+
adapterMode === "http"
111+
? createAgentAdapter({
112+
httpBaseUrl: configuredApiBaseUrl,
113+
toolName: configuredToolName,
114+
})
115+
: createAgentAdapter({
116+
mockMode: selectedScenario.mockMode,
117+
mockDelayMs: 0,
118+
});
119+
120+
setRuntimeMetadata({
121+
mode: resolvedAdapter.mode,
122+
baseUrl: resolvedAdapter.metadata.baseUrl,
123+
toolName: resolvedAdapter.metadata.toolName,
124+
});
125+
126+
const result = await runAgent({
127+
runId: `run_${scenario}_${Date.now()}`,
128+
sessionId: "ui_demo",
129+
input: `演示场景:${scenario}`,
130+
adapter: resolvedAdapter.adapter,
131+
maxRetries: selectedScenario.maxRetries,
132+
timeoutMs: selectedScenario.timeoutMs,
133+
retryDelayMs: selectedScenario.retryDelayMs,
134+
});
95135
setRunState(result);
136+
setRunDurationMs(Math.round(performance.now() - startedAt));
96137
} catch (error) {
97138
setActionError(error instanceof Error ? error.message : "Agent 演示运行失败");
98139
setRunState(null);
140+
setRunDurationMs(Math.round(performance.now() - startedAt));
99141
} finally {
100142
setIsRunning(false);
101143
}
102144
};
103145

104146
const stepState = runState?.steps[0] ?? null;
147+
const scenarioList = useMemo(
148+
() => Object.entries(scenarioConfig) as Array<[AgentScenario, ScenarioConfig]>,
149+
[scenarioConfig]
150+
);
151+
const isHttpMode = adapterMode === "http";
152+
const isHttpConfigured = !isHttpMode || Boolean(configuredApiBaseUrl);
105153

106154
return (
107155
<section className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-700 dark:bg-slate-900">
108156
<div className="flex flex-wrap items-center justify-between gap-3">
109157
<div>
110158
<h2 className="text-sm font-semibold text-slate-800 dark:text-slate-100">Agent MVP 演示面板</h2>
111159
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
112-
本地状态机 + Mock Adapter,验证成功/超时/重试失败三条核心路径。
160+
{isHttpMode
161+
? "当前为 HTTP 联调模式:将调用后端 MCP 工具接口。"
162+
: "当前为 Mock 模式:验证成功/超时/重试失败三条核心路径。"}
113163
</p>
114164
</div>
115-
<div className="rounded-lg border border-amber-200 bg-amber-50 px-2.5 py-1 text-[11px] text-amber-700 dark:border-amber-700/60 dark:bg-amber-900/20 dark:text-amber-300">
116-
MVP:单 run / 单 step / 单工具调用
165+
<div className="flex items-center gap-2">
166+
<div className="rounded-lg border border-slate-200 bg-slate-50 px-2.5 py-1 text-[11px] text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300">
167+
模式:{isHttpMode ? "HTTP" : "Mock"}
168+
</div>
169+
<div className="rounded-lg border border-amber-200 bg-amber-50 px-2.5 py-1 text-[11px] text-amber-700 dark:border-amber-700/60 dark:bg-amber-900/20 dark:text-amber-300">
170+
MVP:单 run / 单 step / 单工具调用
171+
</div>
117172
</div>
118173
</div>
119174

175+
{isHttpMode ? (
176+
<div className="mt-3 rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-600 dark:border-slate-700 dark:bg-slate-800/70 dark:text-slate-300">
177+
<p>
178+
baseUrl:{configuredApiBaseUrl || "未配置(请设置 NEXT_PUBLIC_AGENT_API_BASE_URL)"}
179+
</p>
180+
<p className="mt-1">toolName:{configuredToolName}</p>
181+
</div>
182+
) : null}
183+
184+
{isHttpMode && !isHttpConfigured ? (
185+
<div className="mt-3 rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-700/60 dark:bg-red-900/20 dark:text-red-300">
186+
缺少 HTTP 联调配置:请设置 NEXT_PUBLIC_AGENT_API_BASE_URL。
187+
</div>
188+
) : null}
189+
120190
<div className="mt-4 grid gap-2 md:grid-cols-3">
121-
{(Object.keys(scenarioConfig) as AgentScenario[]).map((scenario) => {
122-
const config = scenarioConfig[scenario];
191+
{scenarioList.map(([scenario, config]) => {
123192
const isActive = activeScenario === scenario;
124193

125194
return (
@@ -170,6 +239,9 @@ export default function AgentMvpPanel() {
170239
events:{runState?.events.length ?? 0}
171240
</span>
172241
</div>
242+
<p className="mt-2 text-xs text-slate-600 dark:text-slate-300">
243+
耗时:{runDurationMs === null ? "暂无" : `${runDurationMs}ms`}
244+
</p>
173245
<p className="mt-2 text-xs text-slate-600 dark:text-slate-300">
174246
错误码:{runState?.lastError?.code ?? "无"}
175247
</p>
@@ -194,6 +266,12 @@ export default function AgentMvpPanel() {
194266
<p className="mt-2 text-xs text-slate-600 dark:text-slate-300">
195267
summary:{stepState?.summary ?? "暂无"}
196268
</p>
269+
{runtimeMetadata ? (
270+
<p className="mt-2 text-xs text-slate-600 dark:text-slate-300">
271+
adapter:{runtimeMetadata.mode}
272+
{runtimeMetadata.toolName ? ` / ${runtimeMetadata.toolName}` : ""}
273+
</p>
274+
) : null}
197275
</div>
198276
</div>
199277

src/app/layout.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import AppShell from "@/components/AppShell";
44
import QueryClientProvider from "@/components/QueryClientProvider";
55
import ThemeProvider from "@/components/ThemeProvider";
66
import ThemeScript from "@/components/ThemeScript";
7-
import { Analytics } from "@vercel/analytics/next";
87

98
export const metadata: Metadata = {
109
title: "DeepScan",
@@ -24,7 +23,6 @@ export default function RootLayout({
2423
<ThemeProvider>
2524
<AppShell>{children}</AppShell>
2625
</ThemeProvider>
27-
<Analytics />
2826
</body>
2927
</html>
3028
</QueryClientProvider>

src/lib/agent/config.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { MockAdapterMode } from "./mockAdapter";
2+
3+
export type AgentAdapterMode = "mock" | "http";
4+
5+
export const DEFAULT_AGENT_ADAPTER_MODE: AgentAdapterMode = "mock";
6+
export const DEFAULT_AGENT_TOOL_NAME = "deepscan.search";
7+
export const DEFAULT_MOCK_MODE: MockAdapterMode = "success";
8+
9+
/**
10+
* 读取前端 adapter 运行模式。
11+
* - mock:本地演示与开发
12+
* - http:对接后端接口联调
13+
*/
14+
export const getAgentAdapterMode = (): AgentAdapterMode => {
15+
const raw = process.env.NEXT_PUBLIC_AGENT_ADAPTER?.trim().toLowerCase();
16+
return raw === "http" ? "http" : DEFAULT_AGENT_ADAPTER_MODE;
17+
};
18+
19+
/**
20+
* 返回 Agent HTTP 基础地址(可为空,调用方负责校验)。
21+
*/
22+
export const getAgentApiBaseUrl = () => {
23+
return process.env.NEXT_PUBLIC_AGENT_API_BASE_URL?.trim() ?? "";
24+
};
25+
26+
/**
27+
* 返回工具名,未配置时使用默认工具占位名。
28+
*/
29+
export const getAgentToolName = () => {
30+
const configured = process.env.NEXT_PUBLIC_AGENT_TOOL_NAME?.trim();
31+
return configured || DEFAULT_AGENT_TOOL_NAME;
32+
};
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { afterEach, describe, expect, it } from "vitest";
2+
import { createAgentAdapter } from "./createAdapter";
3+
import { HttpAgentAdapter } from "./httpAdapter";
4+
import { MockAgentAdapter } from "./mockAdapter";
5+
6+
const clearAgentEnv = () => {
7+
delete process.env.NEXT_PUBLIC_AGENT_ADAPTER;
8+
delete process.env.NEXT_PUBLIC_AGENT_API_BASE_URL;
9+
delete process.env.NEXT_PUBLIC_AGENT_TOOL_NAME;
10+
};
11+
12+
describe("createAgentAdapter", () => {
13+
afterEach(() => {
14+
clearAgentEnv();
15+
});
16+
17+
it("returns mock adapter by default", () => {
18+
clearAgentEnv();
19+
20+
const resolved = createAgentAdapter({ mockMode: "success" });
21+
22+
expect(resolved.mode).toBe("mock");
23+
expect(resolved.adapter).toBeInstanceOf(MockAgentAdapter);
24+
});
25+
26+
it("returns http adapter when mode is http and base url is configured", () => {
27+
process.env.NEXT_PUBLIC_AGENT_ADAPTER = "http";
28+
process.env.NEXT_PUBLIC_AGENT_API_BASE_URL = "https://api.example.com";
29+
process.env.NEXT_PUBLIC_AGENT_TOOL_NAME = "tools.search";
30+
31+
const resolved = createAgentAdapter();
32+
33+
expect(resolved.mode).toBe("http");
34+
expect(resolved.adapter).toBeInstanceOf(HttpAgentAdapter);
35+
expect(resolved.metadata.baseUrl).toBe("https://api.example.com");
36+
expect(resolved.metadata.toolName).toBe("tools.search");
37+
});
38+
39+
it("throws when http mode is enabled but base url is missing", () => {
40+
process.env.NEXT_PUBLIC_AGENT_ADAPTER = "http";
41+
delete process.env.NEXT_PUBLIC_AGENT_API_BASE_URL;
42+
43+
expect(() => createAgentAdapter()).toThrow(
44+
"Missing NEXT_PUBLIC_AGENT_API_BASE_URL for http adapter"
45+
);
46+
});
47+
});

0 commit comments

Comments
 (0)