mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-26 00:53:48 +00:00
* feat(daemon): add stateless generation SSE * test(integration): expect session generation capability * fix(daemon): address generation review findings * fix(daemon): harden generation regressions * fix(daemon): preserve generation error events --------- Co-authored-by: ytahdn <ytahdn@gmail.com> Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
446 lines
14 KiB
TypeScript
446 lines
14 KiB
TypeScript
import { memo, type ReactElement } from 'react';
|
|
import type {
|
|
ACPToolCall,
|
|
Message,
|
|
PermissionRequest,
|
|
TodoItem,
|
|
} from '../adapters/types';
|
|
import type { WebShellAssistantTurnFooterRenderInfo } from '../customization';
|
|
import { useI18n } from '../i18n';
|
|
import { ErrorBoundary } from './ErrorBoundary';
|
|
import { MessageTimestamp } from './MessageTimestamp';
|
|
import { UserMessage } from './messages/UserMessage';
|
|
import {
|
|
AssistantMessage,
|
|
ThinkingMessage,
|
|
type SessionContentGenerator,
|
|
} from './messages/AssistantMessage';
|
|
import { SystemMessage } from './messages/SystemMessage';
|
|
import { ToolGroup } from './messages/ToolGroup';
|
|
import { PlanMessage } from './messages/PlanMessage';
|
|
import { BtwMessage } from './messages/BtwMessage';
|
|
import { UserShellMessage } from './messages/UserShellMessage';
|
|
import { InsightProgress } from './InsightProgress';
|
|
import { InsightReady } from './InsightReady';
|
|
|
|
interface MessageItemProps {
|
|
message: Message;
|
|
pendingApproval?: PermissionRequest | null;
|
|
/** Run /context detail, exactly like typing it (context-usage panels). */
|
|
onShowContextDetail?: () => void;
|
|
workspaceCwd?: string;
|
|
isLatest?: boolean;
|
|
showRetryHint?: boolean;
|
|
onRetryClick?: () => void;
|
|
onBranchSession?: () => void;
|
|
showAssistantActions?: boolean;
|
|
showAssistantBranch?: boolean;
|
|
isLocateFlashing?: boolean;
|
|
assistantTurnFooterInfo?: WebShellAssistantTurnFooterRenderInfo;
|
|
generateContent?: SessionContentGenerator;
|
|
}
|
|
|
|
export const MessageItem = memo(function MessageItem({
|
|
message,
|
|
pendingApproval,
|
|
onShowContextDetail,
|
|
workspaceCwd,
|
|
isLatest = false,
|
|
showRetryHint = false,
|
|
onRetryClick,
|
|
onBranchSession,
|
|
showAssistantActions = false,
|
|
showAssistantBranch = false,
|
|
isLocateFlashing = false,
|
|
assistantTurnFooterInfo,
|
|
generateContent,
|
|
}: MessageItemProps) {
|
|
const { t } = useI18n();
|
|
const body = ((): ReactElement | null => {
|
|
switch (message.role) {
|
|
case 'user':
|
|
return (
|
|
<UserMessage
|
|
content={message.content}
|
|
images={message.images}
|
|
inputAnnotations={message.inputAnnotations}
|
|
isLocateFlashing={isLocateFlashing}
|
|
/>
|
|
);
|
|
case 'assistant':
|
|
return (
|
|
<AssistantMessage
|
|
content={message.content}
|
|
isStreaming={message.isStreaming}
|
|
timestamp={message.timestamp}
|
|
onBranchSession={onBranchSession}
|
|
showFooterActions={showAssistantActions}
|
|
showBranchAction={showAssistantBranch}
|
|
isLocateFlashing={isLocateFlashing}
|
|
customFooterInfo={assistantTurnFooterInfo}
|
|
/>
|
|
);
|
|
case 'thinking':
|
|
return (
|
|
<ThinkingMessage
|
|
messageId={message.id}
|
|
content={message.content}
|
|
isStreaming={message.isStreaming}
|
|
timestamp={message.timestamp}
|
|
isLocateFlashing={isLocateFlashing}
|
|
generateContent={generateContent}
|
|
/>
|
|
);
|
|
case 'tool_group':
|
|
return (
|
|
<ToolGroup
|
|
tools={message.tools}
|
|
pendingApproval={pendingApproval}
|
|
workspaceCwd={workspaceCwd}
|
|
isLocateFlashing={isLocateFlashing}
|
|
/>
|
|
);
|
|
case 'plan':
|
|
return (
|
|
<PlanMessage
|
|
id={message.id}
|
|
todos={message.todos}
|
|
isLocateFlashing={isLocateFlashing}
|
|
/>
|
|
);
|
|
case 'system':
|
|
return (
|
|
<SystemMessage
|
|
content={message.content}
|
|
variant={message.variant}
|
|
source={message.source}
|
|
data={message.data}
|
|
onShowContextDetail={onShowContextDetail}
|
|
isLatest={isLatest}
|
|
showRetryHint={showRetryHint && message.retryable === true}
|
|
onRetryClick={onRetryClick}
|
|
/>
|
|
);
|
|
case 'user_shell':
|
|
return (
|
|
<UserShellMessage command={message.command} output={message.output} />
|
|
);
|
|
case 'btw':
|
|
return (
|
|
<BtwMessage
|
|
question={message.question}
|
|
answer={message.answer}
|
|
isPending={message.isPending}
|
|
/>
|
|
);
|
|
case 'insight_progress':
|
|
return (
|
|
<InsightProgress
|
|
progress={{
|
|
stage: message.stage,
|
|
progress: message.progress,
|
|
detail: message.detail,
|
|
}}
|
|
/>
|
|
);
|
|
case 'insight_ready':
|
|
return <InsightReady path={message.path} />;
|
|
case 'insight_error':
|
|
return (
|
|
<div style={{ color: 'var(--error-color, #e06c75)' }}>
|
|
{message.error}
|
|
</div>
|
|
);
|
|
default:
|
|
return null;
|
|
}
|
|
})();
|
|
|
|
if (body === null) return null;
|
|
|
|
// Isolate each message's render: a throw in Markdown/KaTeX/Mermaid/a tool
|
|
// panel degrades to an inline notice rather than white-screening the whole
|
|
// (embeddable) transcript. `resetKeys={[message]}` lets a streamed/edited/
|
|
// retried update recover on its own; a stable broken message stays on the
|
|
// fallback without looping.
|
|
const safeBody = (
|
|
<ErrorBoundary
|
|
label={`message:${message.role}`}
|
|
resetKeys={[message]}
|
|
fallback={
|
|
<MessageRenderError align={message.role === 'user' ? 'end' : 'start'} />
|
|
}
|
|
>
|
|
{body}
|
|
</ErrorBoundary>
|
|
);
|
|
|
|
// Re-enable text selection on every message row so users can long-press /
|
|
// drag-select reply text. The blanket `html * { user-select: none }` in
|
|
// standalone.css disables selection on UI chrome (native-app feel); this
|
|
// attribute opts the message subtree back in, including descendants
|
|
// (Markdown body, code blocks, tool panels, sub-messages).
|
|
//
|
|
// `display: contents` keeps this wrapper out of layout: several parents
|
|
// (e.g. MessageTimestamp's chat row) are flex containers whose items used
|
|
// to be the message body itself. A plain div here becomes the flex item
|
|
// instead and shrinks to its content width, squeezing user chat bubbles
|
|
// (whose max-width: 80% then resolves against the shrunken wrapper) so
|
|
// even short messages wrap mid-word. The user-select re-enable rule
|
|
// matches `[data-user-selectable] *`, so the boxless wrapper does not
|
|
// affect it.
|
|
const selectableSafeBody = (
|
|
<div data-user-selectable="true" style={{ display: 'contents' }}>
|
|
{safeBody}
|
|
</div>
|
|
);
|
|
|
|
if (message.role === 'assistant') {
|
|
if (showAssistantActions) {
|
|
return selectableSafeBody;
|
|
}
|
|
return (
|
|
<MessageTimestamp timestamp={message.timestamp}>
|
|
{selectableSafeBody}
|
|
</MessageTimestamp>
|
|
);
|
|
}
|
|
|
|
// The cancellation marker is a right-aligned, full-width turn-terminal row;
|
|
// a hover timestamp would overlap its text, so skip the MessageTimestamp
|
|
// wrapper. The data-user-selectable div is still applied for consistency.
|
|
if (message.role === 'system' && message.source === 'prompt_cancelled') {
|
|
return selectableSafeBody;
|
|
}
|
|
|
|
return (
|
|
<MessageTimestamp
|
|
timestamp={message.timestamp}
|
|
chatMode={message.role === 'user'}
|
|
copyText={message.role === 'user' ? message.content : undefined}
|
|
copyTitle={t('common.copy')}
|
|
>
|
|
{selectableSafeBody}
|
|
</MessageTimestamp>
|
|
);
|
|
}, areMessageItemPropsEqual);
|
|
|
|
// Aligns with the message it replaces: user messages are right-aligned bubbles,
|
|
// so the notice sits on the right too and still reads as that user turn's prompt
|
|
// (a left-aligned notice would look like it belongs to the previous turn's
|
|
// output). Assistant and other rows are left-aligned, matching their layout.
|
|
function MessageRenderError({ align }: { align: 'start' | 'end' }) {
|
|
const { t } = useI18n();
|
|
return (
|
|
<div
|
|
role="alert"
|
|
style={{
|
|
display: 'flex',
|
|
justifyContent: align === 'end' ? 'flex-end' : 'flex-start',
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
color: 'var(--error-color, #e06c75)',
|
|
fontSize: '0.85em',
|
|
opacity: 0.85,
|
|
}}
|
|
>
|
|
{t('message.renderError')}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function areMessageItemPropsEqual(
|
|
prev: MessageItemProps,
|
|
next: MessageItemProps,
|
|
): boolean {
|
|
if (prev.pendingApproval?.id !== next.pendingApproval?.id) return false;
|
|
if (prev.onShowContextDetail !== next.onShowContextDetail) return false;
|
|
if (prev.workspaceCwd !== next.workspaceCwd) return false;
|
|
if (prev.isLatest !== next.isLatest) return false;
|
|
if (prev.showRetryHint !== next.showRetryHint) return false;
|
|
if (prev.onRetryClick !== next.onRetryClick) return false;
|
|
if (prev.onBranchSession !== next.onBranchSession) return false;
|
|
if (prev.showAssistantActions !== next.showAssistantActions) return false;
|
|
if (prev.showAssistantBranch !== next.showAssistantBranch) return false;
|
|
if (prev.isLocateFlashing !== next.isLocateFlashing) return false;
|
|
if (prev.generateContent !== next.generateContent) return false;
|
|
if (
|
|
!areAssistantTurnFooterInfosEqual(
|
|
prev.assistantTurnFooterInfo,
|
|
next.assistantTurnFooterInfo,
|
|
)
|
|
) {
|
|
return false;
|
|
}
|
|
return areMessagesEqual(prev.message, next.message);
|
|
}
|
|
|
|
function areAssistantTurnFooterInfosEqual(
|
|
prev?: WebShellAssistantTurnFooterRenderInfo,
|
|
next?: WebShellAssistantTurnFooterRenderInfo,
|
|
): boolean {
|
|
if (prev === next) return true;
|
|
if (!prev || !next) return false;
|
|
return (
|
|
prev.turnId === next.turnId &&
|
|
prev.message.id === next.message.id &&
|
|
prev.message.content === next.message.content &&
|
|
prev.message.isStreaming === next.message.isStreaming &&
|
|
prev.message.timestamp === next.message.timestamp
|
|
);
|
|
}
|
|
|
|
function areMessagesEqual(prev: Message, next: Message): boolean {
|
|
if (prev === next) return true;
|
|
if (prev.id !== next.id || prev.role !== next.role) return false;
|
|
if (prev.timestamp !== next.timestamp) return false;
|
|
switch (prev.role) {
|
|
case 'user':
|
|
return (
|
|
next.role === 'user' &&
|
|
prev.content === next.content &&
|
|
stableImagesEqual(prev.images, next.images)
|
|
);
|
|
case 'assistant':
|
|
return (
|
|
next.role === 'assistant' &&
|
|
prev.content === next.content &&
|
|
prev.isStreaming === next.isStreaming
|
|
);
|
|
case 'thinking':
|
|
return (
|
|
next.role === 'thinking' &&
|
|
prev.content === next.content &&
|
|
prev.isStreaming === next.isStreaming
|
|
);
|
|
case 'system':
|
|
return (
|
|
next.role === 'system' &&
|
|
prev.content === next.content &&
|
|
prev.variant === next.variant &&
|
|
prev.retryable === next.retryable &&
|
|
prev.source === next.source &&
|
|
prev.data === next.data
|
|
);
|
|
case 'user_shell':
|
|
return (
|
|
next.role === 'user_shell' &&
|
|
prev.command === next.command &&
|
|
prev.output === next.output &&
|
|
prev.cwd === next.cwd
|
|
);
|
|
case 'btw':
|
|
return (
|
|
next.role === 'btw' &&
|
|
prev.question === next.question &&
|
|
prev.answer === next.answer &&
|
|
prev.isPending === next.isPending
|
|
);
|
|
case 'insight_progress':
|
|
return (
|
|
next.role === 'insight_progress' &&
|
|
prev.stage === next.stage &&
|
|
prev.progress === next.progress &&
|
|
prev.detail === next.detail
|
|
);
|
|
case 'insight_ready':
|
|
return next.role === 'insight_ready' && prev.path === next.path;
|
|
case 'insight_error':
|
|
return next.role === 'insight_error' && prev.error === next.error;
|
|
case 'plan':
|
|
return next.role === 'plan' && areTodosEqual(prev.todos, next.todos);
|
|
case 'tool_group':
|
|
return (
|
|
next.role === 'tool_group' &&
|
|
prev.tools.length === next.tools.length &&
|
|
prev.tools.every((tool, index) =>
|
|
areToolCallsEqual(tool, next.tools[index]),
|
|
)
|
|
);
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function areTodosEqual(prev: TodoItem[], next: TodoItem[]): boolean {
|
|
return (
|
|
prev.length === next.length &&
|
|
prev.every((todo, index) => {
|
|
const other = next[index];
|
|
return (
|
|
other &&
|
|
todo.id === other.id &&
|
|
todo.content === other.content &&
|
|
todo.status === other.status &&
|
|
todo.priority === other.priority
|
|
);
|
|
})
|
|
);
|
|
}
|
|
|
|
function areToolCallsEqual(
|
|
prev: ACPToolCall,
|
|
next: ACPToolCall | undefined,
|
|
): boolean {
|
|
if (!next) return false;
|
|
return (
|
|
prev.callId === next.callId &&
|
|
prev.toolName === next.toolName &&
|
|
prev.status === next.status &&
|
|
prev.title === next.title &&
|
|
prev.kind === next.kind &&
|
|
prev.startTime === next.startTime &&
|
|
prev.endTime === next.endTime &&
|
|
prev.subContent === next.subContent &&
|
|
stableJson(prev.args) === stableJson(next.args) &&
|
|
stableJson(prev.rawOutput) === stableJson(next.rawOutput) &&
|
|
stableJson(prev.locations) === stableJson(next.locations) &&
|
|
stableJson(prev.content) === stableJson(next.content) &&
|
|
areToolListsEqual(prev.subTools, next.subTools)
|
|
);
|
|
}
|
|
|
|
function areToolListsEqual(
|
|
prev: ACPToolCall[] | undefined,
|
|
next: ACPToolCall[] | undefined,
|
|
): boolean {
|
|
if (!prev && !next) return true;
|
|
if (!prev || !next || prev.length !== next.length) return false;
|
|
return prev.every((tool, index) => areToolCallsEqual(tool, next[index]));
|
|
}
|
|
|
|
const jsonCache = new WeakMap<object, string>();
|
|
|
|
function stableImagesEqual(
|
|
a: Array<{ data: string; mimeType: string }> | undefined,
|
|
b: Array<{ data: string; mimeType: string }> | undefined,
|
|
): boolean {
|
|
if (a === b) return true;
|
|
if (!a || !b || a.length !== b.length) return false;
|
|
return a.every(
|
|
(img, i) => img.data === b[i].data && img.mimeType === b[i].mimeType,
|
|
);
|
|
}
|
|
|
|
function stableJson(value: unknown): string {
|
|
if (value === undefined) return '';
|
|
if (value !== null && typeof value === 'object') {
|
|
let cached = jsonCache.get(value);
|
|
if (cached !== undefined) return cached;
|
|
try {
|
|
cached = JSON.stringify(value);
|
|
} catch {
|
|
cached = String(value);
|
|
}
|
|
jsonCache.set(value, cached);
|
|
return cached;
|
|
}
|
|
try {
|
|
return JSON.stringify(value);
|
|
} catch {
|
|
return String(value);
|
|
}
|
|
}
|