diff --git a/docs/source-analysis.html b/docs/source-analysis.html
index 6f64843..f1439a9 100644
--- a/docs/source-analysis.html
+++ b/docs/source-analysis.html
@@ -623,6 +623,7 @@
2026-05-26 AI 润色中性化:画布 AI 润色 不再复用 SKG 广告文案接口 /creative/copy。后端新增 POST /prompt/polish,前端 useChat、根画布输入框、文本节点和自动执行意图分析改走中性提示词/通用生成接口:只优化用户已经给出的主体、风格、镜头和细节,不主动添加 SKG、按摩产品、TikTok 广告话术或用户没有提到的品牌。当前润色链路会先清理上一次润色遗留的模板尾巴,再判断人物/无人/物体/场景/动物/未知主体;原文明确有人时才声明虚构 AI 角色,原文明确无人时才保留无人物约束,原文没写人时不主动造人但也不追加“必须无人物”的模板尾巴;当输入或参考图已经有人物时,按 AI 生成的虚拟角色继续描述,而不是把人物参考图判定为不可用。
2026-05-26 我的工作流云端版:工作流面板从只有公共模板扩展为“公共工作流 / 我的工作流”两类。当前画布可以保存成当前登录用户自己的云端工作流模板,后续在同一账号的其他电脑或浏览器打开后可插回画布;保存时只沉淀节点结构、连线、配置和提示词,主动清掉已生成图片、视频、任务进度、错误和运行态字段,避免把一次性生成结果误当模板复用。
2026-05-27 画布点击响应优化:大画布启用 Vue Flow 可见节点渲染,并在载入旧项目时补齐节点尺寸,让几百个节点的项目不再一次性渲染全部节点;新增节点、批量插入节点/边和复制节点改为原地追加,避免单次点击触发整条节点数组重复替换。大项目右下角 MiniMap 只在 120 个节点以内显示,优先保证编辑响应速度。
+ 2026-05-27 上传参考图持久化:画布图片节点上传本地文件时先写入后端 creative job,再把 /api/jobs/... 资产 URL 保存到节点和服务端画布项目;不再把浏览器 data: base64 当作图片地址保存。项目自动保存增加内容签名去重和 2 秒防抖,减少连续点击或节点测量触发的重复 PUT /canvas-projects。
2026-05-26 生图配置恢复版:按用户要求撤回后续“低/中/高画质、自定义尺寸、Gemini 官方 1K/2K/4K 尺寸、取消自动模型”的实验改动,恢复最初简单配置:图片模型为 auto、gpt-image-2、gemini-3-pro-image-preview,尺寸只保留 auto、1024x1536、1024x1024、1536x1024,画质回到单一标准项;auto 仍按后端既有策略优先 GPT Image 2,必要时由熔断/兜底走 Gemini。
当前默认业务管线是“个人隔离任务 → 根域名进入个人画布 → 画布项目同步到服务端 Postgres → 用提示词、推荐词、AI 润色、公共工作流或我的工作流创建节点 → 画布自动执行或手动连接图片/视频/文本节点 → 生成结果沉淀在当前个人画布 → 可把当前节点结构保存为我的工作流 → 需要时进入详情页继续编辑”。画布不再被削成三模式入口;首帧、尾帧、参考图、图生视频、多角度分镜、故事板和绘本等上游概念按节点能力保留。底层生成仍由 web/canvas-app/src/hooks/useApi.js 适配到本项目 /creative/jobs/image、/jobs/{id}/frames/{idx}/generate、/jobs/{id}/frames/{idx}/storyboard/video,AI 润色和通用 LLM 文本生成走 /prompt/polish 并保持中性专业:不主动套入 SKG,不主动补产品、平台、广告语境或人物,只扩写用户明确写出的主体、动作、场景、镜头、光线和质量细节;视频提交若带参考图,会在最终提示词中条件声明“参考图里若有人物,应按 AI 生成的虚拟角色处理”,避免把 AI 人像素材误当成真实肖像。生成资产按当前登录用户写入个人 job。图片尺寸只显示 auto、1024x1536、1024x1024、1536x1024;视频画幅只显示 720x1280、1280x720、1024x1024、960x1280;视频时长只显示 5/8/10/12/15 秒。多人互不影响依赖后端 owner_id、画布项目 owner、我的工作流 owner 和飞书 / 备用登录会话隔离。旧 React 单对话框首页、信息流复刻链路仍保留在源码里作为回滚/高级能力,但不作为生产默认入口。
@@ -650,12 +651,12 @@
web/app/globals.css | 全局主题变量、登录页视觉样式、信息流工作台玻璃拟态 token、ReactFlow 样式引用,以及本地开发态 nextjs-portal 遮挡隐藏规则。工作台在 skg-board-theme 内按 Figma 本地 MCP 参考改成黑灰玻璃系统:深灰背景、#383838 胶囊侧栏、rgba(255,255,255,.1) 玻璃面、backdrop-filter: blur(5px)、20px 圆角、10px 10px 10px rgba(0,0,0,.3) 阴影和绿黄状态色;新增 skg-board-shell、skg-board-rail、skg-glass-card、skg-glass-card--flat、skg-status-orb 等样式。侧栏改为跟随视口拉满工作台可用高度的悬停胶囊,桌面最小 600px,展开时在同一侧栏内承载素材输入抽屉。明暗主题已分开维护 shell、panel、glass、stat、action 和音频波形 token;暗色压低灰雾和面板底色,明亮模式改为暖白工作台,避免指标卡、按钮和波形继续残留黑底/白线;顶部指标卡增加紫、黄绿、琥珀、青绿、绿色光斑变量,接近原版多色玻璃卡效果。主/次按钮、指标卡和空状态继续走统一类,避免各板块散写不同玻璃效果。 |
web/app/page.tsx | 旧 React 单对话框生成台源码仍保留,便于以后回滚或抽能力;当前生产根域名已经由 web/canvas-app/ 画布产物覆盖,不再把这个 React 首页作为默认首屏。该页面里的模式也已收敛为文生图、文生视频、图生视频;图生视频只显示“上传图片”,不把“首帧/首尾帧”作为用户入口。旧 TK 复刻工作台组件仍保留在 web/components/ad-recreation-board.tsx,但不再作为默认首页渲染。 |
web/canvas-app/ | SKG 内部画布应用:从 chatfire-AI/huobao-canvas 交互逻辑改造而来。当前策略是“保留成熟画布能力,替换品牌/路由/API”:Vue Flow 节点画布、项目列表、推荐词、AI 润色、自动执行、公共工作流、我的工作流、首帧/尾帧/参考图节点、图片/视频/LLM 配置节点、模型配置和批量下载都保留;可见品牌收敛为 SKG logo,不展示上游注册链接或外部品牌。生产路径固定为根域名 /,内部路由用 /p/:id?;项目列表、画布 JSON 和个人工作流模板优先同步到服务端 Postgres,浏览器本地存储只是缓存/导入来源;来源说明保存在 THIRD_PARTY_NOTICES.md,不展示给终端用户。 |
- web/canvas-app/src/stores/projects.js | 画布项目 Pinia store:启动时先读本地 localStorage["ai-canvas-projects"] 作为缓存,再调用 GET /canvas-projects 拉服务端项目;如果发现本地旧项目,会调用 POST /canvas-projects/import 导入到当前登录用户。新建、重命名、画布节点变更、复制和删除会同步到 /canvas-projects,本地缓存只用于快速打开和网络异常兜底。 |
+ web/canvas-app/src/stores/projects.js | 画布项目 Pinia store:启动时先读本地 localStorage["ai-canvas-projects"] 作为缓存,再调用 GET /canvas-projects 拉服务端项目;如果发现本地旧项目,会调用 POST /canvas-projects/import 导入到当前登录用户。新建、重命名、画布节点变更、复制和删除会同步到 /canvas-projects,本地缓存只用于快速打开和网络异常兜底。远程保存用内容签名跳过重复 payload,并以短防抖合并连续节点更新,避免大画布频繁写入影响交互速度。 |
web/canvas-app/src/stores/workflows.js | 我的工作流 store:调用 GET/POST/DELETE /canvas-workflows 读取、保存和删除当前登录用户自己的云端工作流模板。保存前会清理节点里的 base64、生成 URL、任务进度、错误、视频结果和 LLM 输出等运行态字段,只保留可复用的节点结构、连线、配置和提示词。 |
web/canvas-app/src/views/Canvas.vue | 画布主交互:恢复上游底部 prompt composer、AI 润色、自动执行、推荐词、节点菜单、工作流面板、API/模型设置入口和批量下载入口。自动执行会调用 useWorkflowOrchestrator 分析提示词,创建文生图、图转视频、故事板、多角度分镜或绘本节点组;手动模式只创建文本节点,用户自行连接节点。工作流面板支持公共模板和我的工作流:公共模板走本地 createNodes(),我的工作流从云端 workflow_data 插回当前画布,并重新生成节点 ID、按视口中心重排、按映射重连边。Vue Flow 开启可见节点渲染,大画布不再把所有节点同时挂载到 DOM;节点数超过 120 时隐藏 MiniMap,减少点击后的同步重绘压力。底部推荐词来自共享短词池,4 个一组单行展示,刷新按钮在 30 组内轮换,不改变输入面板高度。 |
web/canvas-app/src/config/suggestions.js | 首页和画布共用的推荐词配置:维护 QUICK_SUGGESTION_GROUPS,当前为 30 组 / 120 个短词,每组 4 个,控制刷新按钮的轮换范围;词条保持短小,避免推荐栏换行或顶起 composer。 |
web/canvas-app/src/config/models.js | 画布媒体模型和规格的前端白名单:图片只内置 auto、gpt-image-2、gemini-3-pro-image-preview,尺寸只内置 auto、1024x1536、1024x1024、1536x1024;视频只内置 seedance / Seedance 2.0 Fast,画幅和时长对齐后端 /health 能力边界。useModelConfig.js 和 Pinia 模型 store 会忽略浏览器本地自定义图片/视频模型,防止旧缓存把不可用模型带回生成下拉。 |
- web/canvas-app/src/hooks/useApi.js | 画布到本项目后端的适配层:不再读取浏览器 API Key,而是使用当前登录会话 Cookie 调用 /api。文生图 / 图生图先创建轻量 creative job,再调用 /frames/0/generate;文生视频 / 图生视频调用 /storyboard/video 并轮询 /jobs/{id},完成后把图片或 mp4 URL 写回画布节点。useChat 已从 SKG 广告文案接口切到 /prompt/polish:AI 润色显式使用 image/video prompt 模式,LLM 节点使用通用 chat 模式,避免自动注入用户没有提到的 SKG、产品、平台或营销语境;后端会清理旧润色模板尾巴、判断人物/无人/物体/场景意图,并在输出后检查“有人却禁止人物、无人却新增人物、未写 SKG 却出现 SKG”等冲突。图生视频实际提交到后端后,后端会对参考图追加 AI 虚拟角色条件说明,不要求前端判断图片里是否有人脸。 |
+ web/canvas-app/src/hooks/useApi.js | 画布到本项目后端的适配层:不再读取浏览器 API Key,而是使用当前登录会话 Cookie 调用 /api。文生图 / 图生图先创建轻量 creative job,再调用 /frames/0/generate;本地上传到图片节点的参考图也会先通过 /creative/jobs/image 写成后端资产,再把 /api/jobs/... URL 保存到节点,避免刷新后丢失。文生视频 / 图生视频调用 /storyboard/video 并轮询 /jobs/{id},完成后把图片或 mp4 URL 写回画布节点。useChat 已从 SKG 广告文案接口切到 /prompt/polish:AI 润色显式使用 image/video prompt 模式,LLM 节点使用通用 chat 模式,避免自动注入用户没有提到的 SKG、产品、平台或营销语境;后端会清理旧润色模板尾巴、判断人物/无人/物体/场景意图,并在输出后检查“有人却禁止人物、无人却新增人物、未写 SKG 却出现 SKG”等冲突。图生视频实际提交到后端后,后端会对参考图追加 AI 虚拟角色条件说明,不要求前端判断图片里是否有人脸。 |
web/scripts/sync-canvas-root.mjs | 构建桥接脚本:在 next build 静态导出完成后,把 Vite 画布产物 web/canvas-app/dist 覆盖到 web/out 根目录,使 https://marketing.skg.com 登录后直接进入画布;旧 web/scripts/sync-canvas-dist.mjs 保留但不再由生产构建调用。 |
web/app/detail/page.tsx | 任务详情页:静态导出路由 /detail/?job=<id>,通过 query 读取 job id,调用 getJob 恢复同一任务。页面展示参考图、全部生成图、视频候选、营销图文方案和历史提示词,可继续调用 generateImage、generateStoryboardVideo、generateCreativeCopy,并支持删除图片/视频。该页继续依赖后端 owner 过滤,用户不能通过切换 URL 读取别人的任务。 |
web/app/agent/page.tsx | 新增一键出片终端页:只保留 TikTok 链接、产品图上传、实时 Agent Terminal 和最终成片播放器;通过 POST /agent-runs 创建受限后台状态机任务,通过 GET /agent-runs/{id} 轮询日志、进度、审片图和最终 mp4。该页不替代旧工作台深度编辑能力,只承接“用户只看成品”的快速出片主路径。 |
@@ -1306,6 +1307,20 @@ ProductRefStateItem {
变更记录
这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。
+
+
+ 2026-05-27 · 修复上传参考图刷新后丢失并降低保存频次
+ Canvas
+ Data
+ Bugfix
+
+
+
问题:Dimi(姚智恒)反馈刷新后刚上传的参考图不见了。线上数据库显示其项目保存请求均为 200,丢失节点是本地上传的 参考图,节点里只剩文件名和类型,没有 url 或 base64。
+
根因:ImageNode.vue 原来把上传文件转成 data: base64;cleanNodeForStorage 为避免浏览器存储爆掉,会在保存到 localStorage 和 Postgres 前删除 base64 以及 data: URL,所以刷新后无法恢复图片。这不是单纯网速问题。
+
改动:图片节点上传时先调用 /creative/jobs/image 存成后端资产,再把 /api/jobs/... URL、sourceJobId 和 sourceFrameIdx 写入节点;项目远程保存增加内容签名去重,防抖从 800ms 调整到 2000ms,减少连续重复 PUT /canvas-projects。
+
影响:web/canvas-app/src/components/nodes/ImageNode.vue、web/canvas-app/src/hooks/useApi.js、web/canvas-app/src/stores/projects.js。本地 Docker 中验证上传参考图后刷新,节点 URL 和页面图片都保留为同一个 /api/jobs/... 地址。
+
+
2026-05-27 · 固化最终产品和本地同构排查基线
diff --git a/web/canvas-app/src/components/nodes/ImageNode.vue b/web/canvas-app/src/components/nodes/ImageNode.vue
index c1ca598..d0374f4 100644
--- a/web/canvas-app/src/components/nodes/ImageNode.vue
+++ b/web/canvas-app/src/components/nodes/ImageNode.vue
@@ -327,6 +327,7 @@ import { Handle, Position, useVueFlow } from '@vue-flow/core'
import { NIcon, NTooltip, NSwitch, NImagePreview, NModal, NButton } from 'naive-ui'
import { TrashOutline, ExpandOutline, ImageOutline, CloseCircleOutline, CopyOutline, VideocamOutline, DownloadOutline, EyeOutline, BrushOutline, RefreshOutline, ColorWandOutline, SwapHorizontalOutline } from '@vicons/ionicons5'
import { updateNode, removeNode, duplicateNode, addNode, addEdge, nodes } from '../../stores/canvas'
+import { uploadCanvasImage } from '../../hooks/useApi'
import NodeHandleMenu from './NodeHandleMenu.vue'
const props = defineProps({
@@ -665,27 +666,17 @@ const createInpaintWorkflow = () => {
window.$message?.success('已创建局部重绘工作流')
}
-// Convert file to base64 | 将文件转换为 base64
-const fileToBase64 = (file) => {
- return new Promise((resolve, reject) => {
- const reader = new FileReader()
- reader.onload = () => resolve(reader.result)
- reader.onerror = reject
- reader.readAsDataURL(file)
- })
-}
-
// Handle file upload | 处理文件上传
const handleFileUpload = async (event) => {
const file = event.target.files[0]
if (file) {
try {
- // Convert to base64 | 转换为 base64
- const base64 = await fileToBase64(file)
- // Store both display URL and base64 | 同时存储显示 URL 和 base64
+ urlLoading.value = true
+ const uploaded = await uploadCanvasImage(file)
updateNode(props.id, {
- url: base64, // Use base64 as display URL | 使用 base64 作为显示 URL
- base64: base64, // Store base64 for API calls | 存储 base64 用于 API 调用
+ url: uploaded.url,
+ sourceJobId: uploaded.jobId,
+ sourceFrameIdx: uploaded.frameIdx,
fileName: file.name,
fileType: file.type,
label: '参考图',
@@ -694,6 +685,9 @@ const handleFileUpload = async (event) => {
} catch (err) {
console.error('File upload error:', err)
window.$message?.error('图片上传失败')
+ } finally {
+ urlLoading.value = false
+ event.target.value = ''
}
}
}
@@ -738,10 +732,12 @@ const handleReplaceFileUpload = async (event) => {
const file = event.target.files[0]
if (file) {
try {
- const base64 = await fileToBase64(file)
+ urlLoading.value = true
+ const uploaded = await uploadCanvasImage(file)
updateNode(props.id, {
- url: base64,
- base64: base64,
+ url: uploaded.url,
+ sourceJobId: uploaded.jobId,
+ sourceFrameIdx: uploaded.frameIdx,
fileName: file.name,
fileType: file.type,
label: '参考图',
@@ -753,6 +749,9 @@ const handleReplaceFileUpload = async (event) => {
} catch (err) {
console.error('File upload error:', err)
window.$message?.error('图片上传失败')
+ } finally {
+ urlLoading.value = false
+ event.target.value = ''
}
}
}
diff --git a/web/canvas-app/src/hooks/useApi.js b/web/canvas-app/src/hooks/useApi.js
index fe2d79f..913c115 100644
--- a/web/canvas-app/src/hooks/useApi.js
+++ b/web/canvas-app/src/hooks/useApi.js
@@ -70,6 +70,18 @@ const uploadReferenceFrame = async (jobId, file) => {
return requestJson(`/jobs/${jobId}/frames/upload`, { method: 'POST', body: form })
}
+export const uploadCanvasImage = async (file) => {
+ if (!file) throw new Error('请选择图片文件')
+ const job = await createCreativeImageJob(file)
+ const frame = job.frames?.[0]
+ if (!frame?.url) throw new Error('图片已上传但未返回可用地址')
+ return {
+ url: toAssetUrl(frame.url),
+ jobId: job.id,
+ frameIdx: frame.index ?? 0
+ }
+}
+
const newestGeneratedImage = (job, frameIdx = 0) => {
const frame = (job.frames || []).find(item => item.index === frameIdx) || job.frames?.[0]
return [...(frame?.generated_images || [])].sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0]
diff --git a/web/canvas-app/src/stores/projects.js b/web/canvas-app/src/stores/projects.js
index d63ff29..47a49c7 100644
--- a/web/canvas-app/src/stores/projects.js
+++ b/web/canvas-app/src/stores/projects.js
@@ -22,6 +22,7 @@ export const projectSyncError = ref('')
const API_BASE = import.meta.env.VITE_SKG_API_BASE || '/api'
const apiUrl = (path) => `${API_BASE}${path.startsWith('/') ? '' : '/'}${path}`
const remoteSaveTimers = new Map()
+const remoteSaveSignatures = new Map()
let initPromise = null
let remoteAvailable = false
@@ -76,6 +77,12 @@ const projectToApi = (project) => ({
source: 'canvas'
})
+const remoteProjectSignature = (project) => {
+ const payload = projectToApi(project)
+ delete payload.updated_at
+ return JSON.stringify(payload)
+}
+
const requestJson = async (path, init = {}) => {
const response = await fetch(apiUrl(path), {
...init,
@@ -126,14 +133,17 @@ export const loadProjects = () => {
const saveRemoteProjectNow = async (project) => {
if (!project?.id) return null
+ const signature = remoteProjectSignature(project)
+ if (remoteSaveSignatures.get(project.id) === signature) return null
const response = await requestJson(`/canvas-projects/${encodeURIComponent(project.id)}`, {
method: 'PUT',
body: JSON.stringify(projectToApi(project))
})
+ remoteSaveSignatures.set(project.id, signature)
return response.item ? projectFromApi(response.item) : null
}
-const scheduleRemoteSave = (project, delay = 800) => {
+const scheduleRemoteSave = (project, delay = 2000) => {
if (!remoteAvailable || !project?.id) return
if (remoteSaveTimers.has(project.id)) {
clearTimeout(remoteSaveTimers.get(project.id))