feat: EAnime AI漫剧制作助手 v1.0
核心功能: - 角色生成:豆包 Seedream 生图 + 角色库管理 - 场景生成:8种场景风格 + 场景库管理 - 脚本生成:小说拆分 → DeepSeek/豆包生成视频脚本 - 视频生成:Seedance 2.0 支持,最长15秒1080p - 会话管理:多轮对话 + 自动保存 - 6种角色风格 + 8种场景风格 + 运镜/灯光预设 - API Key配置 + 自定义存储路径 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
out/
|
||||||
|
dist/
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { resolve } from 'path'
|
||||||
|
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
main: {
|
||||||
|
plugins: [externalizeDepsPlugin()]
|
||||||
|
},
|
||||||
|
preload: {
|
||||||
|
plugins: [externalizeDepsPlugin()]
|
||||||
|
},
|
||||||
|
renderer: {
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve('src/renderer/src')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: [vue()]
|
||||||
|
}
|
||||||
|
})
|
||||||
Generated
+6501
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "eanime",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "AI漫剧制作助手 - 本地桌面工作台",
|
||||||
|
"main": "./out/main/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "electron-vite dev",
|
||||||
|
"build": "electron-vite build",
|
||||||
|
"preview": "electron-vite preview",
|
||||||
|
"postinstall": "electron-builder install-app-deps"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "http://gitea.akaxedx.cn/AKAxedx/EAnime.git"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"ai",
|
||||||
|
"manga",
|
||||||
|
"anime",
|
||||||
|
"creator"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
|
"electron-store": "^10.1.0",
|
||||||
|
"element-plus": "^2.14.3",
|
||||||
|
"vue": "^3.5.40",
|
||||||
|
"vue-router": "^4.6.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@electron-toolkit/preload": "^3.0.2",
|
||||||
|
"@electron-toolkit/utils": "^4.0.0",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.8",
|
||||||
|
"electron": "^43.2.0",
|
||||||
|
"electron-builder": "^26.15.3",
|
||||||
|
"electron-vite": "^5.0.0",
|
||||||
|
"typescript": "^7.0.2",
|
||||||
|
"vite": "^7.3.6",
|
||||||
|
"vue-tsc": "^3.3.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { getApiKeys } from './store'
|
||||||
|
|
||||||
|
const DEEPSEEK_BASE = 'https://api.deepseek.com/v1'
|
||||||
|
|
||||||
|
function getDeepSeekKey(): string {
|
||||||
|
const keys = getApiKeys()
|
||||||
|
return keys['deepseek'] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasDeepSeek(): boolean {
|
||||||
|
return !!getDeepSeekKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deepseekChat(messages: { role: string; content: string }[], model?: string): Promise<string> {
|
||||||
|
const apiKey = getDeepSeekKey()
|
||||||
|
if (!apiKey) throw new Error('DeepSeek API Key 未配置')
|
||||||
|
|
||||||
|
const response = await fetch(DEEPSEEK_BASE + '/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer ' + apiKey
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: model || 'deepseek-v4-pro',
|
||||||
|
messages,
|
||||||
|
stream: false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text()
|
||||||
|
let detail = errText
|
||||||
|
try { detail = JSON.parse(errText).error?.message || errText } catch {}
|
||||||
|
throw new Error('DeepSeek API 错误: ' + detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json() as { choices: { message: { content: string } }[] }
|
||||||
|
return result.choices[0]?.message?.content || ''
|
||||||
|
}
|
||||||
@@ -0,0 +1,495 @@
|
|||||||
|
import { getApiKeys, readImageBase64 } from './store'
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
content: string | ChatContent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatContent {
|
||||||
|
type: 'text' | 'image_url'
|
||||||
|
text?: string
|
||||||
|
image_url?: { url: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 风格预设 =====
|
||||||
|
|
||||||
|
export interface StylePreset {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
textStyle: string
|
||||||
|
imageStyle: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STYLE_PRESETS: StylePreset[] = [
|
||||||
|
{
|
||||||
|
key: 'realistic',
|
||||||
|
label: '写实风格',
|
||||||
|
textStyle: '角色设计采用写实风格,五官和身体比例尽量真实自然,服装材质细节丰富。',
|
||||||
|
imageStyle: 'realistic style, photorealistic rendering, detailed textures, natural lighting, 3D CGI quality'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'anime',
|
||||||
|
label: '日漫风格',
|
||||||
|
textStyle: '角色设计采用经典日式动漫风格,大眼睛、线条干净清晰、发色可以夸张鲜艳。',
|
||||||
|
imageStyle: 'Japanese anime style, cel shading, clean lineart, vibrant colors, 2D animation look'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'comic',
|
||||||
|
label: '美漫风格',
|
||||||
|
textStyle: '角色设计采用美式漫画风格,强调肌肉线条和英雄感的身材比例,厚涂上色、轮廓硬朗。',
|
||||||
|
imageStyle: 'American comic book style, bold outlines, heavy shading, heroic proportions, graphic ink style'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'webtoon',
|
||||||
|
label: '韩漫画风',
|
||||||
|
textStyle: '角色设计采用韩式条漫画风,人物修长精致,五官柔和,服装时尚感强,画面干净通透。',
|
||||||
|
imageStyle: 'Korean webtoon style, clean soft lines, smooth gradients, fashion-forward, elegant pastel tones'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ink',
|
||||||
|
label: '水墨风格',
|
||||||
|
textStyle: '角色设计采用中国水墨画风格,线条写意流畅,淡雅配色,有留白意境和笔触质感。',
|
||||||
|
imageStyle: 'traditional Chinese ink wash painting, watercolor brush strokes, elegant minimal palette, flowing texture, artistic'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cyberpunk',
|
||||||
|
label: '赛博朋克',
|
||||||
|
textStyle: '角色设计采用赛博朋克科幻风格,带有义体改造、科技装备、霓虹灯元素和未来都市感。',
|
||||||
|
imageStyle: 'cyberpunk style, neon lights, futuristic tech, cybernetic implants, dark atmospheric, sci-fi'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export function getStylePreset(key: string): StylePreset | undefined {
|
||||||
|
return STYLE_PRESETS.find(s => s.key === key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 场景风格预设 =====
|
||||||
|
|
||||||
|
export const SCENE_STYLE_PRESETS = [
|
||||||
|
{ key: 'school', label: '校园', textStyle: '校园场景,教室、走廊、操场等学校环境。', imageStyle: 'school setting, campus, classroom, cherry blossoms, Japanese school anime background' },
|
||||||
|
{ key: 'city', label: '都市', textStyle: '现代都市场景,街道、高楼、商业区等城市环境。', imageStyle: 'modern city, streets, skyscrapers, urban landscape, detailed background art' },
|
||||||
|
{ key: 'nature', label: '自然', textStyle: '自然风景场景,森林、山川、湖泊、花海等。', imageStyle: 'nature landscape, forest, mountain, lake, flowers, beautiful natural scenery background' },
|
||||||
|
{ key: 'scifi', label: '科幻', textStyle: '科幻未来场景,太空站、高科技都市、实验室等。', imageStyle: 'sci-fi futuristic, space station, holographic, high tech, sleek metal surfaces' },
|
||||||
|
{ key: 'classical', label: '古典', textStyle: '中国古典场景,宫殿、庭院、古城、山水画境等。', imageStyle: 'ancient Chinese palace, traditional architecture, courtyard, ink painting aesthetic, elegant' },
|
||||||
|
{ key: 'cyberpunk', label: '赛博朋克', textStyle: '赛博朋克城市场景,霓虹街道、暗巷、高科技贫民窟。', imageStyle: 'cyberpunk city, neon lights, rain, dark alleys, holographic billboards, gritty atmosphere' },
|
||||||
|
{ key: 'fantasy', label: '奇幻', textStyle: '奇幻世界场景,魔法森林、浮空岛、龙巢、神秘遗迹等。', imageStyle: 'fantasy world, magical forest, floating islands, mystical ruins, glowing particles, ethereal' },
|
||||||
|
{ key: 'wasteland', label: '末日废土', textStyle: '末日废土场景,废墟、荒漠、残垣断壁、废铁堆积。', imageStyle: 'post-apocalyptic wasteland, ruins, desert, rusted metal, debris, dramatic sky, desolate' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export function getSceneStylePreset(key: string) {
|
||||||
|
return SCENE_STYLE_PRESETS.find(s => s.key === key)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatCompletionResponse {
|
||||||
|
id: string
|
||||||
|
choices: {
|
||||||
|
index: number
|
||||||
|
message: { role: string; content: string }
|
||||||
|
finish_reason: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImageGenerationResponse {
|
||||||
|
data: { url: string; size?: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOUBAO_BASE_URL = 'https://ark.cn-beijing.volces.com/api/v3'
|
||||||
|
|
||||||
|
function getDoubaoKey(): string {
|
||||||
|
const keys = getApiKeys()
|
||||||
|
const key = keys['doubao']
|
||||||
|
if (!key) throw new Error('豆包 API Key 未配置,请先在设置页面配置')
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用对话 API
|
||||||
|
*/
|
||||||
|
export async function chatCompletion(
|
||||||
|
messages: ChatMessage[],
|
||||||
|
model: string
|
||||||
|
): Promise<string> {
|
||||||
|
if (!model) throw new Error('请配置豆包模型名称')
|
||||||
|
const apiKey = getDoubaoKey()
|
||||||
|
|
||||||
|
const response = await fetch(`${DOUBAO_BASE_URL}/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${apiKey}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ model, messages, stream: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
let detail = errorText
|
||||||
|
try { const err = JSON.parse(errorText); detail = err.error?.message || err.message || errorText } catch { /* ignore */ }
|
||||||
|
throw new Error(`豆包 API 错误 (${response.status}): ${detail}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: ChatCompletionResponse = await response.json()
|
||||||
|
return result.choices[0]?.message?.content || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用 Seedream 生图 API
|
||||||
|
*/
|
||||||
|
export async function generateImage(prompt: string, model: string): Promise<string> {
|
||||||
|
if (!model) throw new Error('请选择生图模型')
|
||||||
|
const apiKey = getDoubaoKey()
|
||||||
|
|
||||||
|
const response = await fetch(`${DOUBAO_BASE_URL}/images/generations`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${apiKey}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ model, prompt, n: 1, size: '1920x1920' })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
let detail = errorText
|
||||||
|
try { const err = JSON.parse(errorText); detail = err.error?.message || err.message || errorText } catch { /* ignore */ }
|
||||||
|
throw new Error(`Seedream 生图错误 (${response.status}): ${detail}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: ImageGenerationResponse = await response.json()
|
||||||
|
return result.data[0]?.url || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建角色生成提示词(文本模型用)
|
||||||
|
* 让 AI 同时输出中文设定和英文绘图提示词
|
||||||
|
*/
|
||||||
|
export function buildCharacterMessages(
|
||||||
|
input: string,
|
||||||
|
history?: { role: string; text: string; imageUrl?: string }[],
|
||||||
|
style?: string
|
||||||
|
): ChatMessage[] {
|
||||||
|
const preset = style ? getStylePreset(style) : undefined
|
||||||
|
const textStyle = preset?.textStyle || ''
|
||||||
|
const imageStyle = preset?.imageStyle || 'anime style'
|
||||||
|
|
||||||
|
const systemPrompt: ChatMessage = {
|
||||||
|
role: 'user',
|
||||||
|
content: `你是 EAnime 的角色设计助手。用户会描述他想要的角色,你需要帮他完善并输出结构化的角色设定。
|
||||||
|
${textStyle ? `\n【画风要求】${textStyle}` : ''}
|
||||||
|
|
||||||
|
请严格按照以下格式输出,包含两个部分:
|
||||||
|
|
||||||
|
========== 角色设定 ==========
|
||||||
|
【角色名】xxx
|
||||||
|
【性别】xxx
|
||||||
|
【年龄】xxx
|
||||||
|
【外貌特征】xxx
|
||||||
|
【性格特点】xxx
|
||||||
|
【背景故事】xxx
|
||||||
|
【关键词】xxx
|
||||||
|
|
||||||
|
========== Image Prompt ==========
|
||||||
|
[用英文写一段适合 AI 生图的 prompt。要求:动漫角色全身立绘,纯色无背景,角色居中,包含正面全身和细节展示(类似三视图的感觉)。描述角色的发型、五官、服装、姿势。必须包含风格关键词:${imageStyle}。格式:角色动作描述 + 服装细节 + 全身构图(full-body) + 纯色背景(solid color background) + ${imageStyle}。80词以内]
|
||||||
|
|
||||||
|
回答要简洁具体,用中文写设定部分。`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有历史消息,走新会话流程
|
||||||
|
if (!history || history.length === 0) {
|
||||||
|
return [systemPrompt, { role: 'user', content: input }]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 有历史消息:system prompt + 历史对话 + 新输入
|
||||||
|
const historyMessages: ChatMessage[] = history.map(m => ({
|
||||||
|
role: m.role as 'user' | 'assistant',
|
||||||
|
content: m.text
|
||||||
|
}))
|
||||||
|
|
||||||
|
return [systemPrompt, ...historyMessages, { role: 'user', content: input }]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从角色设定文本中提取 Image Prompt
|
||||||
|
*/
|
||||||
|
export function extractImagePrompt(text: string): string {
|
||||||
|
// 找 ========== Image Prompt ========== 后面的内容(直到结束)
|
||||||
|
const match = text.match(/Image Prompt\s*[=]*\s*\n([\s\S]+)$/)
|
||||||
|
if (match) {
|
||||||
|
const prompt = match[1].trim()
|
||||||
|
if (prompt) return prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没找到标记,用最后一段作为 fallback
|
||||||
|
const paragraphs = text.split('\n').filter(Boolean)
|
||||||
|
const last = paragraphs[paragraphs.length - 1]
|
||||||
|
if (last && last.length > 10 && !last.includes('【')) return last
|
||||||
|
|
||||||
|
// 再 fallback:用外貌特征拼接
|
||||||
|
const appearance = text.match(/【外貌特征】(.+)/)
|
||||||
|
const keywords = text.match(/【关键词】(.+)/)
|
||||||
|
return `anime character, ${appearance ? appearance[1] + ', ' : ''} ${keywords ? keywords[1] : 'original design'}, portrait, high quality, anime style`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成角色(文本 + 图片串联)
|
||||||
|
*/
|
||||||
|
export async function generateCharacter(
|
||||||
|
input: string,
|
||||||
|
chatModel: string,
|
||||||
|
imageModel: string,
|
||||||
|
history?: { role: string; text: string; imageUrl?: string }[],
|
||||||
|
style?: string
|
||||||
|
): Promise<{ text: string; imageUrl: string }> {
|
||||||
|
const messages = buildCharacterMessages(input, history, style)
|
||||||
|
const text = await chatCompletion(messages, chatModel)
|
||||||
|
if (!text) throw new Error('角色设定生成失败')
|
||||||
|
|
||||||
|
const imagePrompt = extractImagePrompt(text)
|
||||||
|
const imageUrl = await generateImage(imagePrompt, imageModel)
|
||||||
|
|
||||||
|
return { text, imageUrl }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 场景生成 =====
|
||||||
|
|
||||||
|
export function buildSceneMessages(
|
||||||
|
input: string,
|
||||||
|
history?: { role: string; text: string }[],
|
||||||
|
style?: string
|
||||||
|
): ChatMessage[] {
|
||||||
|
const preset = style ? getSceneStylePreset(style) : undefined
|
||||||
|
const textStyle = preset?.textStyle || ''
|
||||||
|
const imageStyle = preset?.imageStyle || 'anime background style'
|
||||||
|
|
||||||
|
const systemPrompt: ChatMessage = {
|
||||||
|
role: 'user',
|
||||||
|
content: `你是 EAnime 的场景设计助手。用户会描述他想要的场景,你需要帮他完善并输出结构化的场景设定。
|
||||||
|
${textStyle ? `\n【画风要求】${textStyle}` : ''}
|
||||||
|
|
||||||
|
请严格按照以下格式输出,包含两个部分:
|
||||||
|
|
||||||
|
========== 场景设定 ==========
|
||||||
|
【场景名称】xxx
|
||||||
|
【氛围】xxx
|
||||||
|
【时间段】xxx
|
||||||
|
【天气】xxx
|
||||||
|
【描述】xxx
|
||||||
|
【关键词】xxx
|
||||||
|
|
||||||
|
========== Image Prompt ==========
|
||||||
|
[用英文写一段适合 AI 生图的 prompt。要求:场景全景构图,纯色无背景(或简单的室内/室外背景设定),细节丰富。必须包含风格关键词:${imageStyle}。格式:场景描述 + 构图(wide shot / establishing shot) + ${imageStyle}。80词以内]
|
||||||
|
|
||||||
|
回答要简洁具体,用中文写设定部分。`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!history || history.length === 0) {
|
||||||
|
return [systemPrompt, { role: 'user', content: input }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const historyMessages: ChatMessage[] = history.map(m => ({
|
||||||
|
role: m.role as 'user' | 'assistant',
|
||||||
|
content: m.text
|
||||||
|
}))
|
||||||
|
|
||||||
|
return [systemPrompt, ...historyMessages, { role: 'user', content: input }]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateScene(
|
||||||
|
input: string,
|
||||||
|
chatModel: string,
|
||||||
|
imageModel: string,
|
||||||
|
history?: { role: string; text: string }[],
|
||||||
|
style?: string
|
||||||
|
): Promise<{ text: string; imageUrl: string }> {
|
||||||
|
const messages = buildSceneMessages(input, history, style)
|
||||||
|
const text = await chatCompletion(messages, chatModel)
|
||||||
|
if (!text) throw new Error('场景设定生成失败')
|
||||||
|
|
||||||
|
const imagePrompt = extractImagePrompt(text)
|
||||||
|
const imageUrl = await generateImage(imagePrompt, imageModel)
|
||||||
|
|
||||||
|
return { text, imageUrl }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 运镜 & 灯光预设 =====
|
||||||
|
|
||||||
|
export const CAMERA_MOVEMENTS = [
|
||||||
|
{ key: 'dolly_in', label: '推镜', prompt: 'camera slowly pushes in towards the subject' },
|
||||||
|
{ key: 'dolly_out', label: '拉镜', prompt: 'camera slowly pulls back to reveal more' },
|
||||||
|
{ key: 'pan_right', label: '右摇', prompt: 'camera pans smoothly to the right' },
|
||||||
|
{ key: 'pan_left', label: '左摇', prompt: 'camera pans smoothly to the left' },
|
||||||
|
{ key: 'tilt_up', label: '仰拍', prompt: 'camera tilts upward, low angle shot' },
|
||||||
|
{ key: 'tilt_down', label: '俯拍', prompt: 'camera tilts downward, high angle overhead shot' },
|
||||||
|
{ key: 'tracking', label: '跟镜', prompt: 'camera smoothly tracks alongside the subject' },
|
||||||
|
{ key: 'close_up', label: '特写', prompt: 'close-up shot focusing on facial expression or details' },
|
||||||
|
{ key: 'wide_shot', label: '全景', prompt: 'wide establishing shot showing the full scene' },
|
||||||
|
{ key: 'static', label: '静止', prompt: 'static shot, no camera movement' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export const LIGHTING_PRESETS = [
|
||||||
|
{ key: 'natural', label: '自然光', prompt: 'soft natural daylight, realistic ambient lighting' },
|
||||||
|
{ key: 'rembrandt', label: '伦勃朗光', prompt: 'Rembrandt lighting, dramatic triangular light on cheek' },
|
||||||
|
{ key: 'side_light', label: '侧光', prompt: 'strong side lighting, half face illuminated half in shadow' },
|
||||||
|
{ key: 'backlight', label: '逆光', prompt: 'backlighting, rim light, silhouette effect, golden halo' },
|
||||||
|
{ key: 'soft_diffuse', label: '柔光', prompt: 'soft diffused lighting, gentle shadows, dreamy atmosphere' },
|
||||||
|
{ key: 'neon', label: '霓虹', prompt: 'vibrant neon lights, colorful reflections, cyberpunk aesthetic' },
|
||||||
|
{ key: 'warm', label: '暖光', prompt: 'warm golden hour light, sunset tones, cozy atmosphere' },
|
||||||
|
{ key: 'cool', label: '冷光', prompt: 'cool blue moonlight, sterile white light, cold atmosphere' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// ===== 视频脚本生成 =====
|
||||||
|
|
||||||
|
export function buildScriptMessages(
|
||||||
|
novelText: string,
|
||||||
|
durationSec: number,
|
||||||
|
style?: string
|
||||||
|
): ChatMessage[] {
|
||||||
|
const sceneCount = Math.max(1, Math.floor(durationSec / 3))
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: '你是视频脚本编剧。根据小说片段,生成约 ' + durationSec + ' 秒的视频拍摄脚本,拆分为约 ' + sceneCount + ' 个镜头。\n\n请按以下格式输出:\n\n========== 分镜脚本 ==========\n\n【镜头1】(0~3秒)\n【画面】xxx\n【人物心理】xxx\n【人物对话】xxx\n【运镜建议】xxx\n【灯光建议】xxx\n\n【镜头2】...\n\n========== Video Prompt ==========\n[英文 AI 视频生成提示词,' + (style || 'cinematic anime style') + ',50词以内]'
|
||||||
|
},
|
||||||
|
{ role: 'user', content: novelText }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 视频生成 API =====
|
||||||
|
|
||||||
|
function resolveImageUrl(pathOrUrl: string): string {
|
||||||
|
if (!pathOrUrl) return ''
|
||||||
|
// 已经是 data URL 或 http URL,直接返回
|
||||||
|
if (pathOrUrl.startsWith('data:') || pathOrUrl.startsWith('http')) return pathOrUrl
|
||||||
|
// 本地文件路径,转 base64
|
||||||
|
try {
|
||||||
|
const b64 = readImageBase64(pathOrUrl)
|
||||||
|
return b64 || pathOrUrl
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createVideoTask(
|
||||||
|
prompt: string,
|
||||||
|
durationSec: number,
|
||||||
|
resolution: string,
|
||||||
|
model: string,
|
||||||
|
firstFrameUrl?: string,
|
||||||
|
refUrls?: string[]
|
||||||
|
): Promise<string> {
|
||||||
|
const keys = getApiKeys()
|
||||||
|
const apiKey = keys['doubao']
|
||||||
|
if (!apiKey) throw new Error('API Key 未配置')
|
||||||
|
|
||||||
|
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)
|
||||||
|
if (resolved) content.push({ type: 'image_url', image_url: { url: resolved }, role: 'first_frame' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 参考图:仅 2.0 支持
|
||||||
|
if (isV2 && refUrls) {
|
||||||
|
for (const refUrl of refUrls) {
|
||||||
|
const resolved = resolveImageUrl(refUrl)
|
||||||
|
if (resolved && resolved !== resolveImageUrl(frameUrl)) {
|
||||||
|
content.push({ type: 'image_url', image_url: { url: resolved }, role: 'reference_image' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
content.push({ type: 'text', text: prompt })
|
||||||
|
|
||||||
|
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 })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text()
|
||||||
|
let detail = errText
|
||||||
|
try { detail = JSON.parse(errText).error?.message || errText } catch {}
|
||||||
|
throw new Error('视频生成失败: ' + detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json() as { id: string }
|
||||||
|
return result.id
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pollVideoTask(taskId: string): Promise<{ status: string; videoUrl?: string; duration?: number; resolution?: string }> {
|
||||||
|
const keys = getApiKeys()
|
||||||
|
const apiKey = keys['doubao']
|
||||||
|
|
||||||
|
const response = await fetch('https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks/' + taskId, {
|
||||||
|
headers: { 'Authorization': 'Bearer ' + apiKey }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('查询视频任务失败: ' + response.status)
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
return {
|
||||||
|
status: result.status,
|
||||||
|
videoUrl: result.content?.video_url,
|
||||||
|
duration: result.duration,
|
||||||
|
resolution: result.resolution
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 长文本拆分 =====
|
||||||
|
|
||||||
|
export function buildSplitMessages(novelText: string): ChatMessage[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: `请将以下小说片段拆分为适合制作视频短片的情节单元。每个单元应是一个完整的场景或事件,适合制作 2~12 秒的视频。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
- 每个单元给一个简短标题(如"相遇"、"独白"、"战斗")
|
||||||
|
- 按原文顺序拆分
|
||||||
|
- 每个单元 50~300 字,保持情节连贯
|
||||||
|
|
||||||
|
请严格按以下格式输出,每行一个:
|
||||||
|
|
||||||
|
单元1 | 标题 | 字数
|
||||||
|
原文内容...
|
||||||
|
|
||||||
|
单元2 | 标题 | 字数
|
||||||
|
原文内容...
|
||||||
|
|
||||||
|
不要输出其他内容。`
|
||||||
|
},
|
||||||
|
{ role: 'user', content: novelText }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSplitResult(text: string): { title: string; content: string }[] {
|
||||||
|
const segments: { title: string; content: string }[] = []
|
||||||
|
const lines = text.split('\n')
|
||||||
|
let current: { title: string; content: string } | null = null
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const match = line.match(/^单元\d+\s*\|\s*(.+?)\s*\|\s*\d+/)
|
||||||
|
if (match) {
|
||||||
|
if (current) segments.push(current)
|
||||||
|
current = { title: match[1].trim(), content: '' }
|
||||||
|
} else if (current && line.trim()) {
|
||||||
|
current.content += (current.content ? '\n' : '') + line.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current && current.content) segments.push(current)
|
||||||
|
|
||||||
|
// 如果 AI 没按格式输出,直接按段落拆分
|
||||||
|
if (segments.length === 0) {
|
||||||
|
const paragraphs = text.split(/\n{2,}/).filter(p => p.trim().length > 20)
|
||||||
|
for (let i = 0; i < paragraphs.length; i++) {
|
||||||
|
const p = paragraphs[i]
|
||||||
|
const title = p.slice(0, 30).replace(/[^一-龥\w]/g, '') || '片段' + (i + 1)
|
||||||
|
segments.push({ title, content: p.trim() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return segments
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { app, BrowserWindow } from 'electron'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||||
|
import { registerIpcHandlers } from './ipc-handlers'
|
||||||
|
|
||||||
|
let mainWindow: BrowserWindow | null = null
|
||||||
|
|
||||||
|
function createWindow(): void {
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: 1280,
|
||||||
|
height: 800,
|
||||||
|
minWidth: 960,
|
||||||
|
minHeight: 640,
|
||||||
|
show: false,
|
||||||
|
title: 'EAnime - AI漫剧制作助手',
|
||||||
|
webPreferences: {
|
||||||
|
preload: join(__dirname, '../preload/index.js'),
|
||||||
|
sandbox: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
mainWindow.on('ready-to-show', () => {
|
||||||
|
mainWindow?.show()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||||
|
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||||
|
} else {
|
||||||
|
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
electronApp.setAppUserModelId('com.eanime')
|
||||||
|
|
||||||
|
app.on('browser-window-created', (_, window) => {
|
||||||
|
optimizer.watchWindowShortcuts(window)
|
||||||
|
})
|
||||||
|
|
||||||
|
registerIpcHandlers()
|
||||||
|
createWindow()
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
createWindow()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
app.quit()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { ipcMain } from 'electron'
|
||||||
|
import {
|
||||||
|
getApiKeys, getUiApiKeys, getConfigFileApiKeys,
|
||||||
|
setApiKey, deleteApiKey, openConfigFile, getConfigPath, getEndpoints,
|
||||||
|
getCharacters, saveCharacter, deleteCharacter,
|
||||||
|
getScenes, saveScene, deleteScene,
|
||||||
|
getDataDir, getImagesDir, setDataDir, openFolder, pickFolder,
|
||||||
|
getSessions, getSession, saveSession, deleteSession,
|
||||||
|
downloadImage, readImageBase64, imagesDir
|
||||||
|
} from './store'
|
||||||
|
import { generateCharacter, generateScene, createVideoTask, pollVideoTask, buildScriptMessages, buildSplitMessages, parseSplitResult, chatCompletion } from './doubao'
|
||||||
|
import { deepseekChat, hasDeepSeek } from './deepseek'
|
||||||
|
|
||||||
|
function wrap<T>(fn: () => T): T {
|
||||||
|
try { return fn() } catch (err) { throw new Error((err as Error).message || '操作失败') }
|
||||||
|
}
|
||||||
|
async function wrapAsync<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
try { return await fn() } catch (err) { throw new Error((err as Error).message || '操作失败') }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerIpcHandlers(): void {
|
||||||
|
// === API Key ===
|
||||||
|
ipcMain.handle('store:getApiKeys', () => wrap(() => getApiKeys()))
|
||||||
|
ipcMain.handle('store:getUiApiKeys', () => wrap(() => getUiApiKeys()))
|
||||||
|
ipcMain.handle('store:getConfigFileApiKeys', () => wrap(() => getConfigFileApiKeys()))
|
||||||
|
ipcMain.handle('store:setApiKey', (_e, p, k) => wrap(() => { setApiKey(p, k); return { success: true } }))
|
||||||
|
ipcMain.handle('store:deleteApiKey', (_e, p) => wrap(() => { deleteApiKey(p); return { success: true } }))
|
||||||
|
ipcMain.handle('store:openConfig', () => wrap(() => { openConfigFile(); return { success: true } }))
|
||||||
|
ipcMain.handle('store:getConfigPath', () => wrap(() => getConfigPath()))
|
||||||
|
ipcMain.handle('store:getEndpoints', () => wrap(() => getEndpoints()))
|
||||||
|
ipcMain.handle('store:getDataDir', () => wrap(() => getDataDir()))
|
||||||
|
ipcMain.handle('store:getImagesDir', () => wrap(() => getImagesDir()))
|
||||||
|
ipcMain.handle('store:setDataDir', (_e, path: string) => wrap(() => setDataDir(path)))
|
||||||
|
ipcMain.handle('store:openFolder', (_e, path: string) => wrap(() => openFolder(path)))
|
||||||
|
ipcMain.handle('store:pickFolder', () => wrapAsync(() => pickFolder()))
|
||||||
|
|
||||||
|
// === 角色库 ===
|
||||||
|
ipcMain.handle('characters:getAll', () => wrap(() => getCharacters()))
|
||||||
|
ipcMain.handle('characters:save', async (_e, c) => {
|
||||||
|
return wrapAsync(async () => {
|
||||||
|
if (c.imageUrl && !c.imageUrl.startsWith('data:') && !c.imageUrl.startsWith(imagesDir())) {
|
||||||
|
try {
|
||||||
|
c.imageUrl = await downloadImage(c.imageUrl, c.id + '.jpg')
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('图片下载失败,保留原始 URL:', (e as Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return saveCharacter(c)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
ipcMain.handle('characters:delete', (_e, id) => wrap(() => { deleteCharacter(id); return { success: true } }))
|
||||||
|
|
||||||
|
// === 场景库 ===
|
||||||
|
ipcMain.handle('scenes:getAll', () => wrap(() => getScenes()))
|
||||||
|
ipcMain.handle('scenes:save', async (_e, s) => {
|
||||||
|
return wrapAsync(async () => {
|
||||||
|
if (s.imageUrl && !s.imageUrl.startsWith('data:') && !s.imageUrl.startsWith(imagesDir())) {
|
||||||
|
try { s.imageUrl = await downloadImage(s.imageUrl, 'scene_' + s.id + '.jpg') } catch {}
|
||||||
|
}
|
||||||
|
return saveScene(s)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
ipcMain.handle('scenes:delete', (_e, id) => wrap(() => { deleteScene(id); return { success: true } }))
|
||||||
|
|
||||||
|
// === 图片 ===
|
||||||
|
ipcMain.handle('image:readBase64', (_e, path: string) => {
|
||||||
|
return wrap(() => readImageBase64(path))
|
||||||
|
})
|
||||||
|
|
||||||
|
// === 会话管理 ===
|
||||||
|
ipcMain.handle('sessions:getAll', () => wrap(() => getSessions()))
|
||||||
|
ipcMain.handle('sessions:get', (_e, id) => wrap(() => getSession(id) || null))
|
||||||
|
ipcMain.handle('sessions:save', (_e, s) => wrap(() => saveSession(s)))
|
||||||
|
ipcMain.handle('sessions:delete', (_e, id) => wrap(() => { deleteSession(id); return { success: true } }))
|
||||||
|
|
||||||
|
// === 豆包 ===
|
||||||
|
ipcMain.handle('doubao:generateCharacter', async (_e, input, chatModel, imageModel, history, style) => {
|
||||||
|
return wrapAsync(async () => {
|
||||||
|
const endpoints = getEndpoints()
|
||||||
|
let cm = chatModel || endpoints['doubao'] || 'doubao-seed-2-0-mini-260428'
|
||||||
|
if (cm.includes('deepseek')) cm = 'doubao-seed-2-0-mini-260428'
|
||||||
|
const im = imageModel || endpoints['doubaoImage'] || 'doubao-seedream-5-0-260128'
|
||||||
|
return await generateCharacter(input, cm, im, history, style)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('doubao:generateScene', async (_e, input, chatModel, imageModel, history, style) => {
|
||||||
|
return wrapAsync(async () => {
|
||||||
|
const endpoints = getEndpoints()
|
||||||
|
let scm = chatModel || endpoints['doubao'] || 'doubao-seed-2-0-mini-260428'
|
||||||
|
if (scm.includes('deepseek')) scm = 'doubao-seed-2-0-mini-260428'
|
||||||
|
const im = imageModel || endpoints['doubaoImage'] || 'doubao-seedream-5-0-260128'
|
||||||
|
return await generateScene(input, scm, im, history, style)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// === 视频/脚本 ===
|
||||||
|
ipcMain.handle('doubao:generateScript', async (_e, novelText, duration, style, model) => {
|
||||||
|
return wrapAsync(async () => {
|
||||||
|
const messages = buildScriptMessages(novelText, duration, style)
|
||||||
|
// 优先用 DeepSeek(生成脚本更强),没配 key 就豆包
|
||||||
|
if (hasDeepSeek()) {
|
||||||
|
try {
|
||||||
|
return await deepseekChat(messages as { role: string; content: string }[], model || 'deepseek-v4-pro')
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('DeepSeek 调用失败,fallback 到豆包:', (e as Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const endpoints = getEndpoints()
|
||||||
|
const cm = model || endpoints['doubao'] || 'doubao-seed-2-0-mini-260428'
|
||||||
|
return await chatCompletion(messages, cm)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('doubao:splitNovel', async (_e, novelText) => {
|
||||||
|
return wrapAsync(async () => {
|
||||||
|
const messages = buildSplitMessages(novelText)
|
||||||
|
if (hasDeepSeek()) {
|
||||||
|
try {
|
||||||
|
const raw = await deepseekChat(messages as { role: string; content: string }[], 'deepseek-v4-pro')
|
||||||
|
return parseSplitResult(raw)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('DeepSeek 拆分失败,fallback 到豆包:', (e as Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const endpoints = getEndpoints()
|
||||||
|
const cm = endpoints['doubao'] || 'doubao-seed-2-0-mini-260428'
|
||||||
|
const raw = await chatCompletion(messages, cm)
|
||||||
|
return parseSplitResult(raw)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('doubao:createVideo', async (_e, prompt, duration, resolution, model, firstFrameUrl, refUrls) => {
|
||||||
|
return wrapAsync(async () => {
|
||||||
|
const m = model || 'doubao-seedance-2-0-fast-260128'
|
||||||
|
return await createVideoTask(prompt, duration, resolution, m, firstFrameUrl, refUrls)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('doubao:pollVideo', async (_e, taskId) => {
|
||||||
|
return wrapAsync(async () => await pollVideoTask(taskId))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
import { app, shell, dialog } from 'electron'
|
||||||
|
import { join, dirname } from 'path'
|
||||||
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync, readdirSync, statSync } from 'fs'
|
||||||
|
import { get } from 'https'
|
||||||
|
|
||||||
|
// 懒加载路径,确保 app 已 ready
|
||||||
|
let _configPath: string | null = null
|
||||||
|
let _baseDir: string | null = null
|
||||||
|
|
||||||
|
function configPath(): string {
|
||||||
|
if (!_configPath) _configPath = join(app.getPath('userData'), 'eanime.config.json')
|
||||||
|
return _configPath
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseDir(): string {
|
||||||
|
if (_baseDir) return _baseDir
|
||||||
|
// 从 config 读自定义路径,没有则用默认
|
||||||
|
try {
|
||||||
|
if (existsSync(configPath())) {
|
||||||
|
const raw = readFileSync(configPath(), 'utf-8')
|
||||||
|
const cfg = JSON.parse(raw)
|
||||||
|
if (cfg.dataDir && typeof cfg.dataDir === 'string') {
|
||||||
|
_baseDir = cfg.dataDir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
if (!_baseDir) _baseDir = app.getPath('userData')
|
||||||
|
// 确保目录存在
|
||||||
|
if (!existsSync(_baseDir)) mkdirSync(_baseDir, { recursive: true })
|
||||||
|
return _baseDir
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清除路径缓存(更换存储目录后调用) */
|
||||||
|
function resetPathCache(): void {
|
||||||
|
_baseDir = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function internalPath(): string {
|
||||||
|
return join(baseDir(), 'eanime.data.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 内部存储读写(UI 设置的 key + 角色库) ----
|
||||||
|
interface StoredCharacter {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
imageUrl: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoredScene {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
imageUrl: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
text: string
|
||||||
|
imageUrl?: string
|
||||||
|
saved?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatSession {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
mode: string
|
||||||
|
style: string
|
||||||
|
chatModel: string
|
||||||
|
imageModel: string
|
||||||
|
messages: ChatMessage[]
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InternalData {
|
||||||
|
uiApiKeys: Record<string, string>
|
||||||
|
characters: StoredCharacter[]
|
||||||
|
scenes: StoredScene[]
|
||||||
|
sessions: ChatSession[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function readInternal(): InternalData {
|
||||||
|
const p = internalPath()
|
||||||
|
if (!existsSync(p)) return { uiApiKeys: {}, characters: [], scenes: [], sessions: [] }
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(p, 'utf-8'))
|
||||||
|
} catch {
|
||||||
|
return { uiApiKeys: {}, characters: [], scenes: [], sessions: [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeInternal(data: InternalData): void {
|
||||||
|
const p = internalPath()
|
||||||
|
const dir = dirname(p)
|
||||||
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||||
|
writeFileSync(p, JSON.stringify(data, null, 2), 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 配置文件读取 ----
|
||||||
|
function readConfigFileKeys(): Record<string, string> {
|
||||||
|
const p = configPath()
|
||||||
|
if (!existsSync(p)) return {}
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(p, 'utf-8')
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
return parsed.apiKeys || {}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readConfigEndpoints(): Record<string, string> {
|
||||||
|
const p = configPath()
|
||||||
|
if (!existsSync(p)) return {}
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(p, 'utf-8')
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
return parsed.endpoints || {}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureConfigFile(): void {
|
||||||
|
const p = configPath()
|
||||||
|
if (!existsSync(p)) {
|
||||||
|
const dir = dirname(p)
|
||||||
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||||
|
writeFileSync(p, JSON.stringify({
|
||||||
|
apiKeys: {},
|
||||||
|
endpoints: {
|
||||||
|
doubao: 'doubao-seed-2-0-mini-260428'
|
||||||
|
}
|
||||||
|
}, null, 2), 'utf-8')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 暴露的 API =====
|
||||||
|
|
||||||
|
export function getApiKeys(): Record<string, string> {
|
||||||
|
const internal = readInternal()
|
||||||
|
const configKeys = readConfigFileKeys()
|
||||||
|
const merged: Record<string, string> = { ...configKeys }
|
||||||
|
for (const [key, value] of Object.entries(internal.uiApiKeys)) {
|
||||||
|
if (value) merged[key] = value
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUiApiKeys(): Record<string, string> {
|
||||||
|
return readInternal().uiApiKeys
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConfigFileApiKeys(): Record<string, string> {
|
||||||
|
return readConfigFileKeys()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setApiKey(provider: string, key: string): void {
|
||||||
|
const data = readInternal()
|
||||||
|
data.uiApiKeys[provider] = key
|
||||||
|
writeInternal(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteApiKey(provider: string): void {
|
||||||
|
const data = readInternal()
|
||||||
|
delete data.uiApiKeys[provider]
|
||||||
|
writeInternal(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEndpoints(): Record<string, string> {
|
||||||
|
return readConfigEndpoints()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConfigPath(): string {
|
||||||
|
ensureConfigFile()
|
||||||
|
return configPath()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openConfigFile(): void {
|
||||||
|
ensureConfigFile()
|
||||||
|
shell.openPath(configPath())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 角色库 =====
|
||||||
|
|
||||||
|
export function getCharacters(): StoredCharacter[] {
|
||||||
|
return readInternal().characters || []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveCharacter(char: StoredCharacter): StoredCharacter {
|
||||||
|
const data = readInternal()
|
||||||
|
data.characters = data.characters || []
|
||||||
|
// 检查重名
|
||||||
|
if (data.characters.some(c => c.name === char.name)) {
|
||||||
|
throw new Error(`角色「${char.name}」已存在,请使用其他名称`)
|
||||||
|
}
|
||||||
|
data.characters.push(char)
|
||||||
|
writeInternal(data)
|
||||||
|
return char
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCharacter(id: string): void {
|
||||||
|
const data = readInternal()
|
||||||
|
data.characters = (data.characters || []).filter(c => c.id !== id)
|
||||||
|
writeInternal(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 场景库 =====
|
||||||
|
|
||||||
|
export function getScenes(): StoredScene[] {
|
||||||
|
return readInternal().scenes || []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveScene(scene: StoredScene): StoredScene {
|
||||||
|
const data = readInternal()
|
||||||
|
data.scenes = data.scenes || []
|
||||||
|
if (data.scenes.some(s => s.name === scene.name)) {
|
||||||
|
throw new Error(`场景「${scene.name}」已存在,请使用其他名称`)
|
||||||
|
}
|
||||||
|
data.scenes.push(scene)
|
||||||
|
writeInternal(data)
|
||||||
|
return scene
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteScene(id: string): void {
|
||||||
|
const data = readInternal()
|
||||||
|
data.scenes = (data.scenes || []).filter(s => s.id !== id)
|
||||||
|
writeInternal(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDataDir(): string {
|
||||||
|
return baseDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getImagesDir(): string {
|
||||||
|
return imagesDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 递归复制文件夹 */
|
||||||
|
function copyDirRecursive(src: string, dest: string): void {
|
||||||
|
if (!existsSync(src)) return
|
||||||
|
if (!existsSync(dest)) mkdirSync(dest, { recursive: true })
|
||||||
|
for (const entry of readdirSync(src)) {
|
||||||
|
const srcPath = join(src, entry)
|
||||||
|
const destPath = join(dest, entry)
|
||||||
|
if (statSync(srcPath).isDirectory()) {
|
||||||
|
copyDirRecursive(srcPath, destPath)
|
||||||
|
} else {
|
||||||
|
// 不覆盖已有文件
|
||||||
|
if (!existsSync(destPath)) {
|
||||||
|
copyFileSync(srcPath, destPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setDataDir(newPath: string): void {
|
||||||
|
const oldDir = baseDir()
|
||||||
|
if (!newPath || newPath === oldDir) return
|
||||||
|
|
||||||
|
// 确保新目录存在
|
||||||
|
if (!existsSync(newPath)) mkdirSync(newPath, { recursive: true })
|
||||||
|
|
||||||
|
// 只迁移我们自己的数据文件(避免复制 Electron 缓存文件)
|
||||||
|
const FILES_TO_MIGRATE = ['eanime.data.json']
|
||||||
|
for (const file of FILES_TO_MIGRATE) {
|
||||||
|
const src = join(oldDir, file)
|
||||||
|
const dest = join(newPath, file)
|
||||||
|
if (existsSync(src) && !existsSync(dest)) {
|
||||||
|
copyFileSync(src, dest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 迁移 images 文件夹
|
||||||
|
const oldImages = join(oldDir, 'images')
|
||||||
|
if (existsSync(oldImages)) {
|
||||||
|
copyDirRecursive(oldImages, join(newPath, 'images'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新 config
|
||||||
|
ensureConfigFile()
|
||||||
|
const raw = readFileSync(configPath(), 'utf-8')
|
||||||
|
const cfg = JSON.parse(raw)
|
||||||
|
cfg.dataDir = newPath
|
||||||
|
writeFileSync(configPath(), JSON.stringify(cfg, null, 2), 'utf-8')
|
||||||
|
|
||||||
|
// 重置缓存,让后续读取走新路径
|
||||||
|
resetPathCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openFolder(path: string): void {
|
||||||
|
shell.openPath(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pickFolder(): Promise<string | null> {
|
||||||
|
const result = await dialog.showOpenDialog({
|
||||||
|
properties: ['openDirectory']
|
||||||
|
})
|
||||||
|
return result.canceled ? null : result.filePaths[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 图片本地存储 =====
|
||||||
|
|
||||||
|
export function imagesDir(): string {
|
||||||
|
const dir = join(baseDir(), 'images')
|
||||||
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下载远程图片到本地,返回本地文件路径 */
|
||||||
|
export async function downloadImage(url: string, filename?: string): Promise<string> {
|
||||||
|
if (!url) throw new Error('图片 URL 为空')
|
||||||
|
|
||||||
|
const ext = url.split('?')[0].endsWith('.png') ? '.png' : '.jpg'
|
||||||
|
const fname = filename || (Date.now().toString(36) + Math.random().toString(36).slice(2, 6) + ext)
|
||||||
|
const localPath = join(imagesDir(), fname)
|
||||||
|
|
||||||
|
// 如果已经是本地路径,跳过下载
|
||||||
|
if (url.includes('images') && !url.startsWith('http')) return url
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
get(url, (res) => {
|
||||||
|
if (res.statusCode && res.statusCode >= 400) {
|
||||||
|
reject(new Error(`下载图片失败: ${res.statusCode}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 处理重定向
|
||||||
|
if (res.statusCode && res.statusCode >= 300 && res.headers.location) {
|
||||||
|
downloadImage(res.headers.location, filename).then(resolve).catch(reject)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
res.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
writeFileSync(localPath, Buffer.concat(chunks))
|
||||||
|
resolve(localPath)
|
||||||
|
} catch (e) {
|
||||||
|
reject(new Error(`写文件失败: ${(e as Error).message}`))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
res.on('error', reject)
|
||||||
|
}).on('error', reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取本地图片并转为 base64 data URL */
|
||||||
|
export function readImageBase64(localPath: string): string {
|
||||||
|
try {
|
||||||
|
if (!existsSync(localPath)) return ''
|
||||||
|
const buf = readFileSync(localPath)
|
||||||
|
const ext = localPath.endsWith('.png') ? 'png' : 'jpeg'
|
||||||
|
return `data:image/${ext};base64,${buf.toString('base64')}`
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 会话管理 =====
|
||||||
|
|
||||||
|
export function getSessions(): ChatSession[] {
|
||||||
|
return readInternal().sessions || []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSession(id: string): ChatSession | undefined {
|
||||||
|
return (readInternal().sessions || []).find(s => s.id === id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSession(session: ChatSession): ChatSession {
|
||||||
|
const data = readInternal()
|
||||||
|
data.sessions = data.sessions || []
|
||||||
|
const idx = data.sessions.findIndex(s => s.id === session.id)
|
||||||
|
if (idx >= 0) {
|
||||||
|
data.sessions[idx] = session
|
||||||
|
} else {
|
||||||
|
data.sessions.push(session)
|
||||||
|
}
|
||||||
|
writeInternal(data)
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteSession(id: string): void {
|
||||||
|
const data = readInternal()
|
||||||
|
data.sessions = (data.sessions || []).filter(s => s.id !== id)
|
||||||
|
writeInternal(data)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { contextBridge, ipcRenderer } from 'electron'
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
|
platform: process.platform,
|
||||||
|
|
||||||
|
// API Key & 配置
|
||||||
|
getApiKeys: () => ipcRenderer.invoke('store:getApiKeys'),
|
||||||
|
getUiApiKeys: () => ipcRenderer.invoke('store:getUiApiKeys'),
|
||||||
|
getConfigFileApiKeys: () => ipcRenderer.invoke('store:getConfigFileApiKeys'),
|
||||||
|
setApiKey: (p, k) => ipcRenderer.invoke('store:setApiKey', p, k),
|
||||||
|
deleteApiKey: (p) => ipcRenderer.invoke('store:deleteApiKey', p),
|
||||||
|
getConfigPath: () => ipcRenderer.invoke('store:getConfigPath'),
|
||||||
|
openConfig: () => ipcRenderer.invoke('store:openConfig'),
|
||||||
|
getEndpoints: () => ipcRenderer.invoke('store:getEndpoints'),
|
||||||
|
getDataDir: () => ipcRenderer.invoke('store:getDataDir'),
|
||||||
|
getImagesDir: () => ipcRenderer.invoke('store:getImagesDir'),
|
||||||
|
setDataDir: (path: string) => ipcRenderer.invoke('store:setDataDir', path),
|
||||||
|
openFolder: (path: string) => ipcRenderer.invoke('store:openFolder', path),
|
||||||
|
pickFolder: () => ipcRenderer.invoke('store:pickFolder'),
|
||||||
|
|
||||||
|
// 角色库
|
||||||
|
getCharacters: () => ipcRenderer.invoke('characters:getAll'),
|
||||||
|
saveCharacter: (c) => ipcRenderer.invoke('characters:save', c),
|
||||||
|
deleteCharacter: (id) => ipcRenderer.invoke('characters:delete', id),
|
||||||
|
|
||||||
|
// 场景库
|
||||||
|
getScenes: () => ipcRenderer.invoke('scenes:getAll'),
|
||||||
|
saveScene: (s) => ipcRenderer.invoke('scenes:save', s),
|
||||||
|
deleteScene: (id) => ipcRenderer.invoke('scenes:delete', id),
|
||||||
|
|
||||||
|
// 图片
|
||||||
|
readImageBase64: (path: string) => ipcRenderer.invoke('image:readBase64', path),
|
||||||
|
|
||||||
|
// 会话管理
|
||||||
|
getSessions: () => ipcRenderer.invoke('sessions:getAll'),
|
||||||
|
getSession: (id) => ipcRenderer.invoke('sessions:get', id),
|
||||||
|
saveSession: (s) => ipcRenderer.invoke('sessions:save', s),
|
||||||
|
deleteSession: (id) => ipcRenderer.invoke('sessions:delete', id),
|
||||||
|
|
||||||
|
// 豆包
|
||||||
|
generateCharacter: (input, chatModel?, imageModel?, history?, style?) =>
|
||||||
|
ipcRenderer.invoke('doubao:generateCharacter', input, chatModel || '', imageModel || '', history || null, style || ''),
|
||||||
|
generateScene: (input, chatModel?, imageModel?, history?, style?) =>
|
||||||
|
ipcRenderer.invoke('doubao:generateScene', input, chatModel || '', imageModel || '', history || null, style || ''),
|
||||||
|
splitNovel: (novelText) => ipcRenderer.invoke('doubao:splitNovel', novelText),
|
||||||
|
generateScript: (novelText, duration, style?, model?) =>
|
||||||
|
ipcRenderer.invoke('doubao:generateScript', novelText, duration, style || '', model || ''),
|
||||||
|
createVideo: (prompt, duration, resolution, model?, firstFrameUrl?, refUrls?) =>
|
||||||
|
ipcRenderer.invoke('doubao:createVideo', prompt, duration, resolution, model || '', firstFrameUrl || '', refUrls || []),
|
||||||
|
pollVideo: (taskId) =>
|
||||||
|
ipcRenderer.invoke('doubao:pollVideo', taskId)
|
||||||
|
})
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>EAnime - AI漫剧制作助手</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="./src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<template>
|
||||||
|
<MainLayout />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import MainLayout from './layouts/MainLayout.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Vendored
+85
@@ -0,0 +1,85 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<object, object, unknown>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoredCharacter {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
imageUrl: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoredScene {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
imageUrl: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
text: string
|
||||||
|
imageUrl?: string
|
||||||
|
saved?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatSession {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
mode: string
|
||||||
|
style: string
|
||||||
|
chatModel: string
|
||||||
|
imageModel: string
|
||||||
|
messages: ChatMessage[]
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ElectronAPI {
|
||||||
|
platform: string
|
||||||
|
getApiKeys: () => Promise<Record<string, string>>
|
||||||
|
getUiApiKeys: () => Promise<Record<string, string>>
|
||||||
|
getConfigFileApiKeys: () => Promise<Record<string, string>>
|
||||||
|
setApiKey: (provider: string, key: string) => Promise<void>
|
||||||
|
deleteApiKey: (provider: string) => Promise<void>
|
||||||
|
getConfigPath: () => Promise<string>
|
||||||
|
openConfig: () => Promise<void>
|
||||||
|
getEndpoints: () => Promise<Record<string, string>>
|
||||||
|
getDataDir: () => Promise<string>
|
||||||
|
getImagesDir: () => Promise<string>
|
||||||
|
setDataDir: (path: string) => Promise<void>
|
||||||
|
openFolder: (path: string) => Promise<void>
|
||||||
|
pickFolder: () => Promise<string | null>
|
||||||
|
|
||||||
|
getCharacters: () => Promise<StoredCharacter[]>
|
||||||
|
saveCharacter: (char: StoredCharacter) => Promise<StoredCharacter>
|
||||||
|
deleteCharacter: (id: string) => Promise<void>
|
||||||
|
|
||||||
|
getScenes: () => Promise<StoredScene[]>
|
||||||
|
saveScene: (scene: StoredScene) => Promise<StoredScene>
|
||||||
|
deleteScene: (id: string) => Promise<void>
|
||||||
|
|
||||||
|
readImageBase64: (path: string) => Promise<string>
|
||||||
|
|
||||||
|
getSessions: () => Promise<ChatSession[]>
|
||||||
|
getSession: (id: string) => Promise<ChatSession | undefined>
|
||||||
|
saveSession: (session: ChatSession) => Promise<ChatSession>
|
||||||
|
deleteSession: (id: string) => Promise<void>
|
||||||
|
|
||||||
|
generateCharacter: (input: string, chatModel?: string, imageModel?: string, history?: { role: string; text: string; imageUrl?: string }[] | null, style?: string) => Promise<{ text: string; imageUrl: string }>
|
||||||
|
generateScene: (input: string, chatModel?: string, imageModel?: string, history?: { role: string; text: string }[] | null, style?: string) => Promise<{ text: string; imageUrl: string }>
|
||||||
|
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 }>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
electronAPI: ElectronAPI
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<template>
|
||||||
|
<el-container style="height: 100vh">
|
||||||
|
<!-- 侧边导航 -->
|
||||||
|
<el-aside width="220px" class="sidebar">
|
||||||
|
<div class="logo-section">
|
||||||
|
<h1 class="logo-text">EAnime</h1>
|
||||||
|
<p class="logo-sub">AI漫剧制作助手</p>
|
||||||
|
</div>
|
||||||
|
<el-menu
|
||||||
|
:default-active="currentRoute"
|
||||||
|
:router="true"
|
||||||
|
class="nav-menu"
|
||||||
|
background-color="#1a1a2e"
|
||||||
|
text-color="#a0a0b8"
|
||||||
|
active-text-color="#64ffda"
|
||||||
|
>
|
||||||
|
<el-menu-item index="/">
|
||||||
|
<el-icon><HomeFilled /></el-icon>
|
||||||
|
<span>首页</span>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/characters">
|
||||||
|
<el-icon><UserFilled /></el-icon>
|
||||||
|
<span>角色库</span>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/scenes">
|
||||||
|
<el-icon><PictureFilled /></el-icon>
|
||||||
|
<span>场景库</span>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/settings">
|
||||||
|
<el-icon><Setting /></el-icon>
|
||||||
|
<span>设置</span>
|
||||||
|
</el-menu-item>
|
||||||
|
</el-menu>
|
||||||
|
</el-aside>
|
||||||
|
|
||||||
|
<!-- 主内容 -->
|
||||||
|
<el-container>
|
||||||
|
<el-header class="header">
|
||||||
|
<h2 class="page-title">{{ currentTitle }}</h2>
|
||||||
|
</el-header>
|
||||||
|
<el-main class="main-content">
|
||||||
|
<router-view />
|
||||||
|
</el-main>
|
||||||
|
</el-container>
|
||||||
|
</el-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { HomeFilled, UserFilled, PictureFilled, Setting } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const currentRoute = computed(() => route.path)
|
||||||
|
const currentTitle = computed(() => (route.meta?.title as string) || 'EAnime')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sidebar {
|
||||||
|
background-color: #1a1a2e;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-right: 1px solid #16213e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-section {
|
||||||
|
padding: 24px 20px 16px;
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 1px solid #16213e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #64ffda;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-sub {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #a0a0b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-menu {
|
||||||
|
border-right: none;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-menu .el-menu-item {
|
||||||
|
margin: 4px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #e8e8e8;
|
||||||
|
padding: 0 24px;
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
padding: 24px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(router)
|
||||||
|
app.use(ElementPlus, { locale: undefined })
|
||||||
|
app.mount('#app')
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||||
|
import HomePage from '../views/HomePage.vue'
|
||||||
|
import CharactersPage from '../views/CharactersPage.vue'
|
||||||
|
import ScenesPage from '../views/ScenesPage.vue'
|
||||||
|
import SettingsPage from '../views/SettingsPage.vue'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{ path: '/', name: 'home', component: HomePage, meta: { title: '首页' } },
|
||||||
|
{ path: '/characters', name: 'characters', component: CharactersPage, meta: { title: '角色库' } },
|
||||||
|
{ path: '/scenes', name: 'scenes', component: ScenesPage, meta: { title: '场景库' } },
|
||||||
|
{ path: '/settings', name: 'settings', component: SettingsPage, meta: { title: '设置' } }
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<template>
|
||||||
|
<div class="characters-page">
|
||||||
|
<div class="page-toolbar">
|
||||||
|
<el-input v-model="searchQuery" placeholder="搜索角色..." prefix-icon="Search" clearable style="width: 300px" />
|
||||||
|
<span v-if="searchQuery" class="search-count">找到 {{ filteredCharacters.length }} 个角色</span>
|
||||||
|
<el-tooltip :content="imagesDir" placement="bottom">
|
||||||
|
<el-button text size="small" @click="openDataDir">
|
||||||
|
<el-icon><FolderOpened /></el-icon>
|
||||||
|
打开图片目录
|
||||||
|
</el-button>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="filteredCharacters.length === 0" class="empty">
|
||||||
|
<el-empty :description="searchQuery ? '没有匹配的角色' : '角色库还是空的,去首页生成一个吧'" :image-size="80" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="character-grid">
|
||||||
|
<div v-for="char in filteredCharacters" :key="char.id" class="character-card">
|
||||||
|
<el-image
|
||||||
|
:src="imageSrc(char)"
|
||||||
|
fit="contain"
|
||||||
|
class="char-image"
|
||||||
|
loading="lazy"
|
||||||
|
>
|
||||||
|
<template #error>
|
||||||
|
<div class="img-placeholder">🖼️</div>
|
||||||
|
</template>
|
||||||
|
</el-image>
|
||||||
|
<div class="char-info">
|
||||||
|
<h3 class="char-name">{{ char.name }}</h3>
|
||||||
|
<p class="char-desc">{{ previewDesc(char.description) }}</p>
|
||||||
|
<div class="char-footer">
|
||||||
|
<span class="char-date">{{ formatDate(char.createdAt) }}</span>
|
||||||
|
<el-button size="small" type="danger" text @click="handleDelete(char)">删除</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { FolderOpened } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
const imagesDir = ref('')
|
||||||
|
|
||||||
|
async function openDataDir() {
|
||||||
|
if (imagesDir.value) {
|
||||||
|
await window.electronAPI.openFolder(imagesDir.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const characters = ref<StoredCharacter[]>([])
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const imageCache = ref<Record<string, string>>({})
|
||||||
|
|
||||||
|
const filteredCharacters = computed(() => {
|
||||||
|
const q = searchQuery.value.trim().toLowerCase()
|
||||||
|
if (!q) return characters.value
|
||||||
|
return characters.value.filter(
|
||||||
|
c => c.name.toLowerCase().includes(q) || c.description.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function imageSrc(char: StoredCharacter): string {
|
||||||
|
return imageCache.value[char.id] || char.imageUrl || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewDesc(text: string): string {
|
||||||
|
const match = text.match(/【角色名】(.+?)[\n【]/)
|
||||||
|
if (match) return match[1].trim()
|
||||||
|
return text.slice(0, 60) + '...'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
return iso.slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveImages() {
|
||||||
|
for (const char of characters.value) {
|
||||||
|
if (!char.imageUrl) continue
|
||||||
|
// 已经是 data URL 或 HTTP URL 直接用,本地路径就转 base64
|
||||||
|
if (char.imageUrl.startsWith('data:') || char.imageUrl.startsWith('http')) continue
|
||||||
|
try {
|
||||||
|
const b64 = await window.electronAPI.readImageBase64(char.imageUrl)
|
||||||
|
if (b64) imageCache.value[char.id] = b64
|
||||||
|
} catch { /* 图片不存在就用原始值 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const [chars, dir] = await Promise.all([
|
||||||
|
window.electronAPI.getCharacters(),
|
||||||
|
window.electronAPI.getImagesDir()
|
||||||
|
])
|
||||||
|
characters.value = chars
|
||||||
|
imagesDir.value = dir
|
||||||
|
await resolveImages()
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('加载角色库失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(char: StoredCharacter) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定删除「${char.name}」?`, '确认删除')
|
||||||
|
await window.electronAPI.deleteCharacter(char.id)
|
||||||
|
characters.value = characters.value.filter(c => c.id !== char.id)
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
} catch { /* 取消 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.characters-page { max-width: 1000px; margin: 0 auto; }
|
||||||
|
.page-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
|
||||||
|
.search-count { font-size: 13px; color: #999; }
|
||||||
|
.empty { padding: 80px 0; }
|
||||||
|
|
||||||
|
.character-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 20px; }
|
||||||
|
.character-card { background: #fff; border-radius: 12px; overflow: hidden; border: 1px solid #eee; transition: box-shadow 0.2s; }
|
||||||
|
.character-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.08); }
|
||||||
|
.char-image { width: 100%; height: 300px; display: block; background: #f8f8f8; }
|
||||||
|
.img-placeholder { width: 100%; height: 300px; display: flex; align-items: center; justify-content: center; font-size: 48px; background: #f5f5f5; }
|
||||||
|
.char-info { padding: 14px 16px; }
|
||||||
|
.char-name { margin: 0 0 6px; font-size: 16px; font-weight: 600; color: #1a1a2e; }
|
||||||
|
.char-desc { margin: 0 0 10px; font-size: 13px; color: #888; line-height: 1.4; }
|
||||||
|
.char-footer { display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.char-date { font-size: 12px; color: #bbb; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,905 @@
|
|||||||
|
<template>
|
||||||
|
<div class="chat-layout">
|
||||||
|
<!-- 会话侧栏 -->
|
||||||
|
<div class="session-sidebar">
|
||||||
|
<div class="session-header">
|
||||||
|
<span class="session-title">会话历史</span>
|
||||||
|
<el-button size="small" type="primary" :icon="Plus" circle @click="newSession" />
|
||||||
|
</div>
|
||||||
|
<div class="session-list">
|
||||||
|
<div
|
||||||
|
v-for="s in sessions"
|
||||||
|
:key="s.id"
|
||||||
|
class="session-item"
|
||||||
|
:class="{ active: currentSessionId === s.id }"
|
||||||
|
@click="switchSession(s.id)"
|
||||||
|
>
|
||||||
|
<div class="session-info">
|
||||||
|
<span class="session-name">{{ s.title }}</span>
|
||||||
|
<span class="session-time">{{ formatSessionTime(s.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
text
|
||||||
|
type="danger"
|
||||||
|
:icon="Delete"
|
||||||
|
@click.stop="handleDeleteSession(s)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="sessions.length === 0" class="session-empty">暂无会话</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 聊天区 -->
|
||||||
|
<div class="chat-area">
|
||||||
|
<!-- 顶部工具栏 -->
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-radio-group v-model="mode" @change="onModeChange" size="small">
|
||||||
|
<el-radio-button value="character">
|
||||||
|
<el-icon><Avatar /></el-icon> 生成角色
|
||||||
|
</el-radio-button>
|
||||||
|
<el-radio-button value="scene">
|
||||||
|
<el-icon><Picture /></el-icon> 生成场景
|
||||||
|
</el-radio-button>
|
||||||
|
<el-radio-button value="script">
|
||||||
|
<el-icon><Document /></el-icon> 生成脚本
|
||||||
|
</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
|
||||||
|
<el-select v-if="mode === 'script'" v-model="videoDuration" size="small" style="width: 90px">
|
||||||
|
<el-option v-for="d in [4,5,6,8,10,12,15]" :key="d" :label="d+'秒'" :value="d" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="currentStyle" placeholder="风格" size="small" style="width: 120px">
|
||||||
|
<el-option v-for="s in styles" :key="s.key" :label="s.label" :value="s.key" />
|
||||||
|
</el-select>
|
||||||
|
|
||||||
|
<div class="model-selectors">
|
||||||
|
<!-- 对话模型:仅脚本模式 -->
|
||||||
|
<div class="model-pair" v-if="mode === 'script'">
|
||||||
|
<span class="model-label">对话</span>
|
||||||
|
<el-select v-model="chatModel" filterable allow-create placeholder="对话模型" size="small" style="width: 180px">
|
||||||
|
<el-option v-for="m in scriptChatModels" :key="m" :label="m" :value="m" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<!-- 生图模型:非脚本模式 -->
|
||||||
|
<div class="model-pair" v-if="mode !== 'script'">
|
||||||
|
<span class="model-label">生图</span>
|
||||||
|
<el-select v-model="imageModel" filterable allow-create placeholder="生图模型" size="small" style="width: 180px">
|
||||||
|
<el-option-group label="推荐">
|
||||||
|
<el-option v-for="m in recommendImage" :key="m" :label="m" :value="m" />
|
||||||
|
</el-option-group>
|
||||||
|
<el-option-group v-if="imageOther.length" label="其他">
|
||||||
|
<el-option v-for="m in imageOther" :key="m" :label="m" :value="m" />
|
||||||
|
</el-option-group>
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<!-- 视频模型:仅脚本模式 -->
|
||||||
|
<div class="model-pair" v-if="mode === 'script'">
|
||||||
|
<span class="model-label">视频</span>
|
||||||
|
<el-select v-model="videoModel" filterable allow-create size="small" style="width: 180px">
|
||||||
|
<el-option v-for="v in VIDEO_MODELS" :key="v" :label="v" :value="v" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 消息列表 -->
|
||||||
|
<div class="messages" ref="messagesRef">
|
||||||
|
<div v-if="messages.length === 0" class="welcome">
|
||||||
|
<div class="welcome-icon">🎨</div>
|
||||||
|
<h2>EAnime 创作助手</h2>
|
||||||
|
<p v-if="mode === 'character'">描述你想要生成的角色,AI 会输出设定并绘制形象</p>
|
||||||
|
<p v-else-if="mode === 'scene'">描述你想要生成的场景,AI 会输出设定并绘制画面</p>
|
||||||
|
<div class="suggestions">
|
||||||
|
<el-tag v-for="s in suggestions" :key="s" class="suggestion-tag" @click="useSuggestion(s)">
|
||||||
|
{{ s }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="(msg, i) in messages" :key="i" class="message" :class="msg.role">
|
||||||
|
<div class="msg-avatar">{{ msg.role === 'user' ? '我' : 'E' }}</div>
|
||||||
|
<div class="msg-content">
|
||||||
|
<div v-if="msg.text" class="msg-bubble">
|
||||||
|
<div class="msg-text">{{ msg.text }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="msg.imageUrl" class="msg-image">
|
||||||
|
<el-image
|
||||||
|
:src="msg.imageUrl"
|
||||||
|
:preview-src-list="[msg.imageUrl]"
|
||||||
|
fit="contain"
|
||||||
|
style="max-width: 360px; max-height: 360px; border-radius: 8px;"
|
||||||
|
loading="lazy"
|
||||||
|
>
|
||||||
|
<template #error>
|
||||||
|
<div class="image-error">图片加载失败</div>
|
||||||
|
</template>
|
||||||
|
</el-image>
|
||||||
|
<div v-if="msg.role === 'assistant' && msg.imageUrl" class="msg-actions">
|
||||||
|
<el-button
|
||||||
|
v-if="!msg.saved"
|
||||||
|
type="primary" size="small" plain
|
||||||
|
@click="showSaveDialog(msg)"
|
||||||
|
>
|
||||||
|
<el-icon><Plus /></el-icon> 加入{{ mode === 'scene' ? '场景库' : '角色库' }}
|
||||||
|
</el-button>
|
||||||
|
<el-tag v-else size="small" type="success">已保存</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 脚本模式:视频生成控制(不在 imageUrl 条件内) -->
|
||||||
|
<div v-if="mode === 'script' && msg.role === 'assistant' && i === messages.length - 1" class="script-actions">
|
||||||
|
<el-divider />
|
||||||
|
<div class="video-controls">
|
||||||
|
<!-- 角色映射 -->
|
||||||
|
<div class="control-section">
|
||||||
|
<div class="section-title">角色映射</div>
|
||||||
|
<div v-for="(mapping, mi) in characterMappings" :key="mi" class="control-row">
|
||||||
|
<el-input v-model="mapping.scriptName" size="small" placeholder="脚本中角色名" style="width:130px" />
|
||||||
|
<span style="color:#999">→</span>
|
||||||
|
<el-select v-model="mapping.charId" size="small" placeholder="角色库角色" style="width:140px" clearable @change="onCharMapped(mapping)">
|
||||||
|
<el-option v-for="c in characterList" :key="c.id" :label="c.name" :value="c.id" />
|
||||||
|
</el-select>
|
||||||
|
<el-button size="small" text type="danger" @click="characterMappings.splice(mi,1)" :disabled="characterMappings.length<=1">✕</el-button>
|
||||||
|
</div>
|
||||||
|
<el-button size="small" text type="primary" @click="characterMappings.push({scriptName:'',charId:''})">+ 添加角色映射</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 场景/运镜/灯光 -->
|
||||||
|
<div class="control-row">
|
||||||
|
<span class="control-label">场景</span>
|
||||||
|
<el-select v-model="selectedSceneImage" size="small" placeholder="(可选)场景库" style="width:140px" clearable>
|
||||||
|
<el-option v-for="s in sceneList" :key="s.id" :label="s.name" :value="s.imageUrl" />
|
||||||
|
</el-select>
|
||||||
|
<span class="control-label">运镜</span>
|
||||||
|
<el-select v-model="selectedCamera" size="small" placeholder="选择运镜" style="width:130px" clearable>
|
||||||
|
<el-option v-for="c in CAMERAS" :key="c.key" :label="c.label" :value="c.prompt" />
|
||||||
|
</el-select>
|
||||||
|
<span class="control-label">灯光</span>
|
||||||
|
<el-select v-model="selectedLighting" size="small" placeholder="选择灯光" style="width:130px" clearable>
|
||||||
|
<el-option v-for="l in LIGHTS" :key="l.key" :label="l.label" :value="l.prompt" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 生成按钮 -->
|
||||||
|
<div class="control-row">
|
||||||
|
<el-select v-model="videoResolution" size="small" style="width:90px">
|
||||||
|
<el-option v-for="r in ['480p','720p','1080p']" :key="r" :label="r" :value="r" />
|
||||||
|
</el-select>
|
||||||
|
<el-button type="primary" size="small" :loading="generatingVideo" @click="startVideoGeneration(msg)">
|
||||||
|
<el-icon><VideoCameraFilled /></el-icon> 生成视频
|
||||||
|
</el-button>
|
||||||
|
<el-button v-if="videoTaskId" size="small" @click="checkVideoStatus">
|
||||||
|
<el-icon><Refresh /></el-icon> 查询状态
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="message assistant">
|
||||||
|
<div class="msg-avatar">E</div>
|
||||||
|
<div class="msg-content">
|
||||||
|
<div class="msg-bubble">
|
||||||
|
<div v-if="stepMessage" class="step-msg">{{ stepMessage }}</div>
|
||||||
|
<div class="typing-dots"><span>.</span><span>.</span><span>.</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 输入区 -->
|
||||||
|
<div class="input-area">
|
||||||
|
<el-input v-model="inputText" type="textarea" :rows="2" placeholder="描述你想要生成的角色..." :disabled="loading" @keydown.enter.prevent="sendMessage" />
|
||||||
|
<el-button type="primary" :loading="loading" :disabled="!inputText.trim()" @click="sendMessage" class="send-btn">
|
||||||
|
<el-icon><Promotion /></el-icon> 发送
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Avatar, Picture, Document, Promotion, Plus, Delete, VideoCameraFilled, Refresh } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
const CAMERAS = [
|
||||||
|
{ key: 'dolly_in', label: '推镜', prompt: 'camera slowly pushes in' },
|
||||||
|
{ key: 'dolly_out', label: '拉镜', prompt: 'camera slowly pulls back' },
|
||||||
|
{ key: 'pan_right', label: '右摇', prompt: 'camera pans right' },
|
||||||
|
{ key: 'pan_left', label: '左摇', prompt: 'camera pans left' },
|
||||||
|
{ key: 'tilt_up', label: '仰拍', prompt: 'low angle tilting upward' },
|
||||||
|
{ key: 'tilt_down', label: '俯拍', prompt: 'high angle overhead shot' },
|
||||||
|
{ key: 'tracking', label: '跟镜', prompt: 'tracking alongside subject' },
|
||||||
|
{ key: 'close_up', label: '特写', prompt: 'close-up detail shot' },
|
||||||
|
{ key: 'wide_shot', label: '全景', prompt: 'wide establishing shot' },
|
||||||
|
{ key: 'static', label: '静止', prompt: 'static no movement' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const VIDEO_MODELS = [
|
||||||
|
'doubao-seedance-2-0-fast-260128',
|
||||||
|
'doubao-seedance-2-0-mini-260615',
|
||||||
|
'doubao-seedance-1-0-pro-250528',
|
||||||
|
'doubao-seedance-1-0-pro-fast-251015'
|
||||||
|
]
|
||||||
|
|
||||||
|
const LIGHTS = [
|
||||||
|
{ key: 'natural', label: '自然光', prompt: 'natural daylight' },
|
||||||
|
{ key: 'rembrandt', label: '伦勃朗', prompt: 'Rembrandt dramatic light' },
|
||||||
|
{ key: 'side_light', label: '侧光', prompt: 'side lighting half shadow' },
|
||||||
|
{ key: 'backlight', label: '逆光', prompt: 'backlight rim light' },
|
||||||
|
{ key: 'soft_diffuse', label: '柔光', prompt: 'soft diffused light' },
|
||||||
|
{ key: 'neon', label: '霓虹', prompt: 'neon colorful lights' },
|
||||||
|
{ key: 'warm', label: '暖光', prompt: 'warm golden light' },
|
||||||
|
{ key: 'cool', label: '冷光', prompt: 'cool blue light' }
|
||||||
|
]
|
||||||
|
|
||||||
|
type Mode = 'character' | 'scene' | 'script'
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
text: string
|
||||||
|
imageUrl?: string
|
||||||
|
saved?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 状态 =====
|
||||||
|
const mode = ref<Mode>('character')
|
||||||
|
const inputText = ref('')
|
||||||
|
const messages = ref<Message[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const stepMessage = ref('')
|
||||||
|
const messagesRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
// 风格 & 视频
|
||||||
|
const currentStyle = ref('anime')
|
||||||
|
const videoDuration = ref(15)
|
||||||
|
const selectedCamera = ref('')
|
||||||
|
const selectedLighting = ref('')
|
||||||
|
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 selectedSceneImage = ref('')
|
||||||
|
const mappedCharacter = ref('')
|
||||||
|
const characterList = ref<StoredCharacter[]>([])
|
||||||
|
const sceneList = ref<StoredScene[]>([])
|
||||||
|
const characterMappings = ref<{ scriptName: string; charId: string }[]>([
|
||||||
|
{ scriptName: '', charId: '' }
|
||||||
|
])
|
||||||
|
const charImageMap = ref<Record<string, string>>({})
|
||||||
|
const charStyles = [
|
||||||
|
{ key: 'anime', label: '日漫风格' },
|
||||||
|
{ key: 'comic', label: '美漫风格' },
|
||||||
|
{ key: 'webtoon', label: '韩漫画风' },
|
||||||
|
{ key: 'realistic', label: '写实风格' },
|
||||||
|
{ key: 'ink', label: '水墨风格' },
|
||||||
|
{ key: 'cyberpunk', label: '赛博朋克' }
|
||||||
|
]
|
||||||
|
const sceneStyles = [
|
||||||
|
{ key: 'school', label: '校园' },
|
||||||
|
{ key: 'city', label: '都市' },
|
||||||
|
{ key: 'nature', label: '自然' },
|
||||||
|
{ key: 'scifi', label: '科幻' },
|
||||||
|
{ key: 'classical', label: '古典' },
|
||||||
|
{ key: 'cyberpunk', label: '赛博朋克' },
|
||||||
|
{ key: 'fantasy', label: '奇幻' },
|
||||||
|
{ key: 'wasteland', label: '末日废土' }
|
||||||
|
]
|
||||||
|
const styles = computed(() => mode.value === 'scene' ? sceneStyles : charStyles)
|
||||||
|
|
||||||
|
// 会话
|
||||||
|
const sessions = ref<ChatSession[]>([])
|
||||||
|
const currentSessionId = ref<string>('')
|
||||||
|
|
||||||
|
// 模型
|
||||||
|
const chatModel = ref('doubao-seed-2-0-mini-260428')
|
||||||
|
const imageModel = ref('doubao-seedream-5-0-260128')
|
||||||
|
const allModels = ref<string[]>([])
|
||||||
|
|
||||||
|
const recommendChat = ['doubao-seed-2-0-mini-260428', 'doubao-seed-2-1-turbo-260628', 'doubao-seed-2-1-pro-260628']
|
||||||
|
const recommendImage = ['doubao-seedream-5-0-260128', 'doubao-seedream-5-0-pro-260628', 'doubao-seedream-4-5-251128']
|
||||||
|
const chatOther = computed(() => allModels.value.filter(m => !recommendChat.includes(m) && !recommendImage.includes(m) && !m.includes('deepseek')))
|
||||||
|
const imageOther = computed(() => allModels.value.filter(m => !recommendChat.includes(m) && !recommendImage.includes(m) && !m.includes('deepseek')))
|
||||||
|
|
||||||
|
// 脚本模式用的对话模型推荐(含 DeepSeek)
|
||||||
|
const scriptChatModels = ['doubao-seed-2-0-mini-260428', 'doubao-seed-2-1-turbo-260628', 'deepseek-v4-pro', 'deepseek-v4-flash']
|
||||||
|
|
||||||
|
const suggestions = [
|
||||||
|
'一个15岁的天才魔法少女,红色双马尾,性格活泼',
|
||||||
|
'冷酷的暗夜刺客,黑色风衣,银发独眼',
|
||||||
|
'温柔治愈的图书馆管理员,戴圆框眼镜'
|
||||||
|
]
|
||||||
|
|
||||||
|
// ===== 会话管理 =====
|
||||||
|
let autoSaveTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
/** 生成会话标题(取第一条用户消息) */
|
||||||
|
function makeTitle(text: string): string {
|
||||||
|
return text.length > 30 ? text.slice(0, 30) + '...' : text
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化会话时间显示 */
|
||||||
|
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())}`
|
||||||
|
// 今天的只显示时间,非今天的加日期
|
||||||
|
if (d.toDateString() === now.toDateString()) return time
|
||||||
|
return `${d.getMonth() + 1}/${d.getDate()} ${time}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新建会话 */
|
||||||
|
async function newSession() {
|
||||||
|
if (currentSessionId.value && messages.value.length > 0) {
|
||||||
|
await saveCurrentSession()
|
||||||
|
}
|
||||||
|
currentSessionId.value = ''
|
||||||
|
messages.value = []
|
||||||
|
inputText.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切换会话 */
|
||||||
|
async function switchSession(id: string) {
|
||||||
|
if (currentSessionId.value && messages.value.length > 0) {
|
||||||
|
await saveCurrentSession()
|
||||||
|
}
|
||||||
|
if (id === currentSessionId.value) return
|
||||||
|
currentSessionId.value = id
|
||||||
|
const s = sessions.value.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
|
||||||
|
}
|
||||||
|
await scrollToBottom()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除会话 */
|
||||||
|
async function handleDeleteSession(s: ChatSession) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定删除会话「${s.title}」?`, '确认删除')
|
||||||
|
await window.electronAPI.deleteSession(s.id)
|
||||||
|
sessions.value = sessions.value.filter(x => x.id !== s.id)
|
||||||
|
if (currentSessionId.value === s.id) {
|
||||||
|
currentSessionId.value = ''
|
||||||
|
messages.value = []
|
||||||
|
inputText.value = ''
|
||||||
|
}
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
} catch { /* 取消 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存当前会话到本地 */
|
||||||
|
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 session: ChatSession = {
|
||||||
|
id: currentSessionId.value || (Date.now().toString(36) + Math.random().toString(36).slice(2, 6)),
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
await window.electronAPI.saveSession(session)
|
||||||
|
currentSessionId.value = session.id
|
||||||
|
|
||||||
|
// 刷新会话列表
|
||||||
|
sessions.value = await window.electronAPI.getSessions()
|
||||||
|
return session.id
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加载会话列表 */
|
||||||
|
async function loadSessions() {
|
||||||
|
try {
|
||||||
|
sessions.value = await window.electronAPI.getSessions()
|
||||||
|
} catch { /* silent */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 消息发送 =====
|
||||||
|
async function sendMessage() {
|
||||||
|
const text = inputText.value.trim()
|
||||||
|
if (!text || loading.value) return
|
||||||
|
|
||||||
|
inputText.value = ''
|
||||||
|
|
||||||
|
// 如果是新会话的第一条消息,创建会话并生成标题
|
||||||
|
if (messages.value.length === 0) {
|
||||||
|
// 先保存一个空会话,让用户能切走
|
||||||
|
const tempId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
|
||||||
|
currentSessionId.value = tempId
|
||||||
|
await window.electronAPI.saveSession({
|
||||||
|
id: tempId,
|
||||||
|
title: makeTitle(text),
|
||||||
|
mode: mode.value,
|
||||||
|
style: currentStyle.value,
|
||||||
|
chatModel: chatModel.value,
|
||||||
|
imageModel: imageModel.value,
|
||||||
|
messages: [],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
})
|
||||||
|
sessions.value = await window.electronAPI.getSessions()
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.value.push({ role: 'user', text })
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await scrollToBottom()
|
||||||
|
|
||||||
|
if (mode.value === 'character') {
|
||||||
|
stepMessage.value = '正在生成角色设定...'
|
||||||
|
const history = messages.value.slice(0, -1).map(m => ({ role: m.role, text: m.text }))
|
||||||
|
const result = await window.electronAPI.generateCharacter(text, chatModel.value, imageModel.value, history, currentStyle.value)
|
||||||
|
stepMessage.value = '正在绘制角色形象...'
|
||||||
|
messages.value.push({ role: 'assistant', text: result.text, imageUrl: result.imageUrl })
|
||||||
|
} else if (mode.value === 'scene') {
|
||||||
|
stepMessage.value = '正在生成场景设定...'
|
||||||
|
const history = messages.value.slice(0, -1).map(m => ({ role: m.role, text: m.text }))
|
||||||
|
const result = await window.electronAPI.generateScene(text, chatModel.value, imageModel.value, history, currentStyle.value)
|
||||||
|
stepMessage.value = '正在绘制场景画面...'
|
||||||
|
messages.value.push({ role: 'assistant', text: result.text, imageUrl: result.imageUrl })
|
||||||
|
} else if (mode.value === 'script') {
|
||||||
|
// 长文本先拆分
|
||||||
|
if (text.length > 1500) {
|
||||||
|
stepMessage.value = '正在拆分小说...'
|
||||||
|
const segments = await window.electronAPI.splitNovel(text)
|
||||||
|
let reply = '已将小说拆分为 ' + segments.length + ' 个片段:\n\n'
|
||||||
|
segments.forEach((s, i) => {
|
||||||
|
reply += '**【片段' + (i + 1) + '】' + s.title + '**\n' + s.content.slice(0, 100) + '...\n\n'
|
||||||
|
})
|
||||||
|
reply += '---\n点击下方片段,发送对应编号生成该段脚本(如:发送「1」或复制片段内容)'
|
||||||
|
messages.value.push({ role: 'assistant', text: reply })
|
||||||
|
// 暂存片段供后续使用
|
||||||
|
;(window as Record<string, unknown>).__segments = segments
|
||||||
|
} else {
|
||||||
|
stepMessage.value = '正在生成视频脚本...'
|
||||||
|
const script = await window.electronAPI.generateScript(text, videoDuration.value, currentStyle.value, chatModel.value)
|
||||||
|
messages.value.push({ role: 'assistant', text: script })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
messages.value.push({ role: 'assistant', text: '功能正在开发中' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动保存
|
||||||
|
await saveCurrentSession()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg = e instanceof Error ? e.message : '未知错误'
|
||||||
|
ElMessage.error(msg)
|
||||||
|
messages.value.push({ role: 'assistant', text: `出错了:${msg}` })
|
||||||
|
await saveCurrentSession()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
stepMessage.value = ''
|
||||||
|
await scrollToBottom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存角色/场景到库 */
|
||||||
|
async function showSaveDialog(msg: Message) {
|
||||||
|
try {
|
||||||
|
const libLabel = mode.value === 'scene' ? '场景库' : '角色库'
|
||||||
|
const result = await ElMessageBox.prompt('起个名字', '加入' + libLabel, {
|
||||||
|
inputPlaceholder: '输入名称',
|
||||||
|
confirmButtonText: '保存',
|
||||||
|
cancelButtonText: '取消'
|
||||||
|
})
|
||||||
|
const name = result.value.trim()
|
||||||
|
if (!name) { ElMessage.warning('请输入名称'); return }
|
||||||
|
|
||||||
|
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
|
||||||
|
const item = { id, name, description: msg.text, imageUrl: msg.imageUrl || '', createdAt: new Date().toISOString() }
|
||||||
|
|
||||||
|
if (mode.value === 'scene') {
|
||||||
|
await window.electronAPI.saveScene(item)
|
||||||
|
} else {
|
||||||
|
await window.electronAPI.saveCharacter(item)
|
||||||
|
}
|
||||||
|
msg.saved = true
|
||||||
|
ElMessage.success(`「${name}」已加入${libLabel}`)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const errorMsg = e instanceof Error ? e.message : ''
|
||||||
|
if (errorMsg.includes('已存在')) {
|
||||||
|
ElMessage.warning(errorMsg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 取消弹窗或无关错误静默处理
|
||||||
|
if (!errorMsg || errorMsg.includes('cancel')) return
|
||||||
|
ElMessage.error(errorMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切换模式 */
|
||||||
|
function onCharMapped(mapping: { scriptName: string; charId: string }) {
|
||||||
|
if (mapping.charId) {
|
||||||
|
const c = characterList.value.find(x => x.id === mapping.charId)
|
||||||
|
if (c) charImageMap.value[mapping.charId] = c.imageUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startVideoGeneration(msg: Message) {
|
||||||
|
let prompt = ''
|
||||||
|
const vpMatch = msg.text.match(/Video Prompt[=\s]*\n?([\s\S]+?)$/)
|
||||||
|
if (vpMatch) prompt = vpMatch[1].trim()
|
||||||
|
else prompt = msg.text.slice(-200)
|
||||||
|
|
||||||
|
if (selectedCamera.value) prompt = selectedCamera.value + '. ' + prompt
|
||||||
|
if (selectedLighting.value) prompt = selectedLighting.value + '. ' + prompt
|
||||||
|
|
||||||
|
// 收集映射角色的图片
|
||||||
|
const mappedChars = characterMappings.value.filter(m => m.charId)
|
||||||
|
|
||||||
|
// 首帧:优先用场景图
|
||||||
|
const firstFrameUrl = selectedSceneImage.value || ''
|
||||||
|
// 角色参考图:所有有库内映射的角色图
|
||||||
|
const refUrls = mappedChars.map(m => charImageMap.value[m.charId] || '').filter(Boolean)
|
||||||
|
|
||||||
|
generatingVideo.value = true
|
||||||
|
videoResult.value = null
|
||||||
|
try {
|
||||||
|
videoTaskId.value = await window.electronAPI.createVideo(prompt, videoDuration.value, videoResolution.value, videoModel.value, firstFrameUrl, refUrls)
|
||||||
|
ElMessage.success('视频任务已创建: ' + videoTaskId.value)
|
||||||
|
await pollForVideo()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
ElMessage.error(e instanceof Error ? e.message : '视频生成失败')
|
||||||
|
} finally {
|
||||||
|
generatingVideo.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollForVideo() {
|
||||||
|
if (!videoTaskId.value) return
|
||||||
|
const maxPolls = 40
|
||||||
|
for (let i = 0; i < maxPolls; i++) {
|
||||||
|
await new Promise(r => setTimeout(r, 5000))
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.pollVideo(videoTaskId.value)
|
||||||
|
if (result.status === 'succeeded') {
|
||||||
|
videoResult.value = result
|
||||||
|
ElMessage.success('视频生成完成!')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (result.status === 'failed') {
|
||||||
|
ElMessage.error('视频生成失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch { /* retry */ }
|
||||||
|
}
|
||||||
|
ElMessage.warning('视频生成超时,请稍后查询')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkVideoStatus() {
|
||||||
|
if (!videoTaskId.value) return
|
||||||
|
try {
|
||||||
|
const result = await window.electronAPI.pollVideo(videoTaskId.value)
|
||||||
|
if (result.status === 'succeeded') {
|
||||||
|
videoResult.value = result
|
||||||
|
ElMessage.success('视频已完成!')
|
||||||
|
} else {
|
||||||
|
ElMessage.info('状态: ' + result.status)
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
ElMessage.error(e instanceof Error ? e.message : '查询失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveLastFrame(videoUrl: string) {
|
||||||
|
try {
|
||||||
|
const name = '视频尾帧_' + Date.now().toString(36)
|
||||||
|
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',
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
})
|
||||||
|
ElMessage.success('尾帧已保存到场景库')
|
||||||
|
} catch (e: unknown) {
|
||||||
|
ElMessage.error('保存失败,请尝试手动截图')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onModeChange() {
|
||||||
|
if (messages.value.length > 0) {
|
||||||
|
await saveCurrentSession()
|
||||||
|
}
|
||||||
|
currentStyle.value = mode.value === 'scene' ? 'school' : 'anime'
|
||||||
|
videoDuration.value = 15
|
||||||
|
// 切换出脚本模式时清掉 deepseek,防止带到角色/场景生成
|
||||||
|
if (mode.value !== 'script' && chatModel.value.includes('deepseek')) {
|
||||||
|
chatModel.value = 'doubao-seed-2-0-mini-260428'
|
||||||
|
}
|
||||||
|
selectedCamera.value = ''
|
||||||
|
selectedLighting.value = ''
|
||||||
|
selectedSceneImage.value = ''
|
||||||
|
characterMappings.value = [{ scriptName: '', charId: '' }]
|
||||||
|
newSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 工具 =====
|
||||||
|
function useSuggestion(text: string) { inputText.value = text }
|
||||||
|
async function scrollToBottom() {
|
||||||
|
await nextTick()
|
||||||
|
if (messagesRef.value) messagesRef.value.scrollTop = messagesRef.value.scrollHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模型列表
|
||||||
|
async function loadModels() {
|
||||||
|
try {
|
||||||
|
const keys = await window.electronAPI.getApiKeys()
|
||||||
|
if (keys['doubao']) {
|
||||||
|
const resp = await fetch('https://ark.cn-beijing.volces.com/api/v3/models', {
|
||||||
|
headers: { 'Authorization': `Bearer ${keys['doubao']}` }
|
||||||
|
})
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = await resp.json()
|
||||||
|
allModels.value = data.data
|
||||||
|
.filter((m: { status: string }) => m.status !== 'Shutdown' && m.status !== 'Retiring')
|
||||||
|
.map((m: { id: string }) => m.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* silent */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadSessions()
|
||||||
|
await loadModels()
|
||||||
|
try { characterList.value = await window.electronAPI.getCharacters() } catch {}
|
||||||
|
try { sceneList.value = await window.electronAPI.getScenes() } catch {}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chat-layout {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 会话侧栏 */
|
||||||
|
.session-sidebar {
|
||||||
|
width: 200px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #eee;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item.active {
|
||||||
|
background: #e8f8f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-name {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #333;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 聊天区 */
|
||||||
|
.chat-area {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-selectors {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-pair {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 消息区 */
|
||||||
|
.messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 12px 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
.welcome-icon { font-size: 56px; margin-bottom: 12px; }
|
||||||
|
.welcome h2 { margin: 0 0 8px; font-size: 22px; color: #1a1a2e; }
|
||||||
|
.welcome p { margin: 0 0 20px; color: #999; font-size: 14px; }
|
||||||
|
.suggestions { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; }
|
||||||
|
.suggestion-tag { cursor: pointer; padding: 6px 12px; font-size: 13px; }
|
||||||
|
|
||||||
|
.message { display: flex; gap: 10px; max-width: 90%; }
|
||||||
|
.message.user { align-self: flex-end; flex-direction: row-reverse; }
|
||||||
|
.message.assistant { align-self: flex-start; }
|
||||||
|
|
||||||
|
.msg-avatar {
|
||||||
|
width: 30px; height: 30px; border-radius: 50%;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 11px; font-weight: 600; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.message.user .msg-avatar { background: #64ffda; color: #1a1a2e; }
|
||||||
|
.message.assistant .msg-avatar { background: #1a1a2e; color: #64ffda; }
|
||||||
|
|
||||||
|
.msg-content { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
|
||||||
|
|
||||||
|
.msg-bubble {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.message.user .msg-bubble { background: #64ffda; color: #1a1a2e; }
|
||||||
|
.message.assistant .msg-bubble { background: #fff; color: #333; border: 1px solid #eee; }
|
||||||
|
|
||||||
|
.step-msg { font-size: 13px; color: #999; margin-bottom: 4px; }
|
||||||
|
|
||||||
|
.msg-image {
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #eee;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
.image-error {
|
||||||
|
width: 200px; height: 200px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: #f5f5f5; color: #999; font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-dots { font-size: 24px; color: #999; letter-spacing: 4px; }
|
||||||
|
|
||||||
|
.msg-actions {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 输入区 */
|
||||||
|
.input-area {
|
||||||
|
display: flex; gap: 12px; padding: 12px 0 4px; align-items: flex-end;
|
||||||
|
}
|
||||||
|
.input-area :deep(.el-textarea__inner) { border-radius: 10px; resize: none; }
|
||||||
|
.send-btn { height: 56px; border-radius: 10px; padding: 0 24px; }
|
||||||
|
|
||||||
|
/* 脚本/视频控制 */
|
||||||
|
.script-actions {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.video-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.control-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.control-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.control-section {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #555;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.video-result {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<template>
|
||||||
|
<div class="scenes-page">
|
||||||
|
<div class="page-toolbar">
|
||||||
|
<el-input v-model="searchQuery" placeholder="搜索场景..." prefix-icon="Search" clearable style="width: 300px" />
|
||||||
|
<span v-if="searchQuery" class="search-count">找到 {{ filteredScenes.length }} 个场景</span>
|
||||||
|
<el-tooltip :content="imagesDir" placement="bottom">
|
||||||
|
<el-button text size="small" @click="openDataDir">
|
||||||
|
<el-icon><FolderOpened /></el-icon>
|
||||||
|
打开图片目录
|
||||||
|
</el-button>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="filteredScenes.length === 0" class="empty">
|
||||||
|
<el-empty :description="searchQuery ? '没有匹配的场景' : '场景库还是空的,去首页生成一个吧'" :image-size="80" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="scene-grid">
|
||||||
|
<div v-for="scene in filteredScenes" :key="scene.id" class="scene-card">
|
||||||
|
<el-image :src="imageSrc(scene)" fit="contain" class="scene-image" loading="lazy">
|
||||||
|
<template #error>
|
||||||
|
<div class="img-placeholder">🏞️</div>
|
||||||
|
</template>
|
||||||
|
</el-image>
|
||||||
|
<div class="scene-info">
|
||||||
|
<h3 class="scene-name">{{ scene.name }}</h3>
|
||||||
|
<p class="scene-desc">{{ previewDesc(scene.description) }}</p>
|
||||||
|
<div class="scene-footer">
|
||||||
|
<span class="scene-date">{{ formatDate(scene.createdAt) }}</span>
|
||||||
|
<el-button size="small" type="danger" text @click="handleDelete(scene)">删除</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { FolderOpened } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
const scenes = ref<StoredScene[]>([])
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const imageCache = ref<Record<string, string>>({})
|
||||||
|
const imagesDir = ref('')
|
||||||
|
|
||||||
|
const filteredScenes = computed(() => {
|
||||||
|
const q = searchQuery.value.trim().toLowerCase()
|
||||||
|
if (!q) return scenes.value
|
||||||
|
return scenes.value.filter(
|
||||||
|
s => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function imageSrc(scene: StoredScene): string {
|
||||||
|
return imageCache.value[scene.id] || scene.imageUrl || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewDesc(text: string): string {
|
||||||
|
const match = text.match(/【场景名称】(.+?)[\n【]/)
|
||||||
|
if (match) return match[1].trim()
|
||||||
|
return text.slice(0, 60) + '...'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
return iso.slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveImages() {
|
||||||
|
for (const s of scenes.value) {
|
||||||
|
if (!s.imageUrl) continue
|
||||||
|
if (s.imageUrl.startsWith('data:') || s.imageUrl.startsWith('http')) continue
|
||||||
|
try {
|
||||||
|
const b64 = await window.electronAPI.readImageBase64(s.imageUrl)
|
||||||
|
if (b64) imageCache.value[s.id] = b64
|
||||||
|
} catch { /* noop */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDataDir() {
|
||||||
|
if (imagesDir.value) await window.electronAPI.openFolder(imagesDir.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const [list, dir] = await Promise.all([
|
||||||
|
window.electronAPI.getScenes(),
|
||||||
|
window.electronAPI.getImagesDir()
|
||||||
|
])
|
||||||
|
scenes.value = list
|
||||||
|
imagesDir.value = dir
|
||||||
|
await resolveImages()
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('加载场景库失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(scene: StoredScene) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定删除「${scene.name}」?`, '确认删除')
|
||||||
|
await window.electronAPI.deleteScene(scene.id)
|
||||||
|
scenes.value = scenes.value.filter(s => s.id !== scene.id)
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
} catch { /* 取消 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.scenes-page { max-width: 1000px; margin: 0 auto; }
|
||||||
|
.page-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
|
||||||
|
.search-count { font-size: 13px; color: #999; }
|
||||||
|
.empty { padding: 80px 0; }
|
||||||
|
.scene-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 20px; }
|
||||||
|
.scene-card { background: #fff; border-radius: 12px; overflow: hidden; border: 1px solid #eee; transition: box-shadow 0.2s; }
|
||||||
|
.scene-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.08); }
|
||||||
|
.scene-image { width: 100%; height: 200px; display: block; background: #f8f8f8; }
|
||||||
|
.img-placeholder { width: 100%; height: 200px; display: flex; align-items: center; justify-content: center; font-size: 40px; background: #f5f5f5; }
|
||||||
|
.scene-info { padding: 14px 16px; }
|
||||||
|
.scene-name { margin: 0 0 6px; font-size: 16px; font-weight: 600; color: #1a1a2e; }
|
||||||
|
.scene-desc { margin: 0 0 10px; font-size: 13px; color: #888; line-height: 1.4; }
|
||||||
|
.scene-footer { display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.scene-date { font-size: 12px; color: #bbb; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
<template>
|
||||||
|
<div class="settings-page">
|
||||||
|
<!-- 卡片:UI 设置 -->
|
||||||
|
<el-card class="settings-card" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">API Key 配置</span>
|
||||||
|
<span class="card-desc">在这里设置的 Key 优先级最高;留空则读取 config 文件</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="providers">
|
||||||
|
<div v-for="provider in providers" :key="provider.key" class="provider-item">
|
||||||
|
<div class="provider-info">
|
||||||
|
<div class="provider-name">{{ provider.label }}</div>
|
||||||
|
<div class="provider-desc">{{ provider.desc }}</div>
|
||||||
|
<!-- 来源标记 -->
|
||||||
|
<div v-if="sourceLabel(provider.key)" class="provider-source">
|
||||||
|
<el-tag :type="sourceTagType(provider.key)" size="small" effect="plain">
|
||||||
|
{{ sourceLabel(provider.key) }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="provider-input">
|
||||||
|
<el-input
|
||||||
|
v-model="form[provider.key]"
|
||||||
|
type="password"
|
||||||
|
:placeholder="configFileKeys[provider.key] ? '从 config 文件读取中…' : '未设置'"
|
||||||
|
clearable
|
||||||
|
show-password
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="provider-actions">
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:loading="saving[provider.key]"
|
||||||
|
@click="saveKey(provider.key)"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="hasUiKey(provider.key)"
|
||||||
|
type="danger"
|
||||||
|
plain
|
||||||
|
@click="deleteKey(provider.key)"
|
||||||
|
>
|
||||||
|
清除
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 卡片:存储路径 -->
|
||||||
|
<el-card class="settings-card" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">数据存储位置</span>
|
||||||
|
<span class="card-desc">角色库、会话记录和图片存放的目录,默认在系统 AppData</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="config-path-bar">
|
||||||
|
<el-icon><Folder /></el-icon>
|
||||||
|
<code class="path-text">{{ dataDir }}</code>
|
||||||
|
<el-button size="small" @click="chooseDataDir">
|
||||||
|
<el-icon><EditPen /></el-icon> 更改
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" @click="openDataDir">
|
||||||
|
<el-icon><FolderOpened /></el-icon> 打开
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 卡片:config 文件 -->
|
||||||
|
<el-card class="settings-card config-card" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">Config 文件</span>
|
||||||
|
<span class="card-desc">编辑此文件可批量化管理 API Key</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="config-path-bar">
|
||||||
|
<el-icon><Document /></el-icon>
|
||||||
|
<code class="path-text">{{ configPath }}</code>
|
||||||
|
<el-button size="small" @click="openConfig">
|
||||||
|
<el-icon><EditPen /></el-icon> 编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" @click="refreshConfig">
|
||||||
|
<el-icon><Refresh /></el-icon> 刷新
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="Object.keys(configFileKeys).length" class="config-keys">
|
||||||
|
<div v-for="(value, key) in configFileKeys" :key="key" class="config-key-row">
|
||||||
|
<span class="key-label">{{ providerLabel(key) }}</span>
|
||||||
|
<code class="key-value">{{ maskKey(value) }}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-empty v-else description="config 文件中没有配置" :image-size="50" />
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Document, EditPen, Refresh, Folder, FolderOpened } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
interface Provider {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
desc: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const providers: Provider[] = [
|
||||||
|
{ key: 'doubao', label: '豆包', desc: '用于生成角色设计与剧本' },
|
||||||
|
{ key: 'jimeng', label: '即梦', desc: '用于图像生成与场景绘制' },
|
||||||
|
{ key: 'deepseek', label: 'DeepSeek', desc: '用于文本润色与提示词优化' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const configPath = ref('')
|
||||||
|
const dataDir = ref('')
|
||||||
|
|
||||||
|
// UI 输入框绑定的值(对应内部存储)
|
||||||
|
const form = reactive<Record<string, string>>({})
|
||||||
|
// config 文件中的 Key
|
||||||
|
const configFileKeys = ref<Record<string, string>>({})
|
||||||
|
// 各 provider 是否正在保存
|
||||||
|
const saving = reactive<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
const providerLabels: Record<string, string> = {
|
||||||
|
doubao: '豆包',
|
||||||
|
jimeng: '即梦',
|
||||||
|
deepseek: 'DeepSeek'
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerLabel(key: string): string {
|
||||||
|
return providerLabels[key] || key
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskKey(key: string): string {
|
||||||
|
if (!key || key.length < 8) return key || '(空)'
|
||||||
|
return key.slice(0, 4) + '****' + key.slice(-4)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断当前 provider 的 UI 是否设置了值 */
|
||||||
|
function hasUiKey(key: string): boolean {
|
||||||
|
return !!form[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 显示 Key 实际来自哪里 */
|
||||||
|
function sourceLabel(key: string): string {
|
||||||
|
if (form[key]) return '来自 UI 设置'
|
||||||
|
if (configFileKeys.value[key]) return '来自 config 文件'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceTagType(key: string): 'warning' | 'info' | '' {
|
||||||
|
if (form[key]) return 'warning'
|
||||||
|
if (configFileKeys.value[key]) return 'info'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
try {
|
||||||
|
const [path, uiKeys, configKeys, dir] = await Promise.all([
|
||||||
|
window.electronAPI.getConfigPath(),
|
||||||
|
window.electronAPI.getUiApiKeys(),
|
||||||
|
window.electronAPI.getConfigFileApiKeys(),
|
||||||
|
window.electronAPI.getDataDir()
|
||||||
|
])
|
||||||
|
configPath.value = path
|
||||||
|
configFileKeys.value = configKeys
|
||||||
|
dataDir.value = dir
|
||||||
|
|
||||||
|
// 回显输入框:只填充 UI 设置的,config 的留给 placeholder
|
||||||
|
for (const p of providers) {
|
||||||
|
form[p.key] = uiKeys[p.key] || ''
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg = e instanceof Error ? e.message : '未知错误'
|
||||||
|
ElMessage.error('读取配置失败: ' + msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveKey(key: string) {
|
||||||
|
saving[key] = true
|
||||||
|
try {
|
||||||
|
await window.electronAPI.setApiKey(key, form[key] || '')
|
||||||
|
ElMessage.success(`${providerLabel(key)} 已保存`)
|
||||||
|
// 如果保存的是空字符串,走的就是清除流程
|
||||||
|
if (!form[key]) {
|
||||||
|
// 重新获取 config 刷新 UI
|
||||||
|
const configKeys = await window.electronAPI.getConfigFileApiKeys()
|
||||||
|
configFileKeys.value = configKeys
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('保存失败')
|
||||||
|
} finally {
|
||||||
|
saving[key] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteKey(key: string) {
|
||||||
|
try {
|
||||||
|
await window.electronAPI.deleteApiKey(key)
|
||||||
|
form[key] = ''
|
||||||
|
const configKeys = await window.electronAPI.getConfigFileApiKeys()
|
||||||
|
configFileKeys.value = configKeys
|
||||||
|
ElMessage.success(`已清除 ${providerLabel(key)} 的 UI 设置,将使用 config 文件的值`)
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('清除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openConfig() {
|
||||||
|
try {
|
||||||
|
await window.electronAPI.openConfig()
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('无法打开配置文件')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseDataDir() {
|
||||||
|
try {
|
||||||
|
const folder = await window.electronAPI.pickFolder()
|
||||||
|
if (folder) {
|
||||||
|
await window.electronAPI.setDataDir(folder)
|
||||||
|
dataDir.value = folder
|
||||||
|
ElMessage.success('存储路径已更新,下次启动生效')
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
ElMessage.error('更改失败: ' + (e instanceof Error ? e.message : ''))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDataDir() {
|
||||||
|
if (dataDir.value) await window.electronAPI.openFolder(dataDir.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshConfig() {
|
||||||
|
try {
|
||||||
|
const configKeys = await window.electronAPI.getConfigFileApiKeys()
|
||||||
|
configFileKeys.value = configKeys
|
||||||
|
ElMessage.success('已刷新')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('刷新失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadAll)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.settings-page {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card {
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- UI 设置 ---- */
|
||||||
|
.providers {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-info {
|
||||||
|
width: 120px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-source {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Config 文件 ---- */
|
||||||
|
.config-card {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-path-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-text {
|
||||||
|
flex: 1;
|
||||||
|
word-break: break-all;
|
||||||
|
color: #666;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-keys {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-key-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #1a1a2e;
|
||||||
|
width: 72px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-value {
|
||||||
|
flex: 1;
|
||||||
|
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.node.json" },
|
||||||
|
{ "path": "./tsconfig.web.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"outDir": "./out",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/main/**/*.ts",
|
||||||
|
"src/preload/**/*.ts",
|
||||||
|
"electron.vite.config.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||||
|
"outDir": "./out",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/renderer/src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/renderer/src/**/*.ts",
|
||||||
|
"src/renderer/src/**/*.d.ts",
|
||||||
|
"src/renderer/src/**/*.vue"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user