feat: cancelling status, ThreadSidebar lastActive time, MessageBubble smart summary
- 点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:
co-authored by
Claude Sonnet 4.6
parent
1be8b2602d
commit
73a7175051
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user