diff --git a/web/src/App.tsx b/web/src/App.tsx index 09ddffd5..83e37e7d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 => { 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 = {}; - 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 = {}; + 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'); diff --git a/web/src/api/freeformDoc.ts b/web/src/api/freeformDoc.ts index bce78dd5..e4c34296 100644 --- a/web/src/api/freeformDoc.ts +++ b/web/src/api/freeformDoc.ts @@ -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 { + 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; +} + /** * 从单条智能体回复保存为我的私密文档(幂等) */ diff --git a/web/src/components/chat/InputArea.test.ts b/web/src/components/chat/InputArea.test.ts index edce38bd..9abfcbf8 100644 --- a/web/src/components/chat/InputArea.test.ts +++ b/web/src/components/chat/InputArea.test.ts @@ -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 || '', /服务端注册失败/); +}); diff --git a/web/src/components/chat/InputArea.tsx b/web/src/components/chat/InputArea.tsx index 12743a5e..7b306cdc 100644 --- a/web/src/components/chat/InputArea.tsx +++ b/web/src/components/chat/InputArea.tsx @@ -19,7 +19,7 @@ interface InputAreaProps { text: string, attachments?: InputAttachmentItem[], quotes?: QuotedMessage[] - ) => SendMessageResult; + ) => SendMessageResult | Promise; isThinking: boolean; isStreaming: boolean; onStopStreaming?: () => void; @@ -45,6 +45,7 @@ export const InputArea: React.FC = ({ const [attachments, setAttachments] = useState([]); const [uploadWarning, setUploadWarning] = useState(null); const [sendError, setSendError] = useState(null); + const [sending, setSending] = useState(false); const textareaRef = useRef(null); const fileInputRef = useRef(null); @@ -56,7 +57,7 @@ export const InputArea: React.FC = ({ } }, [text]); - const handleSend = () => { + const handleSend = async () => { const thinkingError = resolveThinkingBlock(isThinking); if (thinkingError) { setSendError(thinkingError); @@ -70,30 +71,42 @@ export const InputArea: React.FC = ({ 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) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); - handleSend(); + void handleSend(); } }; @@ -454,16 +467,16 @@ export const InputArea: React.FC = ({ {/* 智能发送按钮 */}