接入创作工作台会话导出

创作 Agent 工作台在原生壳中消费 file.exportText 导出会话 Markdown

补充会话导出按钮门控、payload 和错误提示测试

更新宿主壳能力文档和共享决策记录
This commit is contained in:
2026-06-19 08:22:38 +08:00
parent 6d7da493b9
commit b88ed43aef
5 changed files with 426 additions and 10 deletions
@@ -708,6 +708,198 @@ test('creation agent workspace imports document text through native HostBridge',
}
});
test('creation agent workspace exports session markdown through native HostBridge', async () => {
ensureScrollApis();
vi.spyOn(hostBridgeServices, 'canUseNativeHostCapability').mockImplementation(
(capability) => capability === 'file.exportText',
);
const exportSpy = vi
.spyOn(hostBridgeServices, 'exportHostTextFile')
.mockResolvedValue({
action: 'saved',
fileName: '创作记录-潮声工坊.md',
bytes: 120,
});
render(
<CreationAgentWorkspace
session={{
sessionId: 'creation-agent-session-1',
title: '潮声工坊',
assistantSummary: '围绕港口工坊推进设定。',
currentTurn: 2,
progressPercent: 64,
anchors: [
{
key: 'tone',
label: '基调',
value: '温暖悬疑',
status: 'confirmed',
},
],
messages: [
{
id: 'message-1',
role: 'user',
kind: 'chat',
text: '主角想找回失踪的师傅。',
},
{
id: 'message-2',
role: 'assistant',
kind: 'chat',
text: '可以把工坊设成潮汐驱动。',
},
],
}}
theme={testTheme}
loadingText="正在准备"
composerPlaceholder="输入消息"
primaryActionLabel="生成结果页"
onBack={() => {}}
onSubmitText={() => {}}
onPrimaryAction={() => {}}
/>,
);
fireEvent.change(screen.getByPlaceholderText('输入消息'), {
target: {
value: '补充当前草稿',
},
});
fireEvent.click(screen.getByRole('button', { name: '导出会话' }));
await waitFor(() => {
expect(exportSpy).toHaveBeenCalledTimes(1);
});
const payload = exportSpy.mock.calls[0]?.[0];
expect(payload?.fileName).toBe('创作记录-潮声工坊.md');
expect(payload?.mimeType).toBe('text/markdown');
expect(payload?.content).toContain('# 潮声工坊');
expect(payload?.content).toContain('创作进度: 64%');
expect(payload?.content).toContain('- 基调: 温暖悬疑 (confirmed)');
expect(payload?.content).toContain('### 用户');
expect(payload?.content).toContain('主角想找回失踪的师傅。');
expect(payload?.content).toContain('## 当前输入');
expect(payload?.content).toContain('补充当前草稿');
});
test('creation agent workspace hides native export when capability or content is missing', () => {
ensureScrollApis();
vi.spyOn(hostBridgeServices, 'canUseNativeHostCapability').mockReturnValue(
false,
);
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={() => {}}
/>,
);
expect(screen.queryByRole('button', { name: '导出会话' })).toBeNull();
vi.spyOn(hostBridgeServices, 'canUseNativeHostCapability').mockReturnValue(
true,
);
rerender(
<CreationAgentWorkspace
session={{
sessionId: 'creation-agent-session-2',
title: null,
assistantSummary: null,
currentTurn: 0,
progressPercent: 0,
anchors: [],
messages: [],
}}
theme={testTheme}
loadingText="正在准备"
composerPlaceholder="输入消息"
primaryActionLabel="生成结果页"
onBack={() => {}}
onSubmitText={() => {}}
onPrimaryAction={() => {}}
/>,
);
expect(screen.queryByRole('button', { name: '导出会话' })).toBeNull();
});
test('creation agent workspace shows native export errors near composer', async () => {
ensureScrollApis();
vi.spyOn(hostBridgeServices, 'canUseNativeHostCapability').mockImplementation(
(capability) => capability === 'file.exportText',
);
vi.spyOn(hostBridgeServices, 'exportHostTextFile').mockRejectedValue(
new Error('系统保存面板暂时不可用。'),
);
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={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '导出会话' }));
await waitFor(() => {
const errorMessage = screen.getByText('系统保存面板暂时不可用。');
expect(errorMessage).toBeTruthy();
expect(errorMessage.className).toContain('platform-status-message');
});
expect(
screen.getByRole('button', { name: '导出会话' }).getAttribute('disabled'),
).toBeNull();
});
test('creation agent workspace renders selected reference image with shared preview row', async () => {
ensureScrollApis();
@@ -1,7 +1,15 @@
import { ArrowLeft, ImagePlus, Paperclip, Send, Sparkles } from 'lucide-react';
import {
ArrowLeft,
Download,
ImagePlus,
Paperclip,
Send,
Sparkles,
} from 'lucide-react';
import type { ChangeEvent } from 'react';
import { useEffect, useRef, useState } from 'react';
import { HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES } from '../../../packages/shared/src/contracts/hostBridge';
import {
type CreationAgentProgressCopy,
normalizeCreationAgentProgress,
@@ -10,6 +18,7 @@ import {
} from '../../services/creation-agent';
import {
canUseNativeHostCapability,
exportHostTextFile,
importHostTextFile,
} from '../../services/host-bridge/hostBridge';
import { PlatformActionButton } from '../common/PlatformActionButton';
@@ -103,6 +112,7 @@ const AUTO_SCROLL_FOLLOW_THRESHOLD_PX = 96;
const DOCUMENT_INPUT_ACCEPT =
'.txt,.md,.markdown,.docx,.csv,.json,text/plain,text/markdown,text/csv,application/json,application/vnd.openxmlformats-officedocument.wordprocessingml.document';
const REFERENCE_IMAGE_INPUT_ACCEPT = 'image/png,image/jpeg,image/webp';
const CREATION_AGENT_EXPORT_MIME_TYPE = 'text/markdown';
function uniqueRecommendedReplies(recommendedReplies: string[] = []) {
return [
@@ -295,6 +305,147 @@ function scrollMessageListToBottom(container: HTMLDivElement) {
container.scrollTop = container.scrollHeight;
}
function getCreationAgentMessageRoleLabel(role: string) {
if (role === 'user') {
return '用户';
}
if (role === 'assistant') {
return '助手';
}
if (role === 'system') {
return '系统';
}
return role.trim() || '消息';
}
function hasCreationAgentExportableContent(
session: CreationAgentSessionView,
streamingReplyText: string,
draftText: string,
) {
return Boolean(
session.title?.trim() ||
session.assistantSummary?.trim() ||
session.anchors.some(
(anchor) => anchor.label.trim() || anchor.value.trim(),
) ||
session.messages.some((message) => message.text.trim()) ||
streamingReplyText.trim() ||
draftText.trim(),
);
}
function buildCreationAgentSessionMarkdown({
session,
progress,
streamingReplyText,
draftText,
}: {
session: CreationAgentSessionView;
progress: number;
streamingReplyText: string;
draftText: string;
}) {
if (!hasCreationAgentExportableContent(session, streamingReplyText, draftText)) {
return null;
}
const sections: string[] = [];
const title = session.title?.trim() || `创作记录 ${session.sessionId}`;
const summary = session.assistantSummary?.trim();
const visibleAnchors = session.anchors.filter(
(anchor) => anchor.label.trim() || anchor.value.trim(),
);
const visibleMessages = session.messages.filter((message) =>
message.text.trim(),
);
const streamingText = streamingReplyText.trim();
const currentDraftText = draftText.trim();
sections.push(`# ${title}`);
sections.push(
[
`会话 ID: ${session.sessionId}`,
`当前轮次: ${session.currentTurn}`,
`创作进度: ${progress}%`,
].join('\n'),
);
if (summary) {
sections.push(['## 摘要', summary].join('\n\n'));
}
if (visibleAnchors.length > 0) {
sections.push(
[
'## 已确认要点',
visibleAnchors
.map((anchor) => {
const label = anchor.label.trim() || anchor.key;
const value = anchor.value.trim();
const status = anchor.status.trim();
const statusText = status ? ` (${status})` : '';
return `- ${label}: ${value || '未填写'}${statusText}`;
})
.join('\n'),
].join('\n\n'),
);
}
if (visibleMessages.length > 0 || streamingText) {
const messageSections = visibleMessages.map((message) => {
const roleLabel = getCreationAgentMessageRoleLabel(message.role);
const timeLabel = message.createdAt?.trim();
const heading = timeLabel ? `${roleLabel} · ${timeLabel}` : roleLabel;
return [`### ${heading}`, message.text.trim()].join('\n\n');
});
if (streamingText) {
messageSections.push(['### 助手(生成中)', streamingText].join('\n\n'));
}
sections.push(['## 对话记录', messageSections.join('\n\n')].join('\n\n'));
}
if (currentDraftText) {
sections.push(['## 当前输入', currentDraftText].join('\n\n'));
}
return `${sections.join('\n\n')}\n`;
}
function normalizeCreationAgentExportFileNameSegment(rawValue: string) {
return rawValue
.trim()
.split('')
.map((character) =>
/[\u0000-\u001f<>:"/\\|?*]/u.test(character) ? '-' : character,
)
.join('')
.replace(/\s+/g, ' ')
.replace(/^[.\s-]+/, '')
.slice(0, 80)
.trim();
}
function buildCreationAgentExportFileName(session: CreationAgentSessionView) {
const segment =
normalizeCreationAgentExportFileNameSegment(session.title ?? '') ||
normalizeCreationAgentExportFileNameSegment(session.sessionId) ||
'creation-agent';
return `创作记录-${segment}.md`;
}
function getUtf8ByteLength(text: string) {
return new TextEncoder().encode(text).length;
}
export function CreationAgentWorkspace({
session,
theme,
@@ -325,6 +476,7 @@ export function CreationAgentWorkspace({
);
const [isParsingDocumentInput, setIsParsingDocumentInput] = useState(false);
const [isReadingReferenceImage, setIsReadingReferenceImage] = useState(false);
const [isExportingSessionText, setIsExportingSessionText] = useState(false);
// 统一聊天区只在用户仍停留在底部附近时跟随新内容,避免流式回复持续抢走阅读位置。
const messageListRef = useRef<HTMLDivElement | null>(null);
const documentInputRef = useRef<HTMLInputElement | null>(null);
@@ -385,6 +537,17 @@ export function CreationAgentWorkspace({
message.role === 'assistant' ? index : lastIndex,
-1,
);
const sessionExportMarkdown = buildCreationAgentSessionMarkdown({
session,
progress,
streamingReplyText,
draftText,
});
const canExportSessionText =
Boolean(sessionExportMarkdown) &&
canUseNativeHostCapability('file.exportText');
const isComposerFileTaskRunning =
isParsingDocumentInput || isReadingReferenceImage || isExportingSessionText;
const armAutoScrollToBottom = () => {
shouldAutoScrollRef.current = true;
@@ -406,7 +569,7 @@ export function CreationAgentWorkspace({
const submit = () => {
const text = draftText.trim();
if (!text || isBusy || isParsingDocumentInput || isReadingReferenceImage) {
if (!text || isBusy || isComposerFileTaskRunning) {
return;
}
@@ -475,6 +638,42 @@ export function CreationAgentWorkspace({
documentInputRef.current?.click();
};
const exportSessionText = async () => {
if (
!sessionExportMarkdown ||
isBusy ||
isComposerFileTaskRunning ||
!canExportSessionText
) {
return;
}
const exportBytes = getUtf8ByteLength(sessionExportMarkdown);
if (exportBytes > HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES) {
setDocumentInputError('会话文本超过 5 MiB,暂时无法导出。');
return;
}
setIsExportingSessionText(true);
setDocumentInputError(null);
try {
await exportHostTextFile({
fileName: buildCreationAgentExportFileName(session),
content: sessionExportMarkdown,
mimeType: CREATION_AGENT_EXPORT_MIME_TYPE,
});
} catch (exportError) {
setDocumentInputError(
exportError instanceof Error
? exportError.message
: '导出会话失败,请稍后重试。',
);
} finally {
setIsExportingSessionText(false);
}
};
const openReferenceImagePicker = () => {
referenceImageInputRef.current?.click();
};
@@ -686,7 +885,7 @@ export function CreationAgentWorkspace({
label={isParsingDocumentInput ? '正在解析文档' : '上传文档'}
title={isParsingDocumentInput ? '正在解析文档' : '上传文档'}
aria-busy={isParsingDocumentInput}
disabled={isBusy || isParsingDocumentInput}
disabled={isBusy || isComposerFileTaskRunning}
onClick={openDocumentInputPicker}
icon={
<Paperclip
@@ -695,6 +894,23 @@ export function CreationAgentWorkspace({
}
className="h-11 w-11 shrink-0"
/>
{canExportSessionText ? (
<PlatformIconButton
label={isExportingSessionText ? '正在导出会话' : '导出会话'}
title={isExportingSessionText ? '正在导出会话' : '导出会话'}
aria-busy={isExportingSessionText}
disabled={isBusy || isComposerFileTaskRunning}
onClick={() => {
void exportSessionText();
}}
icon={
<Download
className={`h-4 w-4 ${isExportingSessionText ? 'animate-pulse' : ''}`}
/>
}
className="h-11 w-11 shrink-0"
/>
) : null}
{onReferenceImageChange ? (
<PlatformIconButton
label={
@@ -704,7 +920,7 @@ export function CreationAgentWorkspace({
isReadingReferenceImage ? '正在读取参考图' : '上传参考图'
}
aria-busy={isReadingReferenceImage}
disabled={isBusy || isReadingReferenceImage}
disabled={isBusy || isComposerFileTaskRunning}
onClick={openReferenceImagePicker}
icon={
<ImagePlus
@@ -717,9 +933,7 @@ export function CreationAgentWorkspace({
<PlatformTextField
variant="textarea"
value={draftText}
disabled={
isBusy || isParsingDocumentInput || isReadingReferenceImage
}
disabled={isBusy || isComposerFileTaskRunning}
rows={2}
size="md"
density="compact"
@@ -741,8 +955,7 @@ export function CreationAgentWorkspace({
aria-label="发送"
disabled={
isBusy ||
isParsingDocumentInput ||
isReadingReferenceImage ||
isComposerFileTaskRunning ||
!draftText.trim()
}
onClick={submit}