@exccoder/dsh-media-workbench 0.0.0-stage → 0.2.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/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
+ }
@@ -0,0 +1,233 @@
1
+ // 插件设置:接口地址、模型、密钥。密钥只留本机,返回浏览器时永远脱敏。
2
+ import { access, chmod, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
3
+ import { constants } from 'node:fs'
4
+ import { join } from 'node:path'
5
+
6
+ export const IMAGE_PRESETS = [
7
+ { id: 'openai', name: 'OpenAI', baseUrl: 'https://api.openai.com/v1', model: 'gpt-image-1', note: 'gpt-image-1 / dall-e-3' },
8
+ { id: 'siliconflow', name: '硅基流动', baseUrl: 'https://api.siliconflow.cn/v1', model: 'Kwai-Kolors/Kolors', note: 'Kolors 等图像模型' },
9
+ { id: 'zhipu', name: '智谱 BigModel', baseUrl: 'https://open.bigmodel.cn/api/paas/v4', model: 'cogview-3-flash', note: 'CogView 系列' },
10
+ { id: 'ark', name: '火山方舟', baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', model: 'doubao-seedream-4-0-250828', note: 'Seedream 系列' },
11
+ { id: 'custom', name: '自定义', baseUrl: '', model: '', note: '任何兼容 /images/generations 的服务' },
12
+ ]
13
+
14
+ export const VIDEO_PRESETS = [
15
+ { id: 'openai', name: 'OpenAI Sora', baseUrl: 'https://api.openai.com/v1', model: 'sora-2', note: 'POST /videos,轮询 GET /videos/{id}' },
16
+ { id: 'custom', name: '自定义', baseUrl: '', model: '', note: '兼容的异步视频接口,可在高级设置里改路径' },
17
+ ]
18
+
19
+ const DEFAULTS = {
20
+ image: {
21
+ preset: 'openai',
22
+ baseUrl: '',
23
+ model: '',
24
+ apiKey: '',
25
+ size: '1024x1024',
26
+ quality: '',
27
+ extra: '',
28
+ },
29
+ video: {
30
+ preset: 'openai',
31
+ baseUrl: '',
32
+ model: '',
33
+ apiKey: '',
34
+ createPath: '/videos',
35
+ statusPath: '/videos/{id}',
36
+ contentPath: '/videos/{id}/content',
37
+ imageField: 'image',
38
+ extra: '',
39
+ },
40
+ language: 'auto',
41
+ storage: {
42
+ outputDir: '',
43
+ },
44
+ }
45
+
46
+ function trim(value, max = 300) {
47
+ return typeof value === 'string' ? value.trim().slice(0, max) : undefined
48
+ }
49
+
50
+ function normalizeBaseUrl(value) {
51
+ if (typeof value !== 'string') return undefined
52
+ const url = value.trim().replace(/\/+$/, '').replace(/\/(images\/generations|images\/edits|videos)$/, '')
53
+ if (url && !/^https?:\/\/[^\s]+$/i.test(url)) {
54
+ const err = new Error('接口地址要以 http:// 或 https:// 开头')
55
+ err.status = 400
56
+ throw err
57
+ }
58
+ return url
59
+ }
60
+
61
+ function normalizePath(value, fallback, max = 200) {
62
+ const text = trim(value, max)
63
+ if (text === undefined) return undefined
64
+ if (!text) return fallback
65
+ if (!text.startsWith('/')) {
66
+ const err = new Error('接口路径要以 / 开头')
67
+ err.status = 400
68
+ throw err
69
+ }
70
+ return text
71
+ }
72
+
73
+ function normalizeOutputDir(value) {
74
+ if (value === undefined) return undefined
75
+ if (typeof value !== 'string') {
76
+ const err = new Error('产物目录要是文本路径')
77
+ err.status = 400
78
+ throw err
79
+ }
80
+ let dir = value.trim()
81
+ if (/^[a-zA-Z]:[\\/]?$/.test(dir)) {
82
+ dir = `${dir.slice(0, 2)}\\`
83
+ } else {
84
+ dir = dir.replace(/[\\/]+$/, '')
85
+ }
86
+ if (!dir) return ''
87
+ if (!/^(?:[a-zA-Z]:[\\/]|\/)/.test(dir)) {
88
+ const err = new Error('产物目录要填绝对路径,例如 D:\\AI作品')
89
+ err.status = 400
90
+ throw err
91
+ }
92
+ return dir
93
+ }
94
+
95
+ function normalizeExtra(value) {
96
+ if (value === undefined) return undefined
97
+ if (typeof value !== 'string') {
98
+ const err = new Error('附加参数要是 JSON 文本')
99
+ err.status = 400
100
+ throw err
101
+ }
102
+ const text = value.trim()
103
+ if (!text) return ''
104
+ let parsed
105
+ try {
106
+ parsed = JSON.parse(text)
107
+ } catch {
108
+ const err = new Error('附加参数不是有效的 JSON')
109
+ err.status = 400
110
+ throw err
111
+ }
112
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
113
+ const err = new Error('附加参数要是一个 JSON 对象')
114
+ err.status = 400
115
+ throw err
116
+ }
117
+ return text
118
+ }
119
+
120
+ export class Settings {
121
+ constructor(root) {
122
+ this.file = join(root, 'settings.json')
123
+ }
124
+
125
+ async read() {
126
+ try {
127
+ const saved = JSON.parse(await readFile(this.file, 'utf8'))
128
+ return {
129
+ ...DEFAULTS,
130
+ ...saved,
131
+ image: { ...DEFAULTS.image, ...(saved.image ?? {}) },
132
+ video: { ...DEFAULTS.video, ...(saved.video ?? {}) },
133
+ storage: { ...DEFAULTS.storage, ...(saved.storage ?? {}) },
134
+ }
135
+ } catch (err) {
136
+ if (err.code !== 'ENOENT') throw err
137
+ return structuredClone(DEFAULTS)
138
+ }
139
+ }
140
+
141
+ async update(patch = {}) {
142
+ const current = await this.read()
143
+ const next = structuredClone(current)
144
+ const image = patch.image ?? {}
145
+ for (const key of ['preset', 'model', 'size', 'quality']) {
146
+ const value = trim(image[key], 200)
147
+ if (value !== undefined) next.image[key] = value
148
+ }
149
+ const imageUrl = normalizeBaseUrl(image.baseUrl)
150
+ if (imageUrl !== undefined) next.image.baseUrl = imageUrl
151
+ const imageExtra = normalizeExtra(image.extra)
152
+ if (imageExtra !== undefined) next.image.extra = imageExtra
153
+ if (typeof image.apiKey === 'string' && image.apiKey.trim()) next.image.apiKey = image.apiKey.trim().slice(0, 500)
154
+ if (image.clearKey === true) next.image.apiKey = ''
155
+
156
+ const video = patch.video ?? {}
157
+ for (const key of ['preset', 'model', 'imageField']) {
158
+ const value = trim(video[key], 200)
159
+ if (value !== undefined) next.video[key] = value
160
+ }
161
+ const videoUrl = normalizeBaseUrl(video.baseUrl)
162
+ if (videoUrl !== undefined) next.video.baseUrl = videoUrl
163
+ const createPath = normalizePath(video.createPath, '/videos')
164
+ if (createPath !== undefined) next.video.createPath = createPath
165
+ const statusPath = normalizePath(video.statusPath, '/videos/{id}')
166
+ if (statusPath !== undefined) next.video.statusPath = statusPath
167
+ const contentPath = normalizePath(video.contentPath, '/videos/{id}/content')
168
+ if (contentPath !== undefined) next.video.contentPath = contentPath
169
+ const videoExtra = normalizeExtra(video.extra)
170
+ if (videoExtra !== undefined) next.video.extra = videoExtra
171
+ if (typeof video.apiKey === 'string' && video.apiKey.trim()) next.video.apiKey = video.apiKey.trim().slice(0, 500)
172
+ if (video.clearKey === true) next.video.apiKey = ''
173
+
174
+ const storage = patch.storage ?? {}
175
+ const outputDir = normalizeOutputDir(storage.outputDir)
176
+ if (outputDir !== undefined) {
177
+ if (outputDir) {
178
+ try {
179
+ await mkdir(outputDir, { recursive: true })
180
+ await access(outputDir, constants.W_OK)
181
+ const probe = join(outputDir, `.media-workbench-write-test-${process.pid}`)
182
+ await writeFile(probe, '')
183
+ await unlink(probe).catch(() => {})
184
+ } catch {
185
+ const err = new Error('产物目录不可写,换一个目录')
186
+ err.status = 400
187
+ throw err
188
+ }
189
+ }
190
+ next.storage.outputDir = outputDir
191
+ }
192
+
193
+ if (['auto', 'zh', 'en'].includes(patch.language)) next.language = patch.language
194
+
195
+ const tmp = `${this.file}.${process.pid}.tmp`
196
+ await writeFile(tmp, JSON.stringify(next, null, 2), { mode: 0o600 })
197
+ await chmod(tmp, 0o600)
198
+ await rename(tmp, this.file)
199
+ return next
200
+ }
201
+ }
202
+
203
+ export function imageKey(settings) {
204
+ return settings.image.apiKey || process.env.MEDIA_WORKBENCH_IMAGE_API_KEY || ''
205
+ }
206
+
207
+ export function videoKey(settings) {
208
+ return settings.video.apiKey || process.env.MEDIA_WORKBENCH_VIDEO_API_KEY || ''
209
+ }
210
+
211
+ export function imageReady(settings) {
212
+ return Boolean(settings.image.baseUrl && settings.image.model && imageKey(settings))
213
+ }
214
+
215
+ export function videoReady(settings) {
216
+ return Boolean(settings.video.baseUrl && settings.video.model && videoKey(settings))
217
+ }
218
+
219
+ export function publicSettings(settings) {
220
+ const image = { ...settings.image }
221
+ const video = { ...settings.video }
222
+ const imageHint = image.apiKey ? image.apiKey.slice(-4) : ''
223
+ const videoHint = video.apiKey ? video.apiKey.slice(-4) : ''
224
+ delete image.apiKey
225
+ delete video.apiKey
226
+ return {
227
+ ...settings,
228
+ image: { ...image, hasKey: Boolean(imageKey(settings)), keyHint: imageHint },
229
+ video: { ...video, hasKey: Boolean(videoKey(settings)), keyHint: videoHint },
230
+ imageReady: imageReady(settings),
231
+ videoReady: videoReady(settings),
232
+ }
233
+ }
package/src/store.js ADDED
@@ -0,0 +1,241 @@
1
+ // 本机持久化:$DSH_HOME/media-workbench-modern/state.json
2
+ import { 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
+ const WELCOME = `写作不是一次成形的。
14
+
15
+ 先把脑子里的画面写下来,不用管顺序;等它有了轮廓,再回头删掉多余的解释。留下动作、声音、光线,和人物没有说出口的那句话。
16
+
17
+ 这一段可以随便改。选中文字,用右侧的「润色」或「改写」;想往下写,就点「续写」。`
18
+
19
+ function seed() {
20
+ const now = new Date().toISOString()
21
+ return {
22
+ version: STATE_VERSION,
23
+ images: [],
24
+ videos: [],
25
+ docs: [
26
+ {
27
+ id: 'doc_welcome',
28
+ title: '欢迎来到写作工作台',
29
+ content: WELCOME,
30
+ outline: '',
31
+ wordGoal: 1200,
32
+ createdAt: now,
33
+ updatedAt: now,
34
+ },
35
+ ],
36
+ graphs: [templateGraph('inspiration')],
37
+ blueprints: [],
38
+ preferences: {
39
+ imageStyle: '电影感',
40
+ imageRatio: '16:9',
41
+ imageCount: 1,
42
+ videoMode: 'text',
43
+ videoRatio: '16:9',
44
+ videoDuration: 5,
45
+ writingTone: '自然叙述',
46
+ writingPov: '第三人称',
47
+ writingLength: 'medium',
48
+ },
49
+ }
50
+ }
51
+
52
+ function obj(value) {
53
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
54
+ }
55
+
56
+ function list(value, max) {
57
+ return Array.isArray(value) ? value.filter(row => row && obj(row) && typeof row.id === 'string').slice(0, max) : []
58
+ }
59
+
60
+ export function normalizeState(input) {
61
+ const state = obj(input)
62
+ const base = seed()
63
+ const preferences = { ...base.preferences, ...obj(state.preferences) }
64
+ const graphs = list(state.graphs, 40).map(g => ({
65
+ ...g,
66
+ name: typeof g.name === 'string' ? g.name : '未命名星图',
67
+ nodes: Array.isArray(g.nodes) ? g.nodes : [],
68
+ edges: Array.isArray(g.edges) ? g.edges : [],
69
+ updatedAt: g.updatedAt ?? new Date().toISOString(),
70
+ }))
71
+ return {
72
+ version: STATE_VERSION,
73
+ images: list(state.images, 300).map(normalizeImage),
74
+ videos: list(state.videos, 150).map(normalizeVideo),
75
+ docs: list(state.docs, 200).map(normalizeDoc),
76
+ graphs: graphs.length ? graphs : base.graphs,
77
+ blueprints: Array.isArray(state.blueprints) ? state.blueprints.slice(0, 60) : [],
78
+ preferences,
79
+ }
80
+ }
81
+
82
+ function normalizeImage(row) {
83
+ const status = ['queued', 'running', 'done', 'error'].includes(row.status) ? row.status : 'done'
84
+ const references = (Array.isArray(row.references) ? row.references : row.reference ? [row.reference] : [])
85
+ .filter(name => typeof name === 'string' && name)
86
+ .slice(0, 8)
87
+ return {
88
+ id: row.id,
89
+ prompt: String(row.prompt ?? ''),
90
+ negative: String(row.negative ?? ''),
91
+ style: String(row.style ?? ''),
92
+ model: String(row.model ?? ''),
93
+ provider: String(row.provider ?? ''),
94
+ size: String(row.size ?? ''),
95
+ reference: String(row.reference ?? references[0] ?? ''),
96
+ references,
97
+ file: String(row.file ?? ''),
98
+ width: Number(row.width) || 0,
99
+ height: Number(row.height) || 0,
100
+ status,
101
+ error: String(row.error ?? ''),
102
+ favorite: Boolean(row.favorite),
103
+ createdAt: row.createdAt ?? new Date().toISOString(),
104
+ }
105
+ }
106
+
107
+ function normalizeVideo(row) {
108
+ const status = ['queued', 'running', 'done', 'error'].includes(row.status) ? row.status : 'done'
109
+ return {
110
+ id: row.id,
111
+ prompt: String(row.prompt ?? ''),
112
+ mode: ['text', 'image', 'storyboard'].includes(row.mode) ? row.mode : 'text',
113
+ model: String(row.model ?? ''),
114
+ aspect: String(row.aspect ?? '16:9'),
115
+ duration: Number(row.duration) || 5,
116
+ reference: String(row.reference ?? ''),
117
+ file: String(row.file ?? ''),
118
+ poster: String(row.poster ?? ''),
119
+ frames: Array.isArray(row.frames) ? row.frames.filter(f => typeof f === 'string').slice(0, 8) : [],
120
+ progress: Math.max(0, Math.min(100, Number(row.progress) || 0)),
121
+ status,
122
+ error: String(row.error ?? ''),
123
+ createdAt: row.createdAt ?? new Date().toISOString(),
124
+ }
125
+ }
126
+
127
+ function normalizeDoc(row) {
128
+ return {
129
+ id: row.id,
130
+ title: String(row.title ?? '未命名作品'),
131
+ content: String(row.content ?? ''),
132
+ outline: String(row.outline ?? ''),
133
+ wordGoal: Math.max(0, Number(row.wordGoal) || 0),
134
+ createdAt: row.createdAt ?? new Date().toISOString(),
135
+ updatedAt: row.updatedAt ?? new Date().toISOString(),
136
+ }
137
+ }
138
+
139
+ export class Store {
140
+ constructor(root = join(dshHome(), 'media-workbench-modern')) {
141
+ this.root = root
142
+ this.file = join(root, 'state.json')
143
+ this.filesDir = join(root, 'files')
144
+ this.state = null
145
+ this.mtimeMs = 0
146
+ this.queue = Promise.resolve()
147
+ }
148
+
149
+ async useFilesDir(dir) {
150
+ this.filesDir = dir
151
+ await mkdir(dir, { recursive: true })
152
+ }
153
+
154
+ async refresh() {
155
+ await mkdir(this.filesDir, { recursive: true })
156
+ let info = null
157
+ try {
158
+ info = await stat(this.file)
159
+ } catch (err) {
160
+ if (err.code !== 'ENOENT') throw err
161
+ }
162
+ if (!info) {
163
+ if (!this.state) {
164
+ this.state = seed()
165
+ await this.persist()
166
+ }
167
+ return
168
+ }
169
+ if (this.state && info.mtimeMs === this.mtimeMs) return
170
+ try {
171
+ this.state = normalizeState(JSON.parse(await readFile(this.file, 'utf8')))
172
+ } catch {
173
+ // 文件坏了不覆盖用户数据:另存一份,再从空状态开始
174
+ const broken = `${this.file}.broken-${Date.now()}`
175
+ await rename(this.file, broken).catch(() => {})
176
+ this.state = seed()
177
+ await this.persist()
178
+ return
179
+ }
180
+ this.mtimeMs = info.mtimeMs
181
+ }
182
+
183
+ async persist() {
184
+ const tmp = `${this.file}.${process.pid}.tmp`
185
+ await writeFile(tmp, JSON.stringify(this.state, null, 2))
186
+ await rename(tmp, this.file)
187
+ this.mtimeMs = (await stat(this.file)).mtimeMs
188
+ }
189
+
190
+ run(task) {
191
+ const next = this.queue.then(task, task)
192
+ this.queue = next.catch(() => {})
193
+ return next
194
+ }
195
+
196
+ read() {
197
+ return this.run(async () => {
198
+ await this.refresh()
199
+ return this.state
200
+ })
201
+ }
202
+
203
+ mutate(fn) {
204
+ return this.run(async () => {
205
+ await this.refresh()
206
+ const result = await fn(this.state)
207
+ await this.persist()
208
+ return result
209
+ })
210
+ }
211
+
212
+ /** 进程重启后,把没跑完的记录标成中断,避免界面一直转圈 */
213
+ settle() {
214
+ return this.mutate(state => {
215
+ let changed = 0
216
+ for (const row of state.images) {
217
+ if (row.status === 'queued' || row.status === 'running') {
218
+ row.status = 'error'
219
+ row.error = '上次生成被中断,可以重试'
220
+ changed++
221
+ }
222
+ }
223
+ for (const row of state.videos) {
224
+ if (row.status === 'queued' || row.status === 'running') {
225
+ row.status = 'error'
226
+ row.error = '上次生成被中断,可以重试'
227
+ changed++
228
+ }
229
+ }
230
+ for (const graph of state.graphs) {
231
+ if (graph.run?.status === 'running' || graph.run?.status === 'queued') {
232
+ graph.run.status = 'error'
233
+ graph.run.error = '上次运行被中断'
234
+ for (const node of graph.nodes) if (node.status === 'running') { node.status = 'error'; node.error = '上次运行被中断' }
235
+ changed++
236
+ }
237
+ }
238
+ return changed
239
+ })
240
+ }
241
+ }