diff --git a/.memory/worklog.json b/.memory/worklog.json index dae9ae3..ad6bae4 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -1,12 +1,5 @@ { "entries": [ - { - "files_changed": 1, - "hash": "4a22ca0", - "message": "docs: record width-first layout deployment", - "ts": "2026-05-20T19:01:07+08:00", - "type": "commit" - }, { "files_changed": 3, "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 3 项未提交变更 · 最近提交:docs: record width-first layout deployment", @@ -3192,6 +3185,13 @@ "message": "auto-save 2026-05-27 14:42 (~2)", "hash": "a699899", "files_changed": 2 + }, + { + "ts": "2026-05-27T14:47:45+08:00", + "type": "commit", + "message": "auto-save 2026-05-27 14:47 (~2)", + "hash": "22398c1", + "files_changed": 2 } ] } diff --git a/.playwright-cli/page-2026-05-27T06-52-23-524Z.yml b/.playwright-cli/page-2026-05-27T06-52-23-524Z.yml new file mode 100644 index 0000000..b5f318d --- /dev/null +++ b/.playwright-cli/page-2026-05-27T06-52-23-524Z.yml @@ -0,0 +1,29 @@ +- generic [active] [ref=e1]: + - main [ref=e2]: + - iframe [ref=e3]: + - generic [active] [ref=f1e1]: + - button "Settings": + - img + - generic [ref=e4]: + - generic: + - generic: + - generic: + - generic: + - generic: + - img + - generic [ref=e5]: + - generic [ref=e6]: + - generic [ref=e8]: + - img [ref=e9] + - textbox [ref=e12] + - generic [ref=e14]: + - img [ref=e15] + - textbox [ref=e19] + - button [ref=e20]: + - img [ref=e21] + - generic [ref=e24] [cursor=pointer]: + - checkbox "保持登录" [checked] [ref=e25] + - generic [ref=e26]: 保持登录 + - button [ref=e27]: + - img [ref=e28] + - alert [ref=e30] \ No newline at end of file diff --git a/.playwright-cli/page-2026-05-27T06-52-55-049Z.yml b/.playwright-cli/page-2026-05-27T06-52-55-049Z.yml new file mode 100644 index 0000000..e69de29 diff --git a/docs/source-analysis.html b/docs/source-analysis.html index bef9e9a..8e19c9b 100644 --- a/docs/source-analysis.html +++ b/docs/source-analysis.html @@ -1277,6 +1277,18 @@ ProductRefStateItem {

变更记录

这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。

+
+
+

2026-05-27 · 视频完成后自动回填画布节点

+ Canvas + Video +
+
+

问题:画布视频任务提交后,完成状态主要依赖单个 VideoNode.vue 组件内部长轮询。如果组件轮询中断、页面后台挂起或节点未稳定挂载,服务端已经生成好的视频不会立刻写回当前画布,用户需要刷新页面后才看到结果。

+

改动:web/canvas-app/src/hooks/useApi.js 新增 readVideoTask,用于按 skg:<jobId>:<videoId> 读取服务端当前视频状态;web/canvas-app/src/views/Canvas.vue 增加页面级待完成视频同步器,每 5 秒扫描带 taskId 且没有 url 的视频节点,完成后自动写入视频 URL、清掉 taskId 并保存画布;VideoNode.vue 只负责展示节点状态,不再承担任务同步。

+

影响:视频排队、生成中和完成状态会在当前打开的画布里自动更新;刷新页面仍可恢复未完成任务,但不再是看到视频结果的必要步骤。

+
+

2026-05-27 · 修复刷新后画布记录被本地缓存覆盖

diff --git a/web/canvas-app/src/components/nodes/VideoNode.vue b/web/canvas-app/src/components/nodes/VideoNode.vue index f94e7a9..ba7be18 100644 --- a/web/canvas-app/src/components/nodes/VideoNode.vue +++ b/web/canvas-app/src/components/nodes/VideoNode.vue @@ -140,12 +140,11 @@ * Video node component | 视频节点组件 * Displays and manages video content */ -import { ref, nextTick, watch, onMounted } from 'vue' +import { ref, nextTick } from 'vue' import { Handle, Position, useVueFlow } from '@vue-flow/core' import { NIcon, NSpin } from 'naive-ui' import { TrashOutline, ExpandOutline, VideocamOutline, CopyOutline, CloseCircleOutline, DownloadOutline, EyeOutline, CreateOutline } from '@vicons/ionicons5' import { updateNode, removeNode, duplicateNode, addNode, addEdge, nodes } from '../../stores/canvas' -import { useVideoGeneration } from '../../hooks/useApi' import NodeHandleMenu from './NodeHandleMenu.vue' const props = defineProps({ @@ -156,9 +155,6 @@ const props = defineProps({ // Vue Flow instance const { updateNodeInternals } = useVueFlow() -// Get pollVideoTask from useVideoGeneration | 从 useVideoGeneration 获取轮询函数 -const { pollVideoTask } = useVideoGeneration() - // Hover state | 悬浮状态 const showActions = ref(false) const showHandleMenu = ref(false) @@ -173,61 +169,6 @@ const operations = [ { type: 'videoConfig', label: '生视频', icon: VideocamOutline } ] -// Polling state | 轮询状态 -const isPolling = ref(false) - -// Watch for taskId changes and start polling | 监听 taskId 变化并开始轮询 -watch(() => props.data?.taskId, (taskId) => { - if (taskId && !props.data?.url && !isPolling.value) { - startPolling(taskId) - } -}) - -// 页面刷新后恢复轮询 | Resume polling after page refresh -onMounted(() => { - const { taskId, url } = props.data || {} - if (taskId && !url && !isPolling.value) { - startPolling(taskId) - } -}) - -// Start polling for video result | 开始轮询获取视频结果 -const startPolling = async (taskId) => { - if (isPolling.value) return - - isPolling.value = true - - try { - const result = await pollVideoTask(taskId, (attempt, percentage) => { - // 更新进度 - updateNode(props.id, { - progress: percentage, - attempt - }) - }) - // 轮询成功,更新视频节点 - updateNode(props.id, { - url: result.url, - loading: false, - progress: 100, - label: '视频生成', - taskId: null // 清除 taskId - }) - window.$message?.success('视频生成成功') - } catch (err) { - // 轮询失败 - updateNode(props.id, { - loading: false, - error: err.message || '生成失败', - label: '生成失败', - taskId: null // 清除 taskId - }) - window.$message?.error(err.message || '视频生成失败') - } finally { - isPolling.value = false - } -} - // Handle menu select | 处理菜单选择 const handleSelect = (item) => { const currentNode = nodes.value.find(n => n.id === props.id) diff --git a/web/canvas-app/src/hooks/index.js b/web/canvas-app/src/hooks/index.js index e3f21e6..277ddfb 100644 --- a/web/canvas-app/src/hooks/index.js +++ b/web/canvas-app/src/hooks/index.js @@ -18,6 +18,7 @@ export { useChat, useImageGeneration, useVideoGeneration, + readVideoTask, useApi } from './useApi' diff --git a/web/canvas-app/src/hooks/useApi.js b/web/canvas-app/src/hooks/useApi.js index e9480f0..fe2d79f 100644 --- a/web/canvas-app/src/hooks/useApi.js +++ b/web/canvas-app/src/hooks/useApi.js @@ -91,6 +91,7 @@ const parseVideoTaskId = (pollTaskId) => { export const readVideoTask = async (pollTaskId) => { const { jobId, videoId } = parseVideoTaskId(pollTaskId) + // Canvas-level video sync reads one snapshot at a time instead of owning a long node-local poll. const job = await requestJson(`/jobs/${jobId}`, { method: 'GET' }) const item = (job.generated_videos || []).find(v => v.id === videoId) if (!item) { diff --git a/web/canvas-app/src/views/Canvas.vue b/web/canvas-app/src/views/Canvas.vue index 70702e3..9d597f0 100644 --- a/web/canvas-app/src/views/Canvas.vue +++ b/web/canvas-app/src/views/Canvas.vue @@ -299,7 +299,7 @@ import { } from '@vicons/ionicons5' import { nodes, edges, addNode, addNodes, addEdge, addEdges, updateNode, initSampleData, loadProject, saveProject, clearCanvas, canvasViewport, updateViewport, undo, redo, canUndo, canRedo, manualSaveHistory, startBatchOperation, endBatchOperation } from '../stores/canvas' import { loadAllModels } from '../stores/models' -import { useChat, useWorkflowOrchestrator } from '../hooks' +import { useChat, useWorkflowOrchestrator, readVideoTask } from '../hooks' import { useModelStore } from '../stores/pinia' import { projects, initProjectsStore, updateProject, renameProject, currentProject } from '../stores/projects' import { myWorkflows, workflowSyncStatus, loadMyWorkflows, saveMyWorkflow, deleteMyWorkflow } from '../stores/workflows' @@ -436,6 +436,10 @@ const projectName = computed(() => { const loadingMyWorkflows = computed(() => workflowSyncStatus.value === 'syncing') +const videoTaskSyncing = new Set() +let videoTaskSyncTimer = null +let videoTaskSyncToastShown = false + // Project dropdown options | 项目下拉选项 const projectOptions = [ { label: '重命名', key: 'rename' }, @@ -631,6 +635,106 @@ const handleDeleteWorkflow = async (workflow) => { } } +const pendingVideoNodes = () => nodes.value.filter(node => + node.type === 'video' && node.data?.taskId && !node.data?.url +) + +const syncPendingVideoNodes = async () => { + const pending = pendingVideoNodes() + if (!pending.length) return + + for (const node of pending) { + const taskId = node.data?.taskId + const syncKey = `${node.id}:${taskId}` + if (!taskId || videoTaskSyncing.has(syncKey)) continue + + videoTaskSyncing.add(syncKey) + readVideoTask(taskId) + .then((snapshot) => { + const item = snapshot.video + if (item.status === 'completed') { + updateNode(node.id, { + url: snapshot.url, + loading: false, + progress: 100, + label: '视频生成', + taskId: null, + error: '', + syncError: '', + updatedAt: Date.now() + }) + window.$message?.success('视频生成成功,已自动回填到画布') + return + } + + if (item.status === 'failed') { + updateNode(node.id, { + loading: false, + error: item.error || '视频生成失败', + label: '生成失败', + taskId: null, + queueMessage: '', + syncError: '', + updatedAt: Date.now() + }) + window.$message?.error(item.error || '视频生成失败') + return + } + + updateNode(node.id, { + loading: true, + progress: item.progress || node.data?.progress || 0, + queuePosition: item.queue_position || 0, + queueSize: item.queue_size || 0, + queueMessage: item.queue_message || '', + error: '', + syncError: '', + updatedAt: Date.now() + }) + }) + .catch((err) => { + if (err.terminal) { + updateNode(node.id, { + loading: false, + error: err.message || '视频任务不存在', + label: '生成失败', + taskId: null, + syncError: '', + updatedAt: Date.now() + }) + return + } + + updateNode(node.id, { + loading: true, + syncError: err.message || '视频状态同步暂时中断', + updatedAt: Date.now() + }) + }) + .finally(() => { + videoTaskSyncing.delete(syncKey) + }) + } + + if (!videoTaskSyncToastShown) { + videoTaskSyncToastShown = true + window.$message?.info('视频生成中,完成后会自动显示在画布里') + } +} + +const startVideoTaskSync = () => { + if (videoTaskSyncTimer) return + syncPendingVideoNodes() + videoTaskSyncTimer = window.setInterval(syncPendingVideoNodes, 5000) +} + +const stopVideoTaskSync = () => { + if (!videoTaskSyncTimer) return + window.clearInterval(videoTaskSyncTimer) + videoTaskSyncTimer = null + videoTaskSyncing.clear() +} + // Handle connection | 处理连接 const onConnect = (params) => { // Check connection types | 检查连接类型 @@ -952,6 +1056,16 @@ watch(showWorkflowPanel, (visible) => { } }) +watch( + () => nodes.value + .filter(node => node.type === 'video') + .map(node => `${node.id}:${node.data?.taskId || ''}:${node.data?.url || ''}`) + .join('|'), + () => { + if (pendingVideoNodes().length) syncPendingVideoNodes() + } +) + // Initialize | 初始化 onMounted(async () => { checkMobile() @@ -963,6 +1077,7 @@ onMounted(async () => { // Load project data | 加载项目数据 loadProjectById(route.params.id) refreshMyWorkflows() + startVideoTaskSync() // Check for initial prompt from home page | 检查来自首页的初始提示词 const initialPrompt = sessionStorage.getItem('ai-canvas-initial-prompt') @@ -979,6 +1094,7 @@ onMounted(async () => { // Cleanup on unmount | 卸载时清理 onUnmounted(() => { window.removeEventListener('resize', checkMobile) + stopVideoTaskSync() // Save project before leaving | 离开前保存项目 saveProject() })