Commit 0355027e authored by edy's avatar edy

feat(ui): show streaming wait state as reasoning trace

parent 362f9163
......@@ -1203,7 +1203,6 @@ export default function App() {
sending,
messageLabels: {
thinking: ui.thinking,
traceTitle: ui.traceTitle,
hideTrace: ui.hideTrace,
traceCollapsed: ui.traceCollapsed
},
......
......@@ -62,7 +62,6 @@ export const ui = {
projectSessionsLabel: "\u4f1a\u8bdd",
newSession: "\u65b0\u5efa\u4f1a\u8bdd",
closeSession: "\u5173\u95ed\u4f1a\u8bdd",
traceTitle: "\u601d\u8003\u8fc7\u7a0b",
hideTrace: "\u6536\u8d77",
traceEmpty: "\u8fd8\u6ca1\u6709\u53ef\u663e\u793a\u7684\u8fdb\u5ea6\u3002",
traceCollapsed: "\u5c55\u5f00",
......@@ -130,4 +129,3 @@ export const expertsPageCopy = {
noExperts: "当前还没有可用专家,先在首页直接对话即可。"
} as const;
......@@ -76,7 +76,6 @@ interface ConversationWorkspaceViewProps {
sending: boolean
messageLabels: {
thinking: string
traceTitle: string
hideTrace: string
traceCollapsed: string
}
......
import type { ChatAttachment, ChatMessage } from "@qjclaw/shared-types"
import { useEffect, useMemo, useState, type ReactNode, type RefObject, type UIEvent } from "react"
import { desktopApi } from "../../lib/desktop-api"
import { getTraceDisplayLines, getTraceLineClassName } from "./messageTraceDisplay"
import { getTraceDisplayLines, getTraceLineClassName, getTraceStripTitle } from "./messageTraceDisplay"
import type { MessageTraceState } from "./useMessageTraces"
type ViewMode = "chat" | "experts" | "plugins" | "settings" | "knowledge"
......@@ -22,7 +22,6 @@ interface VideoStatusCardContent {
interface MessageListLabels {
thinking: string
traceTitle: string
hideTrace: string
traceCollapsed: string
}
......@@ -204,13 +203,14 @@ export function MessageList({
return (
<div ref={messageListRef} onScroll={onScroll} className={"message-list chat-scroll-smooth" + (viewMode === "chat" ? " message-list-home" : "")}>
{messages.map((message) => {
const showThinking = message.role === "assistant" && message.streamState === "streaming" && !message.content.trim()
const videoStatusCard = showThinking ? buildDouyinVideoStatusCard(message, activeExpertKey) : null
const isWaitingForContent = message.role === "assistant" && message.streamState === "streaming" && !message.content.trim()
const videoStatusCard = isWaitingForContent ? buildDouyinVideoStatusCard(message, activeExpertKey) : null
const messageTrace = message.role === "assistant" ? messageTraces[message.id] : undefined
const hasTrace = Boolean(messageTrace?.items.length)
const isTraceExpanded = Boolean(messageTrace?.expanded)
const showReasoningStrip = message.role === "assistant" && !showThinking && (hasTrace || message.streamState === "error")
const showReasoningStrip = message.role === "assistant" && !videoStatusCard && (hasTrace || message.streamState === "error" || isWaitingForContent)
const traceDisplayLines = hasTrace ? getTraceDisplayLines(messageTrace?.items ?? []) : []
const traceTitle = getTraceStripTitle(messageTrace?.items ?? [], labels.thinking, message.statusLabel, message.statusDetail)
const canCopyMessage = Boolean(message.content.trim())
const copyToken = `message:${message.id}`
const reaction = messageReactions[message.id]
......@@ -220,15 +220,15 @@ export function MessageList({
<article key={message.id} className={"message-card group " + message.role + (message.streamState ? " " + message.streamState : "")}>
<div className={"message-bubble" + (message.role === "assistant" ? " message-bubble-assistant" : " message-bubble-user")}>
{showReasoningStrip ? (
<div className="message-trace">
<div className={"message-trace" + (message.streamState === "streaming" ? " streaming" : "")} aria-live={message.streamState === "streaming" ? "polite" : undefined}>
<button
type="button"
className={"reasoning-strip" + (isTraceExpanded ? " expanded" : "") + (message.streamState === "error" ? " error" : "")}
className={"reasoning-strip" + (isTraceExpanded ? " expanded" : "") + (message.streamState === "error" ? " error" : "") + (message.streamState === "streaming" ? " streaming" : "") + (isWaitingForContent ? " waiting" : "")}
onClick={() => onTraceExpandedChange(message.id, !isTraceExpanded)}
aria-expanded={isTraceExpanded}
>
<span className="reasoning-strip-leading" aria-hidden="true" />
<span className="reasoning-strip-title">{labels.traceTitle}</span>
<span className="reasoning-strip-title">{traceTitle}</span>
<span className="reasoning-strip-action">{isTraceExpanded ? labels.hideTrace : labels.traceCollapsed}</span>
</button>
{isTraceExpanded ? (
......@@ -251,8 +251,7 @@ export function MessageList({
) : null}
</div>
) : null}
{showThinking ? (
videoStatusCard ? (
{isWaitingForContent && videoStatusCard ? (
<div className="generation-status-card" aria-live="polite">
<div className="generation-status-leading">
<span className="thinking-spinner generation-status-spinner" aria-hidden="true" />
......@@ -265,13 +264,6 @@ export function MessageList({
<span className="generation-status-progress" aria-hidden="true" />
</div>
</div>
) : (
<div className="thinking-indicator" aria-live="polite">
<span className="thinking-spinner" aria-hidden="true" />
<span className="thinking-label">{message.statusLabel ?? labels.thinking}</span>
{message.statusDetail ? <span className="thinking-detail">{message.statusDetail}</span> : null}
</div>
)
) : message.content ? (
message.role === "assistant" ? (
<div className="markdown-body">
......
......@@ -11,6 +11,8 @@ export interface TraceDisplayLine {
kind: TraceStepKind
}
const TRACE_TITLE_MAX_LENGTH = 34
export function formatTraceTime(value: string): string {
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
......@@ -25,6 +27,33 @@ function compactText(value: string | undefined): string | undefined {
return text || undefined
}
function limitTraceTitle(value: string): string {
if (value.length <= TRACE_TITLE_MAX_LENGTH) {
return value
}
return `${value.slice(0, TRACE_TITLE_MAX_LENGTH - 1)}...`
}
function getTraceTitleCandidate(item: ConversationTraceItem): string | undefined {
if (item.tone === "error") {
return compactText(item.label)
}
return compactText(item.detail) ?? compactText(item.label)
}
export function getTraceStripTitle(
items: ConversationTraceItem[],
fallbackTitle: string,
statusLabel?: string,
statusDetail?: string
): string {
const latestTraceTitle = items.length ? getTraceTitleCandidate(items[items.length - 1]) : undefined
const title = latestTraceTitle ?? compactText(statusDetail) ?? compactText(statusLabel) ?? compactText(fallbackTitle)
return title ? limitTraceTitle(title) : fallbackTitle
}
function classifyTraceItem(item: ConversationTraceItem): TraceStepKind {
const stage = item.stage.toLowerCase()
const value = `${item.stage} ${item.label}`.toLowerCase()
......
......@@ -212,14 +212,6 @@
margin-top: 6px;
}
.thinking-indicator {
display: inline-flex;
align-items: center;
gap: 10px;
padding-top: 6px;
color: #46607f;
}
.thinking-spinner {
width: 16px;
height: 16px;
......@@ -229,11 +221,6 @@
animation: spinner-rotate 0.8s linear infinite;
}
.thinking-label {
font-size: 14px;
font-weight: 600;
}
.message-cursor {
display: inline-block;
width: 8px;
......@@ -253,6 +240,9 @@
}
.reasoning-strip {
position: relative;
isolation: isolate;
overflow: hidden;
width: min(100%, 880px);
min-height: 32px;
display: grid;
......@@ -269,6 +259,11 @@
transition: background-color 160ms ease, color 160ms ease, box-shadow 160ms ease;
}
.reasoning-strip > span {
position: relative;
z-index: 1;
}
.reasoning-strip:hover {
background: rgba(15, 23, 42, 0.07);
color: #263244;
......@@ -284,6 +279,31 @@
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.035);
}
.reasoning-strip.streaming {
background:
linear-gradient(90deg, rgba(13, 148, 136, 0.11), rgba(37, 99, 235, 0.1), rgba(15, 23, 42, 0.045));
color: #27506d;
box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.08);
}
.reasoning-strip.streaming::after {
content: "";
position: absolute;
inset: 0;
z-index: 0;
background: linear-gradient(105deg, transparent 8%, rgba(255, 255, 255, 0.42) 42%, transparent 64%);
opacity: 0.55;
transform: translateX(-115%);
animation: trace-strip-shine 2.4s ease-in-out infinite;
pointer-events: none;
}
.reasoning-strip.streaming:hover,
.reasoning-strip.streaming.expanded {
background:
linear-gradient(90deg, rgba(13, 148, 136, 0.14), rgba(37, 99, 235, 0.13), rgba(15, 23, 42, 0.055));
}
.reasoning-strip.error {
background: rgba(239, 68, 68, 0.09);
color: #893535;
......@@ -305,6 +325,12 @@
box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.12);
}
.reasoning-strip.streaming .reasoning-strip-leading::before {
background: #0d9488;
box-shadow: 0 0 0 3px rgba(13, 148, 136, 0.13);
animation: trace-status-pulse 1.5s ease-in-out infinite;
}
.reasoning-strip.error .reasoning-strip-leading::before {
background: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.12);
......@@ -325,6 +351,15 @@
line-height: 1.45;
}
.reasoning-strip.streaming .reasoning-strip-title {
background: linear-gradient(90deg, #0f766e, #2563eb, #334155);
background-size: 180% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: trace-title-shift 3s ease-in-out infinite;
}
.reasoning-strip-action {
color: #6f8197;
font-size: 12px;
......@@ -1087,6 +1122,47 @@ select:focus-visible {
}
}
@keyframes trace-strip-shine {
0% {
transform: translateX(-115%);
}
48%, 100% {
transform: translateX(115%);
}
}
@keyframes trace-status-pulse {
0%, 100% {
opacity: 0.62;
transform: scale(0.92);
}
50% {
opacity: 1;
transform: scale(1.18);
}
}
@keyframes trace-title-shift {
0%, 100% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
}
@media (prefers-reduced-motion: reduce) {
.reasoning-strip.streaming::after,
.reasoning-strip.streaming .reasoning-strip-leading::before,
.reasoning-strip.streaming .reasoning-strip-title {
animation: none;
}
.reasoning-strip.streaming::after {
opacity: 0;
}
}
@media (max-width: 1100px) {
.hero-line { font-size: 21px; }
.composer-footer {
......
......@@ -572,7 +572,6 @@
line-height: 1.82;
}
.conversation-shell .message-card.assistant .thinking-indicator,
.conversation-shell .message-card.assistant .generation-status-card,
.conversation-shell .message-card.assistant .message-trace {
max-width: min(100%, 880px);
......@@ -582,6 +581,18 @@
border-radius: 22px;
}
.conversation-shell .message-card.assistant .message-trace.streaming {
margin-bottom: 12px;
}
.conversation-shell .message-card.assistant .reasoning-strip.waiting {
min-height: 36px;
}
.conversation-shell .message-card.assistant .reasoning-strip.streaming .reasoning-strip-action {
color: #64748b;
}
.conversation-shell .message-timestamp {
position: absolute;
bottom: 0;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment