feat: enterprise product sprint — log protocol, tool rules, UX polish
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:
co-authored by
Claude Sonnet 4.6
parent
183794dc42
commit
9c96913b4d
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
import "@/agent/utils/config";
|
||||
import { StateGraph, START, END } from "@langchain/langgraph";
|
||||
import {
|
||||
SupervisorAnnotation,
|
||||
|
||||
@@ -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]) : "";
|
||||
|
||||
@@ -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)",
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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" },
|
||||
|
||||
@@ -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="删除对话"
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user