diff --git a/langgraph/src/components/ToolCallStatus.tsx b/langgraph/src/components/ToolCallStatus.tsx index 24e21fa..57f9ec4 100644 --- a/langgraph/src/components/ToolCallStatus.tsx +++ b/langgraph/src/components/ToolCallStatus.tsx @@ -232,6 +232,11 @@ function ToolCallRow({ 原因:{logEntry.errorMessage} )} + {(logEntry as any).inputSummary && ( + + 查询:{(logEntry as any).inputSummary} + + )} )} {expanded && !isFailed && uiItem && stream && components && ( diff --git a/langgraph/src/main.tsx b/langgraph/src/main.tsx index 8ca09d8..9801a1e 100644 --- a/langgraph/src/main.tsx +++ b/langgraph/src/main.tsx @@ -9,7 +9,7 @@ type UIMsgLocal = { id: string; type: string; name: string; props: Record(null); + const handleFeedback = (type: "up" | "down") => { + setFeedback((prev) => (prev === type ? null : type)); + }; + return ( + <> + handleFeedback("up")} + className={cn( + "inline-flex items-center text-xs px-1.5 py-0.5 rounded transition-colors", + feedback === "up" + ? "text-green-600 bg-green-100 dark:bg-green-900/30" + : "opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground hover:bg-accent", + )} + title="有帮助" + > + + + handleFeedback("down")} + className={cn( + "inline-flex items-center text-xs px-1.5 py-0.5 rounded transition-colors", + feedback === "down" + ? "text-red-600 bg-red-100 dark:bg-red-900/30" + : "opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground hover:bg-accent", + )} + title="无帮助" + > + + + > + ); +} + +// ─── Export conversation as Markdown ──────────────────────────────────────── +function ExportButton({ messages }: { messages: Array> }) { + const handleExport = () => { + const lines: string[] = ["# 对话记录\n"]; + for (const msg of messages) { + const isHuman = + (msg as any).getType?.() === "human" || (msg as any).type === "human"; + const raw = (msg as any).content; + const text = + typeof raw === "string" + ? raw + : Array.isArray(raw) + ? raw + .filter((b: any) => b?.type === "text") + .map((b: any) => b.text) + .join("\n") + : ""; + if (!text.trim()) continue; + lines.push(isHuman ? `## 用户\n\n${text}\n` : `## AI\n\n${text}\n`); + } + const blob = new Blob([lines.join("\n")], { type: "text/markdown" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `对话记录_${new Date().toISOString().slice(0, 10)}.md`; + a.click(); + URL.revokeObjectURL(url); + }; + return ( + + + + ); +} + function App() { const [input, setInput] = useState(""); const bottomRef = useRef(null); @@ -285,6 +363,32 @@ function App() { }; }, [configOpen]); + // ─── Global keyboard shortcuts ────────────────────────────────────────── + useEffect(() => { + function handleGlobalKey(e: KeyboardEvent) { + const t = e.target as HTMLElement; + const isInput = t.tagName === "TEXTAREA" || t.tagName === "INPUT" || t.isContentEditable; + // Ctrl/Cmd + Shift + O → new thread + if ((e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === "O" || e.key === "o")) { + e.preventDefault(); handleNewThread(); return; + } + // Ctrl/Cmd + Shift + S → toggle sidebar + if ((e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === "S" || e.key === "s")) { + e.preventDefault(); setSidebarOpen((v) => !v); return; + } + // "/" when not in input → focus textarea + if (e.key === "/" && !isInput) { + e.preventDefault(); textareaRef.current?.focus(); return; + } + // Escape while loading → stop generation + if (e.key === "Escape" && thread.isLoading) { + e.preventDefault(); thread.stop(); return; + } + } + document.addEventListener("keydown", handleGlobalKey); + return () => document.removeEventListener("keydown", handleGlobalKey); + }, [thread.isLoading]); // eslint-disable-line react-hooks/exhaustive-deps + // Load threads on mount useEffect(() => { client.threads @@ -484,7 +588,10 @@ function App() { ); if (isFirstMessage && currentThreadId) { - const titleText = text.slice(0, 20); + // Smart title: truncate at word/punctuation boundary instead of mid-word + const rawTitle = text.slice(0, 30); + const boundaryMatch = rawTitle.match(/^(.{10,}?)[,。!?、;:\s,.!?;:]/); + const titleText = boundaryMatch ? boundaryMatch[1] : rawTitle.replace(/\s+\S*$/, "") || rawTitle; client.threads.update(currentThreadId, { metadata: { title: titleText } }).catch(() => {}); try { localStorage.setItem(`title_${currentThreadId}`, titleText); } catch { /* ignore */ } setThreads((prev) => @@ -956,9 +1063,10 @@ function App() { {thread.isLoading && isLastAi && ( )} - {/* Copy button */} - + {/* Action buttons: copy + feedback */} + + )} @@ -1157,7 +1265,9 @@ function App() { : `${modelLabel} · ${activeToolLabels.length} 个工具`; return ( - + + {/* Export button */} + {/* Summary bar */} setInput(e.target.value)} onCompositionStart={() => { isComposingRef.current = true; }} onCompositionEnd={() => { isComposingRef.current = false; }}