feat: Task#2 ActionBar+SourceBadge+状态语言统一+长消息分层
Deploy LangGraph Server to Azure Web App / build-and-deploy (push) Failing after 19s
Deploy LangGraph UI to Azure Static Web Apps / build-and-deploy (push) Failing after 43s

- 新增 ActionBar 组件:快速操作按钮,通过 soc:prefill-input 事件填充输入框
- 新增 SourceBadge 组件:数据来源标注(内部知识库/工单系统/外部搜索/代码执行)
- 4个 Gen-UI 卡片集成 ActionBar 和 SourceBadge
- main.tsx 添加 soc:prefill-input 事件监听
- ToolCallStatus 状态文案统一为中文(正在xxx.../已完成·xxx)
- MessageBubble 长消息(>800字)显示摘要+展开全文按钮

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gongzhiyong
2026-04-11 19:59:24 +08:00
co-authored by Claude Sonnet 4.6
parent e16b4ce92e
commit 8c5890d66a
9 changed files with 205 additions and 28 deletions
@@ -1,15 +1,21 @@
import { BookOpen } from "lucide-react";
import { ActionBar } from "@/components/ActionBar";
import { SourceBadge } from "@/components/SourceBadge";
interface KnowledgeResultProps {
query: string;
total: number;
results: Array<{ title: string; category: string; snippet: string }>;
sourceType?: string;
confidence?: "high" | "medium" | "low";
}
export default function KnowledgeResult({
query,
total,
results,
sourceType,
confidence,
}: KnowledgeResultProps) {
return (
<div className="w-full max-w-2xl rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden">
@@ -17,6 +23,7 @@ export default function KnowledgeResult({
<div className="flex items-center gap-2 px-4 py-3 border-b border-border">
<BookOpen className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="font-medium text-sm text-foreground">知识库检索</span>
<SourceBadge sourceType={sourceType || "internal_kb"} confidence={confidence} />
<span className="ml-auto inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{total} 条结果
</span>
@@ -33,7 +40,7 @@ export default function KnowledgeResult({
<ul className="divide-y divide-border">
{results.length === 0 ? (
<li className="px-4 py-6 text-center text-sm text-muted-foreground">
未找到相关结果
知识库中暂无相关内容
</li>
) : (
results.map((item, idx) => (
@@ -53,6 +60,13 @@ export default function KnowledgeResult({
))
)}
</ul>
<div className="px-4 pb-3">
<ActionBar
sourceType={sourceType || "internal_kb"}
context={query}
suggestedActions={["生成知识摘要", "继续深入搜索", "导出到文档"]}
/>
</div>
</div>
);
}
@@ -1,5 +1,7 @@
import { Terminal, CheckCircle, XCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import { ActionBar } from "@/components/ActionBar";
import { SourceBadge } from "@/components/SourceBadge";
interface SandboxResultProps {
language: string;
@@ -7,6 +9,8 @@ interface SandboxResultProps {
stdout: string;
has_more: boolean;
duration_ms?: number;
sourceType?: string;
confidence?: "high" | "medium" | "low";
}
const LANGUAGE_LABEL: Record<string, string> = {
@@ -21,6 +25,8 @@ export default function SandboxResult({
stdout,
has_more,
duration_ms,
sourceType,
confidence,
}: SandboxResultProps) {
const success = exit_code === 0;
@@ -30,6 +36,7 @@ export default function SandboxResult({
<div className="flex items-center gap-2 px-4 py-3 border-b border-border">
<Terminal className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="font-medium text-sm text-foreground">代码执行</span>
<SourceBadge sourceType={sourceType || "code_execution"} confidence={confidence} />
{/* Language badge */}
<span className="inline-flex items-center rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground font-mono">
@@ -82,6 +89,13 @@ export default function SandboxResult({
输出已截断,显示前 2000 字符
</div>
)}
<div className="px-4 pb-3">
<ActionBar
sourceType={sourceType || "code_execution"}
context={stdout}
suggestedActions={["解释代码", "优化代码", "保存到文档"]}
/>
</div>
</div>
);
}
@@ -1,9 +1,13 @@
import { Globe } from "lucide-react";
import { ActionBar } from "@/components/ActionBar";
import { SourceBadge } from "@/components/SourceBadge";
interface SearchResultProps {
query: string;
total: number;
results: Array<{ title: string; url: string; snippet: string }>;
sourceType?: string;
confidence?: "high" | "medium" | "low";
}
function getDomain(url: string): string {
@@ -18,6 +22,8 @@ export default function SearchResult({
query,
total,
results,
sourceType,
confidence,
}: SearchResultProps) {
return (
<div className="w-full max-w-2xl rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden">
@@ -25,6 +31,7 @@ export default function SearchResult({
<div className="flex items-center gap-2 px-4 py-3 border-b border-border">
<Globe className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="font-medium text-sm text-foreground">网络搜索</span>
<SourceBadge sourceType={sourceType || "external_web"} confidence={confidence} />
<span className="ml-auto inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{total} 条结果
</span>
@@ -41,7 +48,7 @@ export default function SearchResult({
<ul className="divide-y divide-border">
{results.length === 0 ? (
<li className="px-4 py-6 text-center text-sm text-muted-foreground">
未找到相关结果
未找到相关结果,试试换个关键词?
</li>
) : (
results.map((item, idx) => (
@@ -72,6 +79,13 @@ export default function SearchResult({
))
)}
</ul>
<div className="px-4 pb-3">
<ActionBar
sourceType={sourceType || "external_web"}
context={query}
suggestedActions={["深入阅读", "生成搜索报告", "添加到文档"]}
/>
</div>
</div>
);
}
@@ -1,5 +1,7 @@
import { TicketIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { ActionBar } from "@/components/ActionBar";
import { SourceBadge } from "@/components/SourceBadge";
interface TicketSummaryProps {
total: number;
@@ -12,6 +14,8 @@ interface TicketSummaryProps {
created: string;
}>;
stats: Record<string, number>;
sourceType?: string;
confidence?: "high" | "medium" | "low";
}
function priorityClass(priority: string): string {
@@ -48,6 +52,8 @@ export default function TicketSummary({
total,
tickets,
stats,
sourceType,
confidence,
}: TicketSummaryProps) {
return (
<div className="w-full max-w-2xl rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden">
@@ -55,6 +61,7 @@ export default function TicketSummary({
<div className="flex items-center gap-2 px-4 py-3 border-b border-border">
<TicketIcon className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="font-medium text-sm text-foreground">工单列表</span>
<SourceBadge sourceType={sourceType || "ticket_system"} confidence={confidence} />
<span className="ml-auto inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
共 {total} 条
</span>
@@ -127,6 +134,13 @@ export default function TicketSummary({
))
)}
</ul>
<div className="px-4 pb-3">
<ActionBar
sourceType={sourceType || "ticket_system"}
context={`共 ${total} 条工单`}
suggestedActions={["查看工单详情", "生成处理建议", "生成跟进话术"]}
/>
</div>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import React from "react";
interface ActionBarProps {
sourceType?: string;
context?: string;
suggestedActions?: string[];
}
export function ActionBar({ context = "", suggestedActions = [] }: ActionBarProps) {
const dispatch = (text: string) => {
window.dispatchEvent(new CustomEvent("soc:prefill-input", { detail: { text } }));
};
return (
<div className="flex flex-wrap gap-1.5 mt-3 pt-2 border-t border-border/40">
<button
onClick={() => dispatch(`继续追问:${context}`)}
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>
{suggestedActions.map((action) => (
<button
key={action}
onClick={() => dispatch(`${action},基于以下内容:${context}`)}
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"
>
{action}
</button>
))}
</div>
);
}
@@ -75,8 +75,31 @@ function CodeBlock({
);
}
function extractSummary(text: string): string {
const sentences = text.split(/(?<=[.。!!??])\s+/);
return sentences.slice(0, 2).join(" ").trim();
}
export default function MessageBubble({ content }: MessageBubbleProps) {
const [summaryExpanded, setSummaryExpanded] = useState(false);
const isLong = content.length > 800;
const summary = isLong ? extractSummary(content) : null;
return (
<>
{isLong && summary && !summaryExpanded && (
<div className="mb-2 pb-2 border-b border-border/40">
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">{summary}…</p>
<button
type="button"
onClick={() => setSummaryExpanded(true)}
className="mt-1 text-xs text-primary hover:underline"
>
展开全文
</button>
</div>
)}
{(!isLong || summaryExpanded) && (
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
@@ -216,5 +239,7 @@ export default function MessageBubble({ content }: MessageBubbleProps) {
>
{content}
</ReactMarkdown>
)}
</>
);
}
+27
View File
@@ -0,0 +1,27 @@
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" },
ticket_system: { label: "工单系统", className: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400" },
external_web: { label: "外部搜索", className: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" },
code_execution: { label: "代码执行", className: "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400" },
generated_doc: { label: "生成文档", className: "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400" },
inferred: { label: "模型推断", className: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400" },
};
const CONFIDENCE_ICON: Record<string, string> = { high: "●", medium: "○", low: "!" };
interface SourceBadgeProps {
sourceType?: string;
confidence?: "high" | "medium" | "low";
}
export function SourceBadge({ sourceType = "inferred", confidence = "high" }: SourceBadgeProps) {
const config = SOURCE_CONFIG[sourceType] ?? SOURCE_CONFIG.inferred;
return (
<span className={`inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded font-medium ${config.className}`}>
<span className="opacity-60 text-[10px]">{CONFIDENCE_ICON[confidence]}</span>
{config.label}
</span>
);
}
+51 -26
View File
@@ -3,19 +3,36 @@ import { useState } from "react";
import { LoadExternalComponent } from "@langchain/langgraph-sdk/react-ui";
const TOOL_NAME_MAP: Record<string, string> = {
kb_search: "搜索知识库",
ticket_list: "查询工单列表",
ticket_detail: "查询工单详情",
web_search: "搜索网络",
google_search: "搜索网络",
kb_search: "知识库检索",
ticket_list: "工单查询",
ticket_detail: "工单详情",
web_search: "网络搜索",
google_search: "网络搜索",
web_search_deep: "深度搜索",
web_read: "阅读网页",
code_execute: "执行代码",
code_install: "安装依赖",
doc_create: "创建文档",
doc_edit: "编辑文档",
doc_translate: "翻译文档",
sandbox_run: "运行沙盒",
web_read: "网页阅读",
code_execute: "代码执行",
code_install: "依赖安装",
doc_create: "文档创建",
doc_edit: "文档编辑",
doc_translate: "文档翻译",
sandbox_run: "代码沙盒",
};
// 按工具类型显示 loading 文案
const TOOL_LOADING_MAP: Record<string, string> = {
kb_search: "正在检索知识库...",
ticket_list: "正在查询工单...",
ticket_detail: "正在获取工单详情...",
web_search: "正在搜索网络...",
google_search: "正在搜索网络...",
web_search_deep: "正在深度搜索...",
web_read: "正在阅读网页...",
code_execute: "正在执行代码...",
code_install: "正在安装依赖...",
sandbox_run: "正在运行沙盒...",
doc_create: "正在创建文档...",
doc_edit: "正在编辑文档...",
doc_translate: "正在翻译文档...",
};
interface ToolCall {
@@ -80,7 +97,7 @@ function ToolCallRow({
<Loader2 className="size-3.5 animate-spin shrink-0" />
)}
<span>{label}</span>
<span>{isDone ? (canExpand ? (expanded ? "收起" : "查看结果") : "已完成") : "执行中..."}</span>
<span>{isDone ? (canExpand ? (expanded ? "收起" : "查看结果") : `已完成 · ${label}`) : (tc.name ? (TOOL_LOADING_MAP[tc.name] ?? `正在${label}...`) : "思考中...")}</span>
</button>
{expanded && uiItem && stream && components && (
<div className="mt-2 ml-7">
@@ -105,23 +122,31 @@ export default function ToolCallStatus({
}: ToolCallStatusProps) {
if (!toolCalls.length) return null;
const UI_NAME_MAP: Record<string, string> = {
kb_search: "knowledge-result",
ticket_list: "ticket-summary",
ticket_detail: "ticket-detail",
web_search: "search-result",
google_search: "search-result",
sandbox_run: "sandbox-result",
};
// Track how many times each ui component name has been matched, so multiple
// calls of the same tool type pick different UI cards in order.
const matchCounters: Record<string, number> = {};
return (
<div className="flex flex-col gap-1.5 mb-1">
{toolCalls.map((tc, i) => {
const isDone = !isLoading || (tc.id && completedToolIds?.has(tc.id));
// Match UI item by tool name
const uiItem = uiItems.find((ui) => {
if (!tc.name) return false;
const nameMap: Record<string, string> = {
kb_search: "knowledge-result",
ticket_list: "ticket-summary",
ticket_detail: "ticket-detail",
web_search: "search-result",
google_search: "search-result",
sandbox_run: "sandbox-result",
};
return ui.name === nameMap[tc.name];
});
let uiItem: UIMsgLocal | undefined;
if (tc.name && UI_NAME_MAP[tc.name]) {
const uiName = UI_NAME_MAP[tc.name];
const matchIdx = matchCounters[uiName] ?? 0;
matchCounters[uiName] = matchIdx + 1;
const candidates = uiItems.filter((ui) => ui.name === uiName);
uiItem = candidates[matchIdx];
}
return (
<ToolCallRow
+11
View File
@@ -124,6 +124,17 @@ function App() {
return () => window.removeEventListener("open-canvas", handler);
}, []);
// Listen for prefill-input events from ActionBar
useEffect(() => {
const handler = (e: Event) => {
const ce = e as CustomEvent<{ text: string }>;
setInput(ce.detail.text);
setTimeout(() => textareaRef.current?.focus(), 50);
};
window.addEventListener("soc:prefill-input", handler);
return () => window.removeEventListener("soc:prefill-input", handler);
}, []);
// Load threads on mount
useEffect(() => {
client.threads