@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/CHANGELOG.md +60 -0
- package/LICENSE +21 -0
- package/README.en.md +108 -0
- package/README.md +138 -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 +138 -0
- package/package.json +79 -4
- package/src/graph.js +304 -0
- package/src/image.js +150 -0
- package/src/index.js +865 -0
- package/src/llm.js +60 -0
- package/src/settings.js +233 -0
- package/src/store.js +241 -0
- package/src/tools.js +354 -0
- package/src/util.js +217 -0
- package/src/video.js +245 -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 +126 -0
- package/web/lib/icons.js +65 -0
- package/web/lib/state.js +42 -0
- package/web/styles.css +1143 -0
- package/web/views/constellation-model.js +63 -0
- package/web/views/constellation.js +1110 -0
- package/web/views/image.js +652 -0
- package/web/views/settings.js +269 -0
- package/web/views/video.js +363 -0
- package/web/views/write.js +513 -0
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
import { api, streamWrite } from '../lib/api.js'
|
|
2
|
+
import { $, $$, debounce, downloadUrl, esc, fmtTime, wordCount } from '../lib/dom.js'
|
|
3
|
+
import { icon } from '../lib/icons.js'
|
|
4
|
+
import { app, t } from '../lib/state.js'
|
|
5
|
+
import { ACTIONS, LENGTHS, POVS, TONES } from '../lib/constants.js'
|
|
6
|
+
|
|
7
|
+
export function mount(root) {
|
|
8
|
+
const prefs = app.state?.preferences ?? {}
|
|
9
|
+
let activeDocId = sessionStorage.getItem('media-workbench-modern:doc') || null
|
|
10
|
+
let tone = prefs.writingTone ?? '自然叙述'
|
|
11
|
+
let pov = prefs.writingPov ?? '第三人称'
|
|
12
|
+
let length = prefs.writingLength ?? 'medium'
|
|
13
|
+
let action = 'continue'
|
|
14
|
+
let query = ''
|
|
15
|
+
let dirty = false
|
|
16
|
+
let saving = null
|
|
17
|
+
let selection = { start: 0, end: 0, text: '' }
|
|
18
|
+
let streamController = null
|
|
19
|
+
let resultText = ''
|
|
20
|
+
let resultLabel = ''
|
|
21
|
+
let history = []
|
|
22
|
+
let focusMode = false
|
|
23
|
+
|
|
24
|
+
root.innerHTML = `
|
|
25
|
+
<section class="bench bench-write" id="write-bench">
|
|
26
|
+
<aside class="docs-pane">
|
|
27
|
+
<header class="docs-head">
|
|
28
|
+
<div><span class="eyebrow">${esc(t('作品库', 'LIBRARY'))}</span><h2>${esc(t('写作工作台', 'Writing Studio'))}</h2></div>
|
|
29
|
+
<button class="icon-btn" id="new-doc" title="${esc(t('新建作品', 'New document'))}">${icon('plus', 17)}</button>
|
|
30
|
+
</header>
|
|
31
|
+
<label class="search search-wide">${icon('search', 15)}<input id="doc-search" type="search" placeholder="${esc(t('搜索标题或正文', 'Search title or text'))}"></label>
|
|
32
|
+
<div class="docs-list" id="docs-list"></div>
|
|
33
|
+
<footer class="docs-foot"><span id="docs-total">0</span> ${esc(t('篇作品 · 自动保存在本机', 'documents, saved locally'))}</footer>
|
|
34
|
+
</aside>
|
|
35
|
+
|
|
36
|
+
<section class="editor-pane">
|
|
37
|
+
<div class="editor-top">
|
|
38
|
+
<input id="doc-title" class="title-input" placeholder="${esc(t('未命名作品', 'Untitled'))}" maxlength="120">
|
|
39
|
+
<div class="editor-meta">
|
|
40
|
+
<span id="word-count">0 ${esc(t('字', 'chars'))}</span>
|
|
41
|
+
<span class="dot-sep"></span>
|
|
42
|
+
<span id="save-state">${esc(t('已保存', 'Saved'))}</span>
|
|
43
|
+
<span class="spacer"></span>
|
|
44
|
+
<button class="text-btn" id="export-doc">${icon('download', 14)}${esc(t('导出', 'Export'))}</button>
|
|
45
|
+
<button class="text-btn" id="focus-mode">${icon('eye', 14)}${esc(t('专注', 'Focus'))}</button>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
<div class="editor-wrap">
|
|
49
|
+
<textarea id="doc-content" spellcheck="false" placeholder="${esc(t('从这里开始写。写到卡住时,看右边。', 'Start here. When you get stuck, look right.'))}"></textarea>
|
|
50
|
+
</div>
|
|
51
|
+
<div class="editor-foot">
|
|
52
|
+
<span id="selection-info">${esc(t('选中文字可以只改这一段', 'Select text to edit just that part'))}</span>
|
|
53
|
+
<span class="editor-hint">${esc(t('自动保存', 'Autosave'))} · ${esc(t('Markdown', 'Markdown'))}</span>
|
|
54
|
+
</div>
|
|
55
|
+
</section>
|
|
56
|
+
|
|
57
|
+
<aside class="ai-pane">
|
|
58
|
+
<header class="ai-head">
|
|
59
|
+
<div><span class="eyebrow">${esc(t('写作搭档', 'AI PARTNER'))}</span><h2>${esc(t('接着写', 'Keep writing'))}</h2></div>
|
|
60
|
+
<span class="model-chip" id="ai-model"></span>
|
|
61
|
+
</header>
|
|
62
|
+
<div class="ai-scroll">
|
|
63
|
+
<div class="ai-section">
|
|
64
|
+
<span class="field-label">${esc(t('想做什么', 'Action'))}</span>
|
|
65
|
+
<div class="ai-actions" id="ai-actions">
|
|
66
|
+
${ACTIONS.map(item => `<button type="button" class="ai-action" data-action="${item.id}"><b>${esc(item.label)}</b><small>${esc(item.hint)}</small></button>`).join('')}
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
<div class="ai-section">
|
|
70
|
+
<span class="field-label">${esc(t('文风', 'Voice'))}</span>
|
|
71
|
+
<div class="chips chips-tight" id="tones">
|
|
72
|
+
${TONES.map(item => `<button type="button" class="chip" data-tone="${esc(item)}">${esc(item)}</button>`).join('')}
|
|
73
|
+
</div>
|
|
74
|
+
<div class="row-2">
|
|
75
|
+
<select class="select" id="pov">${POVS.map(item => `<option value="${esc(item)}" ${item === pov ? 'selected' : ''}>${esc(item)}</option>`).join('')}</select>
|
|
76
|
+
<select class="select" id="length">${LENGTHS.map(item => `<option value="${item.id}" ${item.id === length ? 'selected' : ''}>${esc(item.label)}</option>`).join('')}</select>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
<label class="field">
|
|
80
|
+
<span class="field-label">${esc(t('补充指令', 'Note'))}<em>${esc(t('可选', 'optional'))}</em></span>
|
|
81
|
+
<textarea id="instruction" rows="2" maxlength="600" placeholder="${esc(t('例如:把第三段改得更克制;用第一人称重写', 'e.g. make paragraph three colder; rewrite in first person'))}"></textarea>
|
|
82
|
+
</label>
|
|
83
|
+
<label class="check-line"><input type="checkbox" id="use-selection"> <span>${esc(t('只处理选中文字', 'Only the selection'))}</span></label>
|
|
84
|
+
<button class="btn btn-primary btn-block" id="ai-run">${icon('wandSpark', 16)}<span>${esc(t('开始写', 'Write'))}</span></button>
|
|
85
|
+
|
|
86
|
+
<div class="ai-result" id="ai-result" hidden>
|
|
87
|
+
<div class="ai-result-head">
|
|
88
|
+
<b id="result-label">${esc(t('草稿', 'Draft'))}</b>
|
|
89
|
+
<span class="spacer"></span>
|
|
90
|
+
<button class="text-btn" id="ai-stop" hidden>${icon('stop', 13)}${esc(t('停止', 'Stop'))}</button>
|
|
91
|
+
</div>
|
|
92
|
+
<div class="ai-result-body" id="result-body"></div>
|
|
93
|
+
<div class="ai-result-actions">
|
|
94
|
+
<button class="btn btn-small btn-primary" data-apply="append">${esc(t('追加到正文', 'Append'))}</button>
|
|
95
|
+
<button class="btn btn-small btn-ghost" data-apply="replace">${esc(t('替换', 'Replace'))}</button>
|
|
96
|
+
<button class="btn btn-small btn-ghost" data-apply="insert">${esc(t('插入光标', 'Insert'))}</button>
|
|
97
|
+
<button class="btn btn-small btn-ghost" data-apply="copy">${esc(t('复制', 'Copy'))}</button>
|
|
98
|
+
<button class="btn btn-small btn-ghost danger-quiet" data-apply="discard">${esc(t('丢弃', 'Discard'))}</button>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<div class="ai-history" id="ai-history"></div>
|
|
103
|
+
</div>
|
|
104
|
+
</aside>
|
|
105
|
+
</section>
|
|
106
|
+
`
|
|
107
|
+
|
|
108
|
+
const titleEl = $('#doc-title')
|
|
109
|
+
const contentEl = $('#doc-content')
|
|
110
|
+
const listEl = $('#docs-list')
|
|
111
|
+
|
|
112
|
+
const saveLater = debounce(() => { void save() }, 900)
|
|
113
|
+
|
|
114
|
+
// ---- 初始化当前文档 ----
|
|
115
|
+
function docs() {
|
|
116
|
+
return app.state?.docs ?? []
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function currentDoc() {
|
|
120
|
+
return docs().find(doc => doc.id === activeDocId) ?? null
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function loadDoc(doc, { focus = false } = {}) {
|
|
124
|
+
activeDocId = doc?.id ?? null
|
|
125
|
+
if (doc) sessionStorage.setItem('media-workbench-modern:doc', doc.id)
|
|
126
|
+
titleEl.value = doc?.title ?? ''
|
|
127
|
+
contentEl.value = doc?.content ?? ''
|
|
128
|
+
dirty = false
|
|
129
|
+
paintSave('saved')
|
|
130
|
+
updateCounts()
|
|
131
|
+
updateSelection()
|
|
132
|
+
paintDocs()
|
|
133
|
+
if (focus) titleEl.focus()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 首屏:优先上次打开的,其次最新一篇;都没有就留空等用户输入
|
|
137
|
+
const initial = currentDoc() ?? docs()[0] ?? null
|
|
138
|
+
if (!activeDocId && initial) activeDocId = initial.id
|
|
139
|
+
loadDoc(initial)
|
|
140
|
+
|
|
141
|
+
titleEl.addEventListener('input', () => { dirty = true; paintSave('dirty'); saveLater() })
|
|
142
|
+
contentEl.addEventListener('input', () => {
|
|
143
|
+
dirty = true
|
|
144
|
+
paintSave('dirty')
|
|
145
|
+
updateCounts()
|
|
146
|
+
saveLater()
|
|
147
|
+
})
|
|
148
|
+
contentEl.addEventListener('select', updateSelection)
|
|
149
|
+
contentEl.addEventListener('keyup', updateSelection)
|
|
150
|
+
contentEl.addEventListener('mouseup', updateSelection)
|
|
151
|
+
contentEl.addEventListener('click', updateSelection)
|
|
152
|
+
|
|
153
|
+
titleEl.addEventListener('keydown', event => {
|
|
154
|
+
if (event.key === 'Enter') { event.preventDefault(); contentEl.focus() }
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
$('#new-doc').addEventListener('click', async () => {
|
|
158
|
+
await save(true)
|
|
159
|
+
try {
|
|
160
|
+
const data = await api('document/save', { title: t('未命名作品', 'Untitled'), content: '' })
|
|
161
|
+
app.state.docs.unshift(data.doc)
|
|
162
|
+
loadDoc(data.doc, { focus: true })
|
|
163
|
+
paintDocs()
|
|
164
|
+
app.toast(t('新作品已建好', 'New document created'), 'success')
|
|
165
|
+
} catch (err) { app.toast(err.message, 'error') }
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
$('#doc-search').addEventListener('input', () => { query = $('#doc-search').value.trim().toLowerCase(); paintDocs() })
|
|
169
|
+
|
|
170
|
+
listEl.addEventListener('click', async event => {
|
|
171
|
+
const item = event.target.closest('[data-doc]')
|
|
172
|
+
const act = event.target.closest('[data-doc-act]')
|
|
173
|
+
if (act) {
|
|
174
|
+
event.stopPropagation()
|
|
175
|
+
const id = act.dataset.id
|
|
176
|
+
const kind = act.dataset.docAct
|
|
177
|
+
if (kind === 'delete') {
|
|
178
|
+
const ok = await app.confirm({ title: t('删除这篇作品?', 'Delete this document?'), message: t('删除后无法从工作台恢复。', 'This cannot be undone in the workbench.'), confirmText: t('删除', 'Delete'), danger: true })
|
|
179
|
+
if (!ok) return
|
|
180
|
+
try {
|
|
181
|
+
await api('document/action', { id, action: 'delete' })
|
|
182
|
+
app.state.docs = app.state.docs.filter(doc => doc.id !== id)
|
|
183
|
+
if (activeDocId === id) loadDoc(app.state.docs[0] ?? null)
|
|
184
|
+
paintDocs()
|
|
185
|
+
app.toast(t('已删除', 'Deleted'), 'success')
|
|
186
|
+
} catch (err) { app.toast(err.message, 'error') }
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
if (kind === 'duplicate') {
|
|
190
|
+
try {
|
|
191
|
+
const data = await api('document/action', { id, action: 'duplicate' })
|
|
192
|
+
app.state.docs.unshift(data.doc)
|
|
193
|
+
paintDocs()
|
|
194
|
+
app.toast(t('副本已创建', 'Duplicated'), 'success')
|
|
195
|
+
} catch (err) { app.toast(err.message, 'error') }
|
|
196
|
+
}
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
if (!item) return
|
|
200
|
+
const id = item.dataset.doc
|
|
201
|
+
if (id === activeDocId) return
|
|
202
|
+
await save(true)
|
|
203
|
+
const doc = docs().find(row => row.id === id)
|
|
204
|
+
if (doc) loadDoc(doc)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
$('#export-doc').addEventListener('click', () => {
|
|
208
|
+
const title = titleEl.value.trim() || t('未命名作品', 'Untitled')
|
|
209
|
+
const body = `# ${title}\n\n${contentEl.value}`
|
|
210
|
+
const blob = new Blob([body], { type: 'text/markdown;charset=utf-8' })
|
|
211
|
+
const url = URL.createObjectURL(blob)
|
|
212
|
+
downloadUrl(url, `${title.replace(/[/\\:*?"<>|]/g, '_')}.md`)
|
|
213
|
+
setTimeout(() => URL.revokeObjectURL(url), 3000)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
$('#focus-mode').addEventListener('click', () => {
|
|
217
|
+
focusMode = !focusMode
|
|
218
|
+
$('#write-bench').classList.toggle('is-focus', focusMode)
|
|
219
|
+
$('#focus-mode').innerHTML = `${icon('eye', 14)}${focusMode ? t('退出专注', 'Exit focus') : t('专注', 'Focus')}`
|
|
220
|
+
contentEl.focus()
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
$('#tones').addEventListener('click', event => {
|
|
224
|
+
const btn = event.target.closest('[data-tone]')
|
|
225
|
+
if (!btn) return
|
|
226
|
+
tone = btn.dataset.tone
|
|
227
|
+
paintTone()
|
|
228
|
+
})
|
|
229
|
+
$('#pov').addEventListener('change', () => { pov = $('#pov').value })
|
|
230
|
+
$('#length').addEventListener('change', () => { length = $('#length').value })
|
|
231
|
+
$('#use-selection').addEventListener('change', updateSelection)
|
|
232
|
+
$('#ai-actions').addEventListener('click', event => {
|
|
233
|
+
const btn = event.target.closest('[data-action]')
|
|
234
|
+
if (!btn) return
|
|
235
|
+
action = btn.dataset.action
|
|
236
|
+
paintAction()
|
|
237
|
+
if (!streamController) run()
|
|
238
|
+
})
|
|
239
|
+
$('#ai-run').addEventListener('click', run)
|
|
240
|
+
$('#ai-stop').addEventListener('click', () => { streamController?.abort() })
|
|
241
|
+
|
|
242
|
+
$('#ai-result').addEventListener('click', async event => {
|
|
243
|
+
const btn = event.target.closest('[data-apply]')
|
|
244
|
+
if (!btn) return
|
|
245
|
+
const mode = btn.dataset.apply
|
|
246
|
+
if (mode === 'discard') { hideResult(); return }
|
|
247
|
+
if (mode === 'copy') {
|
|
248
|
+
try { await navigator.clipboard.writeText(resultText); app.toast(t('已复制', 'Copied'), 'success') } catch { app.toast(t('复制失败', 'Copy failed'), 'error') }
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
if (!resultText) return
|
|
252
|
+
if (mode === 'replace' && !selection.text) {
|
|
253
|
+
const ok = await app.confirm({ title: t('替换整篇正文?', 'Replace the whole manuscript?'), message: t('当前正文会被这段草稿替换。', 'The current text will be replaced by this draft.'), confirmText: t('替换', 'Replace'), danger: true })
|
|
254
|
+
if (!ok) return
|
|
255
|
+
}
|
|
256
|
+
applyText(mode)
|
|
257
|
+
hideResult()
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
function paintTone() {
|
|
261
|
+
$$('#tones [data-tone]').forEach(btn => btn.classList.toggle('is-on', btn.dataset.tone === tone))
|
|
262
|
+
}
|
|
263
|
+
paintTone()
|
|
264
|
+
|
|
265
|
+
function paintAction() {
|
|
266
|
+
$$('#ai-actions [data-action]').forEach(btn => btn.classList.toggle('is-on', btn.dataset.action === action))
|
|
267
|
+
const hints = {
|
|
268
|
+
rewrite: t('例如:把语气改得更克制,去掉形容词', 'e.g. colder tone, fewer adjectives'),
|
|
269
|
+
custom: t('写下你想怎么改', 'Describe the change'),
|
|
270
|
+
outline: t('可选:想强调什么', 'Optional: what to emphasize'),
|
|
271
|
+
title: t('可选:面向谁、什么平台', 'Optional: audience or platform'),
|
|
272
|
+
}
|
|
273
|
+
$('#instruction').placeholder = hints[action] ?? t('可选:补充你的要求', 'Optional: any extra direction')
|
|
274
|
+
}
|
|
275
|
+
paintAction()
|
|
276
|
+
|
|
277
|
+
function updateCounts() {
|
|
278
|
+
const chars = [...contentEl.value].length
|
|
279
|
+
const words = wordCount(contentEl.value)
|
|
280
|
+
$('#word-count').textContent = app.lang === 'en' ? `${words} words` : `${chars} 字`
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function updateSelection() {
|
|
284
|
+
const start = contentEl.selectionStart ?? 0
|
|
285
|
+
const end = contentEl.selectionEnd ?? 0
|
|
286
|
+
selection = { start, end, text: contentEl.value.slice(start, end) }
|
|
287
|
+
const info = $('#selection-info')
|
|
288
|
+
if (selection.text) {
|
|
289
|
+
info.textContent = t(`已选中 ${[...selection.text].length} 字 · 可以只改这一段`, `${[...selection.text].length} chars selected`)
|
|
290
|
+
} else {
|
|
291
|
+
info.textContent = t('选中文字可以只改这一段', 'Select text to edit just that part')
|
|
292
|
+
}
|
|
293
|
+
const checkbox = $('#use-selection')
|
|
294
|
+
if (!selection.text && checkbox.checked) checkbox.checked = false
|
|
295
|
+
if (selection.text && !checkbox.checked && document.activeElement === contentEl) checkbox.checked = false
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function paintSave(mode) {
|
|
299
|
+
const el = $('#save-state')
|
|
300
|
+
if (!el) return
|
|
301
|
+
if (mode === 'saving') { el.textContent = t('正在保存…', 'Saving…'); el.className = 'save-state is-saving' }
|
|
302
|
+
else if (mode === 'dirty') { el.textContent = t('未保存', 'Unsaved'); el.className = 'save-state is-dirty' }
|
|
303
|
+
else if (mode === 'error') { el.textContent = t('保存失败', 'Save failed'); el.className = 'save-state is-error' }
|
|
304
|
+
else { el.textContent = t('已保存', 'Saved'); el.className = 'save-state' }
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function paintDocs() {
|
|
308
|
+
const all = docs()
|
|
309
|
+
const filtered = all.filter(doc => !query || `${doc.title}\n${doc.content}`.toLowerCase().includes(query))
|
|
310
|
+
$('#docs-total').textContent = String(all.length)
|
|
311
|
+
if (!filtered.length) {
|
|
312
|
+
listEl.innerHTML = `<div class="docs-empty">${esc(all.length ? t('没有匹配的作品', 'No matching documents') : t('还没有作品,点右上角新建。', 'No documents yet. Create one above.'))}</div>`
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
listEl.innerHTML = filtered.map(doc => {
|
|
316
|
+
const isOn = doc.id === activeDocId
|
|
317
|
+
const excerpt = doc.content.replace(/\s+/g, ' ').slice(0, 46)
|
|
318
|
+
return `<article class="doc-item ${isOn ? 'is-on' : ''}" data-doc="${doc.id}" tabindex="0" role="button">
|
|
319
|
+
<div class="doc-item-top"><b>${esc(doc.title || t('未命名作品', 'Untitled'))}</b><time>${esc(fmtTime(doc.updatedAt))}</time></div>
|
|
320
|
+
<p>${esc(excerpt || t('还没有内容', 'Empty'))}</p>
|
|
321
|
+
<div class="doc-item-actions">
|
|
322
|
+
<button type="button" data-doc-act="duplicate" data-id="${doc.id}" title="${esc(t('创建副本', 'Duplicate'))}">${icon('duplicate', 12)}</button>
|
|
323
|
+
<button type="button" data-doc-act="delete" data-id="${doc.id}" title="${esc(t('删除', 'Delete'))}">${icon('trash', 12)}</button>
|
|
324
|
+
</div>
|
|
325
|
+
</article>`
|
|
326
|
+
}).join('')
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function save(force = false) {
|
|
330
|
+
if (!force && !dirty) return null
|
|
331
|
+
const title = titleEl.value.trim() || t('未命名作品', 'Untitled')
|
|
332
|
+
const content = contentEl.value
|
|
333
|
+
paintSave('saving')
|
|
334
|
+
try {
|
|
335
|
+
const data = await api('document/save', { id: activeDocId ?? '', title, content })
|
|
336
|
+
activeDocId = data.doc.id
|
|
337
|
+
sessionStorage.setItem('media-workbench-modern:doc', activeDocId)
|
|
338
|
+
const index = app.state.docs.findIndex(doc => doc.id === activeDocId)
|
|
339
|
+
if (index >= 0) app.state.docs[index] = data.doc
|
|
340
|
+
else app.state.docs.unshift(data.doc)
|
|
341
|
+
dirty = false
|
|
342
|
+
paintSave('saved')
|
|
343
|
+
paintDocs()
|
|
344
|
+
return data.doc
|
|
345
|
+
} catch (err) {
|
|
346
|
+
paintSave('error')
|
|
347
|
+
app.toast(err.message, 'error')
|
|
348
|
+
return null
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function applyText(mode) {
|
|
353
|
+
if (mode === 'append') {
|
|
354
|
+
const base = contentEl.value.replace(/\s+$/, '')
|
|
355
|
+
contentEl.value = base ? `${base}\n\n${resultText}` : resultText
|
|
356
|
+
} else if (mode === 'replace') {
|
|
357
|
+
if (selection.text) contentEl.setRangeText(resultText, selection.start, selection.end, 'end')
|
|
358
|
+
else contentEl.value = resultText
|
|
359
|
+
} else if (mode === 'insert') {
|
|
360
|
+
const at = contentEl.selectionStart ?? contentEl.value.length
|
|
361
|
+
contentEl.setRangeText(resultText, at, at, 'end')
|
|
362
|
+
}
|
|
363
|
+
contentEl.dispatchEvent(new Event('input'))
|
|
364
|
+
contentEl.focus()
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function hideResult() {
|
|
368
|
+
resultText = ''
|
|
369
|
+
$('#ai-result').hidden = true
|
|
370
|
+
$('#result-body').textContent = ''
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function paintResultLabel() {
|
|
374
|
+
const item = ACTIONS.find(row => row.id === action)
|
|
375
|
+
$('#result-label').textContent = `${item?.label ?? t('草稿', 'Draft')} · ${fmtTime(new Date().toISOString())}`
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function run() {
|
|
379
|
+
if (streamController) { app.toast(t('正在写,等这一段落完', 'Still writing — wait for it to finish'), 'info'); return }
|
|
380
|
+
if (!app.model) {
|
|
381
|
+
app.toast(t('当前没有写作模型。先在 DSH 里选一个模型。', 'No writing model. Choose one in DSH first.'), 'error')
|
|
382
|
+
return
|
|
383
|
+
}
|
|
384
|
+
if (action === 'rewrite' && !$('#instruction').value.trim() && !selection.text) {
|
|
385
|
+
// 允许直接改写,但给一句默认指令
|
|
386
|
+
}
|
|
387
|
+
const useSelection = $('#use-selection').checked
|
|
388
|
+
if (useSelection && !selection.text) {
|
|
389
|
+
app.toast(t('先选中一段文字', 'Select some text first'), 'error')
|
|
390
|
+
return
|
|
391
|
+
}
|
|
392
|
+
if (!contentEl.value.trim() && action !== 'outline' && action !== 'title' && action !== 'custom') {
|
|
393
|
+
app.toast(t('正文还是空的,先写一句', 'The manuscript is empty — write a line first'), 'error')
|
|
394
|
+
contentEl.focus()
|
|
395
|
+
return
|
|
396
|
+
}
|
|
397
|
+
const controller = new AbortController()
|
|
398
|
+
streamController = controller
|
|
399
|
+
resultText = ''
|
|
400
|
+
const result = $('#ai-result')
|
|
401
|
+
result.hidden = false
|
|
402
|
+
const body = $('#result-body')
|
|
403
|
+
body.textContent = ''
|
|
404
|
+
body.classList.add('is-streaming')
|
|
405
|
+
paintResultLabel()
|
|
406
|
+
$('#ai-stop').hidden = false
|
|
407
|
+
$('#ai-run').disabled = true
|
|
408
|
+
$('#ai-run').querySelector('span').textContent = t('正在写…', 'Writing…')
|
|
409
|
+
try {
|
|
410
|
+
const data = await streamWrite({
|
|
411
|
+
action,
|
|
412
|
+
title: titleEl.value,
|
|
413
|
+
content: contentEl.value,
|
|
414
|
+
selection: useSelection ? selection.text : '',
|
|
415
|
+
instruction: $('#instruction').value.trim(),
|
|
416
|
+
tone,
|
|
417
|
+
pov,
|
|
418
|
+
length,
|
|
419
|
+
lang: app.lang,
|
|
420
|
+
}, {
|
|
421
|
+
signal: controller.signal,
|
|
422
|
+
onDelta: (_delta, full) => {
|
|
423
|
+
resultText = full
|
|
424
|
+
body.textContent = full
|
|
425
|
+
body.scrollTop = body.scrollHeight
|
|
426
|
+
},
|
|
427
|
+
})
|
|
428
|
+
resultText = data.text || resultText
|
|
429
|
+
body.textContent = resultText
|
|
430
|
+
history.unshift({ action, text: resultText, at: new Date().toISOString(), model: data.model })
|
|
431
|
+
history = history.slice(0, 6)
|
|
432
|
+
paintHistory()
|
|
433
|
+
app.toast(t('写完了', 'Draft ready'), 'success')
|
|
434
|
+
} catch (err) {
|
|
435
|
+
if (controller.signal.aborted) {
|
|
436
|
+
app.toast(t('已停止', 'Stopped'), 'info')
|
|
437
|
+
} else {
|
|
438
|
+
body.textContent = err.message
|
|
439
|
+
body.classList.add('is-error')
|
|
440
|
+
app.toast(err.message, 'error')
|
|
441
|
+
}
|
|
442
|
+
} finally {
|
|
443
|
+
streamController = null
|
|
444
|
+
body.classList.remove('is-streaming')
|
|
445
|
+
$('#ai-stop').hidden = true
|
|
446
|
+
$('#ai-run').disabled = false
|
|
447
|
+
$('#ai-run').querySelector('span').textContent = t('开始写', 'Write')
|
|
448
|
+
if (!resultText) result.hidden = true
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function paintHistory() {
|
|
453
|
+
const host = $('#ai-history')
|
|
454
|
+
if (!history.length) { host.innerHTML = ''; return }
|
|
455
|
+
host.innerHTML = `<span class="field-label">${esc(t('刚才的草稿', 'Recent drafts'))}</span>` + history.map((row, index) => {
|
|
456
|
+
const label = ACTIONS.find(item => item.id === row.action)?.label ?? t('草稿', 'Draft')
|
|
457
|
+
return `<button type="button" class="history-row" data-history="${index}"><b>${esc(label)}</b><span>${esc(row.text.replace(/\s+/g, ' ').slice(0, 40))}</span><time>${esc(fmtTime(row.at))}</time></button>`
|
|
458
|
+
}).join('')
|
|
459
|
+
host.querySelectorAll('[data-history]').forEach(btn => btn.addEventListener('click', () => {
|
|
460
|
+
const row = history[Number(btn.dataset.history)]
|
|
461
|
+
if (!row) return
|
|
462
|
+
resultText = row.text
|
|
463
|
+
action = row.action
|
|
464
|
+
paintAction()
|
|
465
|
+
paintResultLabel()
|
|
466
|
+
$('#ai-result').hidden = false
|
|
467
|
+
$('#result-body').textContent = row.text
|
|
468
|
+
$('#result-body').classList.remove('is-error')
|
|
469
|
+
}))
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function paintModel() {
|
|
473
|
+
const chip = $('#ai-model')
|
|
474
|
+
if (app.model) {
|
|
475
|
+
chip.textContent = app.model.label
|
|
476
|
+
chip.className = 'model-chip is-ok'
|
|
477
|
+
} else {
|
|
478
|
+
chip.textContent = t('未连接模型', 'No model')
|
|
479
|
+
chip.className = 'model-chip is-off'
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// 离开页面时尽量保存
|
|
484
|
+
const onBeforeUnload = () => {
|
|
485
|
+
if (!dirty) return
|
|
486
|
+
navigator.sendBeacon?.('/media-workbench-modern/api/document/save', new Blob([JSON.stringify({ id: activeDocId ?? '', title: titleEl.value, content: contentEl.value })], { type: 'application/json' }))
|
|
487
|
+
}
|
|
488
|
+
window.addEventListener('beforeunload', onBeforeUnload)
|
|
489
|
+
|
|
490
|
+
function update() {
|
|
491
|
+
paintModel()
|
|
492
|
+
paintDocs()
|
|
493
|
+
// 文档可能被外部修改:只在当前没有输入时同步标题
|
|
494
|
+
if (!dirty && activeDocId) {
|
|
495
|
+
const doc = currentDoc()
|
|
496
|
+
if (doc && document.activeElement !== titleEl && document.activeElement !== contentEl) {
|
|
497
|
+
if (titleEl.value !== doc.title) titleEl.value = doc.title
|
|
498
|
+
if (contentEl.value !== doc.content) { contentEl.value = doc.content; updateCounts() }
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
paintModel()
|
|
504
|
+
paintDocs()
|
|
505
|
+
|
|
506
|
+
return {
|
|
507
|
+
update,
|
|
508
|
+
unmount() {
|
|
509
|
+
window.removeEventListener('beforeunload', onBeforeUnload)
|
|
510
|
+
if (dirty) { void save(true) }
|
|
511
|
+
},
|
|
512
|
+
}
|
|
513
|
+
}
|