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
1 change: 0 additions & 1 deletion next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import type { NextConfig } from "next";
const parseAllowedDevOrigins = () => {
const raw = process.env.NEXT_ALLOWED_DEV_ORIGINS?.trim();
if (!raw) {
// 默认放行本地局域网调试来源,避免 Next 未来版本升级后被跨域策略拦截。
return ["192.168.96.167"];
}

Expand Down
1 change: 0 additions & 1 deletion src/app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
} from "@/lib/model/chatGateway";
import { recordServerEvent } from "@/lib/observability/serverEvents";

// Allow streaming responses up to 30 seconds
export const maxDuration = 30;

export async function POST(req: Request) {
Expand Down
101 changes: 88 additions & 13 deletions src/app/chat/[chat_id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { runAgentPipelineForChat } from "@/lib/agent/chatPipeline";
import { createLocalChat, getChatScope, updateLocalChat } from "@/lib/chatStore";
import { getFirstUserMessageText, getMessageText } from "@/lib/chatMessages";
import { readStoredMessages, writeStoredMessages } from "@/lib/chatMessageStorage";
import type { AgentErrorCode, AgentRunStatus } from "@/lib/agent/types";
import { setGlobalChatModel, useGlobalChatModel } from "@/lib/model/globalModel";
import { recordClientEvent } from "@/lib/observability/clientEvents";
import { useHydrated } from "@/lib/useHydrated";
Expand All @@ -27,9 +28,16 @@ type StreamMetric = {
firstTokenAt?: number;
};

/**
* 根据 assistant 消息定位其前置 user 输入,用于“消息级重新生成”。
*/
type AgentRunSummary = {
status: AgentRunStatus;
attempts: number;
durationMs: number;
degraded: boolean;
summary?: string;
adapterMode?: "mock" | "http";
errorCode?: AgentErrorCode;
};

const getPromptForRegenerate = (messages: UIMessage[], assistantMessageId: string) => {
const assistantIndex = messages.findIndex((message) => message.id === assistantMessageId);
if (assistantIndex < 0) return "";
Expand Down Expand Up @@ -68,11 +76,16 @@ function ChatSession({
const [localActionError, setLocalActionError] = useState("");
const [showAgentPanel, setShowAgentPanel] = useState(false);
const [agentHint, setAgentHint] = useState("Agent: idle");
const [agentRunSummary, setAgentRunSummary] = useState<AgentRunSummary | null>(null);
const [lastTtftMs, setLastTtftMs] = useState<number | null>(null);
const inputRef = useRef<HTMLTextAreaElement | null>(null);
const hasAutoSentRef = useRef(false);
const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const streamMetricRef = useRef<StreamMetric | null>(null);
const activePipelineRef = useRef<{
requestId: string;
controller: AbortController;
} | null>(null);

const initialMessages = useMemo(() => {
if (isDraftSession) return [];
Expand Down Expand Up @@ -122,7 +135,6 @@ function ChatSession({
persistTimerRef.current = null;
}

// 流式生成期间以较低频率落盘,降低长会话 localStorage 写入抖动。
const delayMs = isLoading ? 260 : 80;
persistTimerRef.current = setTimeout(() => {
writeStoredMessages(sessionId, safeMessages);
Expand Down Expand Up @@ -169,9 +181,12 @@ function ChatSession({
}
}, [latestAssistantTextLength, sessionId, status]);

/**
* 新建草稿页提交时先创建会话,再跳转到正式会话并自动发送首条消息。
*/
useEffect(() => {
return () => {
activePipelineRef.current?.controller.abort();
};
}, []);

const createChatAndRedirect = useCallback(
async (text: string) => {
const normalizedTitle = text.slice(0, 40) || "新对话";
Expand All @@ -187,9 +202,6 @@ function ChatSession({
[chatScope, globalModel, queryClient, router]
);

/**
* 主链路发送:先执行 Agent,再将结果作为上下文注入模型调用。
*/
const sendWithPipeline = useCallback(
async (
text: string,
Expand All @@ -199,6 +211,18 @@ function ChatSession({
}
) => {
const requestId = `req_${Date.now()}_${Math.random().toString(16).slice(2)}`;
const previousPipeline = activePipelineRef.current;
if (previousPipeline) {
previousPipeline.controller.abort();
recordClientEvent("agent.pipeline.cancel_requested", {
sessionId,
requestId: previousPipeline.requestId,
reason: "superseded_by_new_request",
});
}

const controller = new AbortController();
activePipelineRef.current = { requestId, controller };
streamMetricRef.current = { requestId, startedAt: Date.now() };
recordClientEvent("chat.send.started", {
sessionId,
Expand All @@ -208,11 +232,44 @@ function ChatSession({
});

setAgentHint("Agent: 执行中...");
setAgentRunSummary({
status: "running",
attempts: 0,
durationMs: 0,
degraded: false,
});
const agentResult = await runAgentPipelineForChat({
sessionId,
input: text,
signal: controller.signal,
});

if (activePipelineRef.current?.requestId !== requestId) {
return;
}
activePipelineRef.current = null;

const firstStep = agentResult.state?.steps[0];
const nextSummary: AgentRunSummary = {
status: agentResult.state?.status ?? "failed",
attempts: firstStep?.attempt ?? 0,
durationMs: agentResult.context?.durationMs ?? 0,
degraded: agentResult.degraded,
summary: agentResult.context?.summary,
adapterMode: agentResult.context?.adapterMode,
errorCode: agentResult.state?.lastError?.code,
};
setAgentRunSummary(nextSummary);

if (agentResult.state?.status === "cancelled") {
setAgentHint("Agent: 已取消");
recordClientEvent("agent.pipeline.cancelled", {
sessionId,
requestId,
});
return;
}

if (agentResult.context) {
setAgentHint(`Agent: ${truncate(agentResult.context.summary)}`);
} else if (agentResult.reason) {
Expand All @@ -227,6 +284,8 @@ function ChatSession({
degraded: agentResult.degraded,
runStatus: agentResult.state?.status ?? "unknown",
errorCode: agentResult.state?.lastError?.code,
attempts: nextSummary.attempts,
durationMs: nextSummary.durationMs,
});

const requestBody = {
Expand Down Expand Up @@ -289,7 +348,6 @@ function ChatSession({
const chatId = Number(routeChatId);
if (!Number.isFinite(chatId)) return;

// 在会话产生实际内容后同步标题与更新时间,保持侧栏排序正确。
void updateLocalChat(chatScope, chatId, {
title: firstUserTitle,
model: globalModel,
Expand Down Expand Up @@ -339,6 +397,12 @@ function ChatSession({
});
};

const handleStop = () => {
activePipelineRef.current?.controller.abort();
stop();
setAgentHint("Agent: 已取消");
};

return (
<div className="flex h-screen flex-col bg-[#faf9f5] dark:bg-slate-900">
<header className="border-b border-slate-200/80 bg-white/80 px-4 py-3 backdrop-blur dark:border-slate-700 dark:bg-slate-950/80 md:px-6">
Expand All @@ -354,6 +418,17 @@ function ChatSession({
{lastTtftMs === null ? "" : `TTFT ${lastTtftMs}ms`}
</span>
</p>
{agentRunSummary ? (
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-slate-500 dark:text-slate-400">
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 dark:border-slate-700 dark:bg-slate-900">
Agent {agentRunSummary.status}
</span>
<span>attempts {agentRunSummary.attempts}</span>
<span>{agentRunSummary.durationMs}ms</span>
{agentRunSummary.errorCode ? <span>{agentRunSummary.errorCode}</span> : null}
{agentRunSummary.adapterMode ? <span>{agentRunSummary.adapterMode}</span> : null}
</div>
) : null}
</div>
<div className="flex items-center gap-2">
<ModelSelector
Expand Down Expand Up @@ -396,7 +471,7 @@ function ChatSession({
<main className="flex flex-1 flex-col overflow-hidden">
<div className="flex-1 overflow-hidden px-4 py-4 md:px-6">
<div className="mx-auto flex h-full w-full max-w-4xl flex-col gap-4">
{showAgentPanel ? <AgentMvpPanel /> : null}
{showAgentPanel ? <AgentMvpPanel linkedRunSummary={agentRunSummary} /> : null}
{error ? <ErrorDisplay error={error} onDismiss={clearError} /> : null}
{localActionError ? <ErrorDisplay error={localActionError} /> : null}

Expand Down Expand Up @@ -437,7 +512,7 @@ function ChatSession({
onInputChange={setInput}
onSubmit={handleSubmit}
isLoading={isLoading}
onStop={stop}
onStop={handleStop}
textareaRef={inputRef}
/>
</div>
Expand Down
56 changes: 47 additions & 9 deletions src/app/components/AgentMvpPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
"use client";

import { useMemo, useState } from "react";
import { useMemo, useRef, useState } from "react";
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";
import type { AgentErrorCode, AgentRunState, AgentRunStatus } from "@/lib/agent/types";

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

Expand All @@ -19,9 +19,16 @@ type ScenarioConfig = {

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

/**
* 将状态值映射为统一徽标样式,避免页面里散落大量条件类名判断。
*/
type LinkedRunSummary = {
status: AgentRunStatus;
attempts: number;
durationMs: number;
degraded: boolean;
summary?: string;
adapterMode?: "mock" | "http";
errorCode?: AgentErrorCode;
};

const getStatusBadgeClass = (status: string) => {
if (status === "succeeded") {
return "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-700/60 dark:bg-emerald-900/20 dark:text-emerald-300";
Expand All @@ -35,10 +42,11 @@ const getStatusBadgeClass = (status: string) => {
return "border-slate-200 bg-slate-50 text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300";
};

/**
* Agent MVP 演示面板:展示本地状态机、mock adapter 和三条核心测试路径。
*/
export default function AgentMvpPanel() {
export default function AgentMvpPanel({
linkedRunSummary,
}: {
linkedRunSummary?: LinkedRunSummary | null;
}) {
const adapterMode = useMemo(() => getAgentAdapterMode(), []);
const configuredApiBaseUrl = useMemo(() => getAgentApiBaseUrl(), []);
const configuredToolName = useMemo(() => getAgentToolName(), []);
Expand All @@ -54,6 +62,7 @@ export default function AgentMvpPanel() {
baseUrl?: string;
toolName?: string;
} | null>(null);
const activeControllerRef = useRef<AbortController | null>(null);

const scenarioConfig = useMemo<ScenarioMap>(
() =>
Expand Down Expand Up @@ -105,6 +114,8 @@ export default function AgentMvpPanel() {
setRunDurationMs(null);
setIsRunning(true);
const startedAt = performance.now();
const controller = new AbortController();
activeControllerRef.current = controller;
try {
const resolvedAdapter =
adapterMode === "http"
Expand All @@ -131,6 +142,7 @@ export default function AgentMvpPanel() {
maxRetries: selectedScenario.maxRetries,
timeoutMs: selectedScenario.timeoutMs,
retryDelayMs: selectedScenario.retryDelayMs,
signal: controller.signal,
});
setRunState(result);
setRunDurationMs(Math.round(performance.now() - startedAt));
Expand All @@ -139,6 +151,9 @@ export default function AgentMvpPanel() {
setRunState(null);
setRunDurationMs(Math.round(performance.now() - startedAt));
} finally {
if (activeControllerRef.current === controller) {
activeControllerRef.current = null;
}
setIsRunning(false);
}
};
Expand Down Expand Up @@ -169,9 +184,32 @@ export default function AgentMvpPanel() {
<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>
{isRunning ? (
<button
type="button"
onClick={() => activeControllerRef.current?.abort()}
className="rounded-lg border border-red-200 bg-red-50 px-2.5 py-1 text-[11px] text-red-700 transition hover:bg-red-100 dark:border-red-700/60 dark:bg-red-900/20 dark:text-red-300 dark:hover:bg-red-900/40"
>
取消运行
</button>
) : null}
</div>
</div>

{linkedRunSummary ? (
<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>主聊天最近一次 Agent:{linkedRunSummary.status}</p>
<p className="mt-1">
attempts:{linkedRunSummary.attempts} / duration:{linkedRunSummary.durationMs}ms
{linkedRunSummary.errorCode ? ` / ${linkedRunSummary.errorCode}` : ""}
</p>
<p className="mt-1">
{linkedRunSummary.adapterMode ? `${linkedRunSummary.adapterMode} / ` : ""}
{linkedRunSummary.summary ?? (linkedRunSummary.degraded ? "已降级" : "暂无摘要")}
</p>
</div>
) : null}

{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>
Expand Down
6 changes: 0 additions & 6 deletions src/app/components/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,6 @@ type MessageListProps = {
scrollerRef?: (element: HTMLElement | Window | null) => void;
};

/**
* 提取消息中的纯文本片段,统一作为渲染与复制的数据来源。
*/
const getMessageContent = (message: UIMessage) => {
return message.parts
.map((part) => (part.type === "text" ? part.text : ""))
Expand Down Expand Up @@ -226,9 +223,6 @@ const MessageCard = memo(function MessageCard({
);
});

/**
* 使用虚拟列表承载长会话,降低 DOM 数量并稳定滚动帧率。
*/
export default function MessageList({
messages,
onRegenerate,
Expand Down
3 changes: 0 additions & 3 deletions src/app/components/ModelSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,6 @@ const MODEL_LABELS: Record<SupportedChatModel, string> = {
"deepseek-r1": "DeepSeek R1",
};

/**
* 全局模型选择器:用于统一切换聊天模型,并保持跨会话一致。
*/
export default function ModelSelector({
value,
onChange,
Expand Down
6 changes: 0 additions & 6 deletions src/app/perf/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,6 @@ const getUsedHeapSize = () => {
: null;
};

/**
* 用两帧作为“渲染稳定”采样点,减少单帧偶然抖动影响。
*/
const waitForStableFrame = async () => {
await new Promise<void>((resolve) => {
requestAnimationFrame(() => {
Expand All @@ -42,9 +39,6 @@ const waitForStableFrame = async () => {
});
};

/**
* 通过固定时长自动滚动来估算滚动帧率。
*/
const measureScrollFps = async (element: HTMLElement) => {
const durationMs = 1_500;
const start = performance.now();
Expand Down
Loading
Loading