接入创作工作台会话导出
创作 Agent 工作台在原生壳中消费 file.exportText 导出会话 Markdown 补充会话导出按钮门控、payload 和错误提示测试 更新宿主壳能力文档和共享决策记录
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user