feat: Task#2 ActionBar+SourceBadge+状态语言统一+长消息分层
- 新增 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:
co-authored by
Claude Sonnet 4.6
parent
e16b4ce92e
commit
8c5890d66a
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user