mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-16 04:05:15 +00:00
* feat(web-shell): mark scheduled task turns in timeline * fix(web-shell): confine locate flash to message content * fix(web-shell): flash parallel agent locate target * fix(web-shell): keep scheduled marker source optional * fix(web-shell): omit default scheduled timeline flag * fix(web-shell): repair scheduled timeline UI conflict * fix(web-shell): remove stale shell output prop --------- Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
335 lines
9 KiB
TypeScript
335 lines
9 KiB
TypeScript
import {
|
|
memo,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import { Markdown } from './Markdown';
|
|
import { CompactModeContext } from '../../App';
|
|
import { useI18n } from '../../i18n';
|
|
import { formatTimestamp } from '../MessageTimestamp';
|
|
import flashStyles from '../MessageLocateFlash.module.css';
|
|
import styles from './AssistantMessage.module.css';
|
|
|
|
interface AssistantMessageProps {
|
|
content: string;
|
|
isStreaming?: boolean;
|
|
timestamp?: number;
|
|
onBranchSession?: () => void;
|
|
showFooterActions?: boolean;
|
|
showBranchAction?: boolean;
|
|
isLocateFlashing?: boolean;
|
|
}
|
|
|
|
export const AssistantMessage = memo(function AssistantMessage({
|
|
content,
|
|
isStreaming,
|
|
timestamp,
|
|
onBranchSession,
|
|
showFooterActions = false,
|
|
showBranchAction = false,
|
|
isLocateFlashing = false,
|
|
}: AssistantMessageProps) {
|
|
const { t } = useI18n();
|
|
const [copied, setCopied] = useState(false);
|
|
const showFooter = !!content && !isStreaming && showFooterActions;
|
|
const handleCopy = useCallback(() => {
|
|
const write = navigator.clipboard?.writeText(content);
|
|
if (!write) {
|
|
return;
|
|
}
|
|
void write
|
|
.then(() => {
|
|
setCopied(true);
|
|
window.setTimeout(() => setCopied(false), 2000);
|
|
})
|
|
.catch(() => {});
|
|
}, [content]);
|
|
return (
|
|
<div className={styles.message}>
|
|
{content && (
|
|
<div
|
|
className={`${styles.content}${
|
|
isLocateFlashing ? ` ${flashStyles.flash}` : ''
|
|
}`}
|
|
>
|
|
<div className={styles.contentBody}>
|
|
<Markdown
|
|
content={content}
|
|
source="assistant"
|
|
isStreaming={isStreaming}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{showFooter && (
|
|
<div className={styles.messageFooter}>
|
|
<button
|
|
type="button"
|
|
className={styles.copyButton}
|
|
title={t('assistant.copy')}
|
|
aria-label={t('assistant.copy')}
|
|
onClick={handleCopy}
|
|
>
|
|
{copied ? <CheckIcon /> : <CopyIcon />}
|
|
</button>
|
|
{showBranchAction && onBranchSession && (
|
|
<button
|
|
type="button"
|
|
className={styles.copyButton}
|
|
title={t('assistant.branch')}
|
|
aria-label={t('assistant.branch')}
|
|
onClick={onBranchSession}
|
|
>
|
|
<BranchIcon />
|
|
</button>
|
|
)}
|
|
{timestamp !== undefined && (
|
|
<span className={styles.footerTime} aria-hidden="true">
|
|
{formatTimestamp(timestamp)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
function CopyIcon() {
|
|
return (
|
|
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
<path
|
|
d="M5.2 4.4V3.2c0-.7.5-1.2 1.2-1.2h5.4c.7 0 1.2.5 1.2 1.2v5.4c0 .7-.5 1.2-1.2 1.2h-1.2"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth="1.3"
|
|
/>
|
|
<rect
|
|
x="3"
|
|
y="5.2"
|
|
width="7.8"
|
|
height="7.8"
|
|
rx="1.2"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.3"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function CheckIcon() {
|
|
return (
|
|
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
<path
|
|
d="m3.5 8.3 3 3L12.8 5"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth="1.6"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function BranchIcon() {
|
|
return (
|
|
<svg viewBox="0 0 16 16" aria-hidden="true">
|
|
<path
|
|
d="M5 3.5v5.2c0 2.1 1.7 3.8 3.8 3.8H11"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth="1.35"
|
|
/>
|
|
<path
|
|
d="M5 8.2h3.2c1.5 0 2.8-1.2 2.8-2.8V4"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth="1.35"
|
|
/>
|
|
<circle cx="5" cy="3.5" r="1.5" fill="currentColor" />
|
|
<circle cx="11" cy="4" r="1.5" fill="currentColor" />
|
|
<circle cx="11" cy="12.5" r="1.5" fill="currentColor" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
interface ThinkingMessageProps {
|
|
content: string;
|
|
isStreaming?: boolean;
|
|
timestamp?: number;
|
|
isLocateFlashing?: boolean;
|
|
}
|
|
|
|
export const ThinkingMessage = memo(function ThinkingMessage({
|
|
content,
|
|
isStreaming,
|
|
timestamp,
|
|
isLocateFlashing = false,
|
|
}: ThinkingMessageProps) {
|
|
const { t } = useI18n();
|
|
const compactMode = useContext(CompactModeContext);
|
|
const [thinkingExpanded, setThinkingExpanded] = useState(false);
|
|
const thinkingSummaryKey = getThinkingSummaryKey({ isStreaming });
|
|
const thinkingActive = thinkingSummaryKey === 'thinking.running';
|
|
const startTimeRef = useRef(timestamp ?? Date.now());
|
|
const sawActiveRef = useRef(thinkingActive);
|
|
const [now, setNow] = useState(() => Date.now());
|
|
const [finishedAt, setFinishedAt] = useState<number | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!content || !thinkingActive) return;
|
|
setNow(Date.now());
|
|
const id = setInterval(() => setNow(Date.now()), 1000);
|
|
return () => clearInterval(id);
|
|
}, [content, thinkingActive]);
|
|
|
|
useEffect(() => {
|
|
if (!content) return;
|
|
if (thinkingActive) {
|
|
sawActiveRef.current = true;
|
|
setFinishedAt(null);
|
|
return;
|
|
}
|
|
if (sawActiveRef.current && finishedAt === null) {
|
|
setFinishedAt(Date.now());
|
|
}
|
|
}, [content, finishedAt, thinkingActive]);
|
|
|
|
const thinkingDuration =
|
|
thinkingActive || finishedAt
|
|
? formatThinkingDuration(
|
|
(thinkingActive ? now : finishedAt!) - startTimeRef.current,
|
|
)
|
|
: '';
|
|
|
|
const handleToggle = useCallback(() => {
|
|
setThinkingExpanded((v) => !v);
|
|
}, []);
|
|
|
|
return (
|
|
<div
|
|
className={`${styles.message}${
|
|
isLocateFlashing ? ` ${flashStyles.flash}` : ''
|
|
}`}
|
|
>
|
|
{content && !compactMode && (
|
|
<div className={styles.thinking}>
|
|
<div className={styles.thinkingBody}>
|
|
<button
|
|
type="button"
|
|
className={styles.thinkingSummary}
|
|
onClick={handleToggle}
|
|
aria-expanded={thinkingExpanded}
|
|
title={
|
|
thinkingExpanded ? t('thinking.collapse') : t('thinking.expand')
|
|
}
|
|
>
|
|
<span className={styles.thinkingSummaryIcon} aria-hidden="true">
|
|
<ThinkingDoneIcon />
|
|
</span>
|
|
<span
|
|
className={
|
|
thinkingActive
|
|
? `${styles.thinkingSummaryText} ${styles.thinkingSummaryTextActive}`
|
|
: styles.thinkingSummaryText
|
|
}
|
|
>
|
|
{t(
|
|
thinkingSummaryKey,
|
|
thinkingActive ? { duration: thinkingDuration } : {},
|
|
)}
|
|
</span>
|
|
<span
|
|
className={
|
|
thinkingExpanded
|
|
? styles.thinkingChevronDown
|
|
: styles.thinkingChevronRight
|
|
}
|
|
aria-hidden="true"
|
|
/>
|
|
</button>
|
|
<div
|
|
className={
|
|
thinkingExpanded
|
|
? styles.thinkingExpandedClip
|
|
: `${styles.thinkingExpandedClip} ${styles.thinkingExpandedCollapsed}`
|
|
}
|
|
>
|
|
<div className={styles.thinkingExpandedInner}>
|
|
<div className={styles.thinkingExpandedWrap}>
|
|
<Markdown
|
|
content={content}
|
|
source="thinking"
|
|
isStreaming={isStreaming}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
export function getThinkingSummaryKey({
|
|
isStreaming,
|
|
}: {
|
|
isStreaming?: boolean;
|
|
}): 'thinking.running' | 'thinking.done' {
|
|
return isStreaming ? 'thinking.running' : 'thinking.done';
|
|
}
|
|
|
|
export function formatThinkingDuration(ms: number): string {
|
|
const totalSec = Math.max(1, Math.round(ms / 1000));
|
|
if (totalSec < 60) return `${totalSec}s`;
|
|
const min = Math.floor(totalSec / 60);
|
|
const sec = totalSec % 60;
|
|
return sec > 0 ? `${min}m ${sec}s` : `${min}m`;
|
|
}
|
|
|
|
function ThinkingDoneIcon() {
|
|
return (
|
|
<svg
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 18 18"
|
|
fill="none"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
d="M7.2 15.2h4"
|
|
stroke="currentColor"
|
|
strokeWidth="1.45"
|
|
strokeLinecap="round"
|
|
/>
|
|
<path
|
|
d="M6.5 13.1h5.4"
|
|
stroke="currentColor"
|
|
strokeWidth="1.45"
|
|
strokeLinecap="round"
|
|
/>
|
|
<path
|
|
d="M9.1 2.8c-3 0-5.1 2.3-5.1 5 0 1.7.8 3.1 2.1 4 .5.4.8.8.8 1.4h4.5c0-.6.3-1 .8-1.4 1.3-.9 2.1-2.3 2.1-4 0-.8-.2-1.6-.6-2.3"
|
|
stroke="currentColor"
|
|
strokeWidth="1.45"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
<path
|
|
d="M13.2 1.8 14 3.6l1.8.8-1.8.8-.8 1.8-.8-1.8-1.8-.8 1.8-.8.8-1.8Z"
|
|
fill="currentColor"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|