@exccoder/dsh-media-workbench 0.0.0-stage → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/LICENSE +21 -0
- package/README.en.md +111 -0
- package/README.md +141 -2
- package/SECURITY.md +29 -0
- package/cordis.patch.yml +4 -0
- package/docs/screenshot-graph.webp +0 -0
- package/docs/screenshot-image.webp +0 -0
- package/docs/screenshot-video.webp +0 -0
- package/docs/screenshot-write.webp +0 -0
- package/lib/client.js +110 -0
- package/package.json +79 -4
- package/src/graph.js +304 -0
- package/src/image.js +137 -0
- package/src/index.js +769 -0
- package/src/llm.js +60 -0
- package/src/settings.js +187 -0
- package/src/store.js +274 -0
- package/src/tools.js +354 -0
- package/src/util.js +217 -0
- package/src/video.js +219 -0
- package/src/writing.js +112 -0
- package/web/app.js +268 -0
- package/web/favicon.svg +12 -0
- package/web/index.html +16 -0
- package/web/lib/api.js +79 -0
- package/web/lib/constants.js +69 -0
- package/web/lib/dom.js +99 -0
- package/web/lib/icons.js +65 -0
- package/web/lib/state.js +42 -0
- package/web/styles.css +1096 -0
- package/web/views/constellation-model.js +63 -0
- package/web/views/constellation.js +1110 -0
- package/web/views/image.js +488 -0
- package/web/views/settings.js +244 -0
- package/web/views/video.js +363 -0
- package/web/views/write.js +513 -0
package/src/llm.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// 用 DSH 里已经配好的默认模型写作,不额外存密钥
|
|
2
|
+
export const modelStatus = { ok: null, error: null }
|
|
3
|
+
|
|
4
|
+
export function modelInfo(ctx) {
|
|
5
|
+
try {
|
|
6
|
+
const selection = ctx.get('agentDefaultModel')?.currentSelection()
|
|
7
|
+
if (!ctx.get('llm') || !selection?.provider || !selection?.model) return null
|
|
8
|
+
return { provider: selection.provider, model: selection.model, label: selection.model }
|
|
9
|
+
} catch {
|
|
10
|
+
return null
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 流式调用当前模型。
|
|
16
|
+
* @param {object} ctx
|
|
17
|
+
* @param {{system: string, user: string, maxTokens?: number, signal?: AbortSignal, onDelta?: (t: string) => void}} request
|
|
18
|
+
* @returns {Promise<{text: string, model: string}>}
|
|
19
|
+
*/
|
|
20
|
+
export async function streamText(ctx, { system, user, maxTokens = 4000, signal, onDelta }) {
|
|
21
|
+
const info = modelInfo(ctx)
|
|
22
|
+
if (!info) throw Object.assign(new Error('当前没有可用的写作模型。先在 DSH 里选择模型(或配置 DeepSeek 账号),再回来试。'), { status: 503 })
|
|
23
|
+
const llm = ctx.get('llm')
|
|
24
|
+
let text = ''
|
|
25
|
+
try {
|
|
26
|
+
const call = await llm.prepareCall({ provider: info.provider, model: info.model, maxTokens }, signal, (controls, model) => {
|
|
27
|
+
const effort = model?.reasoning?.efforts?.[0]?.id
|
|
28
|
+
return effort === undefined ? controls : { ...controls, reasoningEffort: effort }
|
|
29
|
+
})
|
|
30
|
+
for await (const chunk of call.stream({
|
|
31
|
+
...call.config,
|
|
32
|
+
system,
|
|
33
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: user }], source: { kind: '@exccoder/dsh-media-workbench' } }],
|
|
34
|
+
signal,
|
|
35
|
+
})) {
|
|
36
|
+
if (chunk.type === 'text-delta') {
|
|
37
|
+
text += chunk.text
|
|
38
|
+
onDelta?.(chunk.text)
|
|
39
|
+
} else if (chunk.type === 'finish' && ['error', 'aborted'].includes(chunk.reason?.kind)) {
|
|
40
|
+
throw new Error(chunk.reason.failure?.message || '模型调用中断')
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
modelStatus.ok = true
|
|
44
|
+
modelStatus.error = null
|
|
45
|
+
return { text, model: info.label }
|
|
46
|
+
} catch (err) {
|
|
47
|
+
modelStatus.ok = false
|
|
48
|
+
modelStatus.error = err.message
|
|
49
|
+
throw err
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function extractJson(text) {
|
|
54
|
+
const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(text)
|
|
55
|
+
const body = fenced ? fenced[1] : text
|
|
56
|
+
const start = body.search(/[{[]/)
|
|
57
|
+
const end = Math.max(body.lastIndexOf('}'), body.lastIndexOf(']'))
|
|
58
|
+
if (start < 0 || end < start) throw new Error('模型没有返回 JSON')
|
|
59
|
+
return JSON.parse(body.slice(start, end + 1))
|
|
60
|
+
}
|
package/src/settings.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// 插件设置:接口地址、模型、密钥。密钥只留本机,返回浏览器时永远脱敏。
|
|
2
|
+
import { chmod, readFile, rename, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
export const IMAGE_PRESETS = [
|
|
6
|
+
{ id: 'openai', name: 'OpenAI', baseUrl: 'https://api.openai.com/v1', model: 'gpt-image-1', note: 'gpt-image-1 / dall-e-3' },
|
|
7
|
+
{ id: 'siliconflow', name: '硅基流动', baseUrl: 'https://api.siliconflow.cn/v1', model: 'Kwai-Kolors/Kolors', note: 'Kolors 等图像模型' },
|
|
8
|
+
{ id: 'zhipu', name: '智谱 BigModel', baseUrl: 'https://open.bigmodel.cn/api/paas/v4', model: 'cogview-3-flash', note: 'CogView 系列' },
|
|
9
|
+
{ id: 'ark', name: '火山方舟', baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', model: 'doubao-seedream-4-0-250828', note: 'Seedream 系列' },
|
|
10
|
+
{ id: 'custom', name: '自定义', baseUrl: '', model: '', note: '任何兼容 /images/generations 的服务' },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
export const VIDEO_PRESETS = [
|
|
14
|
+
{ id: 'openai', name: 'OpenAI Sora', baseUrl: 'https://api.openai.com/v1', model: 'sora-2', note: 'POST /videos,轮询 GET /videos/{id}' },
|
|
15
|
+
{ id: 'custom', name: '自定义', baseUrl: '', model: '', note: '兼容的异步视频接口,可在高级设置里改路径' },
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
const DEFAULTS = {
|
|
19
|
+
image: {
|
|
20
|
+
preset: 'openai',
|
|
21
|
+
baseUrl: '',
|
|
22
|
+
model: '',
|
|
23
|
+
apiKey: '',
|
|
24
|
+
size: '1024x1024',
|
|
25
|
+
quality: '',
|
|
26
|
+
extra: '',
|
|
27
|
+
},
|
|
28
|
+
video: {
|
|
29
|
+
preset: 'openai',
|
|
30
|
+
baseUrl: '',
|
|
31
|
+
model: '',
|
|
32
|
+
apiKey: '',
|
|
33
|
+
createPath: '/videos',
|
|
34
|
+
statusPath: '/videos/{id}',
|
|
35
|
+
contentPath: '/videos/{id}/content',
|
|
36
|
+
imageField: 'image',
|
|
37
|
+
extra: '',
|
|
38
|
+
},
|
|
39
|
+
language: 'auto',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function trim(value, max = 300) {
|
|
43
|
+
return typeof value === 'string' ? value.trim().slice(0, max) : undefined
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizeBaseUrl(value) {
|
|
47
|
+
if (typeof value !== 'string') return undefined
|
|
48
|
+
const url = value.trim().replace(/\/+$/, '').replace(/\/(images\/generations|images\/edits|videos)$/, '')
|
|
49
|
+
if (url && !/^https?:\/\/[^\s]+$/i.test(url)) {
|
|
50
|
+
const err = new Error('接口地址要以 http:// 或 https:// 开头')
|
|
51
|
+
err.status = 400
|
|
52
|
+
throw err
|
|
53
|
+
}
|
|
54
|
+
return url
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function normalizePath(value, fallback, max = 200) {
|
|
58
|
+
const text = trim(value, max)
|
|
59
|
+
if (text === undefined) return undefined
|
|
60
|
+
if (!text) return fallback
|
|
61
|
+
if (!text.startsWith('/')) {
|
|
62
|
+
const err = new Error('接口路径要以 / 开头')
|
|
63
|
+
err.status = 400
|
|
64
|
+
throw err
|
|
65
|
+
}
|
|
66
|
+
return text
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function normalizeExtra(value) {
|
|
70
|
+
if (value === undefined) return undefined
|
|
71
|
+
if (typeof value !== 'string') {
|
|
72
|
+
const err = new Error('附加参数要是 JSON 文本')
|
|
73
|
+
err.status = 400
|
|
74
|
+
throw err
|
|
75
|
+
}
|
|
76
|
+
const text = value.trim()
|
|
77
|
+
if (!text) return ''
|
|
78
|
+
let parsed
|
|
79
|
+
try {
|
|
80
|
+
parsed = JSON.parse(text)
|
|
81
|
+
} catch {
|
|
82
|
+
const err = new Error('附加参数不是有效的 JSON')
|
|
83
|
+
err.status = 400
|
|
84
|
+
throw err
|
|
85
|
+
}
|
|
86
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
87
|
+
const err = new Error('附加参数要是一个 JSON 对象')
|
|
88
|
+
err.status = 400
|
|
89
|
+
throw err
|
|
90
|
+
}
|
|
91
|
+
return text
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export class Settings {
|
|
95
|
+
constructor(root) {
|
|
96
|
+
this.file = join(root, 'settings.json')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async read() {
|
|
100
|
+
try {
|
|
101
|
+
const saved = JSON.parse(await readFile(this.file, 'utf8'))
|
|
102
|
+
return {
|
|
103
|
+
...DEFAULTS,
|
|
104
|
+
...saved,
|
|
105
|
+
image: { ...DEFAULTS.image, ...(saved.image ?? {}) },
|
|
106
|
+
video: { ...DEFAULTS.video, ...(saved.video ?? {}) },
|
|
107
|
+
}
|
|
108
|
+
} catch (err) {
|
|
109
|
+
if (err.code !== 'ENOENT') throw err
|
|
110
|
+
return structuredClone(DEFAULTS)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async update(patch = {}) {
|
|
115
|
+
const current = await this.read()
|
|
116
|
+
const next = structuredClone(current)
|
|
117
|
+
const image = patch.image ?? {}
|
|
118
|
+
for (const key of ['preset', 'model', 'size', 'quality']) {
|
|
119
|
+
const value = trim(image[key], 200)
|
|
120
|
+
if (value !== undefined) next.image[key] = value
|
|
121
|
+
}
|
|
122
|
+
const imageUrl = normalizeBaseUrl(image.baseUrl)
|
|
123
|
+
if (imageUrl !== undefined) next.image.baseUrl = imageUrl
|
|
124
|
+
const imageExtra = normalizeExtra(image.extra)
|
|
125
|
+
if (imageExtra !== undefined) next.image.extra = imageExtra
|
|
126
|
+
if (typeof image.apiKey === 'string' && image.apiKey.trim()) next.image.apiKey = image.apiKey.trim().slice(0, 500)
|
|
127
|
+
if (image.clearKey === true) next.image.apiKey = ''
|
|
128
|
+
|
|
129
|
+
const video = patch.video ?? {}
|
|
130
|
+
for (const key of ['preset', 'model', 'imageField']) {
|
|
131
|
+
const value = trim(video[key], 200)
|
|
132
|
+
if (value !== undefined) next.video[key] = value
|
|
133
|
+
}
|
|
134
|
+
const videoUrl = normalizeBaseUrl(video.baseUrl)
|
|
135
|
+
if (videoUrl !== undefined) next.video.baseUrl = videoUrl
|
|
136
|
+
const createPath = normalizePath(video.createPath, '/videos')
|
|
137
|
+
if (createPath !== undefined) next.video.createPath = createPath
|
|
138
|
+
const statusPath = normalizePath(video.statusPath, '/videos/{id}')
|
|
139
|
+
if (statusPath !== undefined) next.video.statusPath = statusPath
|
|
140
|
+
const contentPath = normalizePath(video.contentPath, '/videos/{id}/content')
|
|
141
|
+
if (contentPath !== undefined) next.video.contentPath = contentPath
|
|
142
|
+
const videoExtra = normalizeExtra(video.extra)
|
|
143
|
+
if (videoExtra !== undefined) next.video.extra = videoExtra
|
|
144
|
+
if (typeof video.apiKey === 'string' && video.apiKey.trim()) next.video.apiKey = video.apiKey.trim().slice(0, 500)
|
|
145
|
+
if (video.clearKey === true) next.video.apiKey = ''
|
|
146
|
+
|
|
147
|
+
if (['auto', 'zh', 'en'].includes(patch.language)) next.language = patch.language
|
|
148
|
+
|
|
149
|
+
const tmp = `${this.file}.${process.pid}.tmp`
|
|
150
|
+
await writeFile(tmp, JSON.stringify(next, null, 2), { mode: 0o600 })
|
|
151
|
+
await chmod(tmp, 0o600)
|
|
152
|
+
await rename(tmp, this.file)
|
|
153
|
+
return next
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function imageKey(settings) {
|
|
158
|
+
return settings.image.apiKey || process.env.MEDIA_WORKBENCH_IMAGE_API_KEY || ''
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function videoKey(settings) {
|
|
162
|
+
return settings.video.apiKey || process.env.MEDIA_WORKBENCH_VIDEO_API_KEY || ''
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function imageReady(settings) {
|
|
166
|
+
return Boolean(settings.image.baseUrl && settings.image.model && imageKey(settings))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function videoReady(settings) {
|
|
170
|
+
return Boolean(settings.video.baseUrl && settings.video.model && videoKey(settings))
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function publicSettings(settings) {
|
|
174
|
+
const image = { ...settings.image }
|
|
175
|
+
const video = { ...settings.video }
|
|
176
|
+
const imageHint = image.apiKey ? image.apiKey.slice(-4) : ''
|
|
177
|
+
const videoHint = video.apiKey ? video.apiKey.slice(-4) : ''
|
|
178
|
+
delete image.apiKey
|
|
179
|
+
delete video.apiKey
|
|
180
|
+
return {
|
|
181
|
+
...settings,
|
|
182
|
+
image: { ...image, hasKey: Boolean(imageKey(settings)), keyHint: imageHint },
|
|
183
|
+
video: { ...video, hasKey: Boolean(videoKey(settings)), keyHint: videoHint },
|
|
184
|
+
imageReady: imageReady(settings),
|
|
185
|
+
videoReady: videoReady(settings),
|
|
186
|
+
}
|
|
187
|
+
}
|
package/src/store.js
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// 本机持久化:$DSH_HOME/media-workbench-modern/state.json
|
|
2
|
+
import { chmod, cp, mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { templateGraph } from './graph.js'
|
|
6
|
+
|
|
7
|
+
export const STATE_VERSION = 1
|
|
8
|
+
|
|
9
|
+
export function dshHome(env = process.env) {
|
|
10
|
+
return env.DSH_HOME || join(homedir(), '.dsh')
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** 改名前的旧目录,只用于一次性迁移 */
|
|
14
|
+
export function legacyRoot(env = process.env) {
|
|
15
|
+
return join(dshHome(env), 'levelup-workbench')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 从旧插件目录复制一次数据(settings / state / files)。
|
|
20
|
+
* 旧目录保留作备份;新目录已经有 state.json 时不再迁移。
|
|
21
|
+
* @returns {Promise<boolean>} 是否执行了迁移
|
|
22
|
+
*/
|
|
23
|
+
export async function migrateLegacyData(root, env = process.env) {
|
|
24
|
+
const legacy = legacyRoot(env)
|
|
25
|
+
if (root === legacy) return false
|
|
26
|
+
try {
|
|
27
|
+
await stat(join(root, 'state.json'))
|
|
28
|
+
return false
|
|
29
|
+
} catch (err) {
|
|
30
|
+
if (err.code !== 'ENOENT') throw err
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
await stat(join(legacy, 'state.json'))
|
|
34
|
+
} catch (err) {
|
|
35
|
+
if (err.code === 'ENOENT') return false
|
|
36
|
+
throw err
|
|
37
|
+
}
|
|
38
|
+
await mkdir(root, { recursive: true })
|
|
39
|
+
// 先素材、再设置、最后 state:中途失败时下次还能重试
|
|
40
|
+
for (const name of ['files', 'settings.json', 'state.json']) {
|
|
41
|
+
const target = join(root, name)
|
|
42
|
+
try {
|
|
43
|
+
await stat(target)
|
|
44
|
+
continue
|
|
45
|
+
} catch (err) {
|
|
46
|
+
if (err.code !== 'ENOENT') throw err
|
|
47
|
+
}
|
|
48
|
+
await cp(join(legacy, name), target, { recursive: true, force: false, errorOnExist: false })
|
|
49
|
+
}
|
|
50
|
+
await writeFile(join(root, '.migrated-from'), `${legacy}\n`, { mode: 0o600 })
|
|
51
|
+
await chmod(join(root, 'settings.json'), 0o600).catch(() => {})
|
|
52
|
+
return true
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const WELCOME = `写作不是一次成形的。
|
|
56
|
+
|
|
57
|
+
先把脑子里的画面写下来,不用管顺序;等它有了轮廓,再回头删掉多余的解释。留下动作、声音、光线,和人物没有说出口的那句话。
|
|
58
|
+
|
|
59
|
+
这一段可以随便改。选中文字,用右侧的「润色」或「改写」;想往下写,就点「续写」。`
|
|
60
|
+
|
|
61
|
+
function seed() {
|
|
62
|
+
const now = new Date().toISOString()
|
|
63
|
+
return {
|
|
64
|
+
version: STATE_VERSION,
|
|
65
|
+
images: [],
|
|
66
|
+
videos: [],
|
|
67
|
+
docs: [
|
|
68
|
+
{
|
|
69
|
+
id: 'doc_welcome',
|
|
70
|
+
title: '欢迎来到写作工作台',
|
|
71
|
+
content: WELCOME,
|
|
72
|
+
outline: '',
|
|
73
|
+
wordGoal: 1200,
|
|
74
|
+
createdAt: now,
|
|
75
|
+
updatedAt: now,
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
graphs: [templateGraph('inspiration')],
|
|
79
|
+
blueprints: [],
|
|
80
|
+
preferences: {
|
|
81
|
+
imageStyle: '电影感',
|
|
82
|
+
imageRatio: '16:9',
|
|
83
|
+
imageCount: 1,
|
|
84
|
+
videoMode: 'text',
|
|
85
|
+
videoRatio: '16:9',
|
|
86
|
+
videoDuration: 5,
|
|
87
|
+
writingTone: '自然叙述',
|
|
88
|
+
writingPov: '第三人称',
|
|
89
|
+
writingLength: 'medium',
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function obj(value) {
|
|
95
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function list(value, max) {
|
|
99
|
+
return Array.isArray(value) ? value.filter(row => row && obj(row) && typeof row.id === 'string').slice(0, max) : []
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function normalizeState(input) {
|
|
103
|
+
const state = obj(input)
|
|
104
|
+
const base = seed()
|
|
105
|
+
const preferences = { ...base.preferences, ...obj(state.preferences) }
|
|
106
|
+
const graphs = list(state.graphs, 40).map(g => ({
|
|
107
|
+
...g,
|
|
108
|
+
name: typeof g.name === 'string' ? g.name : '未命名星图',
|
|
109
|
+
nodes: Array.isArray(g.nodes) ? g.nodes : [],
|
|
110
|
+
edges: Array.isArray(g.edges) ? g.edges : [],
|
|
111
|
+
updatedAt: g.updatedAt ?? new Date().toISOString(),
|
|
112
|
+
}))
|
|
113
|
+
return {
|
|
114
|
+
version: STATE_VERSION,
|
|
115
|
+
images: list(state.images, 300).map(normalizeImage),
|
|
116
|
+
videos: list(state.videos, 150).map(normalizeVideo),
|
|
117
|
+
docs: list(state.docs, 200).map(normalizeDoc),
|
|
118
|
+
graphs: graphs.length ? graphs : base.graphs,
|
|
119
|
+
blueprints: Array.isArray(state.blueprints) ? state.blueprints.slice(0, 60) : [],
|
|
120
|
+
preferences,
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function normalizeImage(row) {
|
|
125
|
+
const status = ['queued', 'running', 'done', 'error'].includes(row.status) ? row.status : 'done'
|
|
126
|
+
return {
|
|
127
|
+
id: row.id,
|
|
128
|
+
prompt: String(row.prompt ?? ''),
|
|
129
|
+
negative: String(row.negative ?? ''),
|
|
130
|
+
style: String(row.style ?? ''),
|
|
131
|
+
model: String(row.model ?? ''),
|
|
132
|
+
provider: String(row.provider ?? ''),
|
|
133
|
+
size: String(row.size ?? ''),
|
|
134
|
+
reference: String(row.reference ?? ''),
|
|
135
|
+
file: String(row.file ?? ''),
|
|
136
|
+
width: Number(row.width) || 0,
|
|
137
|
+
height: Number(row.height) || 0,
|
|
138
|
+
status,
|
|
139
|
+
error: String(row.error ?? ''),
|
|
140
|
+
favorite: Boolean(row.favorite),
|
|
141
|
+
createdAt: row.createdAt ?? new Date().toISOString(),
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function normalizeVideo(row) {
|
|
146
|
+
const status = ['queued', 'running', 'done', 'error'].includes(row.status) ? row.status : 'done'
|
|
147
|
+
return {
|
|
148
|
+
id: row.id,
|
|
149
|
+
prompt: String(row.prompt ?? ''),
|
|
150
|
+
mode: ['text', 'image', 'storyboard'].includes(row.mode) ? row.mode : 'text',
|
|
151
|
+
model: String(row.model ?? ''),
|
|
152
|
+
aspect: String(row.aspect ?? '16:9'),
|
|
153
|
+
duration: Number(row.duration) || 5,
|
|
154
|
+
reference: String(row.reference ?? ''),
|
|
155
|
+
file: String(row.file ?? ''),
|
|
156
|
+
poster: String(row.poster ?? ''),
|
|
157
|
+
frames: Array.isArray(row.frames) ? row.frames.filter(f => typeof f === 'string').slice(0, 8) : [],
|
|
158
|
+
progress: Math.max(0, Math.min(100, Number(row.progress) || 0)),
|
|
159
|
+
status,
|
|
160
|
+
error: String(row.error ?? ''),
|
|
161
|
+
createdAt: row.createdAt ?? new Date().toISOString(),
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function normalizeDoc(row) {
|
|
166
|
+
return {
|
|
167
|
+
id: row.id,
|
|
168
|
+
title: String(row.title ?? '未命名作品'),
|
|
169
|
+
content: String(row.content ?? ''),
|
|
170
|
+
outline: String(row.outline ?? ''),
|
|
171
|
+
wordGoal: Math.max(0, Number(row.wordGoal) || 0),
|
|
172
|
+
createdAt: row.createdAt ?? new Date().toISOString(),
|
|
173
|
+
updatedAt: row.updatedAt ?? new Date().toISOString(),
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export class Store {
|
|
178
|
+
constructor(root = join(dshHome(), 'media-workbench-modern')) {
|
|
179
|
+
this.root = root
|
|
180
|
+
this.file = join(root, 'state.json')
|
|
181
|
+
this.filesDir = join(root, 'files')
|
|
182
|
+
this.state = null
|
|
183
|
+
this.mtimeMs = 0
|
|
184
|
+
this.queue = Promise.resolve()
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async refresh() {
|
|
188
|
+
await mkdir(this.filesDir, { recursive: true })
|
|
189
|
+
let info = null
|
|
190
|
+
try {
|
|
191
|
+
info = await stat(this.file)
|
|
192
|
+
} catch (err) {
|
|
193
|
+
if (err.code !== 'ENOENT') throw err
|
|
194
|
+
}
|
|
195
|
+
if (!info) {
|
|
196
|
+
if (!this.state) {
|
|
197
|
+
this.state = seed()
|
|
198
|
+
await this.persist()
|
|
199
|
+
}
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
if (this.state && info.mtimeMs === this.mtimeMs) return
|
|
203
|
+
try {
|
|
204
|
+
this.state = normalizeState(JSON.parse(await readFile(this.file, 'utf8')))
|
|
205
|
+
} catch {
|
|
206
|
+
// 文件坏了不覆盖用户数据:另存一份,再从空状态开始
|
|
207
|
+
const broken = `${this.file}.broken-${Date.now()}`
|
|
208
|
+
await rename(this.file, broken).catch(() => {})
|
|
209
|
+
this.state = seed()
|
|
210
|
+
await this.persist()
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
this.mtimeMs = info.mtimeMs
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async persist() {
|
|
217
|
+
const tmp = `${this.file}.${process.pid}.tmp`
|
|
218
|
+
await writeFile(tmp, JSON.stringify(this.state, null, 2))
|
|
219
|
+
await rename(tmp, this.file)
|
|
220
|
+
this.mtimeMs = (await stat(this.file)).mtimeMs
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
run(task) {
|
|
224
|
+
const next = this.queue.then(task, task)
|
|
225
|
+
this.queue = next.catch(() => {})
|
|
226
|
+
return next
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
read() {
|
|
230
|
+
return this.run(async () => {
|
|
231
|
+
await this.refresh()
|
|
232
|
+
return this.state
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
mutate(fn) {
|
|
237
|
+
return this.run(async () => {
|
|
238
|
+
await this.refresh()
|
|
239
|
+
const result = await fn(this.state)
|
|
240
|
+
await this.persist()
|
|
241
|
+
return result
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** 进程重启后,把没跑完的记录标成中断,避免界面一直转圈 */
|
|
246
|
+
settle() {
|
|
247
|
+
return this.mutate(state => {
|
|
248
|
+
let changed = 0
|
|
249
|
+
for (const row of state.images) {
|
|
250
|
+
if (row.status === 'queued' || row.status === 'running') {
|
|
251
|
+
row.status = 'error'
|
|
252
|
+
row.error = '上次生成被中断,可以重试'
|
|
253
|
+
changed++
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
for (const row of state.videos) {
|
|
257
|
+
if (row.status === 'queued' || row.status === 'running') {
|
|
258
|
+
row.status = 'error'
|
|
259
|
+
row.error = '上次生成被中断,可以重试'
|
|
260
|
+
changed++
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
for (const graph of state.graphs) {
|
|
264
|
+
if (graph.run?.status === 'running' || graph.run?.status === 'queued') {
|
|
265
|
+
graph.run.status = 'error'
|
|
266
|
+
graph.run.error = '上次运行被中断'
|
|
267
|
+
for (const node of graph.nodes) if (node.status === 'running') { node.status = 'error'; node.error = '上次运行被中断' }
|
|
268
|
+
changed++
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return changed
|
|
272
|
+
})
|
|
273
|
+
}
|
|
274
|
+
}
|