feat: cancelling status, ThreadSidebar lastActive time, MessageBubble smart summary
Deploy LangGraph Server to Azure Web App / build-and-deploy (push) Failing after 23s
Deploy LangGraph UI to Azure Static Web Apps / build-and-deploy (push) Failing after 53s

- 点5: cancelling 独立状态 UI,用轮询替换 300ms setTimeout,StreamStatusBar 加橙色视觉
- 点6: ThreadSidebar formatTime 支持毫秒时间戳,时间展示改用 lastActive
- 点7: MessageBubble 智能摘要(关键词优先/标题次优/跳开场白),折叠阈值提升至25行/800字,展开后可收起

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gongzhiyong
2026-04-12 15:44:58 +08:00
co-authored by Claude Sonnet 4.6
parent 1be8b2602d
commit 73a7175051
3 changed files with 75 additions and 19 deletions
+42 -2
View File
@@ -148,15 +148,40 @@ function CodeBlock({
);
}
const CONCLUSION_KEYWORDS = /总结|结论|建议|综上|总体|总的来说|核心|要点|小结/;
const FILLER_PATTERN = /^(根据|针对|您好|你好|好的|感谢|当然|如您所述|以下是|下面是)/;
function extractSummary(text: string): string {
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
// 1. 优先:含总结关键词的段落首句
for (const line of lines) {
if (CONCLUSION_KEYWORDS.test(line) && line.length > 8 && !line.startsWith("#")) {
return line.replace(/^[*_#>\s-]+/, "").slice(0, 120);
}
}
// 2. 次优先:Markdown 标题(## / ###)之后的第一个非空行
for (let i = 0; i < lines.length; i++) {
if (/^#{2,3}\s/.test(lines[i]) && lines[i + 1]) {
const next = lines[i + 1].replace(/^[*_>\s-]+/, "");
if (next.length > 8) return next.slice(0, 120);
}
}
// 3. Fallback:跳过开场白,取第一个有实质内容的句子
const sentences = text.split(/(?<=[.。!!??])\s+/);
const meaningful = sentences.find((s) => s.length > 10 && !FILLER_PATTERN.test(s.trim()));
if (meaningful) return meaningful.trim().slice(0, 120);
// 4. 最终 fallback
return sentences.slice(0, 2).join(" ").trim();
}
export default function MessageBubble({ content }: MessageBubbleProps) {
const [summaryExpanded, setSummaryExpanded] = useState(false);
const contentLines = content.split("\n").length;
const isLong = contentLines > 20;
const isLong = contentLines > 25 || content.length > 800;
const summary = isLong ? extractSummary(content) : null;
return (
@@ -166,7 +191,7 @@ export default function MessageBubble({ content }: MessageBubbleProps) {
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">{summary}…</p>
<button
type="button"
onClick={() => setSummaryExpanded(true)}
onClick={() => setSummaryExpanded((v) => !v)}
className="mt-1 text-xs text-primary hover:underline"
>
展开全文
@@ -174,6 +199,7 @@ export default function MessageBubble({ content }: MessageBubbleProps) {
</div>
)}
{(!isLong || summaryExpanded) && (
<>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
@@ -324,7 +350,21 @@ export default function MessageBubble({ content }: MessageBubbleProps) {
>
{content}
</ReactMarkdown>
{summaryExpanded && (
<button
type="button"
onClick={() => {
setSummaryExpanded(false);
}}
className="mt-2 text-xs text-primary hover:underline flex items-center gap-0.5 ml-auto"
>
收起 ▲
</button>
)}
</>
)}
</>
);
}
+3 -3
View File
@@ -80,9 +80,9 @@ export function updateThreadLastActive(threadId: string) {
}
}
function formatTime(iso: string) {
function formatTime(isoOrMs: string | number) {
try {
const d = new Date(iso);
const d = typeof isoOrMs === "number" ? new Date(isoOrMs) : new Date(isoOrMs);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffHrs = diffMs / (1000 * 60 * 60);
@@ -224,7 +224,7 @@ export function ThreadSidebar({
{displayLabel}
</p>
<p className="text-[10px] text-muted-foreground">
{formatTime(t.created_at)}
{formatTime(getLastActive(t.thread_id, t.created_at))}
</p>
</div>
<button
+30 -14
View File
@@ -133,7 +133,7 @@ function App() {
const pendingRetryToolRef = useRef<string | null>(null);
// ── Concurrent submit state ───────────────────────────────────────────────
type ConcurrentStatus = "idle" | "generating" | "queued" | "stopping" | "auto-sending";
type ConcurrentStatus = "idle" | "generating" | "queued" | "stopping" | "cancelling" | "auto-sending";
// 完整 payload 快照类型
interface SubmitPayload {
@@ -147,6 +147,7 @@ function App() {
const [concurrentStatus, setConcurrentStatus] = useState<ConcurrentStatus>("idle");
const pendingQueueRef = useRef<Array<() => void>>([]);
const isPollingRef = useRef(false);
const wasStoppedRef = useRef(false);
const [pendingQueueCount, setPendingQueueCount] = useState(0);
const thread = useStream<{ messages: Message[]; ui: UIMsgLocal[] }>({
@@ -175,8 +176,10 @@ function App() {
// Sync concurrent status with thread loading state
useEffect(() => {
if (!thread.isLoading) setConcurrentStatus("idle");
}, [thread.isLoading]);
if (!thread.isLoading && concurrentStatus !== "cancelling" && concurrentStatus !== "queued") {
setConcurrentStatus("idle");
}
}, [thread.isLoading, concurrentStatus]);
// Listen for retry-tool events from error-result cards
useEffect(() => {
@@ -431,17 +434,27 @@ function App() {
function handleConcurrentInterrupt() {
setShowConcurrentDialog(false);
setConcurrentStatus("stopping");
wasStoppedRef.current = true;
thread.stop();
setTimeout(() => {
const next = pendingQueueRef.current.shift();
if (next) {
setPendingQueueCount(pendingQueueRef.current.length);
setConcurrentStatus("generating");
next();
} else {
setConcurrentStatus("idle");
}
}, 300);
setConcurrentStatus("cancelling");
const waitForStop = () => {
if (!thread.isLoading) {
wasStoppedRef.current = false;
const next = pendingQueueRef.current.shift();
if (next) {
setPendingQueueCount(pendingQueueRef.current.length);
setConcurrentStatus("generating");
next();
} else {
setConcurrentStatus("idle");
}
} else {
setTimeout(waitForStop, 200);
}
};
waitForStop();
}, 150);
}
function handleConcurrentQueue() {
@@ -1014,11 +1027,13 @@ function App() {
concurrentStatus === "generating" && "bg-blue-50/80 dark:bg-blue-950/30 text-blue-600 dark:text-blue-400",
concurrentStatus === "queued" && "bg-amber-50/80 dark:bg-amber-950/30 text-amber-600 dark:text-amber-400",
concurrentStatus === "stopping" && "bg-red-50/80 dark:bg-red-950/30 text-red-600 dark:text-red-400",
concurrentStatus === "cancelling" && "bg-orange-50/80 dark:bg-orange-950/30 text-orange-600 dark:text-orange-400",
concurrentStatus === "auto-sending" && "bg-green-50/80 dark:bg-green-950/30 text-green-600 dark:text-green-400",
)}>
{(concurrentStatus === "generating" || concurrentStatus === "auto-sending") && <Loader2 className="size-3 animate-spin shrink-0" />}
{concurrentStatus === "queued" && <Clock className="size-3 shrink-0" />}
{concurrentStatus === "stopping" && <Square className="size-3 shrink-0 fill-current" />}
{concurrentStatus === "queued" && <Clock className="size-3 shrink-0" />}
{concurrentStatus === "stopping" && <Square className="size-3 shrink-0 fill-current" />}
{concurrentStatus === "cancelling" && <Loader2 className="size-3 animate-spin shrink-0" />}
{concurrentStatus === "queued" ? (
<>
<span>
@@ -1043,6 +1058,7 @@ function App() {
<span>
{concurrentStatus === "generating" && "正在生成回复..."}
{concurrentStatus === "stopping" && "正在停止上一轮..."}
{concurrentStatus === "cancelling" && "等待当前任务停止..."}
{concurrentStatus === "auto-sending" && "正在自动发送下一条..."}
</span>
)}