-
[{c.index}]
+
+
[{c.index}]
{c.url ?
{c.title}
:
{c.title}}
{c.source &&
{c.source}}
@@ -86,11 +111,30 @@ export default function KnowledgeResult({
))}
)}
-
+
+ {/* Dedicated quick actions for this card */}
+
+
+
+
diff --git a/langgraph/src/agent-uis/enterprise/next-actions/index.tsx b/langgraph/src/agent-uis/enterprise/next-actions/index.tsx
index a8fa320..44b7890 100644
--- a/langgraph/src/agent-uis/enterprise/next-actions/index.tsx
+++ b/langgraph/src/agent-uis/enterprise/next-actions/index.tsx
@@ -1,7 +1,14 @@
import { Zap } from "lucide-react";
interface Action { label: string; prompt: string; icon: string; }
-interface NextActionsProps { actions: Action[]; }
+interface NextActionsProps {
+ actions: Action[];
+ sourceType?: string;
+ confidence?: "high" | "medium" | "low";
+ sort_key?: number;
+ artifact_id?: string;
+ execution_summary?: string;
+}
export default function NextActions({ actions }: NextActionsProps) {
const dispatch = (text: string) => {
diff --git a/langgraph/src/agent-uis/enterprise/ticket-detail/index.tsx b/langgraph/src/agent-uis/enterprise/ticket-detail/index.tsx
index 25e6f0d..7ab1b58 100644
--- a/langgraph/src/agent-uis/enterprise/ticket-detail/index.tsx
+++ b/langgraph/src/agent-uis/enterprise/ticket-detail/index.tsx
@@ -10,6 +10,12 @@ interface TicketDetailProps {
engineer: string;
created: string;
description: string;
+ sourceType?: string;
+ confidence?: "high" | "medium" | "low";
+ artifact_id?: string;
+ sort_key?: number;
+ source?: string;
+ execution_summary?: string;
}
function priorityClass(priority: string): string {
diff --git a/langgraph/src/agent-uis/enterprise/ticket-summary/index.tsx b/langgraph/src/agent-uis/enterprise/ticket-summary/index.tsx
index 8047f0e..c55c819 100644
--- a/langgraph/src/agent-uis/enterprise/ticket-summary/index.tsx
+++ b/langgraph/src/agent-uis/enterprise/ticket-summary/index.tsx
@@ -17,6 +17,10 @@ interface TicketSummaryProps {
sourceType?: string;
confidence?: "high" | "medium" | "low";
errorMessage?: string;
+ artifact_id?: string;
+ sort_key?: number;
+ source?: string;
+ execution_summary?: string;
}
function priorityClass(priority: string): string {
@@ -144,11 +148,30 @@ export default function TicketSummary({
))
)}
-
+
+ {/* Dedicated quick actions for this card */}
+
+
+
+
diff --git a/langgraph/src/agent-uis/index.tsx b/langgraph/src/agent-uis/index.tsx
index b1e16cd..6f5d3e7 100644
--- a/langgraph/src/agent-uis/index.tsx
+++ b/langgraph/src/agent-uis/index.tsx
@@ -7,6 +7,7 @@ import ChartResult from "./enterprise/chart-result";
import CanvasDoc from "./enterprise/canvas-doc";
import ReplyDraft from "./enterprise/reply-draft";
import NextActions from "./enterprise/next-actions";
+import ErrorResult from "./enterprise/error-result";
const ComponentMap = {
"knowledge-result": KnowledgeResult,
@@ -18,5 +19,6 @@ const ComponentMap = {
"canvas-doc": CanvasDoc,
"reply-draft": ReplyDraft,
"next-actions": NextActions,
+ "error-result": ErrorResult,
} as const;
export default ComponentMap;
diff --git a/langgraph/src/agent/chat-agent/index.ts b/langgraph/src/agent/chat-agent/index.ts
deleted file mode 100644
index 28d1737..0000000
--- a/langgraph/src/agent/chat-agent/index.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import {
- Annotation,
- MessagesAnnotation,
- START,
- StateGraph,
-} from "@langchain/langgraph";
-import { createLlm } from "@/agent/utils/create-llm";
-
-const ChatAgentAnnotation = Annotation.Root({
- messages: MessagesAnnotation.spec["messages"],
-});
-
-const graph = new StateGraph(ChatAgentAnnotation)
- .addNode("chat", async (state) => {
- const model = createLlm();
-
- const response = await model.invoke([
- { role: "system", content: "You are a helpful assistant." },
- ...state.messages,
- ]);
-
- return {
- messages: response,
- };
- })
- .addEdge(START, "chat");
-
-export const agent = graph.compile();
-agent.name = "Chat Agent";
diff --git a/langgraph/src/agent/coder/nodes/tool-executor.ts b/langgraph/src/agent/coder/nodes/tool-executor.ts
index fdaf367..f32b61d 100644
--- a/langgraph/src/agent/coder/nodes/tool-executor.ts
+++ b/langgraph/src/agent/coder/nodes/tool-executor.ts
@@ -8,7 +8,7 @@ import { AIMessage } from "@langchain/core/messages";
import { CoderState, CoderUpdate } from "../types.js";
import { sandboxRun } from "../../enterprise/tools/soc-client.js";
import { codeExecuteSchema, codeInstallSchema } from "./agent.js";
-import { executeWithRetry, formatToolError } from "@/agent/utils/retry";
+import { executeWithRetry } from "@/agent/utils/retry";
export async function toolExecutorNode(
state: CoderState,
diff --git a/langgraph/src/agent/enterprise/nodes/agent.ts b/langgraph/src/agent/enterprise/nodes/agent.ts
index 4a881c5..94895dd 100644
--- a/langgraph/src/agent/enterprise/nodes/agent.ts
+++ b/langgraph/src/agent/enterprise/nodes/agent.ts
@@ -18,15 +18,51 @@ const SYSTEM_PROMPT = `你是企业内部助手,帮助用户查询知识库和
- 不需要工具的问题直接回答
- 工具调用失败时坦诚告知原因,并给出替代建议(如换一种查询方式或联系相关人员)
-## 来源标注规范
-在回答中,每个关键结论前用方括号标注信息来源:
-- [内部知识] — 来自知识库检索的原文信息
-- [工单数据] — 来自工单系统的实际记录
-- [网络搜索] — 来自外部网络搜索结果
-- [模型推断] — 基于训练知识推断,非原始数据依据
+## 图表分析规则
+- 当工具返回多条数据记录(>= 1 条)时,视为"可视化机会"
+- 遇到分析类问题(含"趋势/统计/分析/对比/汇总/多少")时,在文字总结前先思考是否有数据可视化
+- 工单类查询结果必须附带图表(状态分布、优先级分布),系统会自动生成
+- 当你需要对已有数据做额外维度的可视化(如时间趋势、自定义对比),主动调用 chart_generate 工具
+- 知识库返回含数值型数据时,考虑用 chart_generate 生成数值摘要图
-来源可信度:内部知识 > 工单数据 > 网络搜索 > 模型推断
-若结论仅有[模型推断]支撑,必须明确说明不确定性。
+## 规则
+
+### 停止查询条件
+- 已调用工具 3 次仍无有效结果 -> 停止工具调用,直接回答"未找到相关信息"并给出替代建议
+- 工具返回结果已足够回答用户问题 -> 立即停止,不要再调用更多工具
+- 用户问题是闲聊或不涉及业务数据 -> 不调用任何工具,直接回答
+
+### 工具优先级
+当多种工具都可能适用时,按以下优先级选择:
+1. 知识库搜索(kb_search)— 企业内部信息首选
+2. 工单系统(ticket_list / ticket_detail)— 工单相关查询
+3. 网络搜索 — 仅当内部数据不足时使用
+4. 代码沙盒 — 仅需要计算或代码执行时
+5. 图表生成(chart_generate)— 对已有数据做额外维度的可视化
+
+**重要**:当用户问题含"分析/趋势/统计/占比/分布/汇总/对比/多少"时,优先调用 ticket_list(而非 ticket_detail),以触发图表自动生成。获得数据后如需更多维度的可视化,继续调用 chart_generate。
+
+### 工具选择决策树
+- 内部信息(公司规范、系统文档、产品手册)→ kb_search;知识库不足再补网络搜索
+- 公开信息(新闻、行业标准、外部技术文档)→ 直接 google_search/web_search,无需先查知识库
+- 有工单号(如 TK-2026-xxx)→ ticket_detail;无工单号 → ticket_list
+- 已有真实数据需可视化 → chart_generate;无数据不得调用
+- 需执行代码/计算 → sandbox;其他情况不调用
+
+### 主动输出建议
+- 查完工单后,主动给出"建议处理方案"和下一步行动
+- 搜索完知识库后,主动给出"相关操作步骤"
+- 发现异常数据模式(如大量未处理工单、重复故障),主动提示风险
+
+### 失败回退
+- 工具调用失败后,优先利用已有信息(上下文中其他工具结果、对话历史)回答
+- 如果完全没有可用信息,用[模型推断]标注,诚实告知局限性
+- 不要因为一个工具失败就放弃回答
+
+## 来源标注规范
+在关键结论句末用短标签标注来源:[知识库]、[工单]、[网络]、[推断]。
+可信度:[知识库] > [工单] > [网络] > [推断]。
+若仅有[推断]支撑,必须说明不确定性。
## 企业风格输出格式
回答结构(超过200字时使用):
@@ -59,10 +95,10 @@ const SYSTEM_PROMPT = `你是企业内部助手,帮助用户查询知识库和
1. 不要在回答中暴露技术报错、HTTP 状态码、堆栈信息
2. 用中文友好地说明:发生了什么、为什么(用户能理解的语言)
3. 给出至少一条可操作的替代建议,例如:
- - 知识库无结果 → 建议换个关键词,或说明知识库可能暂未收录该内容
- - 工单查询失败 → 建议直接联系工单管理员,或稍后重试
- - 代码执行失败 → 直接分析代码逻辑给出结果,说明沙盒暂时不可用
- - 搜索失败 → 基于已有知识给出答案,标注[模型推断]
+ - 知识库无结果 -> 建议换个关键词,或说明知识库可能暂未收录该内容
+ - 工单查询失败 -> 建议直接联系工单管理员,或稍后重试
+ - 代码执行失败 -> 直接分析代码逻辑给出结果,说明沙盒暂时不可用
+ - 搜索失败 -> 基于已有知识给出答案,标注[模型推断]
4. 语气要稳定专业,不要说"抱歉"超过一次,不要表现出慌乱
## next-actions 触发规范
@@ -72,11 +108,12 @@ const SYSTEM_PROMPT = `你是企业内部助手,帮助用户查询知识库和
- 如果用户的问题已完全回答,结尾简洁即可,不要过度延伸
## 图表触发说明
-当 ticket_list 返回结果时,系统会自动生成工单统计图表(chart-result 卡片)。
+当 ticket_list 返回 >= 1 条工单时,系统会自动生成工单统计图表(chart-result 卡片)。
+当用户问题包含分析类意图(趋势/统计/分析/对比/多少)时,系统还会额外生成时间趋势图。
你在回答中:
- 可以引用图表数据(如"从状态分布图可以看出,待处理工单占比最高")
- 不需要重复列举数据,图表已直观展示
-- 如果工单数量 < 3 条,不必提及图表`;
+- 如果需要展示其他维度的图表,主动调用 chart_generate 工具`;
export async function agentNode(
state: EnterpriseState,
diff --git a/langgraph/src/agent/enterprise/nodes/tool-defs.ts b/langgraph/src/agent/enterprise/nodes/tool-defs.ts
index 3362b4f..936db3c 100644
--- a/langgraph/src/agent/enterprise/nodes/tool-defs.ts
+++ b/langgraph/src/agent/enterprise/nodes/tool-defs.ts
@@ -17,25 +17,37 @@ export const ticketDetailSchema = z.object({
ticket_id: z.string().describe("The ticket number / ID"),
});
+export const chartGenerateSchema = z.object({
+ chart_type: z.enum(["bar", "pie", "line"]).describe("图表类型"),
+ title: z.string().describe("图表标题"),
+ data: z.array(z.object({ name: z.string(), value: z.number() })).describe("图表数据"),
+});
+
export const ALL_ENTERPRISE_TOOLS = [
{
name: "kb_search",
description:
- "搜索内部知识库。当用户询问公司文档、产品信息、技术资料、内部规范、流程制度时使用。传入自然语言查询词。",
+ "搜索内部知识库。适用:公司文档、产品信息、技术资料、内部规范、流程制度等内部信息。不适用:公开新闻、行业标准等外部信息请用 google_search。传入自然语言查询词。",
schema: kbSearchSchema,
},
{
name: "ticket_list",
description:
- "查询工单列表。当用户想了解工单概览、查看最近的工单、查看工单状态汇总时使用。支持分页。",
+ "查询工单列表。适用:无具体工单号时浏览工单概览、最近工单、状态汇总、分析统计。不适用:已有明确工单编号时应直接用 ticket_detail。支持分页。",
schema: ticketListSchema,
},
{
name: "ticket_detail",
description:
- "查询指定工单的详细信息(处理进度、历史记录、负责人等)。当用户提到具体工单编号或想深入了解某个工单时使用。",
+ "查询指定工单详情(处理进度、历史记录、负责人等)。适用:仅当用户明确提供工单编号(如 TK-2026-xxx)时使用。不适用:不知道工单号时先用 ticket_list 查找。",
schema: ticketDetailSchema,
},
+ {
+ name: "chart_generate",
+ description:
+ "根据已查到的真实数据生成图表(柱状图、饼图、折线图)。适用:已通过其他工具获取数据后需要可视化分析。不适用:无数据时不得调用;ticket_list 的状态/优先级分布图由系统自动生成,无需手动调用。",
+ schema: chartGenerateSchema,
+ },
] as const;
export type EnterpriseToolDef = (typeof ALL_ENTERPRISE_TOOLS)[number];
diff --git a/langgraph/src/agent/enterprise/nodes/tool-executor.ts b/langgraph/src/agent/enterprise/nodes/tool-executor.ts
index f745290..6c3e79b 100644
--- a/langgraph/src/agent/enterprise/nodes/tool-executor.ts
+++ b/langgraph/src/agent/enterprise/nodes/tool-executor.ts
@@ -2,12 +2,22 @@
* Tool executor node: executes tool calls from the last AI message,
* pushes Gen-UI cards, and returns ToolMessages.
* Phase 2: only kb_search + ticket_list + ticket_detail remain here.
+ *
+ * Sprint 2026-04-12 optimizations:
+ * - Unified source/confidence/execution_summary on every ui.push()
+ * - Error artifact: push error-result card on tool failure
+ * - AbortController timeout (15s default) with structured error handling
+ * - chart-result auto-derived from ticket_list stats
+ * - Stable artifact_id: `${toolCallId}_${toolName}`
+ * - sort_key on every push
+ * - Duplicate push guard via state.ui
+ * - execution_log entries for frontend visibility
*/
import { typedUi } from "@langchain/langgraph-sdk/react-ui/server";
import type ComponentMap from "../../../agent-uis/index.js";
import { LangGraphRunnableConfig } from "@langchain/langgraph";
import { AIMessage } from "@langchain/core/messages";
-import { EnterpriseState, EnterpriseUpdate } from "../types.js";
+import { EnterpriseState, EnterpriseUpdate, ExecutionLogEntry } from "../types.js";
import type { ToolExecStatus } from "../../types.js";
import {
kbSearch,
@@ -18,9 +28,25 @@ import {
kbSearchSchema,
ticketListSchema,
ticketDetailSchema,
+ chartGenerateSchema,
} from "./tool-defs.js";
import { executeWithRetry, formatToolError } from "@/agent/utils/retry";
+// Structured tool execution trace logger — Azure log stream can filter by field
+function logToolCall(entry: ExecutionLogEntry) {
+ console.log(JSON.stringify({ event: "tool_exec", ...entry }));
+}
+
+/** Truncate a string to maxLen characters for input summaries */
+function truncateInput(s: string, maxLen = 200): string {
+ return s.length > maxLen ? s.slice(0, maxLen) + "..." : s;
+}
+
+/** Default per-tool timeout in ms */
+const TOOL_TIMEOUT_MS = 15_000;
+/** KB search gets a longer timeout due to cold-start */
+const KB_TIMEOUT_MS = 45_000;
+
/** Map raw status codes to Chinese labels for chart display */
function statusLabel(status: string): string {
const map: Record
= {
@@ -34,6 +60,37 @@ function statusLabel(status: string): string {
return map[status?.toLowerCase()] ?? status;
}
+/** Analysis keywords that trigger extra time-trend chart */
+const ANALYSIS_KEYWORDS = /趋势|统计|分析|对比|汇总|多少|走势|变化|增长|下降/;
+
+/** Check if user's latest question contains analysis intent */
+function hasAnalysisIntent(messages: EnterpriseState["messages"]): boolean {
+ // Walk backwards to find the last human message
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const m = messages[i] as Record;
+ const isHuman =
+ m.role === "user" ||
+ (typeof m._getType === "function" && (m._getType as () => string)() === "human") ||
+ m.constructor?.name === "HumanMessage";
+ if (isHuman && "content" in m) {
+ const content = typeof m.content === "string" ? m.content : "";
+ return ANALYSIS_KEYWORDS.test(content);
+ }
+ }
+ return false;
+}
+
+/** Map error to a suggestion type for the error-result card */
+function errorSuggestion(
+ toolName: string,
+ error: unknown,
+): "retry" | "contact_admin" | "check_input" {
+ const msg = error instanceof Error ? error.message : String(error);
+ if (msg.includes("401") || msg.includes("403")) return "contact_admin";
+ if (msg.includes("404") || msg.includes("not found")) return "check_input";
+ return "retry";
+}
+
/** Generate suggested next actions based on which tools ran successfully */
function generateNextActions(
tools: string[],
@@ -76,6 +133,72 @@ function generateNextActions(
return actions.slice(0, 3);
}
+/**
+ * Check whether an artifact with the given toolCallId already exists in state.ui.
+ * Prevents duplicate pushes across retries or re-invocations.
+ */
+function hasArtifactForToolCall(
+ stateUi: EnterpriseState["ui"],
+ toolCallId: string,
+): boolean {
+ return stateUi.some((item) => {
+ const props = ((item as unknown) as Record).props as
+ | Record
+ | undefined;
+ return props?.artifact_id && String(props.artifact_id).startsWith(toolCallId);
+ });
+}
+
+/**
+ * Wrap a tool invocation with an AbortController timeout.
+ * If the function completes before the deadline, the timer is cleared.
+ * On timeout, the AbortError is thrown and caught by the caller.
+ */
+async function withTimeout(
+ fn: (signal: AbortSignal) => Promise,
+ timeoutMs: number,
+): Promise {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
+ try {
+ return await fn(controller.signal);
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
+/**
+ * Pre-validate tool call arguments before execution.
+ * Returns a human-readable block reason, or null if validation passes.
+ */
+function preValidateToolCall(
+ name: string,
+ args: Record,
+ state: EnterpriseState,
+): string | null {
+ // ticket_detail: must have a valid ticket ID
+ if (name === "ticket_detail") {
+ const id = String(args.ticket_id ?? "").trim();
+ if (!id || id === "undefined" || id === "null") {
+ return "ticket_detail 调用被拦截:未提供有效工单编号,请改用 ticket_list 查询工单列表";
+ }
+ }
+
+ // chart_generate: must have prior successful tool data in this conversation
+ if (name === "chart_generate") {
+ const hasData = state.execution_log?.some(
+ (e) =>
+ e.status !== "error" &&
+ ["kb_search", "ticket_list", "ticket_detail"].includes(e.tool),
+ );
+ if (!hasData) {
+ return "chart_generate 调用被拦截:当前对话尚无工具数据,无法生成图表";
+ }
+ }
+
+ return null;
+}
+
export async function toolExecutorNode(
state: EnterpriseState,
config: LangGraphRunnableConfig,
@@ -103,25 +226,63 @@ export async function toolExecutorNode(
}> = [];
const statusList: ToolExecStatus[] = [];
+ const executionLog: ExecutionLogEntry[] = [];
// Execute all tool calls in parallel
const executions = toolCalls.map(async (tc) => {
const name = tc.name;
const args = tc.args;
const id = tc.id ?? "";
+ const artifactId = `${id}_${name}`;
+ const sortKey = Date.now();
+ // Skip if artifact already exists for this toolCallId (duplicate guard)
+ if (hasArtifactForToolCall(state.ui, id)) {
+ return {
+ role: "tool" as const,
+ tool_call_id: id,
+ content: JSON.stringify({ note: "已有此工具的执行结果" }),
+ };
+ }
+
+ // Pre-validate tool call arguments
+ const blockReason = preValidateToolCall(name, args as Record, state);
+ if (blockReason) {
+ statusList.push({ tool: name, status: "error", message: blockReason });
+ executionLog.push({
+ tool: name,
+ status: "error",
+ summary: blockReason,
+ timestamp: Date.now(),
+ durationMs: 0,
+ inputSummary: truncateInput(JSON.stringify(args)),
+ resultCount: 0,
+ errorCode: "PRECONDITION_FAILED",
+ errorMessage: blockReason,
+ });
+ return {
+ role: "tool" as const,
+ tool_call_id: id,
+ content: JSON.stringify({ ok: false, tool: name, summary: blockReason, error: "PRECONDITION_FAILED" }),
+ };
+ }
+
+ const startTime = Date.now();
try {
switch (name) {
case "kb_search": {
const parsed = kbSearchSchema.parse(args);
let kbData: Awaited> | null = null;
- let fallbackUsed = false;
try {
- kbData = await executeWithRetry(
- () => kbSearch(parsed.query),
- 3,
- { backoffMs: 1000, exponential: true },
+ kbData = await withTimeout(
+ () =>
+ executeWithRetry(
+ () => kbSearch(parsed.query),
+ 3,
+ { backoffMs: 1000, exponential: true },
+ ),
+ KB_TIMEOUT_MS,
);
} catch (kbError) {
// Push friendly error card, then try fallback to google_search
@@ -135,24 +296,41 @@ export async function toolExecutorNode(
sourceType: "error",
confidence: "low",
errorMessage: "知识库暂时无响应,已切换到网络搜索",
+ artifact_id: artifactId,
+ sort_key: sortKey,
+ source: "knowledge_base" as const,
+ execution_summary: "知识库检索失败,尝试网络搜索回退",
},
},
{ message: lastAiMessage },
);
try {
const { googleSearch } = await import("../tools/soc-client.js");
- const gData = await googleSearch(parsed.query);
+ const gData = await withTimeout(
+ () => googleSearch(parsed.query),
+ TOOL_TIMEOUT_MS,
+ );
const fallbackResults = gData.results.slice(0, 5).map((r) => ({
title: r.title,
category: "网络搜索",
snippet: r.snippet?.slice(0, 200) ?? "",
}));
- fallbackUsed = true;
statusList.push({
tool: name,
- status: "fallback",
+ status: "fallback_success",
message: "知识库不可用,已使用网络搜索替代",
});
+ const fallbackLogEntry: ExecutionLogEntry = {
+ tool: name,
+ status: "fallback_success",
+ summary: `知识库不可用,回退到网络搜索,获取 ${fallbackResults.length} 条结果`,
+ timestamp: Date.now(),
+ inputSummary: truncateInput(`query: ${parsed.query}`),
+ durationMs: Date.now() - startTime,
+ resultCount: fallbackResults.length,
+ };
+ executionLog.push(fallbackLogEntry);
+ logToolCall(fallbackLogEntry);
return {
role: "tool" as const,
tool_call_id: id,
@@ -164,6 +342,34 @@ export async function toolExecutorNode(
};
} catch {
statusList.push({ tool: name, status: "error", message: formatToolError(name, kbError) });
+ const dblFailEntry: ExecutionLogEntry = {
+ tool: name,
+ status: "error",
+ summary: "知识库及网络搜索均不可用",
+ timestamp: Date.now(),
+ inputSummary: truncateInput(`query: ${parsed.query}`),
+ durationMs: Date.now() - startTime,
+ resultCount: 0,
+ errorCode: "DOUBLE_FALLBACK_FAIL",
+ errorMessage: "知识库及网络搜索均不可用",
+ };
+ executionLog.push(dblFailEntry);
+ logToolCall(dblFailEntry);
+ // Push error artifact
+ ui.push(
+ {
+ name: "error-result" as never,
+ props: {
+ tool: name,
+ message: formatToolError(name, kbError),
+ suggestion: errorSuggestion(name, kbError),
+ timestamp: Date.now(),
+ artifact_id: `${artifactId}_error`,
+ sort_key: Date.now(),
+ } as never,
+ },
+ { message: lastAiMessage },
+ );
return {
role: "tool" as const,
tool_call_id: id,
@@ -183,6 +389,9 @@ export async function toolExecutorNode(
source: r.category,
url: undefined,
}));
+ const execSummary = results.length > 0
+ ? `搜索到 ${results.length} 条知识库结果`
+ : "知识库未找到相关内容";
ui.push(
{
name: "knowledge-result",
@@ -193,10 +402,44 @@ export async function toolExecutorNode(
citations: kbCitations,
sourceType: "internal_kb",
confidence: results.length > 0 ? "high" : "medium",
+ artifact_id: artifactId,
+ sort_key: sortKey,
+ source: "knowledge_base" as const,
+ execution_summary: execSummary,
},
},
{ message: lastAiMessage },
);
+ // Push category distribution chart when kb results span >= 2 categories
+ const kbCategoryStats: Record = {};
+ results.forEach((r) => {
+ const cat = r.category || "其他";
+ kbCategoryStats[cat] = (kbCategoryStats[cat] ?? 0) + 1;
+ });
+ if (Object.keys(kbCategoryStats).length >= 2) {
+ const kbCatChart = Object.entries(kbCategoryStats).map(
+ ([cname, value]) => ({ name: cname, value }),
+ );
+ ui.push(
+ {
+ name: "chart-result",
+ props: {
+ title: "知识库结果分类分布",
+ charts: [
+ { chart_type: "pie", title: "类别分布", data: kbCatChart },
+ ],
+ sourceType: "internal_kb",
+ confidence: "medium",
+ artifact_id: `${artifactId}_chart`,
+ sort_key: sortKey + 1,
+ source: "knowledge_base" as const,
+ execution_summary: `知识库类别分布:${kbCatChart.map((c) => `${c.name}(${c.value})`).join("、")}`,
+ },
+ },
+ { message: lastAiMessage },
+ );
+ }
+
const kbContent: Record = { total: results.length, results };
if (results.length === 0) {
kbContent.hint = "知识库未找到相关内容。建议:可尝试使用搜索引擎查找相关信息。";
@@ -204,6 +447,17 @@ export async function toolExecutorNode(
} else {
statusList.push({ tool: name, status: "ok" });
}
+ const kbLogEntry: ExecutionLogEntry = {
+ tool: name,
+ status: results.length === 0 ? "partial_success" : "success",
+ summary: execSummary,
+ timestamp: Date.now(),
+ inputSummary: truncateInput(`query: ${parsed.query}`),
+ durationMs: Date.now() - startTime,
+ resultCount: results.length,
+ };
+ executionLog.push(kbLogEntry);
+ logToolCall(kbLogEntry);
return {
role: "tool" as const,
tool_call_id: id,
@@ -213,7 +467,10 @@ export async function toolExecutorNode(
case "ticket_list": {
const parsed = ticketListSchema.parse(args);
- const data = await executeWithRetry(() => ticketList(parsed.page ?? 1));
+ const data = await withTimeout(
+ () => executeWithRetry(() => ticketList(parsed.page ?? 1), 2),
+ TOOL_TIMEOUT_MS,
+ );
const tickets = (data.tickets ?? []).map((t) => ({
id: t.ticketNumber,
title: t.description?.slice(0, 80) ?? "",
@@ -226,21 +483,35 @@ export async function toolExecutorNode(
tickets.forEach((t) => {
stats[t.status] = (stats[t.status] ?? 0) + 1;
});
+ const execSummary = tickets.length > 0
+ ? `查询到 ${tickets.length} 条工单`
+ : "未查询到工单";
+
// Only push ticket-summary + chart when there are actual results
if (tickets.length > 0) {
ui.push(
{
name: "ticket-summary",
- props: { total: tickets.length, tickets, stats, sourceType: "ticket_system", confidence: "high" },
+ props: {
+ total: tickets.length,
+ tickets,
+ stats,
+ sourceType: "ticket_system",
+ confidence: "high",
+ artifact_id: artifactId,
+ sort_key: sortKey,
+ source: "ticket_system" as const,
+ execution_summary: execSummary,
+ },
},
{ message: lastAiMessage },
);
}
- // Push chart-result card for ticket distribution
- if (tickets.length > 0) {
- const statusChart = Object.entries(stats).map(([name, value]) => ({
- name: statusLabel(name),
+ // Push chart-result card for ticket distribution (>= 1 ticket)
+ if (tickets.length >= 1) {
+ const statusChart = Object.entries(stats).map(([cname, value]) => ({
+ name: statusLabel(cname),
value,
}));
const priorityStats: Record = {};
@@ -248,19 +519,44 @@ export async function toolExecutorNode(
priorityStats[t.priority] = (priorityStats[t.priority] ?? 0) + 1;
});
const priorityChart = Object.entries(priorityStats).map(
- ([name, value]) => ({ name, value }),
+ ([cname, value]) => ({ name: cname, value }),
);
+ const charts: Array<{ chart_type: string; title: string; data: Array<{ name: string; value: number }> }> = [
+ { chart_type: "pie", title: "状态分布", data: statusChart },
+ { chart_type: "bar", title: "优先级分布", data: priorityChart },
+ ];
+
+ // Add time-trend chart when user has analysis intent
+ if (hasAnalysisIntent(state.messages)) {
+ const dateStats: Record = {};
+ tickets.forEach((t) => {
+ const date = t.created || "未知";
+ dateStats[date] = (dateStats[date] ?? 0) + 1;
+ });
+ const trendData = Object.entries(dateStats)
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([cname, value]) => ({ name: cname, value }));
+ if (trendData.length >= 1) {
+ charts.push({
+ chart_type: "line",
+ title: "工单创建时间趋势",
+ data: trendData,
+ });
+ }
+ }
+
ui.push(
{
name: "chart-result",
props: {
title: "工单分布统计",
- charts: [
- { chart_type: "pie", title: "状态分布", data: statusChart },
- { chart_type: "bar", title: "优先级分布", data: priorityChart },
- ],
+ charts,
sourceType: "ticket_system",
confidence: "high",
+ artifact_id: `${artifactId}_chart`,
+ sort_key: sortKey + 1,
+ source: "ticket_system" as const,
+ execution_summary: `状态分布:${statusChart.map((c) => `${c.name}(${c.value})`).join("、")}`,
},
},
{ message: lastAiMessage },
@@ -269,6 +565,9 @@ export async function toolExecutorNode(
if (tickets.length === 0) {
statusList.push({ tool: name, status: "empty", message: "未查询到工单" });
+ const tlEmptyEntry: ExecutionLogEntry = { tool: name, status: "partial_success", summary: execSummary, timestamp: Date.now(), inputSummary: truncateInput(`page: ${parsed.page ?? 1}`), durationMs: Date.now() - startTime, resultCount: 0 };
+ executionLog.push(tlEmptyEntry);
+ logToolCall(tlEmptyEntry);
return {
role: "tool" as const,
tool_call_id: id,
@@ -280,6 +579,9 @@ export async function toolExecutorNode(
};
}
statusList.push({ tool: name, status: "ok" });
+ const tlOkEntry: ExecutionLogEntry = { tool: name, status: "success", summary: execSummary, timestamp: Date.now(), inputSummary: truncateInput(`page: ${parsed.page ?? 1}`), durationMs: Date.now() - startTime, resultCount: tickets.length };
+ executionLog.push(tlOkEntry);
+ logToolCall(tlOkEntry);
return {
role: "tool" as const,
tool_call_id: id,
@@ -289,7 +591,11 @@ export async function toolExecutorNode(
case "ticket_detail": {
const parsed = ticketDetailSchema.parse(args);
- const t = await executeWithRetry(() => ticketDetail(parsed.ticket_id));
+ const t = await withTimeout(
+ () => executeWithRetry(() => ticketDetail(parsed.ticket_id), 2),
+ TOOL_TIMEOUT_MS,
+ );
+ const execSummary = `获取工单 ${String(t.ticketNumber ?? parsed.ticket_id)} 详情`;
ui.push(
{
name: "ticket-detail",
@@ -309,15 +615,78 @@ export async function toolExecutorNode(
description: String(t.description ?? "").slice(0, 500),
sourceType: "ticket_system",
confidence: "high",
+ artifact_id: artifactId,
+ sort_key: sortKey,
+ source: "ticket_system" as const,
+ execution_summary: execSummary,
},
},
{ message: lastAiMessage },
);
statusList.push({ tool: name, status: "ok" });
+ const tdOkEntry: ExecutionLogEntry = { tool: name, status: "success", summary: execSummary, timestamp: Date.now(), inputSummary: truncateInput(`ticket_id: ${parsed.ticket_id}`), durationMs: Date.now() - startTime, resultCount: 1 };
+ executionLog.push(tdOkEntry);
+ logToolCall(tdOkEntry);
return {
role: "tool" as const,
tool_call_id: id,
- content: JSON.stringify(t),
+ content: JSON.stringify({
+ ok: true,
+ tool: name,
+ summary: `工单 ${String(t.ticketNumber ?? parsed.ticket_id)}: ${String(t.description ?? "").slice(0, 100)}`,
+ data: {
+ id: String(t.ticketNumber ?? parsed.ticket_id),
+ title: String(t.description ?? "").slice(0, 80),
+ status: String(t.status ?? ""),
+ priority: String(t.priority ?? ""),
+ customer: String((t.customer as { name?: string })?.name ?? ""),
+ engineer: String((t.assignedEngineer as { username?: string })?.username ?? "未分配"),
+ created: String(t.createdAt ?? "").slice(0, 10),
+ },
+ }),
+ };
+ }
+
+ case "chart_generate": {
+ const parsed = chartGenerateSchema.parse(args);
+ ui.push(
+ {
+ name: "chart-result",
+ props: {
+ title: parsed.title,
+ charts: [
+ {
+ chart_type: parsed.chart_type,
+ title: parsed.title,
+ data: parsed.data,
+ },
+ ],
+ sourceType: "generated_doc",
+ confidence: "high",
+ artifact_id: artifactId,
+ sort_key: sortKey,
+ source: "generated" as const,
+ execution_summary: `生成${parsed.chart_type}图表:${parsed.title}(${parsed.data.length}项数据)`,
+ },
+ },
+ { message: lastAiMessage },
+ );
+ statusList.push({ tool: name, status: "ok" });
+ const chartLogEntry: ExecutionLogEntry = {
+ tool: name,
+ status: "success",
+ summary: `图表已生成:${parsed.title}`,
+ timestamp: Date.now(),
+ inputSummary: truncateInput(`title: ${parsed.title}, type: ${parsed.chart_type}`),
+ durationMs: Date.now() - startTime,
+ resultCount: 1,
+ };
+ executionLog.push(chartLogEntry);
+ logToolCall(chartLogEntry);
+ return {
+ role: "tool" as const,
+ tool_call_id: id,
+ content: JSON.stringify({ status: "图表已生成", title: parsed.title }),
};
}
@@ -329,13 +698,116 @@ export async function toolExecutorNode(
};
}
} catch (e) {
- statusList.push({ tool: name, status: "error", message: formatToolError(name, e) });
+ const friendlyError = formatToolError(name, e);
+ const suggestion = errorSuggestion(name, e);
+ statusList.push({ tool: name, status: "error", message: friendlyError });
+ const argsStr = typeof args === "object" ? JSON.stringify(args) : String(args);
+ const errLogEntry: ExecutionLogEntry = {
+ tool: name,
+ status: "error",
+ summary: friendlyError,
+ timestamp: Date.now(),
+ inputSummary: truncateInput(argsStr),
+ durationMs: Date.now() - startTime,
+ resultCount: 0,
+ errorMessage: friendlyError,
+ };
+ executionLog.push(errLogEntry);
+ logToolCall(errLogEntry);
+
+ // ticket_detail 404 → fallback to ticket_list (search same customer's other tickets)
+ if (name === "ticket_detail" && suggestion === "check_input") {
+ try {
+ const fallbackData = await withTimeout(
+ () => executeWithRetry(() => ticketList(1), 2),
+ TOOL_TIMEOUT_MS,
+ );
+ const fallbackTickets = (fallbackData.tickets ?? [])
+ .slice(0, 5)
+ .map((t) => ({
+ id: t.ticketNumber,
+ title: t.description?.slice(0, 80) ?? "",
+ status: t.status,
+ priority: t.priority,
+ customer: t.customer?.name ?? "",
+ created: t.createdAt?.slice(0, 10) ?? "",
+ }));
+ if (fallbackTickets.length > 0) {
+ ui.push(
+ {
+ name: "error-result" as never,
+ props: {
+ tool: name,
+ message: "未找到该工单,已为您自动切换到备选方案",
+ suggestion: "check_input",
+ timestamp: Date.now(),
+ artifact_id: `${artifactId}_error`,
+ sort_key: sortKey,
+ } as never,
+ },
+ { message: lastAiMessage },
+ );
+ ui.push(
+ {
+ name: "ticket-summary",
+ props: {
+ total: fallbackTickets.length,
+ tickets: fallbackTickets,
+ stats: {},
+ sourceType: "ticket_system",
+ confidence: "medium",
+ artifact_id: `${artifactId}_fallback`,
+ sort_key: sortKey + 1,
+ source: "ticket_system" as const,
+ execution_summary: `工单详情未找到,回退显示最近 ${fallbackTickets.length} 条工单`,
+ },
+ },
+ { message: lastAiMessage },
+ );
+ statusList.push({
+ tool: name,
+ status: "fallback" as ToolExecStatus["status"],
+ message: "工单详情未找到,已回退到工单列表",
+ });
+ return {
+ role: "tool" as const,
+ tool_call_id: id,
+ content: JSON.stringify({
+ error: "未找到该工单",
+ fallback: "已自动查询最近工单列表",
+ tickets: fallbackTickets,
+ }),
+ };
+ }
+ } catch {
+ // fallback also failed, continue to show error
+ }
+ }
+
+ // Push error artifact card with suggestion
+ ui.push(
+ {
+ name: "error-result" as never,
+ props: {
+ tool: name,
+ message: friendlyError,
+ suggestion,
+ timestamp: Date.now(),
+ artifact_id: artifactId,
+ sort_key: sortKey,
+ } as never,
+ },
+ { message: lastAiMessage },
+ );
return {
role: "tool" as const,
tool_call_id: id,
content: JSON.stringify({
- error: formatToolError(name, e),
- fallback_hint: "工具执行失败。请用中文向用户解释错误原因,并提供替代建议。",
+ ok: false,
+ tool: name,
+ summary: friendlyError,
+ error: friendlyError,
+ fallback: suggestion === "retry" ? "可重试" : suggestion === "check_input" ? "请检查输入" : "请联系管理员",
}),
};
}
@@ -346,7 +818,7 @@ export async function toolExecutorNode(
// Push next-actions card based on successful tool results
const successfulTools = statusList.filter(
- (s) => s.status === "ok" || s.status === "empty",
+ (s) => s.status === "ok" || s.status === "empty" || s.status === "fallback_success",
);
if (successfulTools.length > 0) {
const actions = generateNextActions(successfulTools.map((s) => s.tool));
@@ -366,5 +838,6 @@ export async function toolExecutorNode(
ui: ui.items,
timestamp: Date.now(),
toolStatus: statusList,
+ execution_log: executionLog,
};
}
diff --git a/langgraph/src/agent/enterprise/tools/soc-client.ts b/langgraph/src/agent/enterprise/tools/soc-client.ts
index 9c47396..c0f8097 100644
--- a/langgraph/src/agent/enterprise/tools/soc-client.ts
+++ b/langgraph/src/agent/enterprise/tools/soc-client.ts
@@ -1,8 +1,12 @@
/**
* SOC Enterprise external service clients.
* Each function calls an external API directly (no Python intermediate layer).
+ *
+ * Env validation is handled centrally by @/agent/utils/config.
*/
+import { config } from "@/agent/utils/config";
+
// --- Knowledge Base Search ---
export async function kbSearch(query: string): Promise<{
results: Array<{
@@ -12,12 +16,12 @@ export async function kbSearch(query: string): Promise<{
score: number;
}>;
}> {
- const url = `${process.env.KB_AGENT_URL}${process.env.KB_AGENT_SEARCH_PATH ?? "/api/v1/search"}`;
+ const url = `${config.kb.url}${config.kb.searchPath}`;
const resp = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
- "api-key": process.env.KB_AGENT_API_KEY ?? "",
+ "api-key": config.kb.apiKey,
},
body: JSON.stringify({ query, top: 5, search_mode: "hybrid" }),
signal: AbortSignal.timeout(45000),
@@ -40,9 +44,9 @@ export async function ticketList(
createdAt: string;
}>;
}> {
- const url = `${process.env.GONGDAN_API_BASE}/api/tickets?page=${page}&pageSize=${pageSize}`;
+ const url = `${config.gongdan.apiBase}/api/tickets?page=${page}&pageSize=${pageSize}`;
const resp = await fetch(url, {
- headers: { "X-Api-Key": process.env.GONGDAN_API_KEY ?? "" },
+ headers: { "X-Api-Key": config.gongdan.apiKey },
signal: AbortSignal.timeout(15000),
});
if (!resp.ok) throw new Error(`Ticket list failed: ${resp.status}`);
@@ -53,32 +57,72 @@ export async function ticketList(
export async function ticketDetail(
ticketId: string,
): Promise> {
- const base = process.env.GONGDAN_API_BASE;
- const headers = { "X-Api-Key": process.env.GONGDAN_API_KEY ?? "" };
+ const base = config.gongdan.apiBase;
+ const headers = { "X-Api-Key": config.gongdan.apiKey };
- // If ticketId looks like a ticketNumber (e.g. "TK-2026-296691"), resolve UUID first
+ // If ticketId looks like a ticketNumber (e.g. "TK-2026-296691"), resolve UUID first.
+ // Strategy: try query-param filter first; if no match, fall back to full list + local find.
let resolvedId = ticketId;
if (ticketId.startsWith("TK-")) {
- const searchUrl = `${base}/api/tickets?ticketNumber=${encodeURIComponent(ticketId)}&pageSize=50`;
- const searchResp = await fetch(searchUrl, {
- headers,
- signal: AbortSignal.timeout(15000),
- });
- if (searchResp.ok) {
- const searchData = await searchResp.json();
- const tickets = searchData.tickets ?? [];
- const match = tickets.find((t: Record) => t.ticketNumber === ticketId);
- if (match?.id) {
- resolvedId = String(match.id);
+ // Attempt 1: filter via query param (try both "ticketNumber" and "search" keys)
+ for (const paramName of ["ticketNumber", "search"]) {
+ const searchUrl = `${base}/api/tickets?${paramName}=${encodeURIComponent(ticketId)}&pageSize=50`;
+ const searchResp = await fetch(searchUrl, {
+ headers,
+ signal: AbortSignal.timeout(15000),
+ });
+ if (searchResp.ok) {
+ const searchData = await searchResp.json();
+ // API may return tickets under "tickets", "data", or "items" key
+ const tickets: Record[] =
+ searchData.tickets ?? searchData.data ?? searchData.items ?? [];
+ const match = tickets.find((t) => t.ticketNumber === ticketId);
+ // API may use "id" or "_id" as the primary key
+ const matchId = match?.id ?? match?._id;
+ if (matchId) {
+ resolvedId = String(matchId);
+ break;
+ }
+ }
+ }
+
+ // Attempt 2: if still unresolved, do a plain list and find locally
+ if (resolvedId === ticketId) {
+ const listUrl = `${base}/api/tickets?pageSize=100`;
+ const listResp = await fetch(listUrl, {
+ headers,
+ signal: AbortSignal.timeout(15000),
+ });
+ if (listResp.ok) {
+ const listData = await listResp.json();
+ const allTickets: Record[] =
+ listData.tickets ?? listData.data ?? listData.items ?? [];
+ const match = allTickets.find((t) => t.ticketNumber === ticketId);
+ const matchId = match?.id ?? match?._id;
+ if (matchId) {
+ resolvedId = String(matchId);
+ }
}
}
}
- const url = `${base}/api/tickets/${resolvedId}`;
- const resp = await fetch(url, {
+ // Try detail endpoint first, fall back to base tickets endpoint
+ // Some APIs use /api/tickets/detail/{id}, others use /api/tickets/{id}
+ let url = `${base}/api/tickets/detail/${resolvedId}`;
+ let resp = await fetch(url, {
headers,
signal: AbortSignal.timeout(15000),
});
+
+ // If /detail/ returns 404, fall back to /api/tickets/{id}
+ if (resp.status === 404) {
+ url = `${base}/api/tickets/${resolvedId}`;
+ resp = await fetch(url, {
+ headers,
+ signal: AbortSignal.timeout(15000),
+ });
+ }
+
if (!resp.ok) throw new Error(`Ticket detail failed: ${resp.status}`);
return resp.json();
}
@@ -93,7 +137,7 @@ export async function webSearch(query: string): Promise<{
}>;
}> {
const headers: Record = {
- Authorization: `Bearer ${process.env.JINA_API_KEY}`,
+ Authorization: `Bearer ${config.jina.apiKey}`,
"Content-Type": "application/json",
Accept: "application/json",
};
@@ -152,7 +196,7 @@ export async function googleSearch(query: string): Promise<{
const resp = await fetch("https://google.serper.dev/search", {
method: "POST",
headers: {
- "X-API-KEY": process.env.SERPER_API_KEY ?? "",
+ "X-API-KEY": config.serper.apiKey,
"Content-Type": "application/json",
},
body: JSON.stringify({ q: query, num: 10, gl: "cn", hl: "zh-cn" }),
@@ -182,7 +226,7 @@ export async function googleSearch(query: string): Promise<{
export async function webRead(url: string): Promise<{ content: string; title: string }> {
const resp = await fetch(`https://r.jina.ai/${url}`, {
headers: {
- Authorization: `Bearer ${process.env.JINA_API_KEY}`,
+ Authorization: `Bearer ${config.jina.apiKey}`,
Accept: "application/json",
},
signal: AbortSignal.timeout(10000),
@@ -199,7 +243,7 @@ export async function jinaRerank(query: string, documents: string[], topN = 5):
const resp = await fetch("https://api.jina.ai/v1/rerank", {
method: "POST",
headers: {
- Authorization: `Bearer ${process.env.JINA_API_KEY}`,
+ Authorization: `Bearer ${config.jina.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
@@ -215,58 +259,119 @@ export async function jinaRerank(query: string, documents: string[], topN = 5):
}
// --- Daytona Sandbox Execution ---
+
+/** Maximum allowed code length (characters) */
+const SANDBOX_MAX_CODE_LENGTH = 10_000;
+/** Maximum output length (characters) before truncation */
+const SANDBOX_MAX_OUTPUT_LENGTH = 3_000;
+/** Default code execution timeout (ms) */
+const SANDBOX_DEFAULT_EXEC_TIMEOUT_MS = 15_000;
+
export async function sandboxRun(
code: string,
language = "python",
+ timeoutMs = SANDBOX_DEFAULT_EXEC_TIMEOUT_MS,
): Promise<{
exit_code: number;
stdout: string;
duration_ms: number;
}> {
- const apiUrl = process.env.DAYTONA_API_URL ?? "https://app.daytona.io/api";
- const apiKey = process.env.DAYTONA_API_KEY ?? "";
+ // --- Guard: code length ---
+ if (code.length > SANDBOX_MAX_CODE_LENGTH) {
+ throw new Error(`代码长度超过限制(${SANDBOX_MAX_CODE_LENGTH} 字符)`);
+ }
+
+ const apiUrl = config.daytona.apiUrl;
+ const apiKey = config.daytona.apiKey;
const headers: Record = {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
};
// Create sandbox (Daytona v1 API uses /sandbox, not /workspace)
- const createResp = await fetch(`${apiUrl}/sandbox`, {
- method: "POST",
- headers,
- body: JSON.stringify({ autoStopInterval: 5, autoDeleteInterval: 0 }),
- signal: AbortSignal.timeout(30000),
- });
- if (!createResp.ok)
- throw new Error(`Daytona create failed: ${createResp.status}`);
+ let createResp: Response;
+ try {
+ createResp = await fetch(`${apiUrl}/sandbox`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ autoStopInterval: 5, autoDeleteInterval: 0 }),
+ signal: AbortSignal.timeout(30000),
+ });
+ } catch (e) {
+ const isTimeout = e instanceof DOMException && e.name === "AbortError";
+ if (isTimeout) {
+ return { exit_code: 124, stdout: "沙盒创建超时(超过 30 秒)", duration_ms: 30000 };
+ }
+ // Network error (fetch failed / TypeError)
+ return { exit_code: -1, stdout: "网络错误,无法连接沙盒服务", duration_ms: 0 };
+ }
+ if (!createResp.ok) {
+ return {
+ exit_code: -1,
+ stdout: `沙盒创建失败(HTTP ${createResp.status}),请稍后重试`,
+ duration_ms: 0,
+ };
+ }
const sandbox: { id: string } = await createResp.json();
const sbId = sandbox.id;
const t0 = Date.now();
try {
// Execute code via Daytona Toolbox proxy (process/execute endpoint)
- const cmd =
- language === "python"
- ? `python3 -c '${code.replace(/'/g, "'\\''")}'`
- : language === "javascript"
- ? `node -e '${code.replace(/'/g, "'\\''")}'`
- : code;
- const execResp = await fetch(
- `https://proxy.app.daytona.io/toolbox/${sbId}/process/execute`,
- {
- method: "POST",
- headers,
- body: JSON.stringify({ command: cmd }),
- signal: AbortSignal.timeout(30000),
- },
- );
+ const SUPPORTED_LANGUAGES = ["python", "javascript", "bash"] as const;
+ type SupportedLang = typeof SUPPORTED_LANGUAGES[number];
+ if (!(SUPPORTED_LANGUAGES as readonly string[]).includes(language)) {
+ throw new Error(`Unsupported language: ${language}. Supported: ${SUPPORTED_LANGUAGES.join(", ")}`);
+ }
+ const escaped = code.replace(/'/g, "'\\''");
+ const cmd: Record = {
+ python: `python3 -c '${escaped}'`,
+ javascript: `node -e '${escaped}'`,
+ bash: `bash -c '${escaped}'`,
+ }[language as SupportedLang];
+
+ let execResp: Response;
+ try {
+ execResp = await fetch(
+ `https://proxy.app.daytona.io/toolbox/${sbId}/process/execute`,
+ {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ command: cmd }),
+ signal: AbortSignal.timeout(timeoutMs),
+ },
+ );
+ } catch (e) {
+ const duration_ms = Date.now() - t0;
+ const isTimeout = e instanceof DOMException && e.name === "AbortError";
+ if (isTimeout) {
+ return { exit_code: 124, stdout: `执行超时(超过 ${Math.round(timeoutMs / 1000)} 秒)`, duration_ms };
+ }
+ // Network error during execution
+ return { exit_code: -1, stdout: "网络错误,无法连接沙盒服务", duration_ms };
+ }
+
const execData: { exitCode?: number; result?: string } =
execResp.ok
? await execResp.json()
: { exitCode: 1, result: "Exec failed" };
+
+ const exitCode = execData.exitCode ?? 0;
+ let stdout = String(execData.result ?? "");
+
+ // Truncate output
+ if (stdout.length > SANDBOX_MAX_OUTPUT_LENGTH) {
+ stdout = stdout.slice(0, SANDBOX_MAX_OUTPUT_LENGTH) + "\n[输出已截断,超过 3000 字符]";
+ }
+
+ // Prefix failure info when exit code is non-zero
+ if (exitCode !== 0) {
+ stdout = `[执行失败,exit code: ${exitCode}]\n${stdout}`;
+ }
+
return {
- exit_code: execData.exitCode ?? 0,
- stdout: String(execData.result ?? "").slice(0, 2000),
+ exit_code: exitCode,
+ stdout,
duration_ms: Date.now() - t0,
};
} finally {
diff --git a/langgraph/src/agent/enterprise/types.ts b/langgraph/src/agent/enterprise/types.ts
index 5dc2109..ecc89ea 100644
--- a/langgraph/src/agent/enterprise/types.ts
+++ b/langgraph/src/agent/enterprise/types.ts
@@ -1,11 +1,59 @@
import { Annotation } from "@langchain/langgraph";
import { GenerativeUIAnnotation } from "../types.js";
+/**
+ * Canonical tool execution status.
+ * - success: tool completed and returned usable results
+ * - partial_success: tool completed without error but results are empty/incomplete
+ * - fallback_success: primary tool failed, fallback tool succeeded
+ * - error: tool failed completely
+ */
+export type ToolStatus =
+ | "success"
+ | "partial_success"
+ | "fallback_success"
+ | "error";
+
+/**
+ * A single entry in the execution log visible to the frontend via
+ * `values.execution_log`. Appended by tool-executor after each tool run.
+ */
+export type ExecutionLogEntry = {
+ tool: string;
+ status: ToolStatus;
+ timestamp: number;
+ durationMs: number;
+ inputSummary: string;
+ /** Number of result items returned (documents, tickets, charts, etc.) */
+ resultCount?: number;
+ /** Machine-readable error code for monitoring (e.g. "TIMEOUT", "NETWORK", "AUTH") */
+ errorCode?: string;
+ /** Human-readable error explanation (separated from summary which is for LLM) */
+ errorMessage?: string;
+ /** Natural-language summary for LLM consumption */
+ summary: string;
+};
+
+function executionLogReducer(
+ current: ExecutionLogEntry[],
+ update: ExecutionLogEntry | ExecutionLogEntry[],
+): ExecutionLogEntry[] {
+ const items = Array.isArray(update) ? update : [update];
+ return [...current, ...items];
+}
+
export const EnterpriseAnnotation = Annotation.Root({
messages: GenerativeUIAnnotation.spec.messages,
ui: GenerativeUIAnnotation.spec.ui,
timestamp: GenerativeUIAnnotation.spec.timestamp,
toolStatus: GenerativeUIAnnotation.spec.toolStatus,
+ execution_log: Annotation<
+ ExecutionLogEntry[],
+ ExecutionLogEntry | ExecutionLogEntry[]
+ >({
+ default: () => [],
+ reducer: executionLogReducer,
+ }),
});
export type EnterpriseState = typeof EnterpriseAnnotation.State;
diff --git a/langgraph/src/agent/searcher/nodes/tool-executor.ts b/langgraph/src/agent/searcher/nodes/tool-executor.ts
index 2acded7..f28b09c 100644
--- a/langgraph/src/agent/searcher/nodes/tool-executor.ts
+++ b/langgraph/src/agent/searcher/nodes/tool-executor.ts
@@ -243,11 +243,12 @@ export async function toolExecutorNode(
};
}
} catch (e) {
- statusList.push({ tool: name, status: "error", message: formatToolError(name, e) });
+ const errMsg = formatToolError(name, e);
+ statusList.push({ tool: name, status: "error", message: errMsg });
return {
role: "tool" as const,
tool_call_id: id,
- content: formatToolError(name, e),
+ content: JSON.stringify({ ok: false, tool: name, summary: errMsg, error: errMsg }),
};
}
});
diff --git a/langgraph/src/agent/supervisor/index.ts b/langgraph/src/agent/supervisor/index.ts
index 3800314..b557863 100644
--- a/langgraph/src/agent/supervisor/index.ts
+++ b/langgraph/src/agent/supervisor/index.ts
@@ -1,3 +1,4 @@
+import "@/agent/utils/config";
import { StateGraph, START, END } from "@langchain/langgraph";
import {
SupervisorAnnotation,
diff --git a/langgraph/src/agent/supervisor/nodes/router.ts b/langgraph/src/agent/supervisor/nodes/router.ts
index 39f18e6..3289d7a 100644
--- a/langgraph/src/agent/supervisor/nodes/router.ts
+++ b/langgraph/src/agent/supervisor/nodes/router.ts
@@ -5,6 +5,25 @@ import { formatMessages } from "@/agent/utils/format-messages";
import { createLlm } from "@/agent/utils/create-llm";
import { truncateMessages } from "@/agent/utils/truncate-messages";
+/** Rule-based pre-check for obvious intents — saves an LLM call */
+function preCheckRoute(text: string): "enterprise" | "generalInput" | null {
+ const t = text.trim();
+ const ENTERPRISE = [
+ /tk-\d{4,}/i,
+ /工单\s*(列表|详情|查询|状态)/,
+ /知识库\s*(搜索|查询|查找)/,
+ /内部知识|公司规范|内部系统/,
+ ];
+ const GENERAL = [
+ /^(你好|hi|hello|在吗|嗨|您好)[!!。.??]*$/i,
+ /^(谢谢|感谢|好的|明白|收到|ok|好)[!!。.??]*$/i,
+ /^你(是谁|能做什么|有什么功能|叫什么)[??]?$/,
+ ];
+ for (const re of ENTERPRISE) if (re.test(t)) return "enterprise";
+ for (const re of GENERAL) if (re.test(t)) return "generalInput";
+ return null;
+}
+
export async function router(
state: SupervisorState,
): Promise> {
@@ -70,8 +89,15 @@ ${ALL_TOOL_DESCRIPTIONS}
"你能做什么" → generalInput`;
const truncated = truncateMessages(state.messages);
+
+ // Fast-path: rule-based routing for obvious intents
+ const lastMsg = truncated.at(-1);
+ const lastText = typeof lastMsg?.content === "string" ? lastMsg.content : "";
+ const preChecked = preCheckRoute(lastText);
+ if (preChecked !== null) return { next: preChecked };
+
const allMessagesButLast = truncated.slice(0, -1);
- const lastMessage = truncated.at(-1);
+ const lastMessage = lastMsg;
const formattedPreviousMessages = formatMessages(allMessagesButLast);
const formattedLastMessage = lastMessage ? formatMessages([lastMessage]) : "";
diff --git a/langgraph/src/agent/supervisor/types.ts b/langgraph/src/agent/supervisor/types.ts
index 48a8299..0cec40f 100644
--- a/langgraph/src/agent/supervisor/types.ts
+++ b/langgraph/src/agent/supervisor/types.ts
@@ -11,51 +11,9 @@ export type SupervisorState = typeof SupervisorAnnotation.State;
export type SupervisorUpdate = typeof SupervisorAnnotation.Update;
export const SupervisorZodConfiguration = z.object({
- /**
- * The model ID to use for the reflection generation.
- * Should be in the format `provider/model_name`.
- * Defaults to `anthropic/claude-3-7-sonnet-latest`.
- */
- model: z
- .string()
- .optional()
- .langgraph.metadata({
- type: "select",
- default: "anthropic/claude-3-7-sonnet-latest",
- description: "The model to use in all generations",
- options: [
- {
- label: "Claude 3.7 Sonnet",
- value: "anthropic/claude-3-7-sonnet-latest",
- },
- {
- label: "Claude 3.5 Sonnet",
- value: "anthropic/claude-3-5-sonnet-latest",
- },
- {
- label: "GPT 4o",
- value: "openai/gpt-4o",
- },
- {
- label: "GPT 4.1",
- value: "openai/gpt-4.1",
- },
- {
- label: "o3",
- value: "openai/o3",
- },
- {
- label: "o3 mini",
- value: "openai/o3-mini",
- },
- {
- label: "o4",
- value: "openai/o4",
- },
- ],
- }),
/**
* Model mode preset: flash (fast), pro (detailed), auto (balanced).
+ * Controls which LLM is used in enterprise/coder/searcher/writer agents.
*/
modelMode: z
.enum(["flash", "pro", "auto"])
@@ -84,33 +42,21 @@ export const SupervisorZodConfiguration = z.object({
{ label: "Knowledge Base", value: "kb_search" },
{ label: "Ticket List", value: "ticket_list" },
{ label: "Ticket Detail", value: "ticket_detail" },
+ { label: "Chart Generate", value: "chart_generate" },
],
}),
/**
- * The temperature to use for the reflection generation.
- * Defaults to `0.7`.
+ * Task context for action-bar follow-up. When set, bypasses intent routing
+ * and routes directly to the relevant agent with card context injected.
*/
- temperature: z.number().optional().langgraph.metadata({
- type: "slider",
- default: 0.7,
- min: 0,
- max: 2,
- step: 0.1,
- description: "Controls randomness (0 = deterministic, 2 = creative)",
- }),
- /**
- * The maximum number of tokens to generate.
- * Defaults to `1000`.
- */
- maxTokens: z.number().optional().langgraph.metadata({
- type: "number",
- default: 1000,
- min: 1,
- description: "The maximum number of tokens to generate",
- }),
- systemPrompt: z.string().optional().langgraph.metadata({
- type: "textarea",
- placeholder: "Enter a system prompt...",
- description: "The system prompt to use in all generations",
- }),
+ taskContext: z
+ .object({
+ sourceCardId: z.string(),
+ taskType: z.string(),
+ })
+ .optional()
+ .langgraph.metadata({
+ type: "object",
+ description: "Task context from action-bar follow-up (sourceCardId + taskType)",
+ }),
});
diff --git a/langgraph/src/agent/types.ts b/langgraph/src/agent/types.ts
index 0a9281f..8f00cb5 100644
--- a/langgraph/src/agent/types.ts
+++ b/langgraph/src/agent/types.ts
@@ -11,7 +11,7 @@ import {
*/
export type ToolExecStatus = {
tool: string;
- status: "ok" | "empty" | "error" | "fallback";
+ status: "ok" | "empty" | "error" | "fallback" | "partial_success" | "fallback_success";
message?: string;
};
diff --git a/langgraph/src/agent/utils/checkpointer.ts b/langgraph/src/agent/utils/checkpointer.ts
index 96368e8..1cf8a16 100644
--- a/langgraph/src/agent/utils/checkpointer.ts
+++ b/langgraph/src/agent/utils/checkpointer.ts
@@ -1,16 +1,12 @@
import { PostgresSaver } from "@langchain/langgraph-checkpoint-postgres";
+import { config } from "@/agent/utils/config";
let _checkpointer: PostgresSaver | undefined;
export async function getCheckpointer(): Promise {
if (_checkpointer) return _checkpointer;
- const dbUrl = process.env.DATABASE_URL;
- if (!dbUrl) {
- throw new Error("DATABASE_URL is required for persistent checkpointing");
- }
-
- _checkpointer = PostgresSaver.fromConnString(dbUrl);
+ _checkpointer = PostgresSaver.fromConnString(config.database.url);
await _checkpointer.setup();
return _checkpointer;
}
diff --git a/langgraph/src/agent/utils/config.ts b/langgraph/src/agent/utils/config.ts
new file mode 100644
index 0000000..17bfbe0
--- /dev/null
+++ b/langgraph/src/agent/utils/config.ts
@@ -0,0 +1,86 @@
+/**
+ * Centralized config + startup env validation.
+ * Import this module early (e.g., from supervisor/index.ts) to surface
+ * missing env vars at startup rather than at first call.
+ */
+
+/** Required for ALL deployments — server will not function without these */
+const REQUIRED = [
+ "AZURE_OPENAI_API_KEY",
+ "AZURE_OPENAI_ENDPOINT",
+ "AZURE_OPENAI_API_VERSION",
+ "AZURE_OPENAI_DEPLOYMENT",
+ "GOOGLE_API_KEY",
+ "DATABASE_URL",
+] as const;
+
+/** Required only when the associated tool is called */
+const REQUIRED_BY_TOOL: Record = {
+ kb_search: ["KB_AGENT_URL", "KB_AGENT_API_KEY"],
+ ticket: ["GONGDAN_API_BASE", "GONGDAN_API_KEY"],
+ web_search: ["JINA_API_KEY"],
+ google_search: ["SERPER_API_KEY"],
+ sandbox: ["DAYTONA_API_KEY", "DAYTONA_API_URL"],
+};
+
+function validate() {
+ const missing: string[] = [];
+ for (const key of REQUIRED) {
+ if (!process.env[key]) missing.push(key);
+ }
+ if (missing.length > 0) {
+ // Throw on critical missing vars — server should not start
+ throw new Error(
+ `[config] Missing required env vars: ${missing.join(", ")}. ` +
+ "Check your .env file or Azure Web App application settings.",
+ );
+ }
+
+ // Warn for tool-specific vars (not fatal — some tools may be intentionally disabled)
+ for (const [tool, keys] of Object.entries(REQUIRED_BY_TOOL)) {
+ const missingToolKeys = keys.filter((k) => !process.env[k]);
+ if (missingToolKeys.length > 0) {
+ console.warn(
+ `[config] Tool "${tool}" may not work: missing ${missingToolKeys.join(", ")}`,
+ );
+ }
+ }
+}
+
+// Run at import time
+validate();
+
+/** Typed accessors — safe to use after validation */
+export const config = {
+ azureOpenAI: {
+ apiKey: process.env.AZURE_OPENAI_API_KEY!,
+ endpoint: process.env.AZURE_OPENAI_ENDPOINT!,
+ apiVersion: process.env.AZURE_OPENAI_API_VERSION!,
+ deployment: process.env.AZURE_OPENAI_DEPLOYMENT!,
+ },
+ google: {
+ apiKey: process.env.GOOGLE_API_KEY!,
+ },
+ database: {
+ url: process.env.DATABASE_URL!,
+ },
+ kb: {
+ url: process.env.KB_AGENT_URL ?? "",
+ apiKey: process.env.KB_AGENT_API_KEY ?? "",
+ searchPath: process.env.KB_AGENT_SEARCH_PATH ?? "/api/v1/search",
+ },
+ gongdan: {
+ apiBase: process.env.GONGDAN_API_BASE ?? "",
+ apiKey: process.env.GONGDAN_API_KEY ?? "",
+ },
+ jina: {
+ apiKey: process.env.JINA_API_KEY ?? "",
+ },
+ serper: {
+ apiKey: process.env.SERPER_API_KEY ?? "",
+ },
+ daytona: {
+ apiKey: process.env.DAYTONA_API_KEY ?? "",
+ apiUrl: process.env.DAYTONA_API_URL ?? "https://app.daytona.io/api",
+ },
+} as const;
diff --git a/langgraph/src/agent/utils/create-llm.ts b/langgraph/src/agent/utils/create-llm.ts
index cf882c4..cbb2387 100644
--- a/langgraph/src/agent/utils/create-llm.ts
+++ b/langgraph/src/agent/utils/create-llm.ts
@@ -1,4 +1,5 @@
import { AzureChatOpenAI } from "@langchain/openai";
+import { config } from "@/agent/utils/config";
export type ModelMode = "flash" | "pro" | "auto";
@@ -30,12 +31,10 @@ export function createLlm(options?: {
const maxTokens = options?.maxTokens ?? preset.maxTokens;
return new AzureChatOpenAI({
- azureOpenAIApiKey: process.env.AZURE_OPENAI_API_KEY,
- azureOpenAIEndpoint: process.env.AZURE_OPENAI_ENDPOINT,
- azureOpenAIApiDeploymentName:
- process.env.AZURE_OPENAI_DEPLOYMENT ?? "gpt-5.4",
- azureOpenAIApiVersion:
- process.env.AZURE_OPENAI_API_VERSION ?? "2025-04-01-preview",
+ azureOpenAIApiKey: config.azureOpenAI.apiKey,
+ azureOpenAIEndpoint: config.azureOpenAI.endpoint,
+ azureOpenAIApiDeploymentName: config.azureOpenAI.deployment,
+ azureOpenAIApiVersion: config.azureOpenAI.apiVersion,
temperature,
modelKwargs: { max_completion_tokens: maxTokens },
});
diff --git a/langgraph/src/agent/utils/file-service.ts b/langgraph/src/agent/utils/file-service.ts
index 6c1f206..7f1258a 100644
--- a/langgraph/src/agent/utils/file-service.ts
+++ b/langgraph/src/agent/utils/file-service.ts
@@ -1,7 +1,4 @@
-import {
- BlobServiceClient,
- StorageSharedKeyCredential,
-} from "@azure/storage-blob";
+import { BlobServiceClient } from "@azure/storage-blob";
import * as pdfParseModule from "pdf-parse";
const pdfParse = (pdfParseModule as any).default ?? pdfParseModule;
import * as XLSX from "xlsx";
diff --git a/langgraph/src/agent/utils/retry.ts b/langgraph/src/agent/utils/retry.ts
index 7392d10..6ade6a3 100644
--- a/langgraph/src/agent/utils/retry.ts
+++ b/langgraph/src/agent/utils/retry.ts
@@ -32,13 +32,15 @@ export async function executeWithRetry(
/**
* Classify error type from raw error for context-aware messaging.
*/
-function classifyError(error: unknown): "timeout" | "not_found" | "bad_request" | "generic" {
+function classifyError(error: unknown): "timeout" | "not_found" | "bad_request" | "auth" | "server" | "generic" {
const msg = error instanceof Error ? error.message : String(error);
if (msg.includes("TimeoutError") || msg.includes("abort") || msg.includes("timeout")) {
return "timeout";
}
+ if (msg.includes("401") || msg.includes("403")) return "auth";
if (msg.includes("404")) return "not_found";
if (msg.includes("400")) return "bad_request";
+ if (msg.includes("500") || msg.includes("502") || msg.includes("503")) return "server";
return "generic";
}
@@ -58,28 +60,39 @@ const TOOL_FALLBACK_HINTS: Record = {
const TOOL_ERROR_MAP: Record, string>> & { generic: string }> = {
kb_search: {
timeout: "知识库检索服务暂时响应较慢,请稍后再试",
+ auth: "知识库权限验证失败,请联系管理员",
+ server: "知识库服务暂时不可用",
generic: "知识库检索服务暂时不可用,请稍后再试",
},
ticket_list: {
timeout: "工单系统响应较慢,请稍后再试",
+ auth: "工单系统权限验证失败,请联系管理员",
+ server: "工单系统服务暂时不可用",
generic: "工单列表查询失败,请稍后再试",
},
ticket_detail: {
timeout: "工单系统响应较慢,请稍后再试",
not_found: "未找到该工单,请确认工单编号后重试",
+ auth: "工单系统权限验证失败,请联系管理员",
+ server: "工单系统服务暂时不可用",
generic: "工单详情查询失败,请检查工单编号后重试",
},
google_search: {
+ auth: "搜索服务权限验证失败,请联系管理员",
+ server: "搜索服务暂时不可用",
generic: "搜索服务暂时不可用,请稍后再试",
},
web_search_deep: {
+ server: "深度搜索服务暂时不可用",
generic: "深度搜索暂时不可用,已尝试自动重试",
},
web_read: {
+ not_found: "该网页不存在或已被删除",
generic: "网页读取失败,该页面可能无法访问或已被删除",
},
code_execute: {
bad_request: "代码执行环境暂不可用,请稍后再试",
+ server: "代码执行服务暂时不可用",
generic: "代码执行环境暂不可用,请稍后再试",
},
code_install: {
@@ -87,6 +100,7 @@ const TOOL_ERROR_MAP: Record {
- window.dispatchEvent(new CustomEvent("soc:prefill-input", { detail: { text } }));
+ window.dispatchEvent(
+ new CustomEvent("soc:prefill-input", {
+ detail: {
+ text,
+ taskType,
+ sourceCardId,
+ sourceLabel: cardTitle,
+ },
+ }),
+ );
};
return (
diff --git a/langgraph/src/components/CanvasPanel.tsx b/langgraph/src/components/CanvasPanel.tsx
index c7a873b..e992db0 100644
--- a/langgraph/src/components/CanvasPanel.tsx
+++ b/langgraph/src/components/CanvasPanel.tsx
@@ -5,7 +5,7 @@ import {
oneLight,
} from "react-syntax-highlighter/dist/esm/styles/prism";
import { useState } from "react";
-import { cn } from "@/lib/utils";
+
import MessageBubble from "@/components/MessageBubble.tsx";
export interface CanvasDoc {
diff --git a/langgraph/src/components/MessageBubble.tsx b/langgraph/src/components/MessageBubble.tsx
index 210b7ec..17b9e18 100644
--- a/langgraph/src/components/MessageBubble.tsx
+++ b/langgraph/src/components/MessageBubble.tsx
@@ -7,16 +7,68 @@ import {
oneDark,
oneLight,
} from "react-syntax-highlighter/dist/esm/styles/prism";
-import { useState } from "react";
+import { useState, type ReactNode } from "react";
import { Copy, Check } from "lucide-react";
import { cn } from "@/lib/utils";
import "katex/dist/katex.min.css";
+// Fixed source tag definitions — only these 4 exact patterns are replaced (XSS-safe, no arbitrary HTML)
+const SOURCE_TAGS: { pattern: string; label: string; className: string }[] = [
+ { pattern: "[知识库]", label: "知识库", className: "inline-flex items-center text-[10px] px-1 py-0.5 rounded font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 mx-0.5" },
+ { pattern: "[工单]", label: "工单", className: "inline-flex items-center text-[10px] px-1 py-0.5 rounded font-medium bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400 mx-0.5" },
+ { pattern: "[网络]", label: "网络", className: "inline-flex items-center text-[10px] px-1 py-0.5 rounded font-medium bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 mx-0.5" },
+ { pattern: "[推断]", label: "推断", className: "inline-flex items-center text-[10px] px-1 py-0.5 rounded font-medium bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-700 mx-0.5" },
+];
+
+// Splits a plain text string into React nodes, replacing:
+// 1. Fixed source tags: [知识库] [工单] [网络] [推断] → colored badge spans
+// 2. Citation numbers: [1] [2] [3] … → clickable blue chip buttons that dispatch soc:highlight-citation
+function renderWithSourceBadges(text: string): ReactNode[] {
+ // Combined regex: named source tags OR citation numbers [N]
+ const sourceEscaped = SOURCE_TAGS.map((t) => t.pattern.replace(/[[\]]/g, "\\$&")).join("|");
+ // Citation pattern: [digits] only — must be a pure number to avoid colliding with markdown links
+ const combined = new RegExp(`(${sourceEscaped}|\\[\\d+\\])`, "g");
+ const parts = text.split(combined);
+
+ return parts.map((part, i) => {
+ // Fixed source tag?
+ const tag = SOURCE_TAGS.find((t) => t.pattern === part);
+ if (tag) {
+ return {tag.label};
+ }
+ // Citation chip? Match [N] exactly
+ const citMatch = /^\[(\d+)\]$/.exec(part);
+ if (citMatch) {
+ const num = Number(citMatch[1]);
+ return (
+
+ );
+ }
+ return part;
+ });
+}
+
interface MessageBubbleProps {
content: string;
role: "human" | "ai";
}
+const CODE_COLLAPSE_THRESHOLD = 20;
+const CODE_PREVIEW_LINES = 5;
+
function CodeBlock({
language,
children,
@@ -25,6 +77,9 @@ function CodeBlock({
children: string;
}) {
const [copied, setCopied] = useState(false);
+ const lines = children.split("\n");
+ const isLongCode = lines.length > CODE_COLLAPSE_THRESHOLD;
+ const [codeExpanded, setCodeExpanded] = useState(!isLongCode);
const handleCopy = () => {
navigator.clipboard.writeText(children).then(() => {
@@ -38,6 +93,10 @@ function CodeBlock({
typeof document !== "undefined" &&
document.documentElement.classList.contains("dark");
+ const displayedCode = codeExpanded
+ ? children
+ : lines.slice(0, CODE_PREVIEW_LINES).join("\n");
+
return (
{/* Language label + copy button */}
@@ -69,8 +128,22 @@ function CodeBlock({
}}
PreTag="div"
>
- {children}
+ {displayedCode}
+ {isLongCode && (
+
+
+ {codeExpanded ? `共 ${lines.length} 行` : `已折叠,共 ${lines.length} 行`}
+
+
+
+ )}
);
}
@@ -82,7 +155,8 @@ function extractSummary(text: string): string {
export default function MessageBubble({ content }: MessageBubbleProps) {
const [summaryExpanded, setSummaryExpanded] = useState(false);
- const isLong = content.length > 800;
+ const contentLines = content.split("\n").length;
+ const isLong = contentLines > 20;
const summary = isLong ? extractSummary(content) : null;
return (
@@ -190,9 +264,20 @@ export default function MessageBubble({ content }: MessageBubbleProps) {
);
},
- // Paragraphs
+ // Paragraphs — inline source badges for [知识库] [工单] [网络] [推断]
p({ children }) {
- return {children}
;
+ const processedChildren = Array.isArray(children)
+ ? children.flatMap((child, idx) =>
+ typeof child === "string"
+ ? renderWithSourceBadges(child).map((node, ni) =>
+ typeof node === "string" ? node : {node}
+ )
+ : [child]
+ )
+ : typeof children === "string"
+ ? renderWithSourceBadges(children)
+ : children;
+ return {processedChildren}
;
},
// Links
diff --git a/langgraph/src/components/SourceBadge.tsx b/langgraph/src/components/SourceBadge.tsx
index 93e3cfd..72cd577 100644
--- a/langgraph/src/components/SourceBadge.tsx
+++ b/langgraph/src/components/SourceBadge.tsx
@@ -1,4 +1,3 @@
-import React from "react";
const SOURCE_CONFIG: Record = {
internal_kb: { label: "内部知识库", className: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400" },
diff --git a/langgraph/src/components/ThreadSidebar.tsx b/langgraph/src/components/ThreadSidebar.tsx
index fe665e0..e16fef9 100644
--- a/langgraph/src/components/ThreadSidebar.tsx
+++ b/langgraph/src/components/ThreadSidebar.tsx
@@ -1,5 +1,5 @@
import { Plus, MessageSquare, Trash2, Search } from "lucide-react";
-import { useState } from "react";
+import { useState, useRef } from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
@@ -17,13 +17,39 @@ type Props = {
onDeleteThread: (threadId: string) => void;
};
+// Get lastActive timestamp from localStorage, fallback to created_at
+function getLastActive(threadId: string, createdAt: string): number {
+ try {
+ const stored = localStorage.getItem(`lastActive_${threadId}`);
+ if (stored) return parseInt(stored, 10);
+ } catch {
+ // ignore
+ }
+ return new Date(createdAt).getTime();
+}
+
+// Get thread title from localStorage (saved by main.tsx on first message)
+function getLocalTitle(threadId: string): string | null {
+ try {
+ return localStorage.getItem(`title_${threadId}`);
+ } catch {
+ return null;
+ }
+}
+
function groupByDate(threads: ThreadItem[]): { label: string; threads: ThreadItem[] }[] {
const now = new Date();
const groups: Record = {};
- threads.forEach((t) => {
- const d = new Date(t.created_at);
- const diffDays = (now.getTime() - d.getTime()) / 86400000;
+ // Sort by lastActive descending before grouping
+ const sorted = [...threads].sort(
+ (a, b) =>
+ getLastActive(b.thread_id, b.created_at) - getLastActive(a.thread_id, a.created_at),
+ );
+
+ sorted.forEach((t) => {
+ const lastActive = getLastActive(t.thread_id, t.created_at);
+ const diffDays = (now.getTime() - lastActive) / 86400000;
let label: string;
if (diffDays < 1) label = "今天";
else if (diffDays < 2) label = "昨天";
@@ -39,6 +65,14 @@ function groupByDate(threads: ThreadItem[]): { label: string; threads: ThreadIte
.map((label) => ({ label, threads: groups[label] }));
}
+export function updateThreadLastActive(threadId: string) {
+ try {
+ localStorage.setItem(`lastActive_${threadId}`, String(Date.now()));
+ } catch {
+ // ignore
+ }
+}
+
function formatTime(iso: string) {
try {
const d = new Date(iso);
@@ -62,9 +96,12 @@ export function ThreadSidebar({
onDeleteThread,
}: Props) {
const [searchQuery, setSearchQuery] = useState("");
+ const [searchExpanded, setSearchExpanded] = useState(false);
+ const searchInputRef = useRef(null);
const filtered = threads.filter((t) => {
const label =
+ getLocalTitle(t.thread_id) ??
(t.metadata?.title as string) ??
(t.metadata?.firstMessage as string) ??
t.thread_id;
@@ -77,26 +114,61 @@ export function ThreadSidebar({
{/* New chat button */}
-
+
+
+ {/* Search toggle icon */}
+
+
- {/* Search input */}
-
-
-
setSearchQuery(e.target.value)}
- placeholder="搜索对话..."
- className="w-full text-xs border border-border rounded-md pl-8 pr-3 py-1.5 bg-background/50 text-foreground placeholder:text-muted-foreground outline-none focus:ring-1 focus:ring-ring"
- />
+ {/* Collapsible search input */}
+
@@ -118,9 +190,13 @@ export function ThreadSidebar({
{groupThreads.map((t) => {
const isActive = t.thread_id === currentThreadId;
const itemLabel =
+ getLocalTitle(t.thread_id) ??
(t.metadata?.title as string) ??
(t.metadata?.firstMessage as string) ??
- t.thread_id.slice(0, 8) + "…";
+ "新对话";
+ const displayLabel = itemLabel.length > 16
+ ? itemLabel.slice(0, 16) + "…"
+ : itemLabel;
return (
-
{itemLabel}
+
16 ? itemLabel : undefined}
+ >
+ {displayLabel}
+
{formatTime(t.created_at)}
@@ -143,7 +224,9 @@ export function ThreadSidebar({
className="opacity-0 group-hover:opacity-100 transition-opacity p-0.5 hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
- onDeleteThread(t.thread_id);
+ if (window.confirm("确定要删除这个对话吗?")) {
+ onDeleteThread(t.thread_id);
+ }
}}
title="删除对话"
>
diff --git a/langgraph/src/components/ToolCallStatus.tsx b/langgraph/src/components/ToolCallStatus.tsx
index 49c634c..f4fbc4d 100644
--- a/langgraph/src/components/ToolCallStatus.tsx
+++ b/langgraph/src/components/ToolCallStatus.tsx
@@ -1,5 +1,5 @@
-import { Loader2, CheckCircle2, ChevronRight, ChevronDown, XCircle } from "lucide-react";
-import { useState } from "react";
+import { Loader2, CheckCircle2, ChevronRight, ChevronDown, XCircle, ChevronsUpDown } from "lucide-react";
+import { useState, useCallback } from "react";
import { LoadExternalComponent } from "@langchain/langgraph-sdk/react-ui";
const TOOL_NAME_MAP: Record
= {
@@ -30,6 +30,7 @@ const TOOL_LOADING_MAP: Record = {
code_execute: "正在执行代码...",
code_install: "正在安装依赖...",
sandbox_run: "正在运行沙盒...",
+ chart_generate: "正在生成图表...",
doc_create: "正在创建文档...",
doc_edit: "正在编辑文档...",
doc_translate: "正在翻译文档...",
@@ -58,6 +59,17 @@ interface ToolCallStatusProps {
components?: Parameters[0]["components"];
}
+interface ToolCallRowProps {
+ tc: ToolCall;
+ isDone: boolean;
+ isFailed: boolean;
+ uiItem?: UIMsgLocal;
+ stream?: ToolCallStatusProps["stream"];
+ components?: ToolCallStatusProps["components"];
+ // When globalExpanded is not undefined, the parent controls expand state
+ globalExpanded?: boolean;
+}
+
function ToolCallRow({
tc,
isDone,
@@ -65,17 +77,18 @@ function ToolCallRow({
uiItem,
stream,
components,
-}: {
- tc: ToolCall;
- isDone: boolean;
- isFailed: boolean;
- uiItem?: UIMsgLocal;
- stream?: ToolCallStatusProps["stream"];
- components?: ToolCallStatusProps["components"];
-}) {
- const [expanded, setExpanded] = useState(false);
+ globalExpanded,
+}: ToolCallRowProps) {
+ // Local state used only when globalExpanded is undefined (single tool or no global toggle)
+ const [localExpanded, setLocalExpanded] = useState(isFailed);
+ const expanded = globalExpanded !== undefined ? globalExpanded : localExpanded;
+ const setExpanded = useCallback((val: boolean | ((v: boolean) => boolean)) => {
+ if (globalExpanded === undefined) {
+ setLocalExpanded(val);
+ }
+ }, [globalExpanded]);
const label = tc.name ? (TOOL_NAME_MAP[tc.name] ?? tc.name) : "工具调用";
- const canExpand = isDone && !!uiItem;
+ const canExpand = isDone && (!!uiItem || isFailed);
return (
@@ -103,7 +116,14 @@ function ToolCallRow({
)}
{isFailed ? `${label} · 失败` : isDone ? (canExpand ? `${label} · ${expanded ? "收起" : "查看结果"}` : `${label} · 已完成`) : (tc.name ? (TOOL_LOADING_MAP[tc.name] ?? `正在${label}...`) : "思考中...")}
- {expanded && uiItem && stream && components && (
+ {expanded && isFailed && (
+
+
+ {(uiItem?.props?.errorMessage as string) ?? "工具执行失败,请稍后重试。"}
+
+
+ )}
+ {expanded && !isFailed && uiItem && stream && components && (
)}
- {expanded && !uiItem && (
+ {expanded && !isFailed && !uiItem && (
@@ -137,6 +157,9 @@ export default function ToolCallStatus({
}: ToolCallStatusProps) {
if (!toolCalls.length) return null;
+ // Global expand/collapse state — only active when there are >= 2 tool calls
+ const [globalExpanded, setGlobalExpanded] = useState
(undefined);
+
const UI_NAME_MAP: Record = {
kb_search: "knowledge-result",
ticket_list: "ticket-summary",
@@ -150,8 +173,31 @@ export default function ToolCallStatus({
// calls of the same tool type pick different UI cards in order.
const matchCounters: Record = {};
+ const showGlobalToggle = toolCalls.length >= 2;
+
+ function handleGlobalToggle() {
+ setGlobalExpanded((prev) => {
+ // If currently collapsed (false) → expand all; otherwise → collapse all
+ return prev === false ? true : false;
+ });
+ }
+
return (
+ {/* Global collapse/expand button — only shown when >= 2 tool calls */}
+ {showGlobalToggle && (
+
+
+
+ )}
{toolCalls.map((tc, i) => {
const isDone = !isLoading || (tc.id && completedToolIds?.has(tc.id));
const isFailed = !!(tc.id && failedToolIds?.has(tc.id));
@@ -173,6 +219,7 @@ export default function ToolCallStatus({
uiItem={uiItem}
stream={stream}
components={components}
+ globalExpanded={showGlobalToggle ? globalExpanded : undefined}
/>
);
})}
diff --git a/langgraph/src/index.css b/langgraph/src/index.css
index 02db610..bc5c382 100644
--- a/langgraph/src/index.css
+++ b/langgraph/src/index.css
@@ -163,3 +163,36 @@
@keyframes blink {
50% { opacity: 0; }
}
+
+/* Card slide-in-from-bottom animation */
+@keyframes card-enter {
+ from {
+ opacity: 0;
+ transform: translateY(8px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.card-enter {
+ animation: card-enter 100ms ease-out both;
+}
+
+/* 3-dot loading bounce */
+.dot-bounce {
+ display: inline-block;
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: currentColor;
+ animation: dot-bounce 1.2s ease-in-out infinite;
+}
+.dot-bounce:nth-child(2) { animation-delay: 0.2s; }
+.dot-bounce:nth-child(3) { animation-delay: 0.4s; }
+
+@keyframes dot-bounce {
+ 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
+ 40% { transform: scale(1); opacity: 1; }
+}
diff --git a/langgraph/src/main.tsx b/langgraph/src/main.tsx
index 1db36d8..a9efd88 100644
--- a/langgraph/src/main.tsx
+++ b/langgraph/src/main.tsx
@@ -9,10 +9,10 @@ type UIMsgLocal = { id: string; type: string; name: string; props: Record
= new Set()): string {
+ const counts: Record = {};
+ for (const ui of uiItems) {
+ counts[ui.name] = (counts[ui.name] ?? 0) + 1;
+ }
+ const parts: string[] = [];
+ const failSuffix = (name: string) => failedToolNames.has(name) ? "(失败)" : "";
+ if (counts["knowledge-result"]) parts.push(`查了知识库${failSuffix("knowledge-result")}`);
+ if (counts["ticket-summary"] || counts["ticket-detail"]) {
+ const n = (counts["ticket-summary"] ?? 0) + (counts["ticket-detail"] ?? 0);
+ const failed = failedToolNames.has("ticket-summary") || failedToolNames.has("ticket-detail");
+ parts.push(`看了 ${n} 个工单${failed ? "(失败)" : ""}`);
+ }
+ if (counts["search-result"]) parts.push(`搜索了网络${failSuffix("search-result")}`);
+ if (counts["sandbox-result"]) parts.push(`执行了代码${failSuffix("sandbox-result")}`);
+ if (counts["chart-result"]) parts.push(`生成了图表${failSuffix("chart-result")}`);
+ if (counts["canvas-doc"]) parts.push(`生成了文档${failSuffix("canvas-doc")}`);
+ return parts.join(" · ");
+}
+
// ─── Tool groups ────────────────────────────────────────────────────────────
const TOOL_GROUPS = [
{ key: "knowledge", label: "知识库", icon: BookOpen, tools: ["kb_search"] },
@@ -101,6 +122,7 @@ function App() {
const textareaRef = useRef(null);
const scrollContainerRef = useRef(null);
const [showScrollBtn, setShowScrollBtn] = useState(false);
+ const isComposingRef = useRef(false);
// Tool & model state
const [activeTools, setActiveTools] = useState>(new Set());
@@ -117,6 +139,12 @@ function App() {
// File attachment state
const [attachedFile, setAttachedFile] = useState(null);
+ // Source label from card action buttons (e.g. "来自 知识库检索")
+ const [sourceLabel, setSourceLabel] = useState(null);
+
+ // Pending retry tool name — set by soc:retry-tool, consumed on next submit
+ const pendingRetryToolRef = useRef(null);
+
const thread = useStream<{ messages: Message[]; ui: UIMsgLocal[] }>({
apiUrl: LANGGRAPH_URL,
assistantId: "agent",
@@ -141,11 +169,35 @@ function App() {
return () => window.removeEventListener("open-canvas", handler);
}, []);
+ // Listen for retry-tool events from error-result cards
+ useEffect(() => {
+ const handler = (e: Event) => {
+ const ce = e as CustomEvent<{ toolName: string }>;
+ if (ce.detail.toolName) {
+ pendingRetryToolRef.current = ce.detail.toolName;
+ }
+ };
+ window.addEventListener("soc:retry-tool", handler);
+ return () => window.removeEventListener("soc:retry-tool", handler);
+ }, []);
+
// Listen for prefill-input events from ActionBar
useEffect(() => {
const handler = (e: Event) => {
- const ce = e as CustomEvent<{ text: string }>;
- setInput(ce.detail.text);
+ const ce = e as CustomEvent<{ text?: string; prefix?: string; sourceLabel?: string; taskType?: string; sourceCardId?: string }>;
+ if (ce.detail.prefix) {
+ // Prefix mode: prepend context label to current input
+ setInput((prev) => {
+ const base = prev.trim();
+ return base ? `${ce.detail.prefix}${base}` : ce.detail.prefix!;
+ });
+ } else if (ce.detail.text !== undefined) {
+ setInput(ce.detail.text);
+ }
+ // Show source label tag above textarea if provided
+ if (ce.detail.sourceLabel) {
+ setSourceLabel(ce.detail.sourceLabel);
+ }
setTimeout(() => textareaRef.current?.focus(), 50);
};
window.addEventListener("soc:prefill-input", handler);
@@ -162,6 +214,11 @@ function App() {
// ── Thread actions ──────────────────────────────────────────────────────
const handleNewThread = useCallback(async () => {
+ // Save draft for current thread before switching
+ if (currentThreadId) {
+ try { localStorage.setItem(`draft_${currentThreadId}`, input); } catch { /* ignore */ }
+ }
+ setInput("");
try {
const t = await client.threads.create();
setThreads((prev) => [t as ThreadItem, ...prev]);
@@ -171,12 +228,23 @@ function App() {
setCurrentThreadId(null);
}
setSidebarOpen(false);
- }, []);
+ }, [currentThreadId, input]);
const handleSelectThread = useCallback((threadId: string) => {
+ // Save draft for current thread
+ if (currentThreadId) {
+ try { localStorage.setItem(`draft_${currentThreadId}`, input); } catch { /* ignore */ }
+ }
+ // Restore draft for the new thread
+ try {
+ const saved = localStorage.getItem(`draft_${threadId}`) ?? "";
+ setInput(saved);
+ } catch {
+ setInput("");
+ }
setCurrentThreadId(threadId);
setSidebarOpen(false);
- }, []);
+ }, [currentThreadId, input]);
const handleDeleteThread = useCallback(async (threadId: string) => {
try {
@@ -217,6 +285,12 @@ function App() {
const text = input.trim();
if ((!text && !attachedFile) || thread.isLoading) return;
setInput("");
+ setSourceLabel(null);
+ // Clear draft and update lastActive for this thread
+ if (currentThreadId) {
+ try { localStorage.removeItem(`draft_${currentThreadId}`); } catch { /* ignore */ }
+ updateThreadLastActive(currentThreadId);
+ }
const enabledTools =
activeTools.size > 0
@@ -227,7 +301,8 @@ function App() {
setAttachedFile(null);
const IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
- let messageContent: unknown;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let messageContent: any;
if (file && IMAGE_TYPES.includes(file.mimeType)) {
messageContent = [
@@ -245,6 +320,10 @@ function App() {
// Auto-name thread on first message
const isFirstMessage = thread.messages.length === 0;
+ // Consume pending retry tool hint (set by soc:retry-tool event)
+ const retryTool = pendingRetryToolRef.current;
+ pendingRetryToolRef.current = null;
+
thread.submit(
{ messages: [{ type: "human", content: messageContent }] },
{
@@ -252,6 +331,7 @@ function App() {
configurable: {
enabledTools,
modelMode,
+ ...(retryTool ? { retryTool } : {}),
...(file ? { attachedFile: { name: file.name, mimeType: file.mimeType, base64: file.base64, size: file.size } } : {}),
},
},
@@ -259,12 +339,15 @@ function App() {
);
if (isFirstMessage && currentThreadId) {
- client.threads.update(currentThreadId, { metadata: { title: text.slice(0, 30) } }).catch(() => {});
+ const titleText = text.slice(0, 20);
+ client.threads.update(currentThreadId, { metadata: { title: titleText } }).catch(() => {});
+ // Persist title to localStorage for instant display
+ try { localStorage.setItem(`title_${currentThreadId}`, titleText); } catch { /* ignore */ }
// Optimistically update local thread title
setThreads((prev) =>
prev.map((t) =>
t.thread_id === currentThreadId
- ? { ...t, metadata: { ...t.metadata, title: text.slice(0, 30) } }
+ ? { ...t, metadata: { ...t.metadata, title: titleText } }
: t,
),
);
@@ -436,11 +519,17 @@ function App() {
{thread.messages.map((message, idx) => {
// Render UI cards attached to this message
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- const uiItems = deduplicateUiItems(
+ const uiItemsRaw = deduplicateUiItems(
((thread.values as any)?.ui ?? []).filter(
(ui: UIMsgLocal) => ui.metadata?.message_id === message.id,
) as UIMsgLocal[]
);
+ // Sort by sort_key so multi-tool results appear in deterministic order
+ const uiItems = [...uiItemsRaw].sort((a, b) => {
+ const sa = (a.props?.sort_key as number) ?? 0;
+ const sb = (b.props?.sort_key as number) ?? 0;
+ return sa - sb;
+ });
if (message.type === "human") {
const humanText = typeof message.content === "string"
@@ -511,6 +600,20 @@ function App() {
return (
+ {/* Text reply — rendered first so user sees conclusion before evidence */}
+ {textContent && (
+
+
+ {thread.isLoading && isLastAi && (
+
+ )}
+ {/* Copy button */}
+
+
+
+
+ )}
+
{/* Tool call status (with inline expand/collapse for UI cards) */}
{toolCalls.length > 0 && (
!toolCalls.some((tc) => {
+ {(() => {
+ const standaloneUiItems = uiItems.filter((ui) => !toolCalls.some((tc) => {
const nameMap: Record = {
kb_search: "knowledge-result",
ticket_list: "ticket-summary",
@@ -536,9 +639,10 @@ function App() {
sandbox_run: "sandbox-result",
};
return tc.name && ui.name === nameMap[tc.name];
- }))
- .map((ui: UIMsgLocal) => (
-
+ }));
+ if (standaloneUiItems.length === 0) return null;
+ const cards = standaloneUiItems.map((ui: UIMsgLocal) => (
+
- ))}
+ ));
+ if (standaloneUiItems.length >= 2) {
+ return (
+
+ 综合分析 · {standaloneUiItems.length} 项结果
+ {cards}
+
+ );
+ }
+ return <>{cards}>;
+ })()}
- {/* Text reply */}
- {textContent && (
-
-
- {thread.isLoading && isLastAi && (
-
+ {/* Execution summary */}
+ {uiItems.length > 0 && !thread.isLoading && (
+
+ {buildExecutionSummary(
+ uiItems,
+ new Set(
+ uiItems
+ .filter((ui) => ui.type === "error-result")
+ .map((ui) => ui.props?.tool as string)
+ .filter(Boolean),
+ ),
)}
- {/* Copy button */}
-
-
-
-
+
)}
{/* Regenerate button — only on last AI message, only when not loading */}
@@ -583,6 +698,19 @@ function App() {
return null;
})}
+ {/* Loading placeholder: show 3-dot bounce when waiting for first AI tokens after a human message */}
+ {(() => {
+ const lastMsg = thread.messages[thread.messages.length - 1];
+ const showLoadingDots = thread.isLoading && lastMsg?.type === "human";
+ return showLoadingDots ? (
+
+
+
+
+
+ ) : null;
+ })()}
+
{/* Streaming UI cards not yet attached to a completed message */}
{thread.isLoading &&
deduplicateUiItems(
@@ -594,7 +722,7 @@ function App() {
})
)
.map((ui: UIMsgLocal) => (
-
+
{/* Tool toggles */}
+ {/* Auto chip — active when no tools are selected */}
+
{TOOL_GROUPS.map(({ key, label, icon: Icon }) => {
const isOn = activeTools.has(key);
return (
@@ -633,6 +776,7 @@ function App() {
key={key}
type="button"
onClick={() => toggleTool(key)}
+ title={isOn ? `${label}:已启用` : `${label}:已禁用`}
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium transition-colors",
isOn
@@ -655,11 +799,17 @@ function App() {
type="button"
variant={modelMode === value ? "default" : "ghost"}
size="sm"
- className="h-7 px-2.5 text-xs gap-1"
+ className={cn(
+ "h-7 px-2.5 text-xs gap-1",
+ value === "auto" && modelMode !== value && "font-semibold text-primary",
+ )}
onClick={() => setModelMode(value)}
>
{label}
+ {value === "auto" && modelMode !== value && (
+ 推荐
+ )}
))}
@@ -671,6 +821,22 @@ function App() {
{attachedFile && (
setAttachedFile(null)} />
)}
+ {/* Source label: shown when user clicks a card action button */}
+ {sourceLabel && (
+
+
+ 来自 {sourceLabel}
+
+
+
+ )}
+ {/* Input hint: dynamically changes based on active tools */}
+
+ {activeTools.size === 0
+ ? "可分析工单、查知识库、搜索网络"
+ : activeTools.size === 1 && activeTools.has("knowledge")
+ ? "将在内部知识库中检索"
+ : activeTools.size === 1 && activeTools.has("tickets")
+ ? "将查询工单系统"
+ : activeTools.size === 1 && activeTools.has("search")
+ ? "将使用网络搜索"
+ : activeTools.size === 1 && activeTools.has("sandbox")
+ ? "将执行代码沙盒"
+ : `已启用 ${activeTools.size} 个工具`}
+
diff --git a/test.md b/test.md
index 6c5bc1c..7aaa8f5 100644
--- a/test.md
+++ b/test.md
@@ -1,178 +1,244 @@
-# SOC 端到端功能测试报告
+# SOC 8项优化 端到端验收测试报告
## 测试环境
-- **开发机**: 192.168.30.30
-- **后端**: http://localhost:2024 (LangGraph Server)
-- **前端**: http://localhost:5173 (Vite dev server)
-- **测试时间**: 2026-04-11 (第三轮 -- 产品验收测试)
-- **测试范围**: 全端点功能测试 + 产品验收标准检查
+- **后端**: http://192.168.30.30:2024
+- **前端**: http://192.168.30.30:5173
+- **代码版本**: commit 4ad169e (main)
+- **测试时间**: 2026-04-11
+
+## 基础端点测试
+
+| # | 端点 | 方法 | 状态码 | 响应 | 状态 |
+|---|------|------|--------|------|------|
+| 1 | /ok | GET | 200 | `{"ok":true}` | PASS |
+| 2 | /info | GET | 200 | `{"flags":{"assistants":true,"crons":false}}` | PASS |
+| 3 | /threads | POST | 200 | 返回 thread_id, status: idle | PASS |
+| 4 | 前端首页 | GET | 200 | 页面正常加载 | PASS |
---
-## 1. 端点测试结果
+## 第一轮:8项优化验收测试
-| # | 端点 / 功能 | 方法 | 状态 | 结果 |
-|---|------------|------|------|------|
-| 1 | `/ok` 健康检查 | GET | 200 | PASS - 返回 `{"ok":true}` |
-| 2 | `/info` 服务信息 | GET | 200 | PASS - 返回 `{"flags":{"assistants":true,"crons":false}}` |
-| 3 | 前端 `localhost:5173` | GET | 200 | PASS - 返回 Vite dev HTML |
-| 4 | `/threads` 创建 Thread | POST | 200 | PASS - 返回 thread_id |
-| 5 | Vite Proxy `/threads` | POST | 200 | PASS - 前端端口代理到后端 API |
-| 6 | Supervisor 路由 → generalInput | POST stream | 200 | PASS - "你好" 正确路由到通用对话,生成流畅回复 |
-| 7 | Supervisor 路由 → enterprise (kb_search) | POST stream | 200 | PARTIAL - 路由正确,触发 kb_search 工具,但 KB Agent 超时 |
-| 8 | Supervisor 路由 → enterprise (ticket_list) | POST stream | 200 | PASS - 返回 3 条工单 + ticket-summary Gen-UI 卡片 |
-| 9 | Supervisor 路由 → enterprise (ticket_detail) | POST stream | 200 | PARTIAL - 路由正确,参数解析正确,但 Gongdan API 返回 404 |
-| 10 | Supervisor 路由 → searcher (google_search) | POST stream | 200 | PASS - 返回丰富搜索结果 (8 条新闻),内容质量高 |
-| 11 | Supervisor 路由 → coder (sandbox_run) | POST stream | 200 | PARTIAL - 路由正确,LLM 生成代码正确,Daytona sandbox 执行失败 (400) |
-| 12 | Supervisor 路由 → writer (canvas-doc) | POST stream | 200 | PASS - 生成 canvas-doc Gen-UI 卡片,含完整 markdown 文档 (833字) |
-
-### 端点通过率: 9/12 通过 (75%), 3 个部分通过 (路由均正确,外部服务异常)
+| # | 测试项 | 状态 | 备注 |
+|---|--------|------|------|
+| 1 | P0 Daytona sandbox 修复 | PASS | code_execute 工具成功调用,exit_code=0,stdout="5050",耗时1462ms |
+| 2 | KB Agent retry 验证 | PASS | retry.ts 实现完整(3次重试,指数退避1s基准),已在 enterprise/tool-executor 中正确包裹 kb_search;本次测试 KB 正常响应无需重试 |
+| 3 | ticket_detail 修复 | PASS | TK-xxxx 格式工单 ID 可正常查询详情,返回完整工单数据 |
+| 4 | sourceType 字段 | PASS | 所有 ui items 均包含 sourceType 和 confidence 字段:sandbox-result(code_execution/high), ticket-summary(ticket_system/high), ticket-detail(ticket_system/high), canvas-doc(generated_doc/high), knowledge-result(internal_kb), search-result(external_web) |
+| 5 | 自动工具路由准确性 | PASS (5/5) | "写报告"->writer, "网络安全新闻"->searcher, "Python排序算法"->coder, "公司网络安全规范"->enterprise(kb_search), "你好介绍自己"->generalInput |
+| 6 | 企业风格输出 | PASS | 回复包含 [工单数据] 来源标注,结论/依据/建议三层结构完整,有风险提示和下一步建议 |
+| 7 | 会话恢复 | PASS | 新 thread 发送工具触发消息后,GET /threads/{id}/state 返回 values.ui 数组包含 ticket-summary 卡片(含 sourceType/confidence),UI 状态正确持久化到 checkpoint |
---
-## 2. 产品验收测试结果
+## 第二轮:迭代修复验收测试
-### 一、已实现功能 (回归测试) -- 代码审计
-
-| # | 功能 | 状态 | 验证方式 | 备注 |
-|---|------|------|---------|------|
-| 1 | 停止生成 | PASS | 代码审计 | main.tsx:652-660 `thread.stop()` + Square 图标,isLoading 时显示红色"停止"按钮,否则显示"发送" |
-| 2 | 重新生成 | PASS | 代码审计 | main.tsx:247-266 `handleRegenerate()` 取最后一条 human 消息重新 submit,仅最后一条 AI 消息显示 |
-| 3 | 复制消息 | PASS | 代码审计 | main.tsx:56-79 `CopyButton` 组件,hover AI 消息时通过 `group-hover:opacity-100` 显示,使用 `navigator.clipboard` |
-| 4 | 代码块 | PASS | 代码审计 | MessageBubble.tsx:20-76 `CodeBlock` 组件,Prism 语法高亮 + 语言标签 + 复制按钮,dark/light 跟随主题 (oneDark/oneLight) |
-| 5 | 文件上传 | PASS | 代码审计 | 三种方式均实现:FileUploadButton (main.tsx:633)、拖拽 (onDrop main.tsx:273)、Ctrl+V 粘贴 (onPaste main.tsx:287) |
-| 6 | 主题切换 | PASS | 代码审计 | ThemeProvider + ThemeToggle 组件,代码块高亮跟随 (MessageBubble.tsx:37-39 检测 `.dark` class) |
-| 7 | 移动端 | PASS | 代码审计 | main.tsx:325-330 `md:hidden`/`md:flex` 响应式,hamburger Menu 按钮 (main.tsx:348-354),点击切换 sidebarOpen |
-| 8 | 对话管理 | PASS | 代码审计 + API | 新建 (handleNewThread)、切换 (handleSelectThread)、删除 (handleDeleteThread)、搜索 (ThreadSidebar 搜索框) |
-| 9 | 模型切换 | PASS | 代码审计 | main.tsx:38-42 Flash/Auto/Pro 三模式,609-623 模型选择器 UI |
-| 10 | 工具开关 | PASS | 代码审计 | main.tsx:28-33 四组工具独立 toggle,586-606 工具开关 UI (pill 形态,选中变 primary 色) |
-
-**已实现功能回归: 10/10 通过**
-
-### 二、新增功能验证
-
-| # | 功能 | 状态 | 验证方式 | 备注 |
-|---|------|------|---------|------|
-| 1 | Textarea 多行输入 | PASS | 代码审计 | main.tsx:634-650 `