fix: 视频持久化、会话排序、重命名等多项修复

- 修复视频 URL 存入会话数据,切换/重启不丢失
- 保存时从磁盘重载会话列表,消除内存缓存不一致
- 会话列表按 updatedAt 降序,最新在顶部
- 会话标题自动加序号区分同名
- 角色库和场景库支持重命名
- 尾帧保存时弹窗命名
- DeepSeek 仅脚本模式可用,生图模式过滤
- Seedance 2.0 适配,支持 return_last_frame
- 图片下载改用 https.get 避免 IPC 序列化问题
- 脚本模式工具栏仅显示对话+视频模型

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-26 17:01:57 +08:00
parent 3d10c3f1a2
commit e999f03b00
8 changed files with 171 additions and 45 deletions
+7 -7
View File
@@ -384,18 +384,17 @@ export async function createVideoTask(
const content: Record<string, unknown>[] = []
const isV2 = model.includes('2-0') || model.includes('2.0')
// 首帧:场景图优先,否则用第一个角色图
const frameUrl = firstFrameUrl || (refUrls && refUrls.length > 0 ? refUrls[0] : '')
if (frameUrl) {
const resolved = resolveImageUrl(frameUrl)
// 首帧:场景图(让视频从该场景开始),不填则 AI 自由生成
if (firstFrameUrl) {
const resolved = resolveImageUrl(firstFrameUrl)
if (resolved) content.push({ type: 'image_url', image_url: { url: resolved }, role: 'first_frame' })
}
// 参考图:仅 2.0 支持
// 角色参考图:仅 2.0 支持 reference_image,告诉 AI 角色长啥样
if (isV2 && refUrls) {
for (const refUrl of refUrls) {
const resolved = resolveImageUrl(refUrl)
if (resolved && resolved !== resolveImageUrl(frameUrl)) {
if (resolved) {
content.push({ type: 'image_url', image_url: { url: resolved }, role: 'reference_image' })
}
}
@@ -406,7 +405,7 @@ export async function createVideoTask(
const response = await fetch('https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + apiKey },
body: JSON.stringify({ model, content, duration: durationSec, resolution, generate_audio: false })
body: JSON.stringify({ model, content, duration: durationSec, resolution, generate_audio: false, return_last_frame: true })
})
if (!response.ok) {
@@ -434,6 +433,7 @@ export async function pollVideoTask(taskId: string): Promise<{ status: string; v
return {
status: result.status,
videoUrl: result.content?.video_url,
lastFrameUrl: result.last_frame_url || '',
duration: result.duration,
resolution: result.resolution
}
+4 -2
View File
@@ -2,8 +2,8 @@ import { ipcMain } from 'electron'
import {
getApiKeys, getUiApiKeys, getConfigFileApiKeys,
setApiKey, deleteApiKey, openConfigFile, getConfigPath, getEndpoints,
getCharacters, saveCharacter, deleteCharacter,
getScenes, saveScene, deleteScene,
getCharacters, saveCharacter, deleteCharacter, renameCharacter,
getScenes, saveScene, deleteScene, renameScene,
getDataDir, getImagesDir, setDataDir, openFolder, pickFolder,
getSessions, getSession, saveSession, deleteSession,
downloadImage, readImageBase64, imagesDir
@@ -49,6 +49,7 @@ export function registerIpcHandlers(): void {
})
})
ipcMain.handle('characters:delete', (_e, id) => wrap(() => { deleteCharacter(id); return { success: true } }))
ipcMain.handle('characters:rename', (_e, id, newName) => wrap(() => { renameCharacter(id, newName); return { success: true } }))
// === 场景库 ===
ipcMain.handle('scenes:getAll', () => wrap(() => getScenes()))
@@ -61,6 +62,7 @@ export function registerIpcHandlers(): void {
})
})
ipcMain.handle('scenes:delete', (_e, id) => wrap(() => { deleteScene(id); return { success: true } }))
ipcMain.handle('scenes:rename', (_e, id, newName) => wrap(() => { renameScene(id, newName); return { success: true } }))
// === 图片 ===
ipcMain.handle('image:readBase64', (_e, path: string) => {
+19 -1
View File
@@ -208,6 +208,15 @@ export function deleteCharacter(id: string): void {
writeInternal(data)
}
export function renameCharacter(id: string, newName: string): void {
const data = readInternal()
const chars = data.characters || []
if (chars.some(c => c.name === newName && c.id !== id)) throw new Error('角色「' + newName + '」已存在')
const c = chars.find(c => c.id === id)
if (c) c.name = newName
writeInternal(data)
}
// ===== 场景库 =====
export function getScenes(): StoredScene[] {
@@ -231,6 +240,15 @@ export function deleteScene(id: string): void {
writeInternal(data)
}
export function renameScene(id: string, newName: string): void {
const data = readInternal()
const sc = data.scenes || []
if (sc.some(s => s.name === newName && s.id !== id)) throw new Error('场景「' + newName + '」已存在')
const s = sc.find(s => s.id === id)
if (s) s.name = newName
writeInternal(data)
}
export function getDataDir(): string {
return baseDir()
}
@@ -362,7 +380,7 @@ export function readImageBase64(localPath: string): string {
// ===== 会话管理 =====
export function getSessions(): ChatSession[] {
return readInternal().sessions || []
return (readInternal().sessions || []).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
}
export function getSession(id: string): ChatSession | undefined {
+2
View File
@@ -22,11 +22,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
getCharacters: () => ipcRenderer.invoke('characters:getAll'),
saveCharacter: (c) => ipcRenderer.invoke('characters:save', c),
deleteCharacter: (id) => ipcRenderer.invoke('characters:delete', id),
renameCharacter: (id, newName) => ipcRenderer.invoke('characters:rename', id, newName),
// 场景库
getScenes: () => ipcRenderer.invoke('scenes:getAll'),
saveScene: (s) => ipcRenderer.invoke('scenes:save', s),
deleteScene: (id) => ipcRenderer.invoke('scenes:delete', id),
renameScene: (id, newName) => ipcRenderer.invoke('scenes:rename', id, newName),
// 图片
readImageBase64: (path: string) => ipcRenderer.invoke('image:readBase64', path),
+3 -1
View File
@@ -60,10 +60,12 @@ interface ElectronAPI {
getCharacters: () => Promise<StoredCharacter[]>
saveCharacter: (char: StoredCharacter) => Promise<StoredCharacter>
deleteCharacter: (id: string) => Promise<void>
renameCharacter: (id: string, newName: string) => Promise<void>
getScenes: () => Promise<StoredScene[]>
saveScene: (scene: StoredScene) => Promise<StoredScene>
deleteScene: (id: string) => Promise<void>
renameScene: (id: string, newName: string) => Promise<void>
readImageBase64: (path: string) => Promise<string>
@@ -77,7 +79,7 @@ interface ElectronAPI {
splitNovel: (novelText: string) => Promise<{ title: string; content: string }[]>
generateScript: (novelText: string, duration: number, style?: string, model?: string) => Promise<string>
createVideo: (prompt: string, duration: number, resolution: string, model?: string, firstFrameUrl?: string, refUrls?: string[]) => Promise<string>
pollVideo: (taskId: string) => Promise<{ status: string; videoUrl?: string; duration?: number; resolution?: string }>
pollVideo: (taskId: string) => Promise<{ status: string; videoUrl?: string; lastFrameUrl?: string; duration?: number; resolution?: string }>
}
interface Window {
+16
View File
@@ -32,6 +32,7 @@
<p class="char-desc">{{ previewDesc(char.description) }}</p>
<div class="char-footer">
<span class="char-date">{{ formatDate(char.createdAt) }}</span>
<el-button size="small" text @click="handleRename(char)">重命名</el-button>
<el-button size="small" type="danger" text @click="handleDelete(char)">删除</el-button>
</div>
</div>
@@ -105,6 +106,21 @@ async function load() {
}
}
async function handleRename(char: StoredCharacter) {
try {
const r = await ElMessageBox.prompt('新名称', '重命名角色', { inputValue: char.name, confirmButtonText: '确定' })
const name = r.value.trim()
if (!name || name === char.name) return
await window.electronAPI.renameCharacter(char.id, name)
char.name = name
ElMessage.success('已重命名')
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : ''
if (msg.includes('已存在')) ElMessage.warning(msg)
else if (msg && !msg.includes('cancel')) ElMessage.error('重命名失败')
}
}
async function handleDelete(char: StoredCharacter) {
try {
await ElMessageBox.confirm(`确定删除「${char.name}」?`, '确认删除')
+97 -27
View File
@@ -127,6 +127,17 @@
</div>
</div>
<!-- 视频每条消息独立展示不能在 imageUrl 条件内 -->
<div v-if="msg.videoUrl || (videoResult && i === messages.length - 1)" class="video-result" style="margin-top:8px">
<video v-if="msg.videoUrl || videoResult?.videoUrl" :src="msg.videoUrl || videoResult?.videoUrl" controls style="max-width:100%;max-height:300px;border-radius:8px" />
<el-button v-if="msg.videoUrl || videoResult?.videoUrl" size="small" type="success" style="margin-top:8px" @click="saveLastFrame((msg.videoUrl || videoResult?.videoUrl)!)">
保存尾帧到场景库
</el-button>
</div>
<div v-if="msg.videoTaskId && !msg.videoUrl && !videoResult" style="font-size:12px;color:#999;margin-top:4px">
视频任务: {{ msg.videoTaskId }}
</div>
<!-- 脚本模式视频生成控制不在 imageUrl 条件内 -->
<div v-if="mode === 'script' && msg.role === 'assistant' && i === messages.length - 1" class="script-actions">
<el-divider />
@@ -174,13 +185,6 @@
</el-button>
</div>
<!-- 视频结果 -->
<div v-if="videoResult" class="video-result">
<video v-if="videoResult.videoUrl" :src="videoResult.videoUrl" controls style="max-width:100%;max-height:300px;border-radius:8px" />
<el-button v-if="videoResult.videoUrl" size="small" type="success" style="margin-top:8px" @click="saveLastFrame(videoResult.videoUrl!)">
保存尾帧到场景库
</el-button>
</div>
</div>
</div>
</div>
@@ -251,6 +255,8 @@ interface Message {
text: string
imageUrl?: string
saved?: boolean
videoTaskId?: string
videoUrl?: string
}
// ===== 状态 =====
@@ -270,7 +276,7 @@ const generatingVideo = ref(false)
const videoTaskId = ref('')
const videoResolution = ref('720p')
const videoModel = ref('doubao-seedance-2-0-fast-260128')
const videoResult = ref<{ videoUrl?: string } | null>(null)
const videoResult = ref<{ videoUrl?: string; lastFrameUrl?: string } | null>(null)
const selectedSceneImage = ref('')
const mappedCharacter = ref('')
const characterList = ref<StoredCharacter[]>([])
@@ -325,9 +331,12 @@ const suggestions = [
// ===== 会话管理 =====
let autoSaveTimer: ReturnType<typeof setTimeout> | null = null
/** 生成会话标题(取第一条用户消息 */
/** 生成会话标题(加序号区分同名 */
function makeTitle(text: string): string {
return text.length > 30 ? text.slice(0, 30) + '...' : text
const base = text.length > 30 ? text.slice(0, 30) + '...' : text
// 统计同名会话数,追加序号
const sameCount = sessions.value.filter(s => s.title.startsWith(base)).length + 1
return sameCount > 1 ? base + ` (#${sameCount})` : base
}
/** 格式化会话时间显示 */
@@ -335,8 +344,7 @@ function formatSessionTime(iso: string): string {
const d = new Date(iso)
const now = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
const time = `${pad(d.getHours())}:${pad(d.getMinutes())}`
// 今天的只显示时间,非今天的加日期
const time = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
if (d.toDateString() === now.toDateString()) return time
return `${d.getMonth() + 1}/${d.getDate()} ${time}`
}
@@ -358,13 +366,28 @@ async function switchSession(id: string) {
}
if (id === currentSessionId.value) return
currentSessionId.value = id
const s = sessions.value.find(x => x.id === id)
// 从后端重新加载最新数据
const all = await window.electronAPI.getSessions()
sessions.value = all
const s = all.find(x => x.id === id)
if (s) {
messages.value = s.messages.map(m => ({ ...m }))
mode.value = s.mode as Mode
currentStyle.value = s.style || 'anime'
chatModel.value = s.chatModel
imageModel.value = s.imageModel
// 恢复视频状态
videoResult.value = null
videoTaskId.value = ''
const lastMsg = messages.value[messages.value.length - 1]
if (lastMsg?.videoUrl) {
videoResult.value = { videoUrl: lastMsg.videoUrl, lastFrameUrl: (lastMsg as Record<string,unknown>).lastFrameUrl as string || '' }
videoTaskId.value = lastMsg.videoTaskId || ''
} else if (lastMsg?.videoTaskId) {
// 有任务 ID 但没 URL,自动查询状态
videoTaskId.value = lastMsg.videoTaskId
await pollVideoTask(lastMsg)
}
}
await scrollToBottom()
}
@@ -388,27 +411,33 @@ async function handleDeleteSession(s: ChatSession) {
async function saveCurrentSession(options?: { title?: string }): Promise<string | undefined> {
if (messages.value.length === 0) return
// 从第一条用户消息取标题
const firstUser = messages.value.find(m => m.role === 'user')
const title = options?.title || (firstUser ? makeTitle(firstUser.text) : '新会话')
const now = new Date().toISOString()
const sid = currentSessionId.value || (Date.now().toString(36) + Math.random().toString(36).slice(2, 10))
const existing = sessions.value.find(s => s.id === sid)
const msgCount = messages.value.length
// 内容没变不更新时间(避免浏览时乱排)
const shouldUpdate = !existing || existing.messages.length !== msgCount
const updatedAt = shouldUpdate ? now : (existing?.updatedAt || now)
const session: ChatSession = {
id: currentSessionId.value || (Date.now().toString(36) + Math.random().toString(36).slice(2, 6)),
id: sid,
title,
mode: mode.value,
style: currentStyle.value,
chatModel: chatModel.value,
imageModel: imageModel.value,
messages: JSON.parse(JSON.stringify(messages.value)),
createdAt: currentSessionId.value ? (sessions.value.find(s => s.id === currentSessionId.value)?.createdAt || now) : now,
updatedAt: now
createdAt: existing?.createdAt || now,
updatedAt
}
await window.electronAPI.saveSession(session)
currentSessionId.value = session.id
// 刷新会话列表
// 每次保存都从磁盘重载,保证数据一致
sessions.value = await window.electronAPI.getSessions()
return session.id
}
@@ -430,7 +459,7 @@ async function sendMessage() {
// 如果是新会话的第一条消息,创建会话并生成标题
if (messages.value.length === 0) {
// 先保存一个空会话,让用户能切走
const tempId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
const tempId = Date.now().toString(36) + Math.random().toString(36).slice(2, 10)
currentSessionId.value = tempId
await window.electronAPI.saveSession({
id: tempId,
@@ -561,26 +590,36 @@ async function startVideoGeneration(msg: Message) {
generatingVideo.value = true
videoResult.value = null
const sid = currentSessionId.value // 记录当前会话,防止切换后污染
try {
videoTaskId.value = await window.electronAPI.createVideo(prompt, videoDuration.value, videoResolution.value, videoModel.value, firstFrameUrl, refUrls)
msg.videoTaskId = videoTaskId.value
// 立刻存 taskId
await saveCurrentSession()
ElMessage.success('视频任务已创建: ' + videoTaskId.value)
await pollForVideo()
await pollForVideo(sid, msg)
} catch (e: unknown) {
ElMessage.error(e instanceof Error ? e.message : '视频生成失败')
} finally {
generatingVideo.value = false
if (currentSessionId.value === sid) await saveCurrentSession()
}
}
async function pollForVideo() {
async function pollForVideo(sessionId: string, msg: Message) {
if (!videoTaskId.value) return
const maxPolls = 40
for (let i = 0; i < maxPolls; i++) {
await new Promise(r => setTimeout(r, 5000))
if (currentSessionId.value !== sessionId) return
try {
const result = await window.electronAPI.pollVideo(videoTaskId.value)
if (result.status === 'succeeded') {
videoResult.value = result
if (currentSessionId.value === sessionId) videoResult.value = result
// 立即存到消息和会话,绝不丢失
msg.videoUrl = result.videoUrl
msg.videoTaskId = videoTaskId.value
await saveCurrentSession()
ElMessage.success('视频生成完成!')
return
}
@@ -590,7 +629,7 @@ async function pollForVideo() {
}
} catch { /* retry */ }
}
ElMessage.warning('视频生成超时,请稍后查询')
if (currentSessionId.value === sessionId) ElMessage.warning('视频生成超时,请稍后查询')
}
async function checkVideoStatus() {
@@ -608,19 +647,50 @@ async function checkVideoStatus() {
}
}
/** 从已保存的 taskId 恢复视频 URL */
async function pollVideoTask(msg: Message) {
if (!msg.videoTaskId) return
try {
const result = await window.electronAPI.pollVideo(msg.videoTaskId)
if (result.status === 'succeeded' && result.videoUrl) {
msg.videoUrl = result.videoUrl
if (result.lastFrameUrl) (msg as Record<string,unknown>).lastFrameUrl = result.lastFrameUrl
videoResult.value = { videoUrl: result.videoUrl, lastFrameUrl: result.lastFrameUrl }
await saveCurrentSession()
}
} catch { /* 稍后重试 */ }
}
async function saveLastFrame(videoUrl: string) {
try {
const name = '视频尾帧_' + Date.now().toString(36)
let lfUrl = videoResult.value?.lastFrameUrl || ''
// 没尾帧 URL,尝试从任务查询
if (!lfUrl && videoTaskId.value) {
try {
const r = await window.electronAPI.pollVideo(videoTaskId.value)
lfUrl = r.lastFrameUrl || ''
if (lfUrl && videoResult.value) videoResult.value.lastFrameUrl = lfUrl
} catch {}
}
if (lfUrl) {
let name = '视频尾帧_' + Date.now().toString(36)
try {
const r = await ElMessageBox.prompt('给尾帧起个名字', '保存尾帧', { inputValue: name, confirmButtonText: '保存' })
if (r.value.trim()) name = r.value.trim()
} catch { return }
await window.electronAPI.saveScene({
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
name,
description: '视频生成尾帧 | 任务ID: ' + videoTaskId.value,
imageUrl: videoUrl + '?return_last_frame=true',
imageUrl: lfUrl,
createdAt: new Date().toISOString()
})
ElMessage.success('尾帧已保存到场景库')
} else {
ElMessage.warning('此视频未生成尾帧,请等待下次视频完成后再保存')
}
} catch (e: unknown) {
ElMessage.error('保存失败,请尝试手动截图')
ElMessage.error('保存失败: ' + (e instanceof Error ? e.message : ''))
}
}
+16
View File
@@ -27,6 +27,7 @@
<p class="scene-desc">{{ previewDesc(scene.description) }}</p>
<div class="scene-footer">
<span class="scene-date">{{ formatDate(scene.createdAt) }}</span>
<el-button size="small" text @click="handleRename(scene)">重命名</el-button>
<el-button size="small" type="danger" text @click="handleDelete(scene)">删除</el-button>
</div>
</div>
@@ -96,6 +97,21 @@ async function load() {
}
}
async function handleRename(scene: StoredScene) {
try {
const r = await ElMessageBox.prompt('新名称', '重命名场景', { inputValue: scene.name, confirmButtonText: '确定' })
const name = r.value.trim()
if (!name || name === scene.name) return
await window.electronAPI.renameScene(scene.id, name)
scene.name = name
ElMessage.success('已重命名')
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : ''
if (msg.includes('已存在')) ElMessage.warning(msg)
else if (msg && !msg.includes('cancel')) ElMessage.error('重命名失败')
}
}
async function handleDelete(scene: StoredScene) {
try {
await ElMessageBox.confirm(`确定删除「${scene.name}」?`, '确认删除')