feat: enterprise product sprint — log protocol, tool rules, UX polish
Deploy LangGraph Server to Azure Web App / build-and-deploy (push) Failing after 13s
Deploy LangGraph UI to Azure Static Web Apps / build-and-deploy (push) Failing after 49s

Backend:
- config.ts: unified startup env validation (throw on missing critical vars)
- tool-executor: structured logToolCall() JSON, ToolStatus enum (success/partial_success/fallback_success/error), preValidateToolCall() hardcoded guards for ticket_detail/chart_generate, durationMs/inputSummary/resultCount on all execution log entries
- agent.ts: tool selection decision tree, chart-as-default-path prompt rules, source labels [知识库][工单][网络][推断]
- tool-defs.ts: applicable/not-applicable guidance on all 4 tools
- soc-client.ts: sandbox hardening (10k char limit, 15s timeout, output truncation, error classification), config.* accessors
- router.ts: preCheckRoute() rules — TK-xxx/工单/知识库 → enterprise direct; greetings → generalInput
- supervisor/types.ts: removed dead config fields (model/temperature/maxTokens/systemPrompt)
- Remove chat-agent (legacy entry point)

Frontend:
- MessageBubble: source badge rendering [知识库][工单][网络][推断], CitationChip [1][2] → clickable chips
- ThreadSidebar: auto-title from first message, collapsible search, long title truncation
- ToolCallStatus: tool-specific loading labels, collapse-all toggle for multi-tool
- main.tsx: conclusion-first layout (AI text above artifacts), draft persistence, IME fix, auto chip, multi-tool AnalysisBlock container, sort_key ordering, retry via soc:retry-tool
- chart-result: empty guard, chart/table toggle, multi-chart format support
- ticket-summary/knowledge-result: work-card quick actions (prefill with context)
- ActionBar: structured payload {text, taskType, sourceCardId}, source label UI
- index.css: card-enter slide animation, dot-bounce loading

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gongzhiyong
2026-04-12 13:01:02 +08:00
co-authored by Claude Sonnet 4.6
parent 183794dc42
commit 9c96913b4d
40 changed files with 2379 additions and 763 deletions
+1 -2
View File
@@ -1,8 +1,7 @@
{
"node_version": "20",
"graphs": {
"agent": "./src/agent/supervisor/index.ts:graph",
"chat": "./src/agent/chat-agent/index.ts:agent"
"agent": "./src/agent/supervisor/index.ts:graph"
},
"ui": {
"agent": "./src/agent-uis/index.tsx"
@@ -7,6 +7,13 @@ interface CanvasDocProps {
content: string;
type: "markdown" | "code";
language?: string;
sourceType?: string;
confidence?: "high" | "medium" | "low";
sort_key?: number;
artifact_id?: string;
execution_summary?: string;
report_type?: string;
source?: string;
}
/**
@@ -15,16 +15,35 @@ import {
Tooltip,
Legend,
} from "recharts";
import { BarChart2 } from "lucide-react";
import { BarChart2, Table2 } from "lucide-react";
import { useState } from "react";
interface SingleChart {
chart_type?: "bar" | "line" | "pie" | "area";
title?: string;
data: Array<{ name?: string; label?: string; value?: number; [key: string]: unknown }>;
x_key?: string;
y_keys?: string[];
}
interface ChartResultProps {
title: string;
chart_type: "bar" | "line" | "pie" | "area";
data: Array<{ label: string; value: number; [key: string]: unknown }>;
x_key: string;
y_keys: string[];
title?: string;
// single-chart format (legacy)
chart_type?: "bar" | "line" | "pie" | "area";
data?: Array<{ label?: string; name?: string; value?: number; [key: string]: unknown }>;
x_key?: string;
y_keys?: string[];
colors?: string[];
unit?: string;
// multi-chart format (backend-agent new format)
charts?: SingleChart[];
// common metadata
sourceType?: string;
confidence?: "high" | "medium" | "low";
artifact_id?: string;
sort_key?: number;
source?: string;
execution_summary?: string;
}
// Default color palette — uses CSS variables to respect dark/light theme
@@ -41,18 +60,28 @@ function makeTooltipFormatter(unit?: string) {
unit ? [`${value} ${unit}`, ""] : [String(value), ""];
}
export default function ChartResult({
title,
chart_type,
data,
x_key,
y_keys,
colors,
unit,
}: ChartResultProps) {
export default function ChartResult(props: ChartResultProps) {
const { colors, unit } = props;
const [viewMode, setViewMode] = useState<"chart" | "table">("chart");
const [chartIndex] = useState(0);
const palette = colors?.length ? colors : DEFAULT_COLORS;
const tooltipFormatter = makeTooltipFormatter(unit);
// Normalize to array of charts
const allCharts: SingleChart[] = props.charts
? props.charts
: [{ chart_type: props.chart_type ?? "bar", title: props.title, data: props.data ?? [], x_key: props.x_key ?? "label", y_keys: props.y_keys ?? ["value"] }];
const activeChart = allCharts[Math.min(chartIndex, allCharts.length - 1)];
const title = activeChart.title ?? props.title ?? "图表";
const chart_type = activeChart.chart_type ?? "bar";
const data = activeChart.data ?? [];
const x_key = activeChart.x_key ?? "name";
const y_keys = activeChart.y_keys ?? ["value"];
// Empty data guard
const hasData = Array.isArray(data) && data.length > 0;
const commonProps = {
data,
margin: { top: 4, right: 16, bottom: 4, left: 0 },
@@ -167,14 +196,85 @@ export default function ChartResult({
{/* Header */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-border">
<BarChart2 className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="font-medium text-sm text-foreground">{title}</span>
<span className="font-medium text-sm text-foreground flex-1">{title}</span>
{/* View toggle */}
{hasData && (
<div className="flex items-center gap-0.5 rounded-md border border-border overflow-hidden">
<button
type="button"
onClick={() => setViewMode("chart")}
className={`flex items-center gap-1 px-2 py-1 text-xs transition-colors ${
viewMode === "chart"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
title="图表视图"
>
<BarChart2 className="w-3 h-3" />
图表
</button>
<button
type="button"
onClick={() => setViewMode("table")}
className={`flex items-center gap-1 px-2 py-1 text-xs transition-colors ${
viewMode === "table"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
title="表格视图"
>
<Table2 className="w-3 h-3" />
表格
</button>
</div>
)}
</div>
{/* Chart area */}
{/* Content area */}
<div className="px-4 py-4">
<ResponsiveContainer width="100%" height={240}>
{renderChart()}
</ResponsiveContainer>
{!hasData ? (
<div className="flex items-center justify-center h-32 text-sm text-muted-foreground">
暂无数据
</div>
) : viewMode === "chart" ? (
<ResponsiveContainer width="100%" height={240}>
{renderChart()}
</ResponsiveContainer>
) : (
<div className="overflow-x-auto">
<table className="min-w-full text-xs border-collapse border border-border">
<thead>
<tr>
<th className="border border-border px-3 py-1.5 bg-muted text-left font-medium">
{x_key}
</th>
{y_keys.map((k) => (
<th
key={k}
className="border border-border px-3 py-1.5 bg-muted text-left font-medium"
>
{k}{unit ? ` (${unit})` : ""}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, i) => (
<tr key={i} className={i % 2 === 0 ? "" : "bg-muted/30"}>
<td className="border border-border px-3 py-1.5">
{String(row[x_key] ?? row.label ?? "")}
</td>
{y_keys.map((k) => (
<td key={k} className="border border-border px-3 py-1.5">
{String(row[k] ?? "")}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
@@ -0,0 +1,125 @@
import { AlertTriangle, RefreshCw, ShieldAlert, Edit3, MessageSquare } from "lucide-react";
interface ErrorResultProps {
tool: string;
message: string;
suggestion?: "retry" | "contact_admin" | "check_input";
timestamp?: number;
artifact_id?: string;
sort_key?: number;
}
const TOOL_LABELS: Record<string, string> = {
kb_search: "知识库检索",
ticket_list: "工单列表",
ticket_detail: "工单详情",
google_search: "网络搜索",
web_search_deep: "深度搜索",
sandbox_run: "代码沙盒",
chart_generate: "图表生成",
};
/** Possible reasons per suggestion type */
const REASON_MAP: Record<string, string[]> = {
retry: ["服务暂时繁忙", "网络连接不稳定"],
contact_admin: ["账号权限不足", "服务配置需要更新"],
check_input: ["输入的编号可能有误", "该记录可能已被删除或归档"],
};
/** Action buttons per suggestion type */
const ACTION_MAP: Record<
string,
Array<{ label: string; prompt: string; icon: typeof RefreshCw }>
> = {
retry: [
{ label: "重试", prompt: "请重新执行上一个操作", icon: RefreshCw },
{ label: "换个方式提问", prompt: "", icon: MessageSquare },
],
contact_admin: [
{ label: "联系支持", prompt: "如何联系系统管理员?", icon: ShieldAlert },
{ label: "换个方式提问", prompt: "", icon: MessageSquare },
],
check_input: [
{ label: "重试", prompt: "请重新执行上一个操作", icon: RefreshCw },
{ label: "换个方式提问", prompt: "", icon: Edit3 },
],
};
function dispatchPrefill(text: string) {
if (!text) return;
window.dispatchEvent(
new CustomEvent("soc:prefill-input", { detail: { text } }),
);
}
function dispatchRetryTool(toolName: string, prompt: string) {
// Prefill input with retry prompt
dispatchPrefill(prompt);
// Also signal a targeted retry so the host can pass retryTool in configurable
window.dispatchEvent(
new CustomEvent("soc:retry-tool", { detail: { toolName } }),
);
}
export default function ErrorResult({
tool,
message,
suggestion,
}: ErrorResultProps) {
const label = TOOL_LABELS[tool] ?? tool;
const reasons = suggestion ? REASON_MAP[suggestion] ?? [] : [];
const actions = suggestion ? ACTION_MAP[suggestion] ?? [] : [];
return (
<div className="w-full max-w-2xl rounded-xl border border-red-200 dark:border-red-900/40 bg-red-50 dark:bg-red-950/20 text-card-foreground shadow-sm overflow-hidden">
{/* Part 1: Error header + friendly description */}
<div className="flex items-center gap-2 px-4 py-3">
<AlertTriangle className="w-4 h-4 text-red-500 shrink-0" />
<span className="text-sm font-medium text-red-700 dark:text-red-400">
{label} - 执行异常
</span>
</div>
<div className="px-4 pb-2">
<p className="text-sm text-red-600 dark:text-red-300">{message}</p>
</div>
{/* Part 2: Possible reasons */}
{reasons.length > 0 && (
<div className="px-4 pb-2">
<p className="text-xs text-red-500/80 dark:text-red-400/70 mb-1">
可能原因:
</p>
<ul className="list-disc list-inside text-xs text-red-500/80 dark:text-red-400/70 space-y-0.5">
{reasons.map((r) => (
<li key={r}>{r}</li>
))}
</ul>
</div>
)}
{/* Part 3: Action buttons */}
{actions.length > 0 && (
<div className="flex items-center gap-2 px-4 py-2.5 border-t border-red-200/60 dark:border-red-900/30">
{actions.map((a) => (
<button
key={a.label}
type="button"
onClick={() => {
// "重试" buttons dispatch soc:retry-tool in addition to prefill
if (a.label === "重试") {
dispatchRetryTool(tool, a.prompt);
} else {
dispatchPrefill(a.prompt);
}
}}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs rounded-md border border-red-300 dark:border-red-800 text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors"
>
<a.icon className="w-3 h-3" />
{a.label}
</button>
))}
</div>
)}
</div>
);
}
@@ -1,4 +1,6 @@
import { BookOpen, AlertCircle } from "lucide-react";
import { useState, useEffect } from "react";
import { cn } from "@/lib/utils";
import { ActionBar } from "@/components/ActionBar";
import { SourceBadge } from "@/components/SourceBadge";
@@ -12,6 +14,10 @@ interface KnowledgeResultProps {
confidence?: "high" | "medium" | "low";
errorMessage?: string;
citations?: Citation[];
artifact_id?: string;
sort_key?: number;
source?: string;
execution_summary?: string;
}
export default function KnowledgeResult({
@@ -23,6 +29,19 @@ export default function KnowledgeResult({
errorMessage,
citations,
}: KnowledgeResultProps) {
const [highlightedIndex, setHighlightedIndex] = useState<number | null>(null);
useEffect(() => {
const handler = (e: Event) => {
const ce = e as CustomEvent<{ index: number }>;
setHighlightedIndex(ce.detail.index);
// Auto-clear highlight after 3 s
setTimeout(() => setHighlightedIndex(null), 3000);
};
window.addEventListener("soc:highlight-citation", handler);
return () => window.removeEventListener("soc:highlight-citation", handler);
}, []);
return (
<div className={`w-full max-w-2xl rounded-xl border ${errorMessage && results.length === 0 ? "border-red-200 dark:border-red-900/40" : "border-border"} bg-card text-card-foreground shadow-sm overflow-hidden`}>
{/* Header */}
@@ -77,8 +96,14 @@ export default function KnowledgeResult({
{citations && citations.length > 0 && (
<div className="px-4 py-2 border-t border-border/40 space-y-1">
{citations.map(c => (
<div key={c.index} className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono text-[10px] bg-muted px-1 rounded">[{c.index}]</span>
<div
key={c.index}
className={cn(
"flex items-center gap-2 text-xs text-muted-foreground rounded px-1 py-0.5 transition-colors duration-300",
highlightedIndex === c.index && "bg-blue-50 dark:bg-blue-900/30 ring-1 ring-blue-300 dark:ring-blue-700",
)}
>
<span className="font-mono text-[10px] bg-muted px-1 rounded shrink-0">[{c.index}]</span>
{c.url ? <a href={c.url} target="_blank" rel="noopener noreferrer" className="hover:text-primary hover:underline truncate">{c.title}</a>
: <span className="truncate">{c.title}</span>}
{c.source && <span className="shrink-0 text-[10px] opacity-60">{c.source}</span>}
@@ -86,11 +111,30 @@ export default function KnowledgeResult({
))}
</div>
)}
<div className="px-4 pb-3">
<div className="px-4 pb-3 flex flex-col gap-1.5">
{/* Dedicated quick actions for this card */}
<div className="flex flex-wrap gap-1.5 mt-3 pt-2 border-t border-border/40">
<button
type="button"
onClick={() => window.dispatchEvent(new CustomEvent("soc:prefill-input", { detail: { text: "基于以上知识库内容,整理成操作指南", sourceLabel: "知识库检索", taskType: "knowledge" } }))}
className="inline-flex items-center px-2.5 py-1 text-xs rounded-full border border-border hover:bg-primary/10 hover:border-primary/40 transition-colors text-muted-foreground hover:text-primary"
>
生成操作指南
</button>
<button
type="button"
onClick={() => window.dispatchEvent(new CustomEvent("soc:prefill-input", { detail: { text: "关于以上知识库内容,我想了解更多: ", sourceLabel: "知识库检索", taskType: "knowledge" } }))}
className="inline-flex items-center px-2.5 py-1 text-xs rounded-full border border-border hover:bg-primary/10 hover:border-primary/40 transition-colors text-muted-foreground hover:text-primary"
>
追问细节
</button>
</div>
<ActionBar
sourceType={sourceType || "internal_kb"}
context={query}
suggestedActions={["生成知识摘要", "继续深入搜索", "导出到文档"]}
cardTitle="知识库检索"
taskType="knowledge"
/>
</div>
</div>
@@ -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) => {
@@ -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 {
@@ -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({
))
)}
</ul>
<div className="px-4 pb-3">
<div className="px-4 pb-3 flex flex-col gap-1.5">
{/* Dedicated quick actions for this card */}
<div className="flex flex-wrap gap-1.5 mt-3 pt-2 border-t border-border/40">
<button
type="button"
onClick={() => window.dispatchEvent(new CustomEvent("soc:prefill-input", { detail: { text: "基于以上工单,帮我草拟一份处理方案", sourceLabel: "工单列表", taskType: "tickets" } }))}
className="inline-flex items-center px-2.5 py-1 text-xs rounded-full border border-border hover:bg-primary/10 hover:border-primary/40 transition-colors text-muted-foreground hover:text-primary"
>
草拟处理方案
</button>
<button
type="button"
onClick={() => window.dispatchEvent(new CustomEvent("soc:prefill-input", { detail: { text: "将以上工单汇总生成一份工单分析报告", sourceLabel: "工单列表", taskType: "tickets" } }))}
className="inline-flex items-center px-2.5 py-1 text-xs rounded-full border border-border hover:bg-primary/10 hover:border-primary/40 transition-colors text-muted-foreground hover:text-primary"
>
生成工单报告
</button>
</div>
<ActionBar
sourceType={sourceType || "ticket_system"}
context={`共 ${total} 条工单`}
suggestedActions={["查看工单详情", "生成处理建议", "生成跟进话术"]}
cardTitle="工单列表"
taskType="tickets"
/>
</div>
</div>
+2
View File
@@ -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;
-29
View File
@@ -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";
@@ -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,
+51 -14
View File
@@ -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,
@@ -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];
@@ -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<string, string> = {
@@ -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<string, unknown>;
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<string, unknown>).props as
| Record<string, unknown>
| 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<T>(
fn: (signal: AbortSignal) => Promise<T>,
timeoutMs: number,
): Promise<T> {
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<string, unknown>,
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<string, unknown>, 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<ReturnType<typeof kbSearch>> | 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<string, number> = {};
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<string, unknown> = { 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<string, number> = {};
@@ -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<string, number> = {};
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,
};
}
@@ -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<Record<string, unknown>> {
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<string, unknown>) => 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<string, unknown>[] =
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<string, unknown>[] =
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<string, string> = {
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<string, string> = {
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<SupportedLang, string> = {
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 {
+48
View File
@@ -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;
@@ -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 }),
};
}
});
+1
View File
@@ -1,3 +1,4 @@
import "@/agent/utils/config";
import { StateGraph, START, END } from "@langchain/langgraph";
import {
SupervisorAnnotation,
+27 -1
View File
@@ -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<Partial<SupervisorUpdate>> {
@@ -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]) : "";
+14 -68
View File
@@ -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)",
}),
});
+1 -1
View File
@@ -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;
};
+2 -6
View File
@@ -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<PostgresSaver> {
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;
}
+86
View File
@@ -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<string, readonly string[]> = {
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;
+5 -6
View File
@@ -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 },
});
+1 -4
View File
@@ -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";
+15 -1
View File
@@ -32,13 +32,15 @@ export async function executeWithRetry<T>(
/**
* 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<string, string> = {
const TOOL_ERROR_MAP: Record<string, Partial<Record<ReturnType<typeof classifyError>, 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<string, Partial<Record<ReturnType<typeof classifyEr
},
sandbox_run: {
bad_request: "沙盒执行环境暂不可用,请稍后再试",
server: "沙盒服务暂时不可用",
generic: "沙盒执行环境暂不可用,请稍后再试",
},
doc_create: {
+23 -3
View File
@@ -1,14 +1,34 @@
import React from "react";
interface ActionBarProps {
sourceType?: string;
context?: string;
suggestedActions?: string[];
/** Human-readable card title shown as source label in the input bar */
cardTitle?: string;
/** Machine-readable task type forwarded in the event payload */
taskType?: string;
/** Stable card identifier forwarded in the event payload */
sourceCardId?: string;
}
export function ActionBar({ context = "", suggestedActions = [] }: ActionBarProps) {
export function ActionBar({
context = "",
suggestedActions = [],
cardTitle,
taskType,
sourceCardId,
}: ActionBarProps) {
const dispatch = (text: string) => {
window.dispatchEvent(new CustomEvent("soc:prefill-input", { detail: { text } }));
window.dispatchEvent(
new CustomEvent("soc:prefill-input", {
detail: {
text,
taskType,
sourceCardId,
sourceLabel: cardTitle,
},
}),
);
};
return (
+1 -1
View File
@@ -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 {
+90 -5
View File
@@ -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 <span key={i} className={tag.className}>{tag.label}</span>;
}
// Citation chip? Match [N] exactly
const citMatch = /^\[(\d+)\]$/.exec(part);
if (citMatch) {
const num = Number(citMatch[1]);
return (
<button
key={i}
type="button"
onClick={() =>
window.dispatchEvent(
new CustomEvent("soc:highlight-citation", { detail: { index: num } }),
)
}
className="inline-flex items-center justify-center text-[10px] font-medium rounded px-1 py-0 min-w-[18px] bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 hover:bg-blue-200 dark:hover:bg-blue-800/50 transition-colors cursor-pointer mx-0.5 align-text-bottom"
title={`查看引用 ${num}`}
aria-label={`引用 ${num}`}
>
{num}
</button>
);
}
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 (
<div className="relative group my-2 rounded-lg overflow-hidden border border-border">
{/* Language label + copy button */}
@@ -69,8 +128,22 @@ function CodeBlock({
}}
PreTag="div"
>
{children}
{displayedCode}
</SyntaxHighlighter>
{isLongCode && (
<div className="border-t border-border bg-muted px-3 py-1.5 flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">
{codeExpanded ? `共 ${lines.length} 行` : `已折叠,共 ${lines.length} 行`}
</span>
<button
type="button"
onClick={() => setCodeExpanded((v) => !v)}
className="text-xs text-primary hover:underline"
>
{codeExpanded ? "折叠" : "展开全部"}
</button>
</div>
)}
</div>
);
}
@@ -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 <p className="my-1 leading-relaxed text-foreground">{children}</p>;
const processedChildren = Array.isArray(children)
? children.flatMap((child, idx) =>
typeof child === "string"
? renderWithSourceBadges(child).map((node, ni) =>
typeof node === "string" ? node : <span key={`${idx}-${ni}`}>{node}</span>
)
: [child]
)
: typeof children === "string"
? renderWithSourceBadges(children)
: children;
return <p className="my-1 leading-relaxed text-foreground">{processedChildren}</p>;
},
// Links
-1
View File
@@ -1,4 +1,3 @@
import React from "react";
const SOURCE_CONFIG: Record<string, { label: string; className: string }> = {
internal_kb: { label: "内部知识库", className: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400" },
+109 -26
View File
@@ -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<string, ThreadItem[]> = {};
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<HTMLInputElement>(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({
<div className="w-full h-full shrink-0 border-r border-border flex flex-col bg-muted/30">
{/* New chat button */}
<div className="p-3 border-b border-border flex flex-col gap-2">
<Button
variant="outline"
size="sm"
className="w-full justify-start gap-2"
onClick={onNewThread}
>
<Plus className="size-4" />
新建对话
</Button>
<div className="flex items-center gap-1.5">
<Button
variant="outline"
size="sm"
className="flex-1 justify-start gap-2"
onClick={onNewThread}
>
<Plus className="size-4" />
新建对话
</Button>
{/* Search toggle icon */}
<button
type="button"
title="搜索对话"
onClick={() => {
setSearchExpanded((v) => {
if (!v) setTimeout(() => searchInputRef.current?.focus(), 50);
else setSearchQuery("");
return !v;
});
}}
className={cn(
"p-1.5 rounded-md transition-colors",
searchExpanded
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-accent",
)}
>
<Search className="size-3.5" />
</button>
</div>
{/* Search input */}
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<input
type="text"
value={searchQuery}
onChange={(e) => 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 */}
<div
className={cn(
"overflow-hidden transition-all duration-200",
searchExpanded ? "max-h-10 opacity-100" : "max-h-0 opacity-0",
)}
>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onBlur={() => {
if (!searchQuery) {
setSearchExpanded(false);
}
}}
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"
/>
</div>
</div>
</div>
@@ -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 (
<div
key={t.thread_id}
@@ -134,7 +210,12 @@ export function ThreadSidebar({
>
<MessageSquare className="size-4 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-xs font-medium truncate">{itemLabel}</p>
<p
className="text-xs font-medium truncate"
title={itemLabel.length > 16 ? itemLabel : undefined}
>
{displayLabel}
</p>
<p className="text-[10px] text-muted-foreground">
{formatTime(t.created_at)}
</p>
@@ -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="删除对话"
>
+61 -14
View File
@@ -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<string, string> = {
@@ -30,6 +30,7 @@ const TOOL_LOADING_MAP: Record<string, string> = {
code_execute: "正在执行代码...",
code_install: "正在安装依赖...",
sandbox_run: "正在运行沙盒...",
chart_generate: "正在生成图表...",
doc_create: "正在创建文档...",
doc_edit: "正在编辑文档...",
doc_translate: "正在翻译文档...",
@@ -58,6 +59,17 @@ interface ToolCallStatusProps {
components?: Parameters<typeof LoadExternalComponent>[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 (
<div>
@@ -103,7 +116,14 @@ function ToolCallRow({
)}
<span>{isFailed ? `${label} · 失败` : isDone ? (canExpand ? `${label} · ${expanded ? "收起" : "查看结果"}` : `${label} · 已完成`) : (tc.name ? (TOOL_LOADING_MAP[tc.name] ?? `正在${label}...`) : "思考中...")}</span>
</button>
{expanded && uiItem && stream && components && (
{expanded && isFailed && (
<div className="mt-2 ml-7 rounded-md border border-red-200 bg-red-50 dark:bg-red-950/20 dark:border-red-900 px-3 py-2 animate-in fade-in duration-300">
<p className="text-xs text-red-600 dark:text-red-400">
{(uiItem?.props?.errorMessage as string) ?? "工具执行失败,请稍后重试。"}
</p>
</div>
)}
{expanded && !isFailed && uiItem && stream && components && (
<div className="mt-2 ml-7">
<div className="animate-in fade-in duration-300">
<LoadExternalComponent
@@ -114,7 +134,7 @@ function ToolCallRow({
</div>
</div>
)}
{expanded && !uiItem && (
{expanded && !isFailed && !uiItem && (
<div className="mt-2 ml-7 space-y-2 animate-pulse">
<div className="h-3 bg-muted rounded w-3/4" />
<div className="h-3 bg-muted rounded w-1/2" />
@@ -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<boolean | undefined>(undefined);
const UI_NAME_MAP: Record<string, string> = {
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<string, number> = {};
const showGlobalToggle = toolCalls.length >= 2;
function handleGlobalToggle() {
setGlobalExpanded((prev) => {
// If currently collapsed (false) → expand all; otherwise → collapse all
return prev === false ? true : false;
});
}
return (
<div className="flex flex-col gap-1.5 mb-1">
{/* Global collapse/expand button — only shown when >= 2 tool calls */}
{showGlobalToggle && (
<div className="flex justify-end">
<button
type="button"
onClick={handleGlobalToggle}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors px-1.5 py-0.5 rounded hover:bg-accent"
title={globalExpanded === false ? "展开全部" : "折叠全部"}
>
<ChevronsUpDown className="size-3 shrink-0" />
{globalExpanded === false ? "展开全部" : "折叠全部"}
</button>
</div>
)}
{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}
/>
);
})}
+33
View File
@@ -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; }
}
+214 -31
View File
@@ -9,10 +9,10 @@ type UIMsgLocal = { id: string; type: string; name: string; props: Record<string
import { useState, useRef, useEffect, useCallback } from "react";
import ComponentMap from "./agent-uis/index.tsx";
import "./index.css";
import { BookOpen, Search, Terminal, Ticket, Zap, Cpu, Bot, Menu, Copy, Check, RefreshCw, Square, Pencil, ChevronDown } from "lucide-react";
import { BookOpen, Search, Terminal, Ticket, Zap, Cpu, Bot, Menu, Copy, Check, RefreshCw, Square, Pencil, ChevronDown, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { ThreadSidebar, type ThreadItem } from "@/components/ThreadSidebar.tsx";
import { ThreadSidebar, type ThreadItem, updateThreadLastActive } from "@/components/ThreadSidebar.tsx";
import { ThemeProvider } from "next-themes";
import ThemeToggle from "@/components/ThemeToggle.tsx";
import MessageBubble from "@/components/MessageBubble.tsx";
@@ -41,6 +41,27 @@ function deduplicateUiItems(items: UIMsgLocal[]): UIMsgLocal[] {
});
}
// ─── Execution summary from UI items ─────────────────────────────────────────
function buildExecutionSummary(uiItems: UIMsgLocal[], failedToolNames: Set<string> = new Set()): string {
const counts: Record<string, number> = {};
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<HTMLTextAreaElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [showScrollBtn, setShowScrollBtn] = useState(false);
const isComposingRef = useRef(false);
// Tool & model state
const [activeTools, setActiveTools] = useState<Set<ToolKey>>(new Set());
@@ -117,6 +139,12 @@ function App() {
// File attachment state
const [attachedFile, setAttachedFile] = useState<SelectedFile | null>(null);
// Source label from card action buttons (e.g. "来自 知识库检索")
const [sourceLabel, setSourceLabel] = useState<string | null>(null);
// Pending retry tool name — set by soc:retry-tool, consumed on next submit
const pendingRetryToolRef = useRef<string | null>(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 (
<div key={message.id ?? idx} className="flex flex-col gap-3">
{/* Text reply — rendered first so user sees conclusion before evidence */}
{textContent && (
<div className="group relative max-w-[85%] rounded-2xl rounded-bl-sm bg-muted text-foreground px-4 py-2.5 text-sm">
<MessageBubble content={textContent} role="ai" />
{thread.isLoading && isLastAi && (
<span className="typing-cursor" aria-hidden="true" />
)}
{/* Copy button */}
<div className="flex justify-end mt-1">
<CopyButton text={textContent} />
</div>
</div>
)}
{/* Tool call status (with inline expand/collapse for UI cards) */}
{toolCalls.length > 0 && (
<ToolCallStatus
@@ -525,8 +628,8 @@ function App() {
)}
{/* UI cards not matched to any tool call (standalone) */}
{uiItems
.filter((ui) => !toolCalls.some((tc) => {
{(() => {
const standaloneUiItems = uiItems.filter((ui) => !toolCalls.some((tc) => {
const nameMap: Record<string, string> = {
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) => (
<div key={ui.id} className="animate-in fade-in duration-300">
}));
if (standaloneUiItems.length === 0) return null;
const cards = standaloneUiItems.map((ui: UIMsgLocal) => (
<div key={ui.id} className="card-enter">
<LoadExternalComponent
stream={thread}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -547,20 +651,31 @@ function App() {
components={ComponentMap as any}
/>
</div>
))}
));
if (standaloneUiItems.length >= 2) {
return (
<div className="flex flex-col gap-2 border-l-2 border-primary/20 pl-3 mt-2">
<span className="text-xs text-muted-foreground font-medium">综合分析 · {standaloneUiItems.length} 项结果</span>
{cards}
</div>
);
}
return <>{cards}</>;
})()}
{/* Text reply */}
{textContent && (
<div className="group relative max-w-[85%] rounded-2xl rounded-bl-sm bg-muted text-foreground px-4 py-2.5 text-sm">
<MessageBubble content={textContent} role="ai" />
{thread.isLoading && isLastAi && (
<span className="typing-cursor" aria-hidden="true" />
{/* Execution summary */}
{uiItems.length > 0 && !thread.isLoading && (
<p className="text-[10px] text-muted-foreground px-1">
{buildExecutionSummary(
uiItems,
new Set(
uiItems
.filter((ui) => ui.type === "error-result")
.map((ui) => ui.props?.tool as string)
.filter(Boolean),
),
)}
{/* Copy button */}
<div className="flex justify-end mt-1">
<CopyButton text={textContent} />
</div>
</div>
</p>
)}
{/* 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 ? (
<div className="flex items-center gap-1.5 px-4 py-3 rounded-2xl rounded-bl-sm bg-muted text-muted-foreground w-fit">
<span className="dot-bounce" />
<span className="dot-bounce" />
<span className="dot-bounce" />
</div>
) : null;
})()}
{/* Streaming UI cards not yet attached to a completed message */}
{thread.isLoading &&
deduplicateUiItems(
@@ -594,7 +722,7 @@ function App() {
})
)
.map((ui: UIMsgLocal) => (
<div key={ui.id} className="animate-in fade-in duration-300">
<div key={ui.id} className="card-enter">
<LoadExternalComponent
stream={thread}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -626,6 +754,21 @@ function App() {
<div className="shrink-0 border-t border-border px-4 pt-3 pb-0 flex items-center justify-between max-w-3xl mx-auto w-full">
{/* Tool toggles */}
<div className="flex items-center gap-1.5">
{/* Auto chip — active when no tools are selected */}
<button
type="button"
onClick={() => setActiveTools(new Set())}
title="自动模式:由 AI 决定使用哪些工具"
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium transition-colors",
activeTools.size === 0
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-accent hover:text-foreground",
)}
>
<Sparkles className="size-3.5" />
自动
</button>
{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)}
>
<Icon className="size-3.5" />
{label}
{value === "auto" && modelMode !== value && (
<span className="ml-0.5 text-[9px] text-primary/70">推荐</span>
)}
</Button>
))}
</div>
@@ -671,6 +821,22 @@ function App() {
{attachedFile && (
<FileAttachmentPreview file={attachedFile} onRemove={() => setAttachedFile(null)} />
)}
{/* Source label: shown when user clicks a card action button */}
{sourceLabel && (
<div className="flex items-center gap-1.5">
<span className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400 border border-blue-200 dark:border-blue-800">
来自 {sourceLabel}
</span>
<button
type="button"
onClick={() => setSourceLabel(null)}
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
title="清除来源标签"
>
×
</button>
</div>
)}
<form onSubmit={handleSubmit} className="flex gap-2">
<FileUploadButton onFileSelect={setAttachedFile} disabled={thread.isLoading} />
<textarea
@@ -681,8 +847,10 @@ function App() {
value={input}
rows={1}
onChange={(e) => setInput(e.target.value)}
onCompositionStart={() => { isComposingRef.current = true; }}
onCompositionEnd={() => { isComposingRef.current = false; }}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
if (e.key === "Enter" && !e.shiftKey && !isComposingRef.current) {
e.preventDefault();
handleSubmit(e as unknown as React.FormEvent);
}
@@ -696,8 +864,9 @@ function App() {
type="button"
onClick={() => thread.stop()}
className="rounded-xl bg-destructive text-destructive-foreground px-4 py-2.5 text-sm font-medium hover:opacity-90 transition-opacity flex items-center gap-1.5"
title="点击停止生成"
>
<Square className="size-4" />
<Square className="size-4 fill-current" />
停止
</button>
) : (
@@ -710,6 +879,20 @@ function App() {
</button>
)}
</form>
{/* Input hint: dynamically changes based on active tools */}
<p className="text-[10px] text-muted-foreground/70 pl-1">
{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} 个工具`}
</p>
</div>
</div>
</div>