Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/agent-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,19 @@

- 前端只依赖 adapter 接口。
- 后续切换到 Gin 后端时,仅替换 adapter 实现,不改状态机与测试用例语义。

## 8. Adapter 模式切换(v0.2)

- `mock`:本地演示模式,保留成功/超时/重试失败三条路径。
- `http`:联调模式,调用后端 `POST /v1/mcp/tools/{tool_name}/invoke`。

相关环境变量:

- `NEXT_PUBLIC_AGENT_ADAPTER=mock|http`
- `NEXT_PUBLIC_AGENT_API_BASE_URL`(http 模式必填)
- `NEXT_PUBLIC_AGENT_TOOL_NAME`(可选,默认 `deepscan.search`)

约束:

- 状态机语义不随 adapter 模式变化。
- 错误收敛仍由 runner 统一处理(`TOOL_TIMEOUT / UPSTREAM_ERROR / TOOL_RETRY_EXHAUSTED`)。
4 changes: 4 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,14 @@
环境变量:
- `DEEPSEEK_API_KEY`:模型 API Key
- `BASE_URL`:OpenAI-compatible 接口地址(例如 `https://api.deepseek.com/v1`)
- `NEXT_PUBLIC_AGENT_ADAPTER`:Agent adapter 模式(`mock` 或 `http`,默认 `mock`)
- `NEXT_PUBLIC_AGENT_API_BASE_URL`:当 `NEXT_PUBLIC_AGENT_ADAPTER=http` 时使用的后端地址
- `NEXT_PUBLIC_AGENT_TOOL_NAME`:HTTP 模式下默认工具名(默认 `deepscan.search`)

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

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

Expand Down
190 changes: 134 additions & 56 deletions src/app/components/AgentMvpPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
"use client";

import { useMemo, useState } from "react";
import { MockAgentAdapter } from "@/lib/agent/mockAdapter";
import { createAgentAdapter } from "@/lib/agent/createAdapter";
import { getAgentAdapterMode, getAgentApiBaseUrl, getAgentToolName } from "@/lib/agent/config";
import { runAgent } from "@/lib/agent/runner";
import type { AgentRunState } from "@/lib/agent/types";

type AgentScenario = "success" | "timeout" | "retry_exhausted";
type AgentScenario = "success" | "timeout" | "retry_exhausted" | "remote_call";

type ScenarioConfig = {
label: string;
description: string;
run: () => Promise<AgentRunState>;
maxRetries: number;
timeoutMs: number;
retryDelayMs: number;
mockMode?: "success" | "timeout" | "fail";
};

type ScenarioMap = Partial<Record<AgentScenario, ScenarioConfig>>;

/**
* 将状态值映射为统一徽标样式,避免页面里散落大量条件类名判断。
*/
Expand All @@ -33,93 +39,156 @@ const getStatusBadgeClass = (status: string) => {
* Agent MVP 演示面板:展示本地状态机、mock adapter 和三条核心测试路径。
*/
export default function AgentMvpPanel() {
const [activeScenario, setActiveScenario] = useState<AgentScenario>("success");
const adapterMode = useMemo(() => getAgentAdapterMode(), []);
const configuredApiBaseUrl = useMemo(() => getAgentApiBaseUrl(), []);
const configuredToolName = useMemo(() => getAgentToolName(), []);
const [activeScenario, setActiveScenario] = useState<AgentScenario>(
adapterMode === "http" ? "remote_call" : "success"
);
const [isRunning, setIsRunning] = useState(false);
const [runState, setRunState] = useState<AgentRunState | null>(null);
const [runDurationMs, setRunDurationMs] = useState<number | null>(null);
const [actionError, setActionError] = useState("");
const [runtimeMetadata, setRuntimeMetadata] = useState<{
mode: "mock" | "http";
baseUrl?: string;
toolName?: string;
} | null>(null);

const scenarioConfig = useMemo<Record<AgentScenario, ScenarioConfig>>(
() => ({
success: {
label: "成功路径",
description: "工具调用一次成功,run 最终为 succeeded。",
run: () =>
runAgent({
runId: `run_success_${Date.now()}`,
sessionId: "ui_demo",
input: "演示成功路径",
adapter: new MockAgentAdapter({ mode: "success", delayMs: 0 }),
maxRetries: 2,
timeoutMs: 120,
retryDelayMs: 0,
}),
},
timeout: {
label: "工具超时",
description: "工具调用超过超时阈值,run 直接失败并返回 TOOL_TIMEOUT。",
run: () =>
runAgent({
runId: `run_timeout_${Date.now()}`,
sessionId: "ui_demo",
input: "演示超时路径",
adapter: new MockAgentAdapter({ mode: "timeout" }),
maxRetries: 0,
timeoutMs: 20,
retryDelayMs: 0,
}),
},
retry_exhausted: {
label: "重试失败",
description: "连续上游失败直至重试耗尽,run 返回 TOOL_RETRY_EXHAUSTED。",
run: () =>
runAgent({
runId: `run_retry_${Date.now()}`,
sessionId: "ui_demo",
input: "演示重试耗尽路径",
adapter: new MockAgentAdapter({ mode: "fail", failMessage: "mock upstream error" }),
maxRetries: 2,
timeoutMs: 120,
retryDelayMs: 0,
}),
},
}),
[]
const scenarioConfig = useMemo<ScenarioMap>(
() =>
adapterMode === "http"
? {
remote_call: {
label: "远端联调",
description: "调用后端 MCP 工具接口,验证真实链路可用性。",
maxRetries: 1,
timeoutMs: 10_000,
retryDelayMs: 0,
},
}
: {
success: {
label: "成功路径",
description: "工具调用一次成功,run 最终为 succeeded。",
maxRetries: 2,
timeoutMs: 120,
retryDelayMs: 0,
mockMode: "success",
},
timeout: {
label: "工具超时",
description: "工具调用超过超时阈值,run 直接失败并返回 TOOL_TIMEOUT。",
maxRetries: 0,
timeoutMs: 20,
retryDelayMs: 0,
mockMode: "timeout",
},
retry_exhausted: {
label: "重试失败",
description: "连续上游失败直至重试耗尽,run 返回 TOOL_RETRY_EXHAUSTED。",
maxRetries: 2,
timeoutMs: 120,
retryDelayMs: 0,
mockMode: "fail",
},
},
[adapterMode]
);

const runScenario = async (scenario: AgentScenario) => {
const selectedScenario = scenarioConfig[scenario];
if (!selectedScenario) return;

setActionError("");
setActiveScenario(scenario);
setRunDurationMs(null);
setIsRunning(true);
const startedAt = performance.now();
try {
const result = await scenarioConfig[scenario].run();
const resolvedAdapter =
adapterMode === "http"
? createAgentAdapter({
httpBaseUrl: configuredApiBaseUrl,
toolName: configuredToolName,
})
: createAgentAdapter({
mockMode: selectedScenario.mockMode,
mockDelayMs: 0,
});

setRuntimeMetadata({
mode: resolvedAdapter.mode,
baseUrl: resolvedAdapter.metadata.baseUrl,
toolName: resolvedAdapter.metadata.toolName,
});

const result = await runAgent({
runId: `run_${scenario}_${Date.now()}`,
sessionId: "ui_demo",
input: `演示场景:${scenario}`,
adapter: resolvedAdapter.adapter,
maxRetries: selectedScenario.maxRetries,
timeoutMs: selectedScenario.timeoutMs,
retryDelayMs: selectedScenario.retryDelayMs,
});
setRunState(result);
setRunDurationMs(Math.round(performance.now() - startedAt));
} catch (error) {
setActionError(error instanceof Error ? error.message : "Agent 演示运行失败");
setRunState(null);
setRunDurationMs(Math.round(performance.now() - startedAt));
} finally {
setIsRunning(false);
}
};

const stepState = runState?.steps[0] ?? null;
const scenarioList = useMemo(
() => Object.entries(scenarioConfig) as Array<[AgentScenario, ScenarioConfig]>,
[scenarioConfig]
);
const isHttpMode = adapterMode === "http";
const isHttpConfigured = !isHttpMode || Boolean(configuredApiBaseUrl);

return (
<section className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-700 dark:bg-slate-900">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-sm font-semibold text-slate-800 dark:text-slate-100">Agent MVP 演示面板</h2>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
本地状态机 + Mock Adapter,验证成功/超时/重试失败三条核心路径。
{isHttpMode
? "当前为 HTTP 联调模式:将调用后端 MCP 工具接口。"
: "当前为 Mock 模式:验证成功/超时/重试失败三条核心路径。"}
</p>
</div>
<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">
MVP:单 run / 单 step / 单工具调用
<div className="flex items-center gap-2">
<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">
模式:{isHttpMode ? "HTTP" : "Mock"}
</div>
<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">
MVP:单 run / 单 step / 单工具调用
</div>
</div>
</div>

{isHttpMode ? (
<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">
<p>
baseUrl:{configuredApiBaseUrl || "未配置(请设置 NEXT_PUBLIC_AGENT_API_BASE_URL)"}
</p>
<p className="mt-1">toolName:{configuredToolName}</p>
</div>
) : null}

{isHttpMode && !isHttpConfigured ? (
<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">
缺少 HTTP 联调配置:请设置 NEXT_PUBLIC_AGENT_API_BASE_URL。
</div>
) : null}

<div className="mt-4 grid gap-2 md:grid-cols-3">
{(Object.keys(scenarioConfig) as AgentScenario[]).map((scenario) => {
const config = scenarioConfig[scenario];
{scenarioList.map(([scenario, config]) => {
const isActive = activeScenario === scenario;

return (
Expand Down Expand Up @@ -170,6 +239,9 @@ export default function AgentMvpPanel() {
events:{runState?.events.length ?? 0}
</span>
</div>
<p className="mt-2 text-xs text-slate-600 dark:text-slate-300">
耗时:{runDurationMs === null ? "暂无" : `${runDurationMs}ms`}
</p>
<p className="mt-2 text-xs text-slate-600 dark:text-slate-300">
错误码:{runState?.lastError?.code ?? "无"}
</p>
Expand All @@ -194,6 +266,12 @@ export default function AgentMvpPanel() {
<p className="mt-2 text-xs text-slate-600 dark:text-slate-300">
summary:{stepState?.summary ?? "暂无"}
</p>
{runtimeMetadata ? (
<p className="mt-2 text-xs text-slate-600 dark:text-slate-300">
adapter:{runtimeMetadata.mode}
{runtimeMetadata.toolName ? ` / ${runtimeMetadata.toolName}` : ""}
</p>
) : null}
</div>
</div>

Expand Down
2 changes: 0 additions & 2 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import AppShell from "@/components/AppShell";
import QueryClientProvider from "@/components/QueryClientProvider";
import ThemeProvider from "@/components/ThemeProvider";
import ThemeScript from "@/components/ThemeScript";
import { Analytics } from "@vercel/analytics/next";

export const metadata: Metadata = {
title: "DeepScan",
Expand All @@ -24,7 +23,6 @@ export default function RootLayout({
<ThemeProvider>
<AppShell>{children}</AppShell>
</ThemeProvider>
<Analytics />
</body>
</html>
</QueryClientProvider>
Expand Down
32 changes: 32 additions & 0 deletions src/lib/agent/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { MockAdapterMode } from "./mockAdapter";

export type AgentAdapterMode = "mock" | "http";

export const DEFAULT_AGENT_ADAPTER_MODE: AgentAdapterMode = "mock";
export const DEFAULT_AGENT_TOOL_NAME = "deepscan.search";
export const DEFAULT_MOCK_MODE: MockAdapterMode = "success";

/**
* 读取前端 adapter 运行模式。
* - mock:本地演示与开发
* - http:对接后端接口联调
*/
export const getAgentAdapterMode = (): AgentAdapterMode => {
const raw = process.env.NEXT_PUBLIC_AGENT_ADAPTER?.trim().toLowerCase();
return raw === "http" ? "http" : DEFAULT_AGENT_ADAPTER_MODE;
};

/**
* 返回 Agent HTTP 基础地址(可为空,调用方负责校验)。
*/
export const getAgentApiBaseUrl = () => {
return process.env.NEXT_PUBLIC_AGENT_API_BASE_URL?.trim() ?? "";
};

/**
* 返回工具名,未配置时使用默认工具占位名。
*/
export const getAgentToolName = () => {
const configured = process.env.NEXT_PUBLIC_AGENT_TOOL_NAME?.trim();
return configured || DEFAULT_AGENT_TOOL_NAME;
};
47 changes: 47 additions & 0 deletions src/lib/agent/createAdapter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { afterEach, describe, expect, it } from "vitest";
import { createAgentAdapter } from "./createAdapter";
import { HttpAgentAdapter } from "./httpAdapter";
import { MockAgentAdapter } from "./mockAdapter";

const clearAgentEnv = () => {
delete process.env.NEXT_PUBLIC_AGENT_ADAPTER;
delete process.env.NEXT_PUBLIC_AGENT_API_BASE_URL;
delete process.env.NEXT_PUBLIC_AGENT_TOOL_NAME;
};

describe("createAgentAdapter", () => {
afterEach(() => {
clearAgentEnv();
});

it("returns mock adapter by default", () => {
clearAgentEnv();

const resolved = createAgentAdapter({ mockMode: "success" });

expect(resolved.mode).toBe("mock");
expect(resolved.adapter).toBeInstanceOf(MockAgentAdapter);
});

it("returns http adapter when mode is http and base url is configured", () => {
process.env.NEXT_PUBLIC_AGENT_ADAPTER = "http";
process.env.NEXT_PUBLIC_AGENT_API_BASE_URL = "https://api.example.com";
process.env.NEXT_PUBLIC_AGENT_TOOL_NAME = "tools.search";

const resolved = createAgentAdapter();

expect(resolved.mode).toBe("http");
expect(resolved.adapter).toBeInstanceOf(HttpAgentAdapter);
expect(resolved.metadata.baseUrl).toBe("https://api.example.com");
expect(resolved.metadata.toolName).toBe("tools.search");
});

it("throws when http mode is enabled but base url is missing", () => {
process.env.NEXT_PUBLIC_AGENT_ADAPTER = "http";
delete process.env.NEXT_PUBLIC_AGENT_API_BASE_URL;

expect(() => createAgentAdapter()).toThrow(
"Missing NEXT_PUBLIC_AGENT_API_BASE_URL for http adapter"
);
});
});
Loading