diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go index 4e1c939e7..3e2ca3aae 100644 --- a/backend/internal/repository/repository.go +++ b/backend/internal/repository/repository.go @@ -420,7 +420,7 @@ func (r *Repository) Tasks(userID string, limit int, projectID string, activeOnl if limit <= 0 || limit > 100 { limit = 50 } - query := r.db.Select("id", "session_id", "project_id", "type", "status", "stage", "progress", "prompt", "operation", "provider", "model", "result_json", "billing_order_id", "attempts", "started_at", "completed_at", "created_at", "updated_at"). + query := r.db.Select("id", "session_id", "project_id", "type", "status", "stage", "progress", "prompt", "operation", "provider", "model", "input_json", "result_json", "billing_order_id", "attempts", "started_at", "completed_at", "created_at", "updated_at"). Where("user_id = ?", userID) if strings.TrimSpace(projectID) != "" { query = query.Where("project_id = ?", strings.TrimSpace(projectID)) diff --git a/backend/internal/service/service.go b/backend/internal/service/service.go index 07c1a42ff..25237b87e 100644 --- a/backend/internal/service/service.go +++ b/backend/internal/service/service.go @@ -89,6 +89,14 @@ type TaskSummary struct { CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` Billing *TaskBillingSummary `json:"billing,omitempty"` + ClientContext *TaskClientContext `json:"clientContext,omitempty"` +} + +type TaskClientContext struct { + ConversationID string `json:"conversationId"` + MessageID string `json:"messageId"` + BatchIndex int `json:"batchIndex,omitempty"` + BatchCount int `json:"batchCount,omitempty"` } type TaskBillingSummary struct { @@ -608,6 +616,29 @@ func taskSummaryForOutput(task model.Task) TaskSummary { CompletedAt: task.CompletedAt, CreatedAt: task.CreatedAt, UpdatedAt: task.UpdatedAt, + ClientContext: taskClientContext(task.InputJSON), + } +} + +// 列表只暴露创作页恢复所需的关联 ID,不下发完整任务输入或其他 metadata。 +func taskClientContext(raw string) *TaskClientContext { + var input struct { + Metadata struct { + Source string `json:"source"` + ConversationID string `json:"conversationId"` + MessageID string `json:"messageId"` + BatchIndex int `json:"batchIndex"` + BatchCount int `json:"batchCount"` + } `json:"metadata"` + } + if json.Unmarshal([]byte(raw), &input) != nil || input.Metadata.Source != "create-page" || input.Metadata.ConversationID == "" || input.Metadata.MessageID == "" { + return nil + } + return &TaskClientContext{ + ConversationID: input.Metadata.ConversationID, + MessageID: input.Metadata.MessageID, + BatchIndex: input.Metadata.BatchIndex, + BatchCount: input.Metadata.BatchCount, } } diff --git a/web/src/components/canvas/canvas-resource-mention-textarea.tsx b/web/src/components/canvas/canvas-resource-mention-textarea.tsx index bd540b0bb..9bf9bddc9 100644 --- a/web/src/components/canvas/canvas-resource-mention-textarea.tsx +++ b/web/src/components/canvas/canvas-resource-mention-textarea.tsx @@ -37,11 +37,12 @@ type Props = Omit, "onChange" | "val containerClassName?: string; highlightLabels?: boolean; mentionMenuWidth?: number; + sendOnEnter?: boolean; onContentSizeChange?: (height: number) => void; }; export const CanvasResourceMentionTextarea = forwardRef(function CanvasResourceMentionTextarea( - { value, references, onChange, onSubmit, onKeyDown, className, containerClassName, style, highlightLabels = true, mentionMenuWidth = 256, onContentSizeChange, ...props }, + { value, references, onChange, onSubmit, onKeyDown, className, containerClassName, style, highlightLabels = true, mentionMenuWidth = 256, sendOnEnter = true, onContentSizeChange, ...props }, forwardedRef, ) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; @@ -244,7 +245,8 @@ export const CanvasResourceMentionTextarea = forwardRef(null); const threadScrollRef = useRef(null); const followLatestMessageRef = useRef(true); + const taskSyncWarningRef = useRef(false); const activeConversation = useMemo(() => conversations.find((item) => item.id === activeId) || conversations[0], [activeId, conversations]); const historyConversations = useMemo( @@ -97,6 +100,7 @@ export default function CreatePage() { const selectedModel = mode === "text" ? config.textModel : mode === "image" ? config.imageModel : config.videoModel; const mentionReferences = useMemo(() => buildCreationMentionReferences(addedSkills, attachments, draftReferences), [addedSkills, attachments, draftReferences]); const isEmpty = !activeConversation?.messages.length; + const pendingMediaKey = useMemo(() => pendingCreationMediaKey(conversations), [conversations]); useEffect(() => { let cancelled = false; @@ -117,6 +121,33 @@ export default function CreatePage() { if (hydrated) void localforage.setItem(STORAGE_KEY, conversations); }, [conversations, hydrated]); + useEffect(() => { + if (!hydrated || !pendingMediaKey) return; + let cancelled = false; + const syncTasks = async () => { + try { + const summaries = await listGenerationTasks(100); + const tasks = await enrichCreationTaskSummaries(summaries); + if (cancelled) return; + taskSyncWarningRef.current = false; + setConversations((current) => reconcileCreationTaskMessages(current, tasks)); + } catch (error) { + if (cancelled) return; + console.warn("创作任务状态同步失败", error); + if (!taskSyncWarningRef.current) { + taskSyncWarningRef.current = true; + toast.warning("任务状态暂时无法同步,请稍后刷新"); + } + } + }; + void syncTasks(); + const timer = window.setInterval(() => void syncTasks(), 3000); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [hydrated, pendingMediaKey, toast]); + useEffect(() => { let cancelled = false; listAddedSkills().then(({ skills }) => { @@ -193,6 +224,12 @@ export default function CreatePage() { followLatestMessageRef.current = true; const userMessage = newMessage("user", text, { mode, model: selectedModel, attachments, references, settings }); const assistantMessage = newMessage("assistant", "", { mode, model: selectedModel, status: mode === "text" ? "streaming" : "pending", settings }); + const boundTaskIds = new Set(); + const bindTask = (task: GenerationTask) => { + if (boundTaskIds.has(task.id)) return; + boundTaskIds.add(task.id); + updateAssistant(assistantMessage.id, (item) => ({ ...item, taskIds: Array.from(new Set([...(item.taskIds || []), task.id])) })); + }; updateActive((conversation) => ({ ...conversation, title: conversation.messages.length ? conversation.title : text.slice(0, 24), @@ -224,6 +261,7 @@ export default function CreatePage() { referenceImages: attachments, signal: controller.signal, metadata: { source: "create-page", conversationId: activeConversation.id, messageId: assistantMessage.id, ...referenceMetadata }, + onTaskUpdate: bindTask, count: taskCount, }); if (controller.signal.aborted) throw new DOMException("Aborted", "AbortError"); @@ -243,6 +281,7 @@ export default function CreatePage() { referenceImages: attachments, signal: controller.signal, metadata: { source: "create-page", conversationId: activeConversation.id, messageId: assistantMessage.id, videoEditOperation: attachments.length ? "image_to_video" : "text_to_video", ...referenceMetadata }, + onTaskUpdate: bindTask, }); if (!result.video?.dataUrl) throw new Error("后端任务没有返回视频"); const videoUrl = result.video.dataUrl; @@ -400,12 +439,20 @@ function CreationHistoryDrawer({ open, conversations, activeId, onClose, onSelec } function CreationMessageView({ item, modelName, onRetryFailure, onCreateVariant }: { item: CreationMessage; modelName: string; onRetryFailure: () => void; onCreateVariant: () => void }) { - if (item.role === "user") return
{displayCreationPrompt(item.content, item.references || [])}
{item.references?.length ? : null}{item.attachments?.length ?
{item.attachments.map((attachment) => {attachment.name})}
: null}
; + if (item.role === "user") return ; const mode = item.mode || "text"; const stateLabel = item.status === "pending" ? "生成中" : item.status === "cancelled" ? "已停止" : ""; return
{modeLabels[mode]}{modelName ? {modelName} : null}{stateLabel ? {stateLabel} : null}
{mode === "text" ?
{item.content ? {item.content} : 正在生成…}
: }{item.error ?
{generationErrorMessage(item.error)}
: null}
; } +function CreationUserMessage({ item }: { item: CreationMessage }) { + const [previewUrl, setPreviewUrl] = useState(""); + return
{displayCreationPrompt(item.content, item.references || [])}
{item.references?.length ? : null}{item.attachments?.length ?
{item.attachments.map((attachment) => { + const url = attachment.previewUrl || attachment.dataUrl || attachment.url || ""; + return ; + })}
: null} setPreviewUrl("")} />
; +} + function CreationMessageReferences({ references }: { references: CreationReference[] }) { return
{references.map((reference) => { const Icon = reference.kind === "skill" ? Sparkles : reference.kind === "image" ? ImageIcon : reference.kind === "video" ? Film : reference.kind === "audio" ? Music2 : FileText; @@ -419,8 +466,13 @@ function MediaResult({ item, onRetryFailure, onCreateVariant }: { item: Creation const resultUrls = item.resultUrls; const openPreview = (url: string, type: "image" | "video") => { setPreviewType(type); setPreviewUrl(url); }; if (item.status === "pending") return
正在生成{item.mode === "video" ? "视频" : "图片"}…
; + if ((item.status === "error" || item.status === "cancelled") && !resultUrls?.length) return null; if (!resultUrls?.length) return
没有返回可预览结果
; - return
{item.mode === "video" ? :
{resultUrls.map((url) => )}
}
{item.mode === "video" ? "视频结果" : `${resultUrls.length} 张图片`}添加到画布{resultUrls.map((url, index) => {resultUrls.length > 1 ? `下载 ${index + 1}` : <>下载})}
setPreviewUrl("")} className="creation-media-preview-modal" styles={{ body: { padding: 0 } }}>{previewUrl ? previewType === "video" ?
; + return
{item.mode === "video" ? :
{resultUrls.map((url) => )}
}
{item.mode === "video" ? "视频结果" : `${resultUrls.length} 张图片`}添加到画布{resultUrls.map((url, index) => {resultUrls.length > 1 ? `下载 ${index + 1}` : <>下载})}
setPreviewUrl("")} />
; +} + +function CreationMediaPreviewModal({ url, type, onClose }: { url: string; type: "image" | "video"; onClose: () => void }) { + return {url ? type === "video" ? ; } type ComposerProps = { @@ -465,7 +517,7 @@ function CreationComposer(props: ComposerProps) {
- + {props.attachments.length ?
{props.attachments.map((item) =>
{item.name}
)}
: null}
@@ -562,6 +614,64 @@ function removeReferenceTokens(value: string, references: CreationReference[]) { return references.reduce((current, reference) => current.split(canvasResourceMentionToken(reference)).join(""), value); } +function pendingCreationMediaKey(conversations: CreationConversation[]) { + return conversations.flatMap((conversation) => conversation.messages.flatMap((message) => message.role === "assistant" && message.status === "pending" && message.mode !== "text" ? [`${conversation.id}:${message.id}:${(message.taskIds || []).join(",")}`] : [])).join("|"); +} + +async function enrichCreationTaskSummaries(tasks: GenerationTask[]) { + return Promise.all(tasks.map(async (task) => { + if (!task.clientContext || (task.status !== "failed" && (task.status !== "succeeded" || task.previewUrl))) return task; + const detail = await queryGenerationTask(task.id).catch(() => null); + return detail ? { ...task, ...detail, clientContext: task.clientContext } : task; + })); +} + +function reconcileCreationTaskMessages(conversations: CreationConversation[], tasks: GenerationTask[]) { + let changed = false; + const next = conversations.map((conversation) => { + let conversationChanged = false; + let completedAt = conversation.updatedAt; + const messages = conversation.messages.map((message) => { + if (message.role !== "assistant" || message.status !== "pending" || message.mode === "text") return message; + const taskIds = new Set(message.taskIds || []); + const matches = tasks + .filter((task) => taskIds.has(task.id) || (task.clientContext?.conversationId === conversation.id && task.clientContext.messageId === message.id)) + .sort((left, right) => (left.clientContext?.batchIndex || 0) - (right.clientContext?.batchIndex || 0)); + const expectedTaskCount = Math.max(0, ...matches.map((task) => task.clientContext?.batchCount || 0)); + if (!matches.length || (expectedTaskCount > 0 && matches.length < expectedTaskCount) || matches.some((task) => task.status === "queued" || task.status === "running")) return message; + + const resultUrls = Array.from(new Set(matches.filter((task) => task.status === "succeeded").flatMap(creationTaskResultUrls))); + const succeededCount = matches.filter((task) => task.status === "succeeded").length; + const failedCount = matches.length - succeededCount; + const nextTaskIds = Array.from(new Set([...(message.taskIds || []), ...matches.map((task) => task.id)])); + completedAt = matches.reduce((latest, task) => conversationTimestamp(task.updatedAt) > conversationTimestamp(latest) ? task.updatedAt : latest, completedAt); + conversationChanged = true; + changed = true; + + if (resultUrls.length) { + const content = message.mode === "video" ? "视频已生成" : failedCount ? `${resultUrls.length} 张图片已生成,${failedCount} 张失败` : "图片已生成"; + return { ...message, status: "done" as const, content, resultUrls, error: undefined, taskIds: nextTaskIds }; + } + if (matches.every((task) => task.status === "cancelled")) return { ...message, status: "cancelled" as const, content: "已停止", error: undefined, taskIds: nextTaskIds }; + const failed = matches.find((task) => task.status === "failed"); + return { ...message, status: "error" as const, content: "生成失败", error: generationErrorMessage(failed?.error || "任务已结束,但生成结果暂时无法读取"), taskIds: nextTaskIds }; + }); + return conversationChanged ? { ...conversation, messages, updatedAt: completedAt } : conversation; + }); + return changed ? next : conversations; +} + +function creationTaskResultUrls(task: GenerationTask) { + if (task.previewUrl) return [task.previewUrl]; + if (!task.resultJson) return []; + try { + const result = parseBackendGenerationResult(task); + return [...(result.images || []).map((image) => image.dataUrl), result.video?.dataUrl].filter((url): url is string => Boolean(url)); + } catch { + return []; + } +} + function conversationTimestamp(value: string) { const timestamp = new Date(value).getTime(); return Number.isFinite(timestamp) ? timestamp : 0; diff --git a/web/src/services/api/task-center.ts b/web/src/services/api/task-center.ts index 3a7db7955..8747b2dfd 100644 --- a/web/src/services/api/task-center.ts +++ b/web/src/services/api/task-center.ts @@ -40,6 +40,12 @@ export type GenerationTask = { amountMicrocredits: number; status: TaskBillingStatus; }; + clientContext?: { + conversationId: string; + messageId: string; + batchIndex?: number; + batchCount?: number; + }; created_at?: string; updated_at?: string; }; diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index 183156854..ac619e2c3 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -4778,6 +4778,7 @@ html:not(.dark) .creation-home .creation-chat-composer .creation-model-picker.ca color: var(--creation-text); font-size: 14px; line-height: 1.7; + white-space: pre-wrap; } .creation-user-message-references { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; } @@ -4786,6 +4787,12 @@ html:not(.dark) .creation-home .creation-chat-composer .creation-model-picker.ca .creation-user-message-reference svg { width: 12px; height: 12px; flex: 0 0 auto; } .creation-user-message-reference span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } html:not(.dark) .creation-user-message-reference { background: rgba(28, 32, 37, .07); } +.creation-user-message-attachments button { position: relative; width: 44px; height: 44px; flex: 0 0 auto; overflow: hidden; border: 0; border-radius: 7px; background: transparent; padding: 0; } +.creation-user-message-attachments button:focus-visible { outline: 2px solid var(--creation-text); outline-offset: 2px; } +.creation-user-message-attachments button > span { position: absolute; right: 3px; bottom: 3px; display: grid; width: 17px; height: 17px; place-items: center; border-radius: 5px; background: rgba(0, 0, 0, .68); color: #fff; opacity: 0; transition: opacity 140ms ease; } +.creation-user-message-attachments button:hover > span, +.creation-user-message-attachments button:focus-visible > span { opacity: 1; } +.creation-user-message-attachments button > span svg { width: 10px; height: 10px; } .creation-assistant-message { max-width: 900px; margin: 0 0 48px; } .creation-message-heading { gap: 7px; color: var(--creation-faint); font-size: 11px; }