feat(web): await freeform upload register before send

Make InputArea/onSendMessage async; fail closed with an explicit UI error
when createFreeformDoc fails, and persist fileStore under server doc-free IDs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-13 11:12:03 +00:00
co-authored by Cursor
parent a3c6df309b
commit ef0b2645e5
6 changed files with 392 additions and 82 deletions
+31 -60
View File
@@ -11,6 +11,7 @@ import { CommentSidePanel } from './components/doc/CommentSidePanel';
import { DocGovActionBar } from './components/doc/DocGovActionBar';
import { FreeformDocumentActionBar } from './components/doc/FreeformDocumentActionBar';
import {
createFreeformDoc,
saveMessageToPrivate,
listFreeformDocs,
fetchFreeformDoc,
@@ -18,6 +19,7 @@ import {
unshareFreeformDoc,
deleteFreeformDoc,
} from './api/freeformDoc';
import { registerUploadAttachments } from './lib/uploadFreeformRegister';
import { fetchChatSessions, upsertChatSession } from './api/chatSessions';
import { listProjectWorkflowDocuments } from './api/projectDocuments';
import { patchDocumentContent, getRevisionState } from './api/documentRevision';
@@ -2708,11 +2710,11 @@ export default function App() {
};
// 发送消息与对话驱动双轨逻辑
const handleSendMessage = (
const handleSendMessage = async (
text: string,
attachments?: { name: string; size: string; type: string; docId?: string; fileRaw?: File | Blob; fileUrl?: string }[],
quotes?: QuotedMessage[]
): SendMessageResult => {
): Promise<SendMessageResult> => {
const trimmed = text.trim();
let clientTurnId: string;
try {
@@ -2724,53 +2726,32 @@ export default function App() {
};
}
// 若包含附件,则自动将用户上传的文件批量注册为个人私密文档 (category: 'private')
// 附件必须先注册为服务端 freeform(server docId);失败硬拦,禁止本地 orphan 回退
let processedAttachments = attachments;
if (attachments && attachments.length > 0) {
const newDeliverablesList: DeliverableItem[] = [];
const newDocCacheEntries: Record<string, DocumentAST> = {};
const now = Date.now();
processedAttachments = attachments.map((att, idx) => {
const fileDocId = att.docId || `doc-upload-${now}-${idx}`;
const baseTitle = att.name.replace(/\.[^/.]+$/, '');
const extMatch = att.name.match(/\.([^.]+)$/);
const ext = extMatch ? extMatch[1].toLowerCase() : '';
const newDeliverable: DeliverableItem = {
docId: fileDocId,
let registered;
try {
registered = await registerUploadAttachments({
attachments,
projectId: currentProjectId,
taskKey: 'user_requirements',
title: baseTitle,
stage: 'V0.5',
category: 'private',
creatorId: currentUser.id,
creatorName: currentUser.name,
isFixedWorkflow: false,
isTemplateOnly: false,
lifecycleState: 'drafting',
isClaimed: true,
author: currentUser.name,
reviewer: '李主管',
approver: '王总监',
updatedAt: new Date().toISOString().substring(0, 10),
summary: `用户上传的私密需求文档《${att.name}》,归档至个人私密文档库,可一键共享至项目参考库。`,
space: 'my_private',
governanceMode: 'freeform',
sourceType: 'uploaded_file',
fileMeta: {
originalName: att.name,
size: att.size,
mimeType: att.type,
extension: ext,
fileRaw: att.fileRaw,
fileUrl: att.fileUrl,
uploadedAt: new Date().toISOString(),
},
userId: currentUser.id,
userName: currentUser.name,
createFreeformDoc,
});
} catch (err) {
return {
accepted: false,
error: err instanceof Error ? err.message : '附件服务端注册失败,请重试',
};
const initialMd = `# ${baseTitle}\n\n> 上传人:${currentUser.name} | 上传时间:${new Date().toLocaleString()} | 文件大小:${att.size}\n\n## 1. 文档概述与背景\n\n本文件由用户上传并归档至【个人私密文档库】。原始附件格式:\`${att.type || 'document'}\`。\n\n## 2. 协作与共享说明\n\n- **当前状态**:🔒 **个人私密 (仅本人可见)**\n- 您可在底部操作栏点击「一键共享给项目组」,将该文档共享至项目协作参考库供其他成员查阅与评审。\n- 也可以随时点击「撤回为个人私密」恢复独立隔离。`;
}
const newDeliverablesList = registered.map((r) => r.deliverable);
const newDocCacheEntries: Record<string, DocumentAST> = {};
for (const r of registered) {
const baseTitle = r.deliverable.title;
const initialMd = r.detail.content_markdown;
const newAST = markdownToBlockAST(initialMd, {
docId: fileDocId,
docId: r.serverDocId,
title: baseTitle,
taskKey: 'user_requirements',
version: 'V1.0',
@@ -2785,24 +2766,15 @@ export default function App() {
});
newAST.governanceMode = 'freeform';
newAST.space = 'my_private';
newDeliverablesList.push(newDeliverable);
newDocCacheEntries[fileDocId] = newAST;
// ✅ 将文件内容持久化到 IndexedDB,支持页面刷新后恢复预览
// fire-and-forget:写入失败不影响正常流程
if (att.fileRaw) {
saveFile(fileDocId, att.fileRaw, att.name).catch((e) =>
console.warn('[fileStore] 保存上传文件失败:', fileDocId, e)
newDocCacheEntries[r.serverDocId] = newAST;
if (r.attachment.fileRaw) {
saveFile(r.serverDocId, r.attachment.fileRaw, r.attachment.name).catch((e) =>
console.warn('[fileStore] 保存上传文件失败:', r.serverDocId, e),
);
}
}
return {
...att,
docId: fileDocId,
};
});
processedAttachments = registered.map((r) => r.attachment);
const newDocIds = new Set(newDeliverablesList.map((d) => d.docId));
setDeliverables((prev) => [...newDeliverablesList, ...prev.filter((d) => !newDocIds.has(d.docId))]);
setDocCache((prev) => ({
@@ -2810,7 +2782,6 @@ export default function App() {
...newDocCacheEntries,
}));
// 默认激活打开刚上传的第 1 个文档
if (processedAttachments.length > 0 && processedAttachments[0].docId) {
setActiveDocId(processedAttachments[0].docId);
setActiveCategory('private');
+30
View File
@@ -43,6 +43,36 @@ export interface CreateFromMessageResponse {
data: FreeformDocDetail;
}
/**
* 直接创建自由文档(上传附件即时注册服务端事实源)
*/
export async function createFreeformDoc(payload: {
projectId: number;
title: string;
contentMarkdown: string;
space?: 'my_private' | 'project_shared';
clientKey?: string;
}): Promise<FreeformDocDetail> {
const res = await fetch('/api/v1/freeform-documents', {
method: 'POST',
headers: getBearerAuthHeaders('application/json'),
body: JSON.stringify({
project_id: payload.projectId,
title: payload.title,
content_markdown: payload.contentMarkdown,
space: payload.space || 'my_private',
client_key: payload.clientKey || undefined,
}),
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
const msg = errData.message || errData.error_code || `创建失败 (${res.status})`;
throw new Error(typeof msg === 'string' ? msg : `创建失败 (${res.status})`);
}
const body = await res.json();
return body.data;
}
/**
* 从单条智能体回复保存为我的私密文档(幂等)
*/
+10
View File
@@ -26,3 +26,13 @@ test('InputArea shows thinking block message', () => {
assert.equal(resolveThinkingBlock(true), '正在思考,请稍候');
assert.equal(resolveThinkingBlock(false), null);
});
test('InputArea keeps draft when attachment registration fails', () => {
const applied = applySendResult(
{ accepted: false, error: '附件「a.pdf」服务端注册失败:forbidden。未发送消息,请检查网络后重试。' },
draft,
);
assert.equal(applied.cleared, false);
assert.deepEqual(applied.draft, draft);
assert.match(applied.error || '', /服务端注册失败/);
});
+40 -22
View File
@@ -19,7 +19,7 @@ interface InputAreaProps {
text: string,
attachments?: InputAttachmentItem[],
quotes?: QuotedMessage[]
) => SendMessageResult;
) => SendMessageResult | Promise<SendMessageResult>;
isThinking: boolean;
isStreaming: boolean;
onStopStreaming?: () => void;
@@ -45,6 +45,7 @@ export const InputArea: React.FC<InputAreaProps> = ({
const [attachments, setAttachments] = useState<InputAttachmentItem[]>([]);
const [uploadWarning, setUploadWarning] = useState<string | null>(null);
const [sendError, setSendError] = useState<string | null>(null);
const [sending, setSending] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -56,7 +57,7 @@ export const InputArea: React.FC<InputAreaProps> = ({
}
}, [text]);
const handleSend = () => {
const handleSend = async () => {
const thinkingError = resolveThinkingBlock(isThinking);
if (thinkingError) {
setSendError(thinkingError);
@@ -70,30 +71,42 @@ export const InputArea: React.FC<InputAreaProps> = ({
return;
}
if (sending) {
return;
}
if (!text.trim() && attachments.length === 0) {
return;
}
const result = onSendMessage(text.trim(), attachments, quotes);
const applied = applySendResult(result, { text: text.trim(), attachments });
if (!applied.cleared) {
setSendError(applied.error);
return;
}
setSending(true);
setSendError(null);
setText(applied.draft.text);
setAttachments(applied.draft.attachments);
setUploadWarning(null);
if (textareaRef.current) {
textareaRef.current.style.height = '42px';
try {
const result = await onSendMessage(text.trim(), attachments, quotes);
const applied = applySendResult(result, { text: text.trim(), attachments });
if (!applied.cleared) {
setSendError(applied.error);
return;
}
setSendError(null);
setText(applied.draft.text);
setAttachments(applied.draft.attachments);
setUploadWarning(null);
if (textareaRef.current) {
textareaRef.current.style.height = '42px';
}
} catch (err) {
setSendError(err instanceof Error ? err.message : '消息发送失败,请重试');
} finally {
setSending(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
void handleSend();
}
};
@@ -454,16 +467,16 @@ export const InputArea: React.FC<InputAreaProps> = ({
{/* 智能发送按钮 */}
<button
onClick={handleSend}
disabled={!text.trim() && attachments.length === 0 && !isStreaming}
className={isThinking ? 'agent-pulse-active' : ''}
onClick={() => void handleSend()}
disabled={sending || (!text.trim() && attachments.length === 0 && !isStreaming)}
className={isThinking || sending ? 'agent-pulse-active' : ''}
style={{
width: '36px',
height: '36px',
borderRadius: 'var(--r-md)',
background: isStreaming
? 'var(--danger)'
: isThinking
: isThinking || sending
? 'var(--brand-600)'
: 'var(--brand-grad)',
color: '#fff',
@@ -473,14 +486,19 @@ export const InputArea: React.FC<InputAreaProps> = ({
flexShrink: 0,
marginBottom: '3px',
transition: 'all 0.2s ease',
opacity: !text.trim() && attachments.length === 0 && !isThinking && !isStreaming ? 0.45 : 1,
boxShadow: isThinking
opacity:
sending || (!text.trim() && attachments.length === 0 && !isThinking && !isStreaming)
? 0.45
: 1,
boxShadow: isThinking || sending
? '0 0 12px rgba(107, 132, 255, 0.7)'
: '0 2px 8px rgba(79, 107, 255, 0.25)',
}}
title={
isStreaming
? '停止当前生成'
: sending
? '正在注册附件并发送…'
: isThinking
? '正在思考,请稍候'
: '发送 (Enter)'
@@ -488,7 +506,7 @@ export const InputArea: React.FC<InputAreaProps> = ({
>
{isStreaming ? (
<Square size={14} fill="#fff" />
) : isThinking ? (
) : isThinking || sending ? (
<Loader2 size={16} className="animate-spin-fast" />
) : (
<Send size={15} />
+128
View File
@@ -0,0 +1,128 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
buildUploadClientKey,
deliverableFromFreeformUpload,
registerUploadAttachments,
} from './uploadFreeformRegister';
import type { FreeformDocDetail } from '../api/freeformDoc';
describe('uploadFreeformRegister', () => {
it('buildUploadClientKey prefers explicit hint then doc-upload stamp', () => {
assert.equal(buildUploadClientKey(100, 0, 'doc-upload-100-0'), 'doc-upload-100-0');
assert.equal(buildUploadClientKey(100, 1), 'doc-upload-100-1');
});
it('registerUploadAttachments hard-fails without creating orphan on API error', async () => {
await assert.rejects(
() =>
registerUploadAttachments({
attachments: [{ name: 'a.pdf', size: '1KB', type: 'application/pdf' }],
projectId: 12,
userId: 2,
userName: '张工',
createFreeformDoc: async () => {
throw new Error('network down');
},
}),
/服务端注册失败/,
);
});
it('registerUploadAttachments rejects local doc-upload id from server', async () => {
await assert.rejects(
() =>
registerUploadAttachments({
attachments: [{ name: 'a.pdf', size: '1KB', type: 'application/pdf' }],
projectId: 12,
userId: 2,
userName: '张工',
createFreeformDoc: async () =>
({
document: {
doc_id: 'doc-upload-bad',
project_id: 12,
owner_user_id: 2,
source_content_sha256: 'x',
title: 'a',
governance_mode: 'freeform',
space: 'my_private',
current_version_id: 1,
current_sha256: 'x',
is_deleted: false,
created_at: '',
updated_at: '',
},
content_markdown: '# a',
}) as FreeformDocDetail,
}),
/未返回服务端文档 ID/,
);
});
it('registerUploadAttachments returns server doc-free ids and freeform facts', async () => {
const registered = await registerUploadAttachments({
attachments: [{ name: 'spec.pdf', size: '2KB', type: 'application/pdf' }],
projectId: 12,
userId: 2,
userName: '张工',
now: 1789263145197,
createFreeformDoc: async (payload) => {
assert.equal(payload.clientKey, 'doc-upload-1789263145197-0');
assert.ok(payload.contentMarkdown.includes('spec.pdf'));
return {
document: {
doc_id: 'doc-free-1789263145197-2',
project_id: 12,
owner_user_id: 2,
source_content_sha256: 'abc',
client_key: payload.clientKey,
title: payload.title,
governance_mode: 'freeform',
space: 'my_private',
current_version_id: 1,
current_sha256: 'abc',
is_deleted: false,
created_at: '',
updated_at: '',
},
content_markdown: payload.contentMarkdown,
};
},
});
assert.equal(registered.length, 1);
assert.equal(registered[0].serverDocId, 'doc-free-1789263145197-2');
assert.equal(registered[0].deliverable.governanceMode, 'freeform');
assert.equal(registered[0].deliverable.sourceType, 'uploaded_file');
assert.equal(registered[0].attachment.docId, 'doc-free-1789263145197-2');
});
it('deliverableFromFreeformUpload maps server id and upload facts', () => {
const d = deliverableFromFreeformUpload({
detail: {
document: {
doc_id: 'doc-free-1-2',
project_id: 9,
owner_user_id: 2,
source_content_sha256: 'h',
title: 't',
governance_mode: 'freeform',
space: 'my_private',
current_version_id: 1,
current_sha256: 'h',
is_deleted: false,
created_at: '',
updated_at: '',
},
content_markdown: '# t',
},
projectId: 9,
userId: 2,
userName: '张工',
att: { name: 't.pdf', size: '1KB', type: 'application/pdf' },
});
assert.equal(d.docId, 'doc-free-1-2');
assert.equal(d.sourceType, 'uploaded_file');
assert.equal(d.governanceMode, 'freeform');
});
});
+153
View File
@@ -0,0 +1,153 @@
/**
* 上传附件 → 服务端 freeform 注册辅助(失败必须硬失败,禁止本地 orphan 回退)。
*/
import type { FreeformDocDetail } from '../api/freeformDoc';
import type { DeliverableItem } from '../types/workbench';
import type { DocumentAST } from '../types/block';
export type UploadAttachmentInput = {
name: string;
size: string;
type: string;
docId?: string;
fileRaw?: File | Blob;
fileUrl?: string;
};
export function buildUploadClientKey(now: number, idx: number, hint?: string): string {
const base = hint?.trim() || `doc-upload-${now}-${idx}`;
return base;
}
export function buildUploadRegistrationMarkdown(params: {
title: string;
userName: string;
fileName: string;
fileSize: string;
mimeType: string;
}): string {
const { title, userName, fileName, fileSize, mimeType } = params;
return `# ${title}\n\n> 上传人:${userName} | 上传时间:${new Date().toLocaleString()} | 文件大小:${fileSize}\n\n## 1. 文档概述与背景\n\n本文件由用户上传并归档至【个人私密文档库】。原始附件:\`${fileName}\`(\`${mimeType || 'document'}\`)。\n\n## 2. 协作与共享说明\n\n- **当前状态**:🔒 **个人私密 (仅本人可见)**\n- 您可在底部操作栏点击「一键共享给项目组」,将该文档共享至项目协作参考库供其他成员查阅与评审。\n`;
}
export type RegisteredUpload = {
clientKey: string;
serverDocId: string;
detail: FreeformDocDetail;
deliverable: DeliverableItem;
attachment: UploadAttachmentInput;
};
export function deliverableFromFreeformUpload(params: {
detail: FreeformDocDetail;
projectId: number;
userId: number;
userName: string;
att: UploadAttachmentInput;
}): DeliverableItem {
const { detail, projectId, userId, userName, att } = params;
const doc = detail.document;
const extMatch = att.name.match(/\.([^.]+)$/);
const ext = extMatch ? extMatch[1].toLowerCase() : '';
return {
docId: doc.doc_id,
projectId: doc.project_id || projectId,
taskKey: 'user_requirements',
title: doc.title || att.name.replace(/\.[^/.]+$/, ''),
stage: 'V0.5',
category: 'private',
creatorId: userId,
creatorName: userName,
isFixedWorkflow: false,
isTemplateOnly: false,
lifecycleState: 'drafting',
isClaimed: true,
author: userName,
reviewer: '李主管',
approver: '王总监',
updatedAt: new Date().toISOString().substring(0, 10),
summary: `用户上传的私密需求文档《${att.name}》,已注册服务端版本账本。`,
space: doc.space || 'my_private',
governanceMode: 'freeform',
sourceType: 'uploaded_file',
fileMeta: {
originalName: att.name,
size: att.size,
mimeType: att.type,
extension: ext,
fileRaw: att.fileRaw,
fileUrl: att.fileUrl,
uploadedAt: new Date().toISOString(),
},
};
}
/** 将一批附件全部注册为服务端 freeform;任一失败则抛错(调用方不得静默回退 orphan)。 */
export async function registerUploadAttachments(params: {
attachments: UploadAttachmentInput[];
projectId: number;
userId: number;
userName: string;
createFreeformDoc: (payload: {
projectId: number;
title: string;
contentMarkdown: string;
space?: 'my_private' | 'project_shared';
clientKey?: string;
}) => Promise<FreeformDocDetail>;
now?: number;
}): Promise<RegisteredUpload[]> {
const { attachments, projectId, userId, userName, createFreeformDoc } = params;
if (!projectId || projectId <= 0) {
throw new Error('请先进入有效项目后再上传附件');
}
const now = params.now ?? Date.now();
const out: RegisteredUpload[] = [];
for (let idx = 0; idx < attachments.length; idx++) {
const att = attachments[idx];
const clientKey = buildUploadClientKey(now, idx, att.docId);
const baseTitle = att.name.replace(/\.[^/.]+$/, '');
const markdown = buildUploadRegistrationMarkdown({
title: baseTitle,
userName,
fileName: att.name,
fileSize: att.size,
mimeType: att.type,
});
let detail: FreeformDocDetail;
try {
detail = await createFreeformDoc({
projectId,
title: baseTitle || att.name,
contentMarkdown: markdown,
space: 'my_private',
clientKey,
});
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
throw new Error(`附件「${att.name}」服务端注册失败:${msg}。未发送消息,请检查网络后重试。`);
}
const serverDocId = detail?.document?.doc_id;
if (!serverDocId || serverDocId.startsWith('doc-upload-')) {
throw new Error(`附件「${att.name}」未返回服务端文档 ID,已阻止生成本地 orphan。`);
}
const deliverable = deliverableFromFreeformUpload({
detail,
projectId,
userId,
userName,
att,
});
out.push({
clientKey,
serverDocId,
detail,
deliverable,
attachment: { ...att, docId: serverDocId },
});
}
return out;
}
export type UploadAstPair = { docId: string; ast: DocumentAST };