dsh-convert-core 0.1.0-alpha.1

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.
Files changed (115) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -0
  3. package/bin/media-to-md.mjs +168 -0
  4. package/bin/tita-convert.mjs +85 -0
  5. package/docs/media-to-md.md +143 -0
  6. package/lib/adapters/anydoc.d.ts +73 -0
  7. package/lib/adapters/anydoc.d.ts.map +1 -0
  8. package/lib/adapters/anydoc.js +295 -0
  9. package/lib/adapters/anydoc.js.map +1 -0
  10. package/lib/adapters/iddoc.d.ts +28 -0
  11. package/lib/adapters/iddoc.d.ts.map +1 -0
  12. package/lib/adapters/iddoc.js +153 -0
  13. package/lib/adapters/iddoc.js.map +1 -0
  14. package/lib/adapters/index.d.ts +41 -0
  15. package/lib/adapters/index.d.ts.map +1 -0
  16. package/lib/adapters/index.js +115 -0
  17. package/lib/adapters/index.js.map +1 -0
  18. package/lib/adapters/scanned.d.ts +28 -0
  19. package/lib/adapters/scanned.d.ts.map +1 -0
  20. package/lib/adapters/scanned.js +121 -0
  21. package/lib/adapters/scanned.js.map +1 -0
  22. package/lib/adapters/text.d.ts +29 -0
  23. package/lib/adapters/text.d.ts.map +1 -0
  24. package/lib/adapters/text.js +135 -0
  25. package/lib/adapters/text.js.map +1 -0
  26. package/lib/adapters/types.d.ts +65 -0
  27. package/lib/adapters/types.d.ts.map +1 -0
  28. package/lib/adapters/types.js +11 -0
  29. package/lib/adapters/types.js.map +1 -0
  30. package/lib/contract.d.ts +185 -0
  31. package/lib/contract.d.ts.map +1 -0
  32. package/lib/contract.js +59 -0
  33. package/lib/contract.js.map +1 -0
  34. package/lib/delivery.d.ts +36 -0
  35. package/lib/delivery.d.ts.map +1 -0
  36. package/lib/delivery.js +97 -0
  37. package/lib/delivery.js.map +1 -0
  38. package/lib/http.d.ts +34 -0
  39. package/lib/http.d.ts.map +1 -0
  40. package/lib/http.js +372 -0
  41. package/lib/http.js.map +1 -0
  42. package/lib/markdown.d.ts +32 -0
  43. package/lib/markdown.d.ts.map +1 -0
  44. package/lib/markdown.js +145 -0
  45. package/lib/markdown.js.map +1 -0
  46. package/lib/media/binaries.d.ts +26 -0
  47. package/lib/media/binaries.d.ts.map +1 -0
  48. package/lib/media/binaries.js +60 -0
  49. package/lib/media/binaries.js.map +1 -0
  50. package/lib/media/convert.d.ts +19 -0
  51. package/lib/media/convert.d.ts.map +1 -0
  52. package/lib/media/convert.js +210 -0
  53. package/lib/media/convert.js.map +1 -0
  54. package/lib/media/index.d.ts +27 -0
  55. package/lib/media/index.d.ts.map +1 -0
  56. package/lib/media/index.js +27 -0
  57. package/lib/media/index.js.map +1 -0
  58. package/lib/media/markdown.d.ts +40 -0
  59. package/lib/media/markdown.d.ts.map +1 -0
  60. package/lib/media/markdown.js +89 -0
  61. package/lib/media/markdown.js.map +1 -0
  62. package/lib/media/media.d.ts +45 -0
  63. package/lib/media/media.d.ts.map +1 -0
  64. package/lib/media/media.js +167 -0
  65. package/lib/media/media.js.map +1 -0
  66. package/lib/media/models.d.ts +31 -0
  67. package/lib/media/models.d.ts.map +1 -0
  68. package/lib/media/models.js +87 -0
  69. package/lib/media/models.js.map +1 -0
  70. package/lib/media/scanned.d.ts +18 -0
  71. package/lib/media/scanned.d.ts.map +1 -0
  72. package/lib/media/scanned.js +179 -0
  73. package/lib/media/scanned.js.map +1 -0
  74. package/lib/media/types.d.ts +154 -0
  75. package/lib/media/types.d.ts.map +1 -0
  76. package/lib/media/types.js +23 -0
  77. package/lib/media/types.js.map +1 -0
  78. package/lib/server.d.ts +35 -0
  79. package/lib/server.d.ts.map +1 -0
  80. package/lib/server.js +64 -0
  81. package/lib/server.js.map +1 -0
  82. package/lib/service.d.ts +170 -0
  83. package/lib/service.d.ts.map +1 -0
  84. package/lib/service.js +562 -0
  85. package/lib/service.js.map +1 -0
  86. package/package.json +59 -0
  87. package/scripts/asr.py +55 -0
  88. package/scripts/ocr.py +40 -0
  89. package/scripts/pdf_pages.py +49 -0
  90. package/scripts/vendor.mjs +76 -0
  91. package/src/adapters/anydoc.ts +356 -0
  92. package/src/adapters/iddoc.ts +171 -0
  93. package/src/adapters/index.ts +147 -0
  94. package/src/adapters/scanned.ts +139 -0
  95. package/src/adapters/text.ts +146 -0
  96. package/src/adapters/types.ts +76 -0
  97. package/src/contract.ts +230 -0
  98. package/src/delivery.ts +124 -0
  99. package/src/http.ts +394 -0
  100. package/src/markdown.ts +141 -0
  101. package/src/media/binaries.ts +67 -0
  102. package/src/media/convert.ts +232 -0
  103. package/src/media/index.ts +43 -0
  104. package/src/media/markdown.ts +105 -0
  105. package/src/media/media.ts +204 -0
  106. package/src/media/models.ts +124 -0
  107. package/src/media/scanned.ts +200 -0
  108. package/src/media/types.ts +171 -0
  109. package/src/server.ts +85 -0
  110. package/src/service.ts +639 -0
  111. package/web/app.js +382 -0
  112. package/web/index.html +217 -0
  113. package/web/styles.css +263 -0
  114. package/web/vendor/icons.js +25 -0
  115. package/web/vendor/vue.global.prod.js +14 -0
package/web/app.js ADDED
@@ -0,0 +1,382 @@
1
+ /*!
2
+ * Tita 转换内核 · 控制台
3
+ *
4
+ * Vue 3 Options API, deliberately buildless: this file is served as-is and
5
+ * talks to the kernel API under the same base path. The DSH plugin embeds this
6
+ * exact page instead of reimplementing it.
7
+ */
8
+ (function () {
9
+ 'use strict'
10
+
11
+ var meta = document.querySelector('meta[name="convert-base"]')
12
+ var BASE = String((meta && meta.content) || '').replace(/\/+$/, '')
13
+ var ICONS = window.__CONVERT_ICONS__ || {}
14
+
15
+ var KIND_ICONS = { file: 'file-text', text: 'clipboard-type' }
16
+
17
+ function encodePath(path) {
18
+ return String(path).split('/').map(encodeURIComponent).join('/')
19
+ }
20
+
21
+ function escapeHtml(value) {
22
+ return String(value).replace(/[&<>"]/g, function (character) {
23
+ return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[character]
24
+ })
25
+ }
26
+
27
+ /** Small Markdown renderer for the delivery preview. Input is our own output. */
28
+ function renderMarkdown(source, taskId) {
29
+ var lines = String(source || '').split('\n')
30
+ var out = []
31
+ var listStack = []
32
+ var inCode = false
33
+ var codeBuffer = []
34
+ var paragraph = []
35
+
36
+ function flushParagraph() {
37
+ if (paragraph.length) {
38
+ out.push('<p>' + inline(paragraph.join(' ')) + '</p>')
39
+ paragraph = []
40
+ }
41
+ }
42
+ function closeLists(toDepth) {
43
+ while (listStack.length > toDepth) out.push('</' + listStack.pop() + '>')
44
+ }
45
+ function imageSrc(raw) {
46
+ if (/^https?:/i.test(raw) || raw.indexOf('data:') === 0) return raw
47
+ return BASE + '/api/tasks/' + encodeURIComponent(taskId) + '/files/' + encodePath(decodeURIComponent(raw))
48
+ }
49
+ function inline(text) {
50
+ var escaped = escapeHtml(text)
51
+ escaped = escaped.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, function (_all, alt, url) {
52
+ return '<img alt="' + alt + '" src="' + imageSrc(url) + '" loading="lazy" />'
53
+ })
54
+ escaped = escaped.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>')
55
+ escaped = escaped.replace(/`([^`]+)`/g, '<code>$1</code>')
56
+ escaped = escaped.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
57
+ escaped = escaped.replace(/(^|[\s(])\*([^*]+)\*/g, '$1<em>$2</em>')
58
+ escaped = escaped.replace(/~~([^~]+)~~/g, '<del>$1</del>')
59
+ return escaped
60
+ }
61
+
62
+ for (var index = 0; index < lines.length; index += 1) {
63
+ var line = lines[index]
64
+ var fence = /^```(.*)$/.exec(line)
65
+ if (fence) {
66
+ if (inCode) {
67
+ out.push('<pre><code>' + escapeHtml(codeBuffer.join('\n')) + '</code></pre>')
68
+ codeBuffer = []
69
+ inCode = false
70
+ } else {
71
+ flushParagraph()
72
+ closeLists(0)
73
+ inCode = true
74
+ }
75
+ continue
76
+ }
77
+ if (inCode) {
78
+ codeBuffer.push(line)
79
+ continue
80
+ }
81
+ if (!line.trim()) {
82
+ flushParagraph()
83
+ closeLists(0)
84
+ continue
85
+ }
86
+ var heading = /^(#{1,6})\s+(.*)$/.exec(line)
87
+ if (heading) {
88
+ flushParagraph()
89
+ closeLists(0)
90
+ var level = heading[1].length
91
+ out.push('<h' + level + '>' + inline(heading[2]) + '</h' + level + '>')
92
+ continue
93
+ }
94
+ if (/^(-{3,}|\*{3,})$/.test(line.trim())) {
95
+ flushParagraph()
96
+ closeLists(0)
97
+ out.push('<hr />')
98
+ continue
99
+ }
100
+ var quote = /^>\s?(.*)$/.exec(line)
101
+ if (quote) {
102
+ flushParagraph()
103
+ out.push('<blockquote>' + inline(quote[1]) + '</blockquote>')
104
+ continue
105
+ }
106
+ var bullet = /^(\s*)([-*+]|\d+\.)\s+(.*)$/.exec(line)
107
+ if (bullet) {
108
+ flushParagraph()
109
+ var depth = Math.floor(bullet[1].length / 2) + 1
110
+ var type = /\d/.test(bullet[2]) ? 'ol' : 'ul'
111
+ closeLists(depth - 1)
112
+ while (listStack.length < depth) {
113
+ out.push('<' + type + '>')
114
+ listStack.push(type)
115
+ }
116
+ out.push('<li>' + inline(bullet[3]) + '</li>')
117
+ continue
118
+ }
119
+ if (/^\|.*\|$/.test(line.trim())) {
120
+ var next = lines[index + 1] || ''
121
+ if (/^\|[\s:|-]+\|$/.test(next.trim())) {
122
+ flushParagraph()
123
+ closeLists(0)
124
+ var header = line.trim().slice(1, -1).split('|').map(function (cell) { return inline(cell.trim()) })
125
+ index += 1
126
+ var rows = []
127
+ while (index + 1 < lines.length && /^\|.*\|$/.test((lines[index + 1] || '').trim())) {
128
+ index += 1
129
+ rows.push(lines[index].trim().slice(1, -1).split('|').map(function (cell) { return inline(cell.trim()) }))
130
+ }
131
+ out.push('<table><thead><tr>' + header.map(function (cell) { return '<th>' + cell + '</th>' }).join('') + '</tr></thead><tbody>' +
132
+ rows.map(function (row) { return '<tr>' + row.map(function (cell) { return '<td>' + cell + '</td>' }).join('') + '</tr>' }).join('') +
133
+ '</tbody></table>')
134
+ continue
135
+ }
136
+ }
137
+ paragraph.push(line.trim())
138
+ }
139
+ if (inCode && codeBuffer.length) out.push('<pre><code>' + escapeHtml(codeBuffer.join('\n')) + '</code></pre>')
140
+ flushParagraph()
141
+ closeLists(0)
142
+ return out.join('\n')
143
+ }
144
+
145
+ var app = Vue.createApp({
146
+ data: function () {
147
+ return {
148
+ base: BASE,
149
+ tab: 'convert',
150
+ tabs: [
151
+ { id: 'convert', label: '转换', icon: 'file-up' },
152
+ { id: 'tasks', label: '任务', icon: 'inbox' },
153
+ { id: 'delivery', label: '交付', icon: 'package' },
154
+ ],
155
+ kinds: [
156
+ { id: 'file', label: '文件', icon: 'file-text' },
157
+ { id: 'text', label: '文本', icon: 'clipboard-type' },
158
+ ],
159
+ form: { kind: 'file', text: '', title: '', sourceType: '', adapter: '', file: null },
160
+ health: {},
161
+ capabilities: null,
162
+ tasks: [],
163
+ selectedId: '',
164
+ detail: null,
165
+ ingest: null,
166
+ preview: '',
167
+ error: null,
168
+ notice: null,
169
+ loading: false,
170
+ submitting: false,
171
+ stream: null,
172
+ }
173
+ },
174
+ computed: {
175
+ adapters: function () { return (this.capabilities && this.capabilities.adapters) || [] },
176
+ runtime: function () { return (this.capabilities && this.capabilities.runtime) || {} },
177
+ extensions: function () { return ((this.capabilities && this.capabilities.extensions) || []).map(function (item) { return item.replace('.', '') }) },
178
+ taskCount: function () { return this.tasks.length },
179
+ succeeded: function () { return this.tasks.filter(function (task) { return task.state === 'succeeded' }) },
180
+ instruction: function () {
181
+ if (!this.ingest || !this.detail) return ''
182
+ var ingested = this.ingest
183
+ return [
184
+ '把这次转换的结果录入知识库:',
185
+ '1) 交付目录:' + (this.detail.deliveryDir || ''),
186
+ '2) 图片资产:' + ingested.assets.map(function (asset) { return asset.rel }).join(', '),
187
+ '3) external_id:' + ingested.externalId + '(幂等键,重复录入应更新同一条来源)',
188
+ '4) source_type 候选:' + ingested.sourceTypeCandidates.join(' / ') + '(按 0013:分类由录入段判定)',
189
+ '5) 请调用知识插件 add_source,并在卡片中引用图片,否则图片拿不到多模态向量。',
190
+ ].join('\n')
191
+ },
192
+ },
193
+ methods: {
194
+ ic: function (name) { return ICONS[name] || ICONS['layers'] || '' },
195
+
196
+ api: function (path, options) {
197
+ return fetch(BASE + path, options).then(function (response) {
198
+ return response.text().then(function (text) {
199
+ var payload
200
+ try { payload = text ? JSON.parse(text) : {} } catch (cause) { payload = { ok: false, error: { code: 'BAD_RESPONSE', message: text.slice(0, 300) } } }
201
+ if (!response.ok || payload.ok === false) {
202
+ var detail = payload.error || { code: 'HTTP_' + response.status, message: '请求失败' }
203
+ var error = new Error(detail.message)
204
+ error.code = detail.code
205
+ error.suggestion = detail.suggestion
206
+ throw error
207
+ }
208
+ return payload.data
209
+ })
210
+ })
211
+ },
212
+
213
+ describeError: function (cause) {
214
+ if (!cause) return '未知错误'
215
+ return [cause.code, cause.message].filter(Boolean).join(' · ') + (cause.suggestion ? ' — 建议:' + cause.suggestion : '')
216
+ },
217
+
218
+ refresh: async function () {
219
+ this.loading = true
220
+ try {
221
+ var results = await Promise.all([
222
+ this.api('/api/tasks'),
223
+ this.capabilities ? Promise.resolve(null) : this.api('/api/capabilities'),
224
+ this.api('/api/health'),
225
+ ])
226
+ this.tasks = results[0].tasks || []
227
+ if (results[1]) this.capabilities = results[1]
228
+ this.health = results[2] || {}
229
+ if (this.selectedId && !this.tasks.some(function (task) { return task.id === this.selectedId }.bind(this))) {
230
+ this.selectedId = ''
231
+ this.detail = null
232
+ this.ingest = null
233
+ }
234
+ } catch (cause) {
235
+ this.error = this.describeError(cause)
236
+ } finally {
237
+ this.loading = false
238
+ }
239
+ },
240
+
241
+ onFile: function (event) {
242
+ this.form.file = (event.target.files && event.target.files[0]) || null
243
+ },
244
+
245
+ resetForm: function () {
246
+ this.form.text = ''
247
+ this.form.title = ''
248
+ this.form.sourceType = ''
249
+ this.form.adapter = ''
250
+ this.form.file = null
251
+ },
252
+
253
+ submit: async function () {
254
+ this.error = null
255
+ this.notice = null
256
+ this.submitting = true
257
+ try {
258
+ var kind = this.form.kind
259
+ var task
260
+ var common = {
261
+ title: this.form.title || undefined,
262
+ source_type: this.form.sourceType || undefined,
263
+ adapter: this.form.adapter || undefined,
264
+ }
265
+ if (kind === 'text') {
266
+ if (!this.form.text.trim()) throw new Error('请输入要转换的文本')
267
+ task = await this.api('/api/tasks', {
268
+ method: 'POST',
269
+ headers: { 'content-type': 'application/json' },
270
+ body: JSON.stringify(Object.assign({ kind: 'text', text: this.form.text }, common)),
271
+ })
272
+ } else {
273
+ if (!this.form.file) throw new Error('请选择文件')
274
+ var body = new FormData()
275
+ body.append('kind', kind)
276
+ if (common.title) body.append('title', common.title)
277
+ if (common.source_type) body.append('source_type', common.source_type)
278
+ if (common.adapter) body.append('adapter', common.adapter)
279
+ body.append('file', this.form.file)
280
+ task = await this.api('/api/tasks', { method: 'POST', body: body })
281
+ }
282
+ this.notice = '任务已受理:' + task.id
283
+ this.selectedId = task.id
284
+ this.tab = 'tasks'
285
+ this.resetForm()
286
+ await this.refresh()
287
+ } catch (cause) {
288
+ this.error = this.describeError(cause)
289
+ } finally {
290
+ this.submitting = false
291
+ }
292
+ },
293
+
294
+ cancel: async function (id) {
295
+ try {
296
+ await this.api('/api/tasks/' + encodeURIComponent(id) + '/cancel', { method: 'POST' })
297
+ await this.refresh()
298
+ } catch (cause) { this.error = this.describeError(cause) }
299
+ },
300
+
301
+ retry: async function (id) {
302
+ try {
303
+ await this.api('/api/tasks/' + encodeURIComponent(id) + '/retry', { method: 'POST' })
304
+ await this.refresh()
305
+ } catch (cause) { this.error = this.describeError(cause) }
306
+ },
307
+
308
+ openTask: async function (id) {
309
+ if (!id) { this.selectedId = ''; this.detail = null; this.ingest = null; return }
310
+ this.selectedId = id
311
+ try {
312
+ var detail = await this.api('/api/tasks/' + encodeURIComponent(id))
313
+ this.detail = detail
314
+ this.ingest = detail.ingest || null
315
+ this.preview = detail.ingest && detail.ingest.content ? renderMarkdown(detail.ingest.content, id) : ''
316
+ } catch (cause) { this.error = this.describeError(cause) }
317
+ },
318
+
319
+ fileUrl: function (rel) {
320
+ return BASE + '/api/tasks/' + encodeURIComponent(this.selectedId) + '/files/' + encodePath(rel)
321
+ },
322
+
323
+ copy: function (text) {
324
+ var self = this
325
+ var value = String(text || '')
326
+ var done = function () { self.notice = '已复制到剪贴板' }
327
+ if (navigator.clipboard && navigator.clipboard.writeText) {
328
+ navigator.clipboard.writeText(value).then(done).catch(function () { self.error = '复制失败,请手动选择文本' })
329
+ return
330
+ }
331
+ var area = document.createElement('textarea')
332
+ area.value = value
333
+ document.body.appendChild(area)
334
+ area.select()
335
+ try { document.execCommand('copy'); done() } catch (cause) { self.error = '复制失败' }
336
+ document.body.removeChild(area)
337
+ },
338
+
339
+ stateLabel: function (state) {
340
+ return { queued: '排队', running: '进行中', succeeded: '已交付', failed: '失败', cancelled: '已取消' }[state] || state
341
+ },
342
+
343
+ formatBytes: function (bytes) {
344
+ if (!bytes && bytes !== 0) return ''
345
+ var units = ['B', 'KB', 'MB', 'GB']
346
+ var value = Number(bytes)
347
+ var unit = 0
348
+ while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit += 1 }
349
+ return value.toFixed(value >= 10 || unit === 0 ? 0 : 1) + ' ' + units[unit]
350
+ },
351
+
352
+ connect: function () {
353
+ var self = this
354
+ if (typeof EventSource === 'undefined') return
355
+ try {
356
+ this.stream = new EventSource(BASE + '/api/events')
357
+ this.stream.addEventListener('task', function (event) {
358
+ try {
359
+ var incoming = JSON.parse(event.data)
360
+ var index = self.tasks.findIndex(function (task) { return task.id === incoming.id })
361
+ if (index >= 0) self.tasks.splice(index, 1, incoming)
362
+ else self.tasks.unshift(incoming)
363
+ if (self.selectedId === incoming.id && (incoming.state === 'succeeded' || incoming.state === 'failed')) {
364
+ self.openTask(incoming.id)
365
+ }
366
+ } catch (cause) { /* ignore malformed frame */ }
367
+ })
368
+ this.stream.addEventListener('error', function () { /* EventSource retries by itself */ })
369
+ } catch (cause) { /* SSE is an optimisation, not a requirement */ }
370
+ },
371
+ },
372
+ mounted: function () {
373
+ this.refresh()
374
+ this.connect()
375
+ },
376
+ beforeUnmount: function () {
377
+ if (this.stream) this.stream.close()
378
+ },
379
+ })
380
+
381
+ app.mount('#app')
382
+ })()
package/web/index.html ADDED
@@ -0,0 +1,217 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta name="convert-base" content="__CONVERT_BASE__" />
7
+ <title>Tita 转换内核 · 多格式 → 知识就绪 Markdown</title>
8
+ <link rel="stylesheet" href="__CONVERT_BASE__/styles.css" />
9
+ </head>
10
+ <body>
11
+ <div id="app">
12
+ <header class="topbar">
13
+ <div class="brand">
14
+ <span class="brand-mark"><span class="ico" v-html="ic('layers')"></span></span>
15
+ <div>
16
+ <h1>Tita 转换内核</h1>
17
+ <p>Word / PowerPoint / Excel / OpenDocument / RTF / EPUB / CSV / 文本层 PDF → 知识就绪 Markdown + 本地图片</p>
18
+ </div>
19
+ </div>
20
+ <nav class="tabs">
21
+ <button
22
+ v-for="item in tabs"
23
+ :key="item.id"
24
+ type="button"
25
+ :class="['tab', { active: tab === item.id }]"
26
+ @click="tab = item.id"
27
+ >
28
+ <span class="ico" v-html="ic(item.icon)"></span><span>{{ item.label }}</span>
29
+ </button>
30
+ </nav>
31
+ <div class="topbar-actions">
32
+ <span class="pill" :title="health.rootDir">
33
+ <span class="ico" v-html="ic('hard-drive')"></span>{{ taskCount }} 个任务
34
+ </span>
35
+ <button type="button" class="ghost" :disabled="loading" @click="refresh">
36
+ <span class="ico" v-html="ic('refresh-cw')"></span>刷新
37
+ </button>
38
+ </div>
39
+ </header>
40
+
41
+ <main>
42
+ <p v-if="error" class="banner error"><span class="ico" v-html="ic('alert-triangle')"></span>{{ error }}</p>
43
+ <p v-if="notice" class="banner ok"><span class="ico" v-html="ic('check-circle')"></span>{{ notice }}</p>
44
+
45
+ <!-- ============ 转换 ============ -->
46
+ <section v-show="tab === 'convert'" class="split">
47
+ <form class="card" @submit.prevent="submit">
48
+ <h2><span class="ico" v-html="ic('file-up')"></span>新转换</h2>
49
+
50
+ <div class="segmented">
51
+ <button
52
+ v-for="kind in kinds"
53
+ :key="kind.id"
54
+ type="button"
55
+ :class="['segment', { active: form.kind === kind.id }]"
56
+ @click="form.kind = kind.id"
57
+ >
58
+ <span class="ico" v-html="ic(kind.icon)"></span>{{ kind.label }}
59
+ </button>
60
+ </div>
61
+
62
+ <label v-if="form.kind === 'file'" class="field">
63
+ <span>输入文件</span>
64
+ <input type="file" @change="onFile" />
65
+ <small v-if="form.file">{{ form.file.name }} · {{ formatBytes(form.file.size) }}</small>
66
+ <small v-else>{{ extensions.join(' ') }}(单文件 ≤ 256 MB)</small>
67
+ </label>
68
+
69
+ <label v-if="form.kind === 'text'" class="field">
70
+ <span>文本</span>
71
+ <textarea v-model="form.text" rows="8" placeholder="粘贴 Markdown / 纯文本"></textarea>
72
+ </label>
73
+
74
+ <div class="grid-2">
75
+ <label class="field">
76
+ <span>标题(可选)</span>
77
+ <input v-model.trim="form.title" type="text" placeholder="留空则自动推断" />
78
+ </label>
79
+ <label class="field">
80
+ <span>adapter(可选)</span>
81
+ <select v-model="form.adapter">
82
+ <option value="">自动选择</option>
83
+ <option v-for="adapter in adapters" :key="adapter.id" :value="adapter.id">
84
+ {{ adapter.id }}{{ adapter.availability.available ? '' : '(当前不可用)' }}
85
+ </option>
86
+ </select>
87
+ </label>
88
+ </div>
89
+
90
+ <label class="field">
91
+ <span>source_type(业务分类,可选)</span>
92
+ <input v-model.trim="form.sourceType" type="text" placeholder="article / book / prd / document …" />
93
+ <small>按 0013 的边界:转换内核不替你决定分类,这里留空就只写候选,交由录入段判定。</small>
94
+ </label>
95
+
96
+ <button type="submit" class="primary" :disabled="submitting">
97
+ <span class="ico" v-html="ic(submitting ? 'loader' : 'play')"></span>{{ submitting ? '提交中…' : '开始转换' }}
98
+ </button>
99
+ </form>
100
+
101
+ <section class="card">
102
+ <h2><span class="ico" v-html="ic('activity')"></span>能力自检</h2>
103
+ <p class="muted">缺失的依赖不会静默降级,这里如实列出与安装命令。</p>
104
+ <ul class="cap-list">
105
+ <li v-for="adapter in adapters" :key="adapter.id">
106
+ <span :class="['dot', adapter.availability.available ? 'on' : 'off']"></span>
107
+ <div>
108
+ <strong>{{ adapter.id }}</strong>
109
+ <p>{{ adapter.description }}</p>
110
+ <p v-if="!adapter.availability.available" class="muted">{{ adapter.availability.reason }}</p>
111
+ <p v-if="adapter.availability.suggestion" class="hint">{{ adapter.availability.suggestion }}</p>
112
+ </div>
113
+ </li>
114
+ </ul>
115
+ <h3>支持的输入格式</h3>
116
+ <p class="muted small">{{ extensions.join(' ') }}</p>
117
+ <h3>引擎</h3>
118
+ <ul class="bin-list">
119
+ <li v-for="(probe, name) in runtime" :key="name">
120
+ <code>{{ name }}</code>
121
+ <span :class="['dot', probe.available ? 'on' : 'off']"></span>
122
+ <span v-if="probe.available" class="muted">{{ probe.version }}</span>
123
+ <span v-else class="hint">{{ probe.error }}</span>
124
+ </li>
125
+ </ul>
126
+ </section>
127
+ </section>
128
+
129
+ <!-- ============ 任务 ============ -->
130
+ <section v-show="tab === 'tasks'" class="card">
131
+ <h2><span class="ico" v-html="ic('inbox')"></span>转换任务</h2>
132
+ <p v-if="!tasks.length" class="muted">还没有任务。切到「转换」页提交一个输入。</p>
133
+ <table v-else class="tasks">
134
+ <thead>
135
+ <tr><th>标题</th><th>状态</th><th>进度</th><th>产物</th><th></th></tr>
136
+ </thead>
137
+ <tbody>
138
+ <tr v-for="task in tasks" :key="task.id" :class="{ selected: selectedId === task.id }">
139
+ <td>
140
+ <strong>{{ task.title }}</strong>
141
+ <div class="muted small">{{ task.id }} · {{ task.parser || task.inputKind }}</div>
142
+ <div v-if="task.error" class="error-text small">{{ task.error.code }} · {{ task.error.message }}</div>
143
+ </td>
144
+ <td><span :class="['state', task.state]">{{ stateLabel(task.state) }}</span></td>
145
+ <td>
146
+ <div class="progress">
147
+ <div class="bar" :class="{ indeterminate: task.state === 'running' && task.progress === null }" :style="{ width: task.progress === null ? '100%' : Math.round(task.progress * 100) + '%' }"></div>
148
+ </div>
149
+ <div class="muted small">{{ task.stage }}{{ task.message ? ' · ' + task.message : '' }}</div>
150
+ </td>
151
+ <td class="muted small">
152
+ {{ task.assetCount }} 图 / {{ task.wordCount }} 字
153
+ <div v-if="task.warnings && task.warnings.length">{{ task.warnings.length }} 条 warning</div>
154
+ </td>
155
+ <td class="row-actions">
156
+ <button type="button" class="ghost" @click="openTask(task.id)"><span class="ico" v-html="ic('scan-eye')"></span></button>
157
+ <button v-if="task.state === 'running' || task.state === 'queued'" type="button" class="ghost" @click="cancel(task.id)"><span class="ico" v-html="ic('x')"></span></button>
158
+ <button v-else type="button" class="ghost" @click="retry(task.id)"><span class="ico" v-html="ic('rotate-ccw')"></span></button>
159
+ </td>
160
+ </tr>
161
+ </tbody>
162
+ </table>
163
+ </section>
164
+
165
+ <!-- ============ 交付 ============ -->
166
+ <section v-show="tab === 'delivery'" class="card">
167
+ <h2><span class="ico" v-html="ic('package')"></span>交付目录与录入负载</h2>
168
+ <p class="muted">产物写入 <code>{{ health.outDir }}/&lt;taskId&gt;/</code>;下面的 ingest 负载就是交给知识录入的那一份。</p>
169
+ <p v-if="!succeeded.length" class="muted">还没有成功交付的任务。</p>
170
+ <template v-else>
171
+ <label class="field">
172
+ <span>选择任务</span>
173
+ <select :value="selectedId" @change="openTask($event.target.value)">
174
+ <option value="">—</option>
175
+ <option v-for="task in succeeded" :key="task.id" :value="task.id">{{ task.title }} ({{ task.id }})</option>
176
+ </select>
177
+ </label>
178
+ <div v-if="ingest" class="delivery">
179
+ <dl class="kv">
180
+ <div><dt>deliveryDir</dt><dd><code>{{ ingest.deliveryDir || detail.deliveryDir }}</code></dd></div>
181
+ <div><dt>external_id</dt><dd><code>{{ ingest.externalId }}</code></dd></div>
182
+ <div><dt>content_hash</dt><dd><code>{{ ingest.contentHash }}</code></dd></div>
183
+ <div><dt>source_format</dt><dd>{{ ingest.sourceFormat }} · parser {{ ingest.parser }}</dd></div>
184
+ <div><dt>source_type 候选</dt><dd>{{ ingest.sourceTypeCandidates.join(', ') }}</dd></div>
185
+ <div><dt>图片资产</dt><dd>{{ ingest.assets.length }} 个(引用基准:{{ ingest.assetRefBase }})</dd></div>
186
+ </dl>
187
+ <div class="row-actions">
188
+ <button type="button" class="ghost" @click="copy(ingest.markdown)"><span class="ico" v-html="ic('copy')"></span>复制 Markdown</button>
189
+ <a class="ghost" :href="fileUrl('index.md')" target="_blank" rel="noreferrer"><span class="ico" v-html="ic('download')"></span>index.md</a>
190
+ <a class="ghost" :href="fileUrl('manifest.json')" target="_blank" rel="noreferrer"><span class="ico" v-html="ic('download')"></span>manifest.json</a>
191
+ </div>
192
+ <h3>预览</h3>
193
+ <article class="preview" v-html="preview"></article>
194
+ <h3>assets/</h3>
195
+ <div class="assets">
196
+ <figure v-for="asset in ingest.assets" :key="asset.rel">
197
+ <img :src="fileUrl(asset.rel)" :alt="asset.rel" loading="lazy" />
198
+ <figcaption>{{ asset.rel }}<br /><span class="muted">{{ formatBytes(asset.bytes) }}</span></figcaption>
199
+ </figure>
200
+ </div>
201
+ </div>
202
+ <p v-else class="muted">选择一个已交付的任务查看产物。</p>
203
+ </template>
204
+ </section>
205
+ </main>
206
+
207
+ <footer class="footer">
208
+ <span>内核:<code>dsh-convert-core</code></span>
209
+ <span>交付根目录:<code>{{ health.rootDir }}</code></span>
210
+ </footer>
211
+ </div>
212
+
213
+ <script src="__CONVERT_BASE__/vendor/vue.global.prod.js"></script>
214
+ <script src="__CONVERT_BASE__/vendor/icons.js"></script>
215
+ <script src="__CONVERT_BASE__/app.js"></script>
216
+ </body>
217
+ </html>