init with react+axum+spacetimedb
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-26 18:06:23 +08:00
commit cbc27bad4a
20199 changed files with 883714 additions and 0 deletions

View File

@@ -0,0 +1,551 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import * as creationAgentServices from '../../services/creation-agent';
import { createCreationAgentChatQuickActions } from '../../services/creation-agent';
import {
type CreationAgentTheme,
CreationAgentWorkspace,
} from './CreationAgentWorkspace';
const testTheme: CreationAgentTheme = {
accentTextClass: 'text-emerald-100',
accentBgClass: 'bg-emerald-300',
accentButtonClass: 'bg-emerald-200',
userBubbleClass: 'bg-emerald-600 text-white',
heroClass: 'border border-emerald-100/20 bg-slate-900',
};
afterEach(() => {
vi.restoreAllMocks();
});
function ensureScrollApis() {
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
if (!HTMLElement.prototype.scrollTo) {
HTMLElement.prototype.scrollTo = () => {};
}
}
test('creation agent workspace keeps initial chat progress at zero percent', () => {
ensureScrollApis();
render(
<CreationAgentWorkspace
session={{
sessionId: 'creation-agent-session-1',
title: null,
currentTurn: 0,
progressPercent: 0,
anchors: [],
messages: [],
}}
theme={testTheme}
loadingText="正在准备"
composerPlaceholder="输入消息"
primaryActionLabel="生成结果页"
onBack={() => {}}
onSubmitText={() => {}}
onPrimaryAction={() => {}}
/>,
);
const progressbar = screen.getByRole('progressbar');
expect(progressbar.getAttribute('aria-valuenow')).toBe('0');
expect(
(progressbar.firstElementChild as HTMLElement | null)?.style.width,
).toBe('0%');
});
test('creation agent workspace filters duplicate recommended replies', () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined);
ensureScrollApis();
render(
<CreationAgentWorkspace
session={{
sessionId: 'creation-agent-session-1',
title: '统一共创',
currentTurn: 2,
progressPercent: 40,
anchors: [],
messages: [
{
id: '',
role: 'assistant',
kind: 'chat',
text: '先把方向收一下。',
},
],
recommendedReplies: [
'',
'继续补充冲突',
'继续补充冲突',
' 先确定玩家身份 ',
],
}}
theme={testTheme}
loadingText="正在准备"
composerPlaceholder="输入消息"
primaryActionLabel="生成结果页"
onBack={() => {}}
onSubmitText={() => {}}
onPrimaryAction={() => {}}
/>,
);
expect(screen.getByRole('button', { name: '继续补充冲突' })).toBeTruthy();
expect(screen.getByRole('button', { name: '先确定玩家身份' })).toBeTruthy();
expect(screen.queryByRole('button', { name: /^\s*$/u })).toBeNull();
const duplicateKeyCalls = consoleErrorSpy.mock.calls.filter((call) =>
call.some(
(arg) =>
typeof arg === 'string' &&
arg.includes('Encountered two children with the same key'),
),
);
expect(duplicateKeyCalls).toHaveLength(0);
});
test('creation agent workspace renders streaming assistant text', () => {
ensureScrollApis();
render(
<CreationAgentWorkspace
session={{
sessionId: 'creation-agent-session-1',
title: '统一共创',
currentTurn: 1,
progressPercent: 20,
anchors: [],
messages: [
{
id: 'message-1',
role: 'user',
kind: 'chat',
text: '我想做一个潮湿压抑的海上世界。',
},
],
}}
theme={testTheme}
loadingText="正在准备"
composerPlaceholder="输入消息"
primaryActionLabel="生成结果页"
streamingReplyText="那我先顺着这个方向收一下,开场时你更想让玩家撞上什么麻烦"
isStreamingReply
onBack={() => {}}
onSubmitText={() => {}}
onPrimaryAction={() => {}}
/>,
);
expect(screen.getByText(//u)).toBeTruthy();
});
test('creation agent workspace renders waiting dots before first streamed token', () => {
ensureScrollApis();
render(
<CreationAgentWorkspace
session={{
sessionId: 'creation-agent-session-1',
title: '统一共创',
currentTurn: 1,
progressPercent: 20,
anchors: [],
messages: [
{
id: 'message-1',
role: 'user',
kind: 'chat',
text: '我想做一个潮湿压抑的海上世界。',
},
],
}}
theme={testTheme}
loadingText="正在准备"
composerPlaceholder="输入消息"
primaryActionLabel="生成结果页"
streamingReplyText=""
isStreamingReply
onBack={() => {}}
onSubmitText={() => {}}
onPrimaryAction={() => {}}
/>,
);
expect(screen.getByTestId('creation-agent-waiting-dots')).toBeTruthy();
});
test('creation agent workspace appends streaming assistant message after stable message list', () => {
ensureScrollApis();
render(
<CreationAgentWorkspace
session={{
sessionId: 'creation-agent-session-1',
title: '统一共创',
currentTurn: 2,
progressPercent: 40,
anchors: [],
messages: [
{
id: 'message-user-1',
role: 'user',
kind: 'chat',
text: '我想做一个潮湿压抑的海上世界。',
},
{
id: 'message-assistant-1',
role: 'assistant',
kind: 'chat',
text: '我先接住这个方向。',
},
{
id: 'message-user-2',
role: 'user',
kind: 'chat',
text: '开场我想先撞上一场假航灯事故。',
},
],
}}
theme={testTheme}
loadingText="正在准备"
composerPlaceholder="输入消息"
primaryActionLabel="生成结果页"
streamingReplyText="那我就把开场事故往沉船旧案上收。"
isStreamingReply
onBack={() => {}}
onSubmitText={() => {}}
onPrimaryAction={() => {}}
/>,
);
const bubbles = screen
.getByTestId('creation-agent-message-list')
.querySelectorAll('.whitespace-pre-wrap');
const bubbleTexts = Array.from(bubbles).map((node) =>
node.textContent?.trim(),
);
expect(bubbleTexts).toEqual([
'我想做一个潮湿压抑的海上世界。',
'我先接住这个方向。',
'开场我想先撞上一场假航灯事故。',
'那我就把开场事故往沉船旧案上收。',
]);
});
test('creation agent workspace hides anchors and primary action before completed progress', () => {
ensureScrollApis();
render(
<CreationAgentWorkspace
session={{
sessionId: 'creation-agent-session-1',
title: '统一共创',
currentTurn: 2,
progressPercent: 99,
anchors: [
{
key: 'worldPromise',
label: '世界承诺',
value: '一个被潮雾改写航线秩序的群岛世界。',
status: 'confirmed',
},
],
messages: [
{
id: 'message-1',
role: 'assistant',
kind: 'chat',
text: '我们继续把设定收住。',
},
],
}}
theme={testTheme}
loadingText="正在准备"
composerPlaceholder="输入消息"
primaryActionLabel="生成结果页"
onBack={() => {}}
onSubmitText={() => {}}
onPrimaryAction={() => {}}
/>,
);
expect(screen.queryByRole('button', { name: '生成结果页' })).toBeNull();
expect(screen.queryByText('世界承诺')).toBeNull();
expect(screen.queryByText('一个被潮雾改写航线秩序的群岛世界。')).toBeNull();
});
test('creation agent workspace shows primary and progress actions at completed progress', () => {
ensureScrollApis();
render(
<CreationAgentWorkspace
session={{
sessionId: 'creation-agent-session-1',
title: '统一共创',
currentTurn: 2,
progressPercent: 100,
anchors: [],
messages: [
{
id: 'message-1',
role: 'assistant',
kind: 'chat',
text: '设定已经可以进入生成。',
},
],
}}
theme={testTheme}
loadingText="正在准备"
composerPlaceholder="输入消息"
primaryActionLabel="生成结果页"
quickActions={createCreationAgentChatQuickActions()}
onBack={() => {}}
onSubmitText={() => {}}
onPrimaryAction={() => {}}
/>,
);
expect(screen.getByRole('button', { name: '生成结果页' })).toBeTruthy();
expect(screen.getByRole('button', { name: '总结当前设定' })).toBeTruthy();
expect(screen.getByRole('button', { name: '补充剩余设定' })).toBeTruthy();
});
test('creation agent workspace hides hero copy area when title and summary are absent', () => {
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
render(
<CreationAgentWorkspace
session={{
sessionId: 'creation-agent-session-1',
title: null,
assistantSummary: null,
currentTurn: 2,
progressPercent: 60,
anchors: [],
messages: [
{
id: 'message-1',
role: 'assistant',
kind: 'chat',
text: '继续把设定收束到可生成状态。',
},
],
}}
theme={testTheme}
loadingText="正在准备"
composerPlaceholder="输入消息"
primaryActionLabel="生成结果页"
onBack={() => {}}
onSubmitText={() => {}}
onPrimaryAction={() => {}}
/>,
);
expect(screen.queryByText('统一共创')).toBeNull();
expect(screen.getByText('创作进度')).toBeTruthy();
});
test('creation agent workspace stops auto-follow when user scrolls away from bottom', () => {
ensureScrollApis();
const scrollToSpy = vi.fn();
HTMLElement.prototype.scrollTo = scrollToSpy;
const { rerender } = render(
<CreationAgentWorkspace
session={{
sessionId: 'creation-agent-session-1',
title: '统一共创',
currentTurn: 1,
progressPercent: 20,
anchors: [],
messages: [
{
id: 'message-1',
role: 'assistant',
kind: 'chat',
text: '先确定一下世界方向。',
},
],
}}
theme={testTheme}
loadingText="正在准备"
composerPlaceholder="输入消息"
primaryActionLabel="生成结果页"
onBack={() => {}}
onSubmitText={() => {}}
onPrimaryAction={() => {}}
/>,
);
const messageList = screen.getByTestId('creation-agent-message-list');
let scrollTop = 120;
Object.defineProperty(messageList, 'scrollHeight', {
configurable: true,
value: 640,
});
Object.defineProperty(messageList, 'clientHeight', {
configurable: true,
value: 240,
});
Object.defineProperty(messageList, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value) => {
scrollTop = Number(value);
},
});
fireEvent.scroll(messageList);
scrollToSpy.mockClear();
rerender(
<CreationAgentWorkspace
session={{
sessionId: 'creation-agent-session-1',
title: '统一共创',
currentTurn: 1,
progressPercent: 20,
anchors: [],
messages: [
{
id: 'message-1',
role: 'assistant',
kind: 'chat',
text: '先确定一下世界方向。',
},
],
}}
theme={testTheme}
loadingText="正在准备"
composerPlaceholder="输入消息"
primaryActionLabel="生成结果页"
streamingReplyText="继续往下收束开场冲突。"
isStreamingReply
onBack={() => {}}
onSubmitText={() => {}}
onPrimaryAction={() => {}}
/>,
);
expect(scrollToSpy).not.toHaveBeenCalled();
});
test('creation agent workspace appends parsed document text into composer', async () => {
ensureScrollApis();
vi.spyOn(
creationAgentServices,
'parseCreationAgentDocumentInput',
).mockResolvedValue({
document: {
fileName: '世界设定.md',
contentType: 'text/markdown',
sizeBytes: 24,
text: '第一章:潮湿的港口',
},
});
render(
<CreationAgentWorkspace
session={{
sessionId: 'creation-agent-session-1',
title: null,
currentTurn: 0,
progressPercent: 0,
anchors: [],
messages: [],
}}
theme={testTheme}
loadingText="正在准备"
composerPlaceholder="输入消息"
primaryActionLabel="生成结果页"
onBack={() => {}}
onSubmitText={() => {}}
onPrimaryAction={() => {}}
/>,
);
fireEvent.change(screen.getByPlaceholderText('输入消息'), {
target: {
value: '已有方向',
},
});
const input = document.querySelector<HTMLInputElement>('input[type="file"]');
expect(input).toBeTruthy();
fireEvent.change(input!, {
target: {
files: [new File(['unused'], '世界设定.md', { type: 'text/markdown' })],
},
});
await waitFor(() => {
expect(
(screen.getByPlaceholderText('输入消息') as HTMLTextAreaElement).value,
).toBe('已有方向\n\n第一章潮湿的港口');
});
});
test('creation agent workspace shows document parse error near composer', async () => {
ensureScrollApis();
vi.spyOn(
creationAgentServices,
'parseCreationAgentDocumentInput',
).mockRejectedValue(new Error('暂时只支持 txt、md、csv、json 文本文档。'));
render(
<CreationAgentWorkspace
session={{
sessionId: 'creation-agent-session-1',
title: null,
currentTurn: 0,
progressPercent: 0,
anchors: [],
messages: [],
}}
theme={testTheme}
loadingText="正在准备"
composerPlaceholder="输入消息"
primaryActionLabel="生成结果页"
onBack={() => {}}
onSubmitText={() => {}}
onPrimaryAction={() => {}}
/>,
);
const input = document.querySelector<HTMLInputElement>('input[type="file"]');
expect(input).toBeTruthy();
fireEvent.change(input!, {
target: {
files: [new File(['unused'], '世界设定.docx')],
},
});
await waitFor(() => {
expect(
screen.getByText('暂时只支持 txt、md、csv、json 文本文档。'),
).toBeTruthy();
});
});

View File

@@ -0,0 +1,612 @@
import { ArrowLeft, Paperclip, Send, Sparkles } from 'lucide-react';
import type { ChangeEvent } from 'react';
import { useEffect, useRef, useState } from 'react';
import {
type CreationAgentProgressCopy,
normalizeCreationAgentProgress,
parseCreationAgentDocumentInput,
resolveCreationAgentProgressHint,
} from '../../services/creation-agent';
export type CreationAgentAnchorView = {
key: string;
label: string;
value: string;
status: string;
};
export type CreationAgentMessageView = {
id: string;
role: string;
kind?: string;
text: string;
createdAt?: string;
};
export type CreationAgentOperationView = {
operationId?: string;
type?: string;
status: string;
phaseLabel: string;
phaseDetail?: string;
progress: number;
error?: string | null;
};
export type CreationAgentSessionView = {
sessionId: string;
title?: string | null;
assistantSummary?: string | null;
currentTurn: number;
progressPercent: number;
anchors: CreationAgentAnchorView[];
messages: CreationAgentMessageView[];
recommendedReplies?: string[];
};
export type CreationAgentTheme = {
accentTextClass: string;
accentBgClass: string;
accentButtonClass: string;
userBubbleClass: string;
heroClass: string;
anchorGridClass?: string;
};
export type CreationAgentQuickAction = {
key: string;
label: string;
minTurn?: number;
minProgress?: number;
showWhenComplete?: boolean;
};
type CreationAgentWorkspaceProps = {
session: CreationAgentSessionView | null;
theme: CreationAgentTheme;
loadingText: string;
composerPlaceholder: string;
primaryActionLabel: string;
progressCopy?: CreationAgentProgressCopy;
activeOperation?: CreationAgentOperationView | null;
streamingReplyText?: string;
isStreamingReply?: boolean;
isBusy?: boolean;
error?: string | null;
quickActions?: CreationAgentQuickAction[];
onBack: () => void;
onSubmitText: (text: string, quickActionKey?: string) => void;
onPrimaryAction: () => void;
onQuickAction?: (action: CreationAgentQuickAction) => void;
};
const AUTO_SCROLL_FOLLOW_THRESHOLD_PX = 96;
const DOCUMENT_INPUT_ACCEPT =
'.txt,.md,.markdown,.csv,.json,text/plain,text/markdown,text/csv,application/json';
function uniqueRecommendedReplies(recommendedReplies: string[] = []) {
return [
...new Set(recommendedReplies.map((reply) => reply.trim()).filter(Boolean)),
].slice(0, 3);
}
function CreationAgentOperationBanner({
operation,
}: {
operation: CreationAgentOperationView | null | undefined;
}) {
const [visibleOperation, setVisibleOperation] =
useState<CreationAgentOperationView | null>(operation ?? null);
useEffect(() => {
setVisibleOperation(operation ?? null);
if (operation?.status !== 'completed') {
return;
}
const timeoutId = window.setTimeout(() => {
setVisibleOperation((current) =>
current?.operationId === operation.operationId ? null : current,
);
}, 1200);
return () => window.clearTimeout(timeoutId);
}, [operation]);
if (!visibleOperation) {
return null;
}
const isFailed = visibleOperation.status === 'failed';
const isRunning =
visibleOperation.status === 'running' ||
visibleOperation.status === 'queued';
const bannerToneClass = isFailed
? 'platform-banner--danger'
: isRunning
? 'platform-banner--info'
: 'platform-banner--success';
const progress = normalizeCreationAgentProgress(visibleOperation.progress);
const progressFillStyle = isFailed
? { background: 'linear-gradient(90deg, #fb7185 0%, #f43f5e 100%)' }
: isRunning
? { background: 'var(--platform-button-primary-fill)' }
: { background: 'linear-gradient(90deg, #86efac 0%, #34d399 100%)' };
return (
<div
className={`platform-remap-surface platform-banner rounded-[1.4rem] px-4 py-4 ${bannerToneClass}`}
>
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold">
{visibleOperation.phaseLabel}
</div>
<div className="text-xs opacity-80">{progress}%</div>
</div>
{visibleOperation.phaseDetail ? (
<div className="mt-1 text-xs opacity-80">
{visibleOperation.phaseDetail}
</div>
) : null}
{visibleOperation.error ? (
<div className="mt-2 text-sm opacity-90">{visibleOperation.error}</div>
) : null}
<div className="platform-progress-track mt-3 h-2 overflow-hidden rounded-full">
<div
className="h-full rounded-full transition-[width] duration-300"
style={{
width: `${Math.max(8, progress)}%`,
...progressFillStyle,
}}
/>
</div>
</div>
);
}
function CreationAgentMessageBubble({
message,
theme,
recommendedReplies,
isStreaming = false,
onRecommendedReply,
}: {
message: CreationAgentMessageView;
theme: CreationAgentTheme;
recommendedReplies?: string[];
isStreaming?: boolean;
onRecommendedReply: (text: string) => void;
}) {
const isUser = message.role === 'user';
const isSystem = message.role === 'system';
const visibleRecommendedReplies =
isUser || isStreaming ? [] : uniqueRecommendedReplies(recommendedReplies);
const bubbleToneClass = isUser
? theme.userBubbleClass
: isSystem
? 'border border-[var(--platform-warm-border)] bg-[var(--platform-warm-bg)] text-[var(--platform-warm-text)]'
: 'platform-subpanel text-[var(--platform-text-strong)]';
return (
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
<div
className={`max-w-[90%] rounded-[1.4rem] px-4 py-3 text-sm leading-7 break-words sm:max-w-[82%] ${bubbleToneClass}`}
>
{isStreaming ? (
message.text ? (
<div className="whitespace-pre-wrap">
{message.text}
<span
className={`ml-1 inline-block h-4 w-1 animate-pulse rounded-full align-[-2px] ${theme.accentBgClass}`}
/>
</div>
) : (
<div
data-testid="creation-agent-waiting-dots"
aria-label="等待回复"
className="flex items-center gap-1.5 py-1"
>
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)] [animation-delay:-0.2s]" />
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)] [animation-delay:-0.1s]" />
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)]" />
</div>
)
) : (
<div className="whitespace-pre-wrap">{message.text}</div>
)}
{visibleRecommendedReplies.length > 0 ? (
<div className="mt-2.5 flex flex-col gap-1.5">
{visibleRecommendedReplies.map((reply, replyIndex) => (
<button
key={`recommended-reply-${replyIndex}-${reply}`}
type="button"
onClick={() => onRecommendedReply(reply)}
className="platform-button platform-button--ghost min-h-0 justify-start rounded-[0.95rem] px-2.5 py-1.5 text-left text-[11px] leading-4.5 whitespace-normal"
>
{reply}
</button>
))}
</div>
) : null}
</div>
</div>
);
}
function shouldShowQuickAction(
action: CreationAgentQuickAction,
session: CreationAgentSessionView,
progress: number,
) {
if (action.showWhenComplete && progress < 100) {
return false;
}
if (
typeof action.minTurn === 'number' &&
session.currentTurn < action.minTurn
) {
return false;
}
if (typeof action.minProgress === 'number' && progress < action.minProgress) {
return false;
}
return true;
}
function isMessageListNearBottom(container: HTMLDivElement) {
return (
container.scrollHeight - container.scrollTop - container.clientHeight <=
AUTO_SCROLL_FOLLOW_THRESHOLD_PX
);
}
function scrollMessageListToBottom(container: HTMLDivElement) {
if (typeof container.scrollTo === 'function') {
container.scrollTo({
top: container.scrollHeight,
behavior: 'auto',
});
return;
}
container.scrollTop = container.scrollHeight;
}
export function CreationAgentWorkspace({
session,
theme,
loadingText,
composerPlaceholder,
primaryActionLabel,
progressCopy,
activeOperation = null,
streamingReplyText = '',
isStreamingReply = false,
isBusy = false,
error = null,
quickActions = [],
onBack,
onSubmitText,
onPrimaryAction,
onQuickAction,
}: CreationAgentWorkspaceProps) {
const [draftText, setDraftText] = useState('');
const [documentInputError, setDocumentInputError] = useState<string | null>(
null,
);
const [isParsingDocumentInput, setIsParsingDocumentInput] = useState(false);
// 统一聊天区只在用户仍停留在底部附近时跟随新内容,避免流式回复持续抢走阅读位置。
const messageListRef = useRef<HTMLDivElement | null>(null);
const documentInputRef = useRef<HTMLInputElement | null>(null);
const shouldAutoScrollRef = useRef(true);
useEffect(() => {
const container = messageListRef.current;
if (!container || !shouldAutoScrollRef.current) {
return;
}
scrollMessageListToBottom(container);
}, [
session?.sessionId,
session?.messages,
streamingReplyText,
isStreamingReply,
]);
if (!session) {
return (
<div className="flex h-full items-center justify-center">
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
{error || loadingText}
</div>
</div>
);
}
const progress = normalizeCreationAgentProgress(session.progressPercent);
const progressFillWidth = progress <= 0 ? '0%' : `${Math.max(6, progress)}%`;
const hasHeroCopy = Boolean(session.title || session.assistantSummary);
const canShowPrimaryAction = progress >= 100;
const visibleQuickActions = quickActions.filter((action) =>
shouldShowQuickAction(action, session, progress),
);
const streamingMessageId = `streaming-assistant-${session.sessionId}`;
// 用户消息提交后、首个流式文本到达前,也要立刻展示等待气泡。
const shouldShowStreamingReply = isStreamingReply;
const displayedMessages = shouldShowStreamingReply
? [
...session.messages,
{
id: streamingMessageId,
role: 'assistant',
kind: 'chat',
text: streamingReplyText,
} satisfies CreationAgentMessageView,
]
: session.messages;
const lastAssistantMessageIndex = session.messages.reduce(
(lastIndex, message, index) =>
message.role === 'assistant' ? index : lastIndex,
-1,
);
const armAutoScrollToBottom = () => {
shouldAutoScrollRef.current = true;
};
const handleMessageListScroll = () => {
const container = messageListRef.current;
if (!container) {
return;
}
shouldAutoScrollRef.current = isMessageListNearBottom(container);
};
const submitRecommendedReply = (text: string) => {
armAutoScrollToBottom();
onSubmitText(text);
};
const submit = () => {
const text = draftText.trim();
if (!text || isBusy || isParsingDocumentInput) {
return;
}
armAutoScrollToBottom();
onSubmitText(text);
setDraftText('');
setDocumentInputError(null);
};
const appendDocumentInputText = (text: string) => {
setDraftText((current) => {
const currentText = current.trimEnd();
const nextText = text.trim();
return currentText ? `${currentText}\n\n${nextText}` : nextText;
});
};
const openDocumentInputPicker = () => {
documentInputRef.current?.click();
};
const handleDocumentInputChange = async (
event: ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0] ?? null;
event.target.value = '';
if (!file || isBusy || isParsingDocumentInput) {
return;
}
setIsParsingDocumentInput(true);
setDocumentInputError(null);
try {
const response = await parseCreationAgentDocumentInput(file);
appendDocumentInputText(response.document.text);
} catch (parseError) {
setDocumentInputError(
parseError instanceof Error
? parseError.message
: '解析文档失败,请重新选择文件。',
);
} finally {
setIsParsingDocumentInput(false);
}
};
return (
<div className="mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col gap-3 overflow-hidden px-1 sm:px-0">
<div
className={`relative overflow-hidden rounded-[1.8rem] px-4 py-4 text-white shadow-[0_20px_60px_rgba(15,23,42,0.18)] sm:px-5 ${theme.heroClass}`}
>
<div className="flex items-start justify-between gap-3">
<button
type="button"
aria-label="返回"
onClick={onBack}
disabled={isBusy}
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-white/10 text-white/84 disabled:opacity-45"
>
<ArrowLeft className="h-4 w-4" />
</button>
{canShowPrimaryAction ? (
<button
type="button"
disabled={isBusy}
onClick={onPrimaryAction}
className={`inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-bold text-slate-950 shadow-lg disabled:cursor-not-allowed disabled:opacity-50 ${theme.accentButtonClass}`}
>
<Sparkles className="h-4 w-4" />
{primaryActionLabel}
</button>
) : null}
</div>
{hasHeroCopy ? (
<div className="mt-6">
{session.title ? (
<div className="text-2xl font-black leading-tight sm:text-3xl">
{session.title}
</div>
) : null}
{session.assistantSummary ? (
<div className="mt-2 max-w-2xl text-sm leading-6 text-white/76">
{session.assistantSummary}
</div>
) : null}
</div>
) : null}
<div className={hasHeroCopy ? 'mt-4' : 'mt-6'}>
<div className="mb-2 flex items-center justify-between gap-3">
<span className="text-xs font-semibold tracking-[0.14em] text-white/72">
</span>
<span className="text-sm font-semibold text-white/88">
{progress}%
</span>
</div>
<div
className="h-2 overflow-hidden rounded-full bg-white/12"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={progress}
>
<div
className={`h-full rounded-full transition-all ${theme.accentBgClass}`}
style={{ width: progressFillWidth }}
/>
</div>
<div className="mt-2 text-xs leading-5 text-white/64">
{resolveCreationAgentProgressHint(progress, progressCopy)}
</div>
</div>
{visibleQuickActions.length > 0 ? (
<div className="mt-4 flex flex-wrap gap-2">
{visibleQuickActions.map((action) => (
<button
key={action.key}
type="button"
disabled={isBusy}
onClick={() => onQuickAction?.(action)}
className="rounded-full border border-white/14 bg-white/10 px-3 py-1.5 text-xs font-semibold text-white/78 disabled:cursor-not-allowed disabled:opacity-45"
>
{action.label}
</button>
))}
</div>
) : null}
</div>
<CreationAgentOperationBanner operation={activeOperation} />
<div className="min-h-0 flex-1 overflow-hidden rounded-[1.6rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)]">
<div className="flex h-full min-h-0 flex-col">
<div
ref={messageListRef}
data-testid="creation-agent-message-list"
onScroll={handleMessageListScroll}
className="min-h-0 flex-1 space-y-3 overflow-y-auto px-4 py-4"
>
{displayedMessages.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-[var(--platform-text-soft)]">
</div>
) : (
displayedMessages.map((message, index) => (
<CreationAgentMessageBubble
key={message.id || `message-${index}`}
message={message}
theme={theme}
isStreaming={message.id === streamingMessageId}
recommendedReplies={
message.id !== streamingMessageId &&
index === lastAssistantMessageIndex
? session.recommendedReplies
: []
}
onRecommendedReply={submitRecommendedReply}
/>
))
)}
</div>
{documentInputError || error ? (
<div className="mx-4 mb-3 rounded-[1rem] border border-red-200/70 bg-red-50 px-3 py-2 text-sm text-red-600">
{documentInputError || error}
</div>
) : null}
<div className="border-t border-[var(--platform-subpanel-border)] p-3">
<div className="flex items-end gap-2 rounded-[1.25rem] bg-white/80 p-2">
<input
ref={documentInputRef}
type="file"
accept={DOCUMENT_INPUT_ACCEPT}
className="hidden"
onChange={handleDocumentInputChange}
/>
<button
type="button"
aria-label={
isParsingDocumentInput ? '正在解析文档' : '上传文档'
}
title={isParsingDocumentInput ? '正在解析文档' : '上传文档'}
aria-busy={isParsingDocumentInput}
disabled={isBusy || isParsingDocumentInput}
onClick={openDocumentInputPicker}
className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-[var(--platform-text-base)] hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-40"
>
<Paperclip
className={`h-4 w-4 ${isParsingDocumentInput ? 'animate-pulse' : ''}`}
/>
</button>
<textarea
value={draftText}
disabled={isBusy || isParsingDocumentInput}
rows={2}
onChange={(event) => {
setDraftText(event.target.value);
setDocumentInputError(null);
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
submit();
}
}}
placeholder={composerPlaceholder}
className="min-h-[3rem] flex-1 resize-none bg-transparent px-2 py-2 text-sm text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]"
/>
<button
type="button"
aria-label="发送"
disabled={isBusy || isParsingDocumentInput || !draftText.trim()}
onClick={submit}
className={`inline-flex h-11 w-11 items-center justify-center rounded-full text-white disabled:cursor-not-allowed disabled:opacity-40 ${theme.userBubbleClass}`}
>
<Send className="h-4 w-4" />
</button>
</div>
</div>
</div>
</div>
</div>
);
}
export default CreationAgentWorkspace;

View File

@@ -0,0 +1 @@
export * from './CreationAgentWorkspace';