auto-save 2026-05-27 17:24 (~4)

This commit is contained in:
2026-05-27 17:24:16 +08:00
parent 9ab541796b
commit fb939b8fcf
4 changed files with 93 additions and 13 deletions

View File

@@ -1,12 +1,5 @@
{ {
"entries": [ "entries": [
{
"files_changed": 3,
"hash": "5e0afce",
"message": "auto-save 2026-05-20 19:44 (~3)",
"ts": "2026-05-20T19:44:10+08:00",
"type": "commit"
},
{ {
"files_changed": 2, "files_changed": 2,
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 2 项未提交变更 · 最近提交auto-save 2026-05-20 19:44 (~3)", "message": "Codex 会话活跃 · 最近命令codex · 分支 main · 2 项未提交变更 · 最近提交auto-save 2026-05-20 19:44 (~3)",
@@ -3196,6 +3189,13 @@
"message": "auto-save 2026-05-27 17:13 (~2)", "message": "auto-save 2026-05-27 17:13 (~2)",
"hash": "8999fe0", "hash": "8999fe0",
"files_changed": 2 "files_changed": 2
},
{
"ts": "2026-05-27T17:18:45+08:00",
"type": "commit",
"message": "auto-save 2026-05-27 17:18 (~9)",
"hash": "9ab5417",
"files_changed": 9
} }
] ]
} }

View File

@@ -624,9 +624,10 @@
<p><strong>2026-05-26 我的工作流云端版:</strong>工作流面板从只有公共模板扩展为“公共工作流 / 我的工作流”两类。当前画布可以保存成当前登录用户自己的云端工作流模板,后续在同一账号的其他电脑或浏览器打开后可插回画布;保存时只沉淀节点结构、连线、配置和提示词,主动清掉已生成图片、视频、任务进度、错误和运行态字段,避免把一次性生成结果误当模板复用。</p> <p><strong>2026-05-26 我的工作流云端版:</strong>工作流面板从只有公共模板扩展为“公共工作流 / 我的工作流”两类。当前画布可以保存成当前登录用户自己的云端工作流模板,后续在同一账号的其他电脑或浏览器打开后可插回画布;保存时只沉淀节点结构、连线、配置和提示词,主动清掉已生成图片、视频、任务进度、错误和运行态字段,避免把一次性生成结果误当模板复用。</p>
<p><strong>2026-05-27 画布点击响应优化:</strong>大画布启用 Vue Flow 可见节点渲染,并在载入旧项目时补齐节点尺寸,让几百个节点的项目不再一次性渲染全部节点;新增节点、批量插入节点/边和复制节点改为原地追加,避免单次点击触发整条节点数组重复替换。大项目右下角 MiniMap 只在 120 个节点以内显示,优先保证编辑响应速度。</p> <p><strong>2026-05-27 画布点击响应优化:</strong>大画布启用 Vue Flow 可见节点渲染,并在载入旧项目时补齐节点尺寸,让几百个节点的项目不再一次性渲染全部节点;新增节点、批量插入节点/边和复制节点改为原地追加,避免单次点击触发整条节点数组重复替换。大项目右下角 MiniMap 只在 120 个节点以内显示,优先保证编辑响应速度。</p>
<p><strong>2026-05-27 上传参考图持久化:</strong>画布图片节点上传本地文件时先写入后端 creative job再把 <code>/api/jobs/...</code> 资产 URL 保存到节点和服务端画布项目;不再把浏览器 <code>data:</code> base64 当作图片地址保存。项目自动保存增加内容签名去重和 2 秒防抖,减少连续点击或节点测量触发的重复 <code>PUT /canvas-projects</code></p> <p><strong>2026-05-27 上传参考图持久化:</strong>画布图片节点上传本地文件时先写入后端 creative job再把 <code>/api/jobs/...</code> 资产 URL 保存到节点和服务端画布项目;不再把浏览器 <code>data:</code> base64 当作图片地址保存。项目自动保存增加内容签名去重和 2 秒防抖,减少连续点击或节点测量触发的重复 <code>PUT /canvas-projects</code></p>
<p><strong>2026-05-27 图片模型配置化:</strong>图片生成不再把主模型写死为 <code>gpt-image-2</code>。后端通过 <code>IMAGE_MODEL</code><code>IMAGE_FALLBACK_MODELS</code><code>IMAGE_EXTRA_MODELS</code><code>IMAGE_MODEL_CONFIGS_JSON</code> 和 Ark 专用 <code>ARK_IMAGE_BASE_URL</code>/<code>ARK_IMAGE_API_KEY</code>/<code>ARK_SEEDREAM_IMAGE_MODEL</code> 注册模型;默认仍保持 GPT Image 2 + Gemini 兜底,新增可选 <code>doubao-seedream-4-5-251128</code>Seedream 走 <code>/images/generations</code> + <code>reference_images</code> 并使用 2K/4K 尺寸。</p>
<p><strong>2026-05-26 生图配置恢复版:</strong>按用户要求撤回后续“低/中/高画质、自定义尺寸、Gemini 官方 1K/2K/4K 尺寸、取消自动模型”的实验改动,恢复最初简单配置:图片模型为 <code>auto</code><code>gpt-image-2</code><code>gemini-3-pro-image-preview</code>,尺寸只保留 <code>auto</code><code>1024x1536</code><code>1024x1024</code><code>1536x1024</code>,画质回到单一标准项;<code>auto</code> 仍按后端既有策略优先 GPT Image 2必要时由熔断/兜底走 Gemini。</p> <p><strong>2026-05-26 生图配置恢复版:</strong>按用户要求撤回后续“低/中/高画质、自定义尺寸、Gemini 官方 1K/2K/4K 尺寸、取消自动模型”的实验改动,恢复最初简单配置:图片模型为 <code>auto</code><code>gpt-image-2</code><code>gemini-3-pro-image-preview</code>,尺寸只保留 <code>auto</code><code>1024x1536</code><code>1024x1024</code><code>1536x1024</code>,画质回到单一标准项;<code>auto</code> 仍按后端既有策略优先 GPT Image 2必要时由熔断/兜底走 Gemini。</p>
</div> </div>
<p>当前默认业务管线是“个人隔离任务 → 根域名进入个人画布 → 画布项目同步到服务端 Postgres → 用提示词、推荐词、AI 润色、公共工作流或我的工作流创建节点 → 画布自动执行或手动连接图片/视频/文本节点 → 生成结果沉淀在当前个人画布 → 可把当前节点结构保存为我的工作流 → 需要时进入详情页继续编辑”。画布不再被削成三模式入口;首帧、尾帧、参考图、图生视频、多角度分镜、故事板和绘本等上游概念按节点能力保留。底层生成仍由 <code>web/canvas-app/src/hooks/useApi.js</code> 适配到本项目 <code>/creative/jobs/image</code><code>/jobs/{id}/frames/{idx}/generate</code><code>/jobs/{id}/frames/{idx}/storyboard/video</code>AI 润色和通用 LLM 文本生成走 <code>/prompt/polish</code> 并保持中性专业:不主动套入 SKG不主动补产品、平台、广告语境或人物只扩写用户明确写出的主体、动作、场景、镜头、光线和质量细节视频提交若带参考图会在最终提示词中条件声明“参考图里若有人物应按 AI 生成的虚拟角色处理”,避免把 AI 人像素材误当成真实肖像。生成资产按当前登录用户写入个人 job。图片尺寸只显示 <code>auto</code><code>1024x1536</code><code>1024x1024</code><code>1536x1024</code>视频画幅只显示 <code>720x1280</code><code>1280x720</code><code>1024x1024</code><code>960x1280</code>;视频时长只显示 <code>5/8/10/12/15</code> 秒。多人互不影响依赖后端 <code>owner_id</code>、画布项目 owner、我的工作流 owner 和飞书 / 备用登录会话隔离。旧 React 单对话框首页、信息流复刻链路仍保留在源码里作为回滚/高级能力,但不作为生产默认入口。</p> <p>当前默认业务管线是“个人隔离任务 → 根域名进入个人画布 → 画布项目同步到服务端 Postgres → 用提示词、推荐词、AI 润色、公共工作流或我的工作流创建节点 → 画布自动执行或手动连接图片/视频/文本节点 → 生成结果沉淀在当前个人画布 → 可把当前节点结构保存为我的工作流 → 需要时进入详情页继续编辑”。画布不再被削成三模式入口;首帧、尾帧、参考图、图生视频、多角度分镜、故事板和绘本等上游概念按节点能力保留。底层生成仍由 <code>web/canvas-app/src/hooks/useApi.js</code> 适配到本项目 <code>/creative/jobs/image</code><code>/jobs/{id}/frames/{idx}/generate</code><code>/jobs/{id}/frames/{idx}/storyboard/video</code>AI 润色和通用 LLM 文本生成走 <code>/prompt/polish</code> 并保持中性专业:不主动套入 SKG不主动补产品、平台、广告语境或人物只扩写用户明确写出的主体、动作、场景、镜头、光线和质量细节视频提交若带参考图会在最终提示词中条件声明“参考图里若有人物应按 AI 生成的虚拟角色处理”,避免把 AI 人像素材误当成真实肖像。生成资产按当前登录用户写入个人 job。图片模型由后端运行时配置决定,默认 <code>auto</code> 仍优先当前 <code>IMAGE_MODEL</code> 并按 <code>IMAGE_FALLBACK_MODELS</code> 兜底;前端同时提供 GPT/Gemini 旧尺寸和 Seedream 2K/4K 尺寸。视频画幅只显示 <code>720x1280</code><code>1280x720</code><code>1024x1024</code><code>960x1280</code>;视频时长只显示 <code>5/8/10/12/15</code> 秒。多人互不影响依赖后端 <code>owner_id</code>、画布项目 owner、我的工作流 owner 和飞书 / 备用登录会话隔离。旧 React 单对话框首页、信息流复刻链路仍保留在源码里作为回滚/高级能力,但不作为生产默认入口。</p>
<div class="pipeline"> <div class="pipeline">
<div class="step"><div class="num">01</div><h3>个人任务</h3><p><code>GET /jobs</code> 按当前登录用户过滤;旧无 owner 任务只对备用账号可见。</p></div> <div class="step"><div class="num">01</div><h3>个人任务</h3><p><code>GET /jobs</code> 按当前登录用户过滤;旧无 owner 任务只对备用账号可见。</p></div>
<div class="step"><div class="num">02</div><h3>进入画布</h3><p>用户直接在根域名个人画布里操作;项目列表优先读取服务端 <code>/canvas-projects</code>,本地旧项目会首次导入。</p></div> <div class="step"><div class="num">02</div><h3>进入画布</h3><p>用户直接在根域名个人画布里操作;项目列表优先读取服务端 <code>/canvas-projects</code>,本地旧项目会首次导入。</p></div>
@@ -1307,6 +1308,19 @@ ProductRefStateItem {
<h2>变更记录</h2> <h2>变更记录</h2>
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p> <p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
<div class="changelog"> <div class="changelog">
<article class="change">
<header>
<h3>2026-05-27 · 图片 API 改为运行时可配置并接入 Ark Seedream</h3>
<span class="tag blue">API</span>
<span class="tag violet">Model</span>
<span class="tag green">Canvas</span>
</header>
<div class="body">
<p><strong>问题:</strong>生图链路把主模型写死为 <code>gpt-image-2</code>,新增火山方舟 <code>doubao-seedream-4-5-251128</code> 时不能只靠 env 切换;同时 Seedream 4.5 的尺寸要求和图生图请求格式不同于现有 OpenAI-compatible <code>/images/edits</code> 路径。</p>
<p><strong>改动:</strong><code>api/main.py</code> 新增图片模型运行时注册:<code>IMAGE_MODEL</code> 决定主模型,<code>IMAGE_FALLBACK_MODELS</code> 支持多备用模型,<code>IMAGE_EXTRA_MODELS</code><code>IMAGE_MODEL_CONFIGS_JSON</code> 支持后续扩展Ark Seedream 使用 <code>ARK_IMAGE_BASE_URL</code><code>ARK_IMAGE_API_KEY</code><code>ARK_SEEDREAM_IMAGE_MODEL</code> 独立配置。普通模型继续走 <code>/images/generations</code> / <code>/images/edits</code>Seedream 图生图改走 <code>/images/generations</code> + <code>reference_images</code></p>
<p><strong>影响:</strong><code>web/canvas-app/src/config/models.js</code> 增加 <code>Seedream 4.5</code><code>2K/2048/1440x2560/2560x1440/4K</code> 尺寸;<code>/health</code> 返回每个图片模型的 provider、base URL、配置状态和尺寸能力。真实 Ark key 只应写入本地或 VPS 的 gitignored env 文件,不能提交到仓库。</p>
</div>
</article>
<article class="change"> <article class="change">
<header> <header>
<h3>2026-05-27 · 修复上传参考图刷新后丢失并降低保存频次</h3> <h3>2026-05-27 · 修复上传参考图刷新后丢失并降低保存频次</h3>

View File

@@ -103,6 +103,49 @@ const isModelSupported = (model, provider) => {
return model.provider.includes(provider) return model.provider.includes(provider)
} }
const normalizeRuntimeSizeOptions = (items = []) => {
if (!Array.isArray(items)) return []
return items
.map(item => {
const key = item?.value || item?.key || item?.id
if (!key) return null
return {
label: item.label || key,
key
}
})
.filter(Boolean)
}
const normalizeRuntimeImageModel = (item) => {
const key = item?.id || item?.model
if (!key) return null
const sizeOptions = normalizeRuntimeSizeOptions(item.size_options)
return {
label: item.label || item.model || key,
key,
provider: ['chatfire'],
sizes: sizeOptions.map(option => option.key),
sizeOptions,
qualities: [{ label: '标准', key: 'standard' }],
defaultParams: {
size: item.default_size || sizeOptions[0]?.key || 'auto',
quality: 'standard',
style: item.provider === 'ark_seedream' ? 'commercial' : 'vivid'
},
available: item.available !== false,
providerName: item.provider,
isRuntime: true
}
}
const mergeModels = (builtInModels, runtimeModels) => {
const byKey = new Map()
builtInModels.forEach(model => byKey.set(model.key, { ...model, isCustom: false }))
runtimeModels.forEach(model => byKey.set(model.key, { ...byKey.get(model.key), ...model, isCustom: false }))
return Array.from(byKey.values())
}
export const useModelStore = defineStore('model', () => { export const useModelStore = defineStore('model', () => {
// ============ Provider 状态 | Provider State ============ // ============ Provider 状态 | Provider State ============
@@ -162,6 +205,7 @@ export const useModelStore = defineStore('model', () => {
const customChatModelsByProvider = ref(getStoredJson(STORAGE_KEYS.CUSTOM_CHAT_MODELS_BY_PROVIDER, {})) const customChatModelsByProvider = ref(getStoredJson(STORAGE_KEYS.CUSTOM_CHAT_MODELS_BY_PROVIDER, {}))
const customImageModelsByProvider = ref(getStoredJson(STORAGE_KEYS.CUSTOM_IMAGE_MODELS_BY_PROVIDER, {})) const customImageModelsByProvider = ref(getStoredJson(STORAGE_KEYS.CUSTOM_IMAGE_MODELS_BY_PROVIDER, {}))
const customVideoModelsByProvider = ref(getStoredJson(STORAGE_KEYS.CUSTOM_VIDEO_MODELS_BY_PROVIDER, {})) const customVideoModelsByProvider = ref(getStoredJson(STORAGE_KEYS.CUSTOM_VIDEO_MODELS_BY_PROVIDER, {}))
const runtimeImageModels = ref([])
// 选中的模型 // 选中的模型
const selectedChatModel = ref(getStored(STORAGE_KEYS.SELECTED_CHAT_MODEL, DEFAULT_CHAT_MODEL)) const selectedChatModel = ref(getStored(STORAGE_KEYS.SELECTED_CHAT_MODEL, DEFAULT_CHAT_MODEL))
@@ -211,7 +255,7 @@ export const useModelStore = defineStore('model', () => {
]) ])
const allImageModels = computed(() => const allImageModels = computed(() =>
IMAGE_MODELS.map(m => ({ ...m, isCustom: false })) mergeModels(IMAGE_MODELS, runtimeImageModels.value)
) )
const allVideoModels = computed(() => const allVideoModels = computed(() =>
@@ -226,7 +270,7 @@ export const useModelStore = defineStore('model', () => {
) )
const availableImageModels = computed(() => const availableImageModels = computed(() =>
allImageModels.value.filter(m => isModelSupported(m, currentProvider.value)) allImageModels.value.filter(m => isModelSupported(m, currentProvider.value) && m.available !== false)
) )
const availableVideoModels = computed(() => const availableVideoModels = computed(() =>
@@ -239,7 +283,8 @@ export const useModelStore = defineStore('model', () => {
const allImageModelOptions = computed(() => const allImageModelOptions = computed(() =>
allImageModels.value.map(m => ({ allImageModels.value.map(m => ({
label: m.label, label: m.label,
key: m.key key: m.key,
disabled: m.available === false
})) }))
) )
@@ -263,7 +308,8 @@ export const useModelStore = defineStore('model', () => {
const imageModelOptions = computed(() => const imageModelOptions = computed(() =>
availableImageModels.value.map(m => ({ availableImageModels.value.map(m => ({
label: m.label, label: m.label,
key: m.key key: m.key,
disabled: m.available === false
})) }))
) )
@@ -343,6 +389,23 @@ export const useModelStore = defineStore('model', () => {
const getImageModel = (key) => allImageModels.value.find(m => m.key === key) const getImageModel = (key) => allImageModels.value.find(m => m.key === key)
const getVideoModel = (key) => allVideoModels.value.find(m => m.key === key) const getVideoModel = (key) => allVideoModels.value.find(m => m.key === key)
const loadRuntimeModels = async () => {
try {
const response = await fetch('/api/health', { credentials: 'include' })
if (!response.ok) return false
const data = await response.json()
const imageOptions = data?.models?.image_options || []
runtimeImageModels.value = imageOptions
.filter(item => item?.id && item.id !== 'auto')
.map(normalizeRuntimeImageModel)
.filter(Boolean)
return true
} catch (err) {
console.warn('[model store] runtime model load failed', err)
return false
}
}
// ============ Methods: Get API Endpoints ============ // ============ Methods: Get API Endpoints ============
// 获取图片端点 // 获取图片端点
@@ -383,7 +446,7 @@ export const useModelStore = defineStore('model', () => {
provider: [provider] provider: [provider]
})) }))
] ]
const image = IMAGE_MODELS const image = allImageModels.value
.filter(m => isModelSupported(m, provider)) .filter(m => isModelSupported(m, provider))
.map(m => ({ ...m, isCustom: false })) .map(m => ({ ...m, isCustom: false }))
const video = VIDEO_MODELS const video = VIDEO_MODELS
@@ -500,6 +563,7 @@ export const useModelStore = defineStore('model', () => {
allChatModels, allChatModels,
allImageModels, allImageModels,
allVideoModels, allVideoModels,
runtimeImageModels,
// Available models filtered by provider // Available models filtered by provider
availableChatModels, availableChatModels,
@@ -551,6 +615,7 @@ export const useModelStore = defineStore('model', () => {
getChatModel, getChatModel,
getImageModel, getImageModel,
getVideoModel, getVideoModel,
loadRuntimeModels,
// Get API endpoints // Get API endpoints
getImageEndpoint, getImageEndpoint,

View File

@@ -319,6 +319,7 @@ const isApiConfigured = computed(() => !!modelStore.currentApiKey)
// Initialize models on page load | 页面加载时初始化模型 // Initialize models on page load | 页面加载时初始化模型
onMounted(() => { onMounted(() => {
loadAllModels() loadAllModels()
modelStore.loadRuntimeModels()
}) })
// Chat templates | 问答模板 // Chat templates | 问答模板