dsh-data-domain-knowledge 0.1.0-alpha.12 → 0.1.0-alpha.13

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 (62) hide show
  1. package/README.md +15 -7
  2. package/lib/client.js +64 -7
  3. package/lib/types/environment/install.d.ts +2 -1
  4. package/lib/types/environment/install.d.ts.map +1 -1
  5. package/lib/types/environment/install.js +20 -11
  6. package/lib/types/environment/install.js.map +1 -1
  7. package/lib/types/environment/manifest.d.ts +203 -12
  8. package/lib/types/environment/manifest.d.ts.map +1 -1
  9. package/lib/types/environment/manifest.js +23 -21
  10. package/lib/types/environment/manifest.js.map +1 -1
  11. package/lib/types/environment/model-catalog.d.ts +191 -0
  12. package/lib/types/environment/model-catalog.d.ts.map +1 -0
  13. package/lib/types/environment/model-catalog.js +91 -0
  14. package/lib/types/environment/model-catalog.js.map +1 -0
  15. package/lib/types/environment/selection.d.ts +9 -0
  16. package/lib/types/environment/selection.d.ts.map +1 -0
  17. package/lib/types/environment/selection.js +29 -0
  18. package/lib/types/environment/selection.js.map +1 -0
  19. package/lib/types/environment/service.d.ts +1 -1
  20. package/lib/types/environment/service.d.ts.map +1 -1
  21. package/lib/types/environment/service.js +54 -33
  22. package/lib/types/environment/service.js.map +1 -1
  23. package/lib/types/environment/types.d.ts +7 -1
  24. package/lib/types/environment/types.d.ts.map +1 -1
  25. package/lib/types/images.d.ts +26 -0
  26. package/lib/types/images.d.ts.map +1 -0
  27. package/lib/types/images.js +196 -0
  28. package/lib/types/images.js.map +1 -0
  29. package/lib/types/index.d.ts.map +1 -1
  30. package/lib/types/index.js +25 -3
  31. package/lib/types/index.js.map +1 -1
  32. package/lib/types/layout.d.ts +2 -0
  33. package/lib/types/layout.d.ts.map +1 -1
  34. package/lib/types/layout.js +2 -0
  35. package/lib/types/layout.js.map +1 -1
  36. package/lib/types/runtime.d.ts +9 -3
  37. package/lib/types/runtime.d.ts.map +1 -1
  38. package/lib/types/runtime.js +73 -16
  39. package/lib/types/runtime.js.map +1 -1
  40. package/lib/types/store/migrate.d.ts.map +1 -1
  41. package/lib/types/store/migrate.js +5 -4
  42. package/lib/types/store/migrate.js.map +1 -1
  43. package/lib/types/types.d.ts +1 -1
  44. package/lib/types/types.d.ts.map +1 -1
  45. package/lib/types/types.js +3 -4
  46. package/lib/types/types.js.map +1 -1
  47. package/package.json +1 -1
  48. package/python/prepare-models.py +1 -1
  49. package/scripts/setup-embedding.sh +11 -2
  50. package/src/client/EnvironmentSetup.tsx +54 -8
  51. package/src/environment/install.ts +22 -10
  52. package/src/environment/manifest.ts +25 -22
  53. package/src/environment/model-catalog.ts +105 -0
  54. package/src/environment/selection.ts +29 -0
  55. package/src/environment/service.ts +46 -26
  56. package/src/environment/types.ts +5 -1
  57. package/src/images.ts +203 -0
  58. package/src/index.ts +30 -4
  59. package/src/layout.ts +2 -0
  60. package/src/runtime.ts +68 -16
  61. package/src/store/migrate.ts +4 -4
  62. package/src/types.ts +4 -5
@@ -0,0 +1,105 @@
1
+ /** Models supported by the local embedding and reranking workers. Revisions are pinned in manifest.ts. */
2
+ export const EMBEDDING_MODELS = [
3
+ {
4
+ id: 'bge-small-zh-v1.5', repo: 'BAAI/bge-small-zh-v1.5', directory: 'bge-small-zh-v1.5',
5
+ label: 'BGE Small 中文', dimension: 512, download: '约 96 MB',
6
+ description: '资源占用最低,适合先跑通本地知识检索。',
7
+ },
8
+ {
9
+ id: 'bge-base-zh-v1.5', repo: 'BAAI/bge-base-zh-v1.5', directory: 'bge-base-zh-v1.5',
10
+ label: 'BGE Base 中文', dimension: 768, download: '约 409 MB',
11
+ description: '效果与资源占用较均衡。',
12
+ },
13
+ {
14
+ id: 'bge-large-zh-v1.5', repo: 'BAAI/bge-large-zh-v1.5', directory: 'bge-large-zh-v1.5',
15
+ label: 'BGE Large 中文', dimension: 1024, download: '约 1.3 GB',
16
+ description: '中文向量效果优先,需要更多内存与磁盘。',
17
+ },
18
+ {
19
+ id: 'wemm-embedding-2b-quantized', repo: 'ewin-reg/WeMM-Embedding-2B-Quantized', directory: 'WeMM-Embedding-2B-Quantized',
20
+ label: 'WeMM Embedding 2B 社区量化版', dimension: 2048, download: '约 1.8 GB',
21
+ description: '社区维护的 INT4 / FP8 量化模型,非腾讯官方发布;模型卡标注 license: other,请查看协议。',
22
+ },
23
+ {
24
+ id: 'wemm-embedding-2b', repo: 'tencent/WeMM-Embedding-2B', directory: 'WeMM-Embedding-2B',
25
+ label: '腾讯 WeMM Embedding 2B(BF16)', dimension: 2048, download: '约 5.5 GB',
26
+ description: '多模态向量模型,支持文本和图片;下载与推理资源占用较高。',
27
+ },
28
+ ] as const
29
+
30
+ export const RERANK_MODELS = [
31
+ {
32
+ id: 'bge-reranker-base', repo: 'BAAI/bge-reranker-base', directory: 'bge-reranker-base',
33
+ label: 'BGE Reranker Base', download: '约 1.1 GB',
34
+ description: '中文重排的轻量选项。',
35
+ },
36
+ {
37
+ id: 'qwen3-reranker-0.6b', repo: 'Qwen/Qwen3-Reranker-0.6B', directory: 'Qwen3-Reranker-0.6B',
38
+ label: 'Qwen3 Reranker 0.6B', download: '约 1.2 GB',
39
+ description: '支持 100 多种语言,适合先试用 Qwen 重排。',
40
+ },
41
+ {
42
+ id: 'bge-reranker-large', repo: 'BAAI/bge-reranker-large', directory: 'bge-reranker-large',
43
+ label: 'BGE Reranker Large', download: '约 2.2 GB',
44
+ description: '中文和英文重排效果优先,推理速度较慢。',
45
+ },
46
+ {
47
+ id: 'qwen3-reranker-4b', repo: 'Qwen/Qwen3-Reranker-4B', directory: 'Qwen3-Reranker-4B',
48
+ label: 'Qwen3 Reranker 4B', download: '约 8.1 GB',
49
+ description: 'Qwen 中档重排模型;请先确认磁盘与内存充足。',
50
+ },
51
+ {
52
+ id: 'qwen3-reranker-8b', repo: 'Qwen/Qwen3-Reranker-8B', directory: 'Qwen3-Reranker-8B',
53
+ label: 'Qwen3 Reranker 8B', download: '约 16.4 GB',
54
+ description: '资源占用最高;下载前请确认有足够磁盘与内存。',
55
+ },
56
+ ] as const
57
+
58
+ export type EmbeddingModelId = (typeof EMBEDDING_MODELS)[number]['id']
59
+ export type RerankModelId = (typeof RERANK_MODELS)[number]['id']
60
+ export interface ModelSelection { embedding: EmbeddingModelId; rerank: RerankModelId }
61
+ export interface ModelOptionView {
62
+ id: string
63
+ label: string
64
+ dimension?: number
65
+ download: string
66
+ description: string
67
+ modelCardUrl?: string
68
+ }
69
+ export const DEFAULT_MODEL_SELECTION: ModelSelection = {
70
+ embedding: 'bge-small-zh-v1.5',
71
+ rerank: 'bge-reranker-base',
72
+ }
73
+
74
+ export function isModelSelection(value: unknown): value is ModelSelection {
75
+ if (!value || typeof value !== 'object') return false
76
+ const selection = value as Record<string, unknown>
77
+ return EMBEDDING_MODELS.some(model => model.id === selection.embedding)
78
+ && RERANK_MODELS.some(model => model.id === selection.rerank)
79
+ }
80
+
81
+ export function parseModelSelection(value: unknown, fallback = DEFAULT_MODEL_SELECTION): ModelSelection {
82
+ if (value === undefined || value === null) return { ...fallback }
83
+ if (!isModelSelection(value)) throw new Error('模型选择无效,请重新选择向量模型和重排模型')
84
+ return { embedding: value.embedding, rerank: value.rerank }
85
+ }
86
+
87
+ export function embeddingModel(id: EmbeddingModelId) {
88
+ return EMBEDDING_MODELS.find(model => model.id === id)!
89
+ }
90
+
91
+ export function rerankModel(id: RerankModelId) {
92
+ return RERANK_MODELS.find(model => model.id === id)!
93
+ }
94
+
95
+ export function modelOptionsView() {
96
+ const present = <T extends { id: string; label: string; download: string; description: string; repo: string }>(model: T) => ({
97
+ id: model.id, label: model.label, download: model.download, description: model.description,
98
+ modelCardUrl: `https://huggingface.co/${model.repo}`,
99
+ ...('dimension' in model ? { dimension: model.dimension as number } : {}),
100
+ })
101
+ return {
102
+ embedding: EMBEDDING_MODELS.map(present),
103
+ rerank: RERANK_MODELS.map(present),
104
+ }
105
+ }
@@ -0,0 +1,29 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { mkdir, rename, writeFile } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+ import { DEFAULT_MODEL_SELECTION, embeddingModel, isModelSelection, type ModelSelection } from './model-catalog.js'
5
+
6
+ export const MODEL_SELECTION_FILE = 'environment-model-selection.json'
7
+
8
+ export function readModelSelection(resourceRoot: string): ModelSelection {
9
+ try {
10
+ const value: unknown = JSON.parse(readFileSync(join(resourceRoot, MODEL_SELECTION_FILE), 'utf8'))
11
+ if (isModelSelection(value)) return { embedding: value.embedding, rerank: value.rerank }
12
+ } catch { /* first install or damaged selection: use the shipped defaults */ }
13
+ return { ...DEFAULT_MODEL_SELECTION }
14
+ }
15
+
16
+ export function selectedEmbedding(resourceRoot: string | undefined): { path?: string; dimension?: number } {
17
+ if (!resourceRoot) return {}
18
+ const model = embeddingModel(readModelSelection(resourceRoot).embedding)
19
+ const path = join(resourceRoot, 'models', model.directory)
20
+ return existsSync(path) ? { path, dimension: model.dimension } : {}
21
+ }
22
+
23
+ export async function writeModelSelection(resourceRoot: string, selection: ModelSelection): Promise<void> {
24
+ await mkdir(resourceRoot, { recursive: true })
25
+ const destination = join(resourceRoot, MODEL_SELECTION_FILE)
26
+ const temporary = `${destination}.${process.pid}.tmp`
27
+ await writeFile(temporary, `${JSON.stringify(selection, null, 2)}\n`, 'utf8')
28
+ await rename(temporary, destination)
29
+ }
@@ -5,8 +5,10 @@ import { dirname, isAbsolute, join } from 'node:path'
5
5
  import { embeddingFingerprint } from '../compiler/embedding-identity.js'
6
6
  import type { RuntimeKnowledgeConfig } from '../types.js'
7
7
  import { installRequiredEnvironment, requirementsFile } from './install.js'
8
- import { ENVIRONMENT_PROBE_VERSION, PYTHON_PROBE } from './manifest.js'
8
+ import { modelOptionsView, parseModelSelection, type ModelSelection } from './model-catalog.js'
9
+ import { ENVIRONMENT_PROBE_VERSION } from './manifest.js'
9
10
  import { runProcess } from './process.js'
11
+ import { readModelSelection, writeModelSelection } from './selection.js'
10
12
  import type { EnvironmentItem, EnvironmentItemId, EnvironmentReport, EnvironmentView } from './types.js'
11
13
 
12
14
  export interface EnvironmentOptions {
@@ -18,11 +20,22 @@ export interface EnvironmentOptions {
18
20
  install?: typeof installRequiredEnvironment
19
21
  }
20
22
 
21
- const labels = { python: 'Python 运行环境', embedding: '向量模型', rerank: '重排模型' }
23
+ const labels = { embedding: '向量模型', rerank: '重排模型' }
22
24
  function blankReport(): EnvironmentReport {
23
- return { state: 'unchecked', required: true, items: (['python', 'embedding', 'rerank'] as const).map(id => ({ id, label: labels[id], state: 'unchecked', message: '首次使用前必须完成环境自检' })) }
25
+ return { state: 'unchecked', required: true, items: (['embedding', 'rerank'] as const).map(id => ({ id, label: labels[id], state: 'unchecked', message: '首次使用前必须完成模型推理自检' })) }
24
26
  }
25
27
  function stamp(path: string): unknown { try { const value = statSync(path); return [value.size, value.mtimeMs] } catch { return null } }
28
+ function sitePackagesPath(pythonPath: string): string {
29
+ const environmentRoot = dirname(dirname(pythonPath))
30
+ const windows = join(environmentRoot, 'Lib', 'site-packages')
31
+ try { if (statSync(windows).isDirectory()) return windows } catch { /* use the platform-neutral lib layout */ }
32
+ const libraryRoot = join(environmentRoot, 'lib')
33
+ try {
34
+ const versionDirectory = readdirSync(libraryRoot).find(name => /^python\d+\.\d+$/.test(name))
35
+ if (versionDirectory) return join(libraryRoot, versionDirectory, 'site-packages')
36
+ } catch { /* no venv/site-packages directory */ }
37
+ return join(libraryRoot, 'site-packages')
38
+ }
26
39
 
27
40
  export function modelFilesProblem(path: string): string | undefined {
28
41
  if (!isAbsolute(path)) return '尚未准备本地模型,请安装默认资源或填写已下载模型的完整路径'
@@ -55,13 +68,14 @@ export class KnowledgeEnvironment {
55
68
  probe: ENVIRONMENT_PROBE_VERSION, paths,
56
69
  provider: config.embeddingProvider, rerank: config.rerankProvider, device: config.embeddingDevice, dimension: config.embeddingDimension, endpoint: config.embeddingBaseUrl,
57
70
  embedding: embeddingFingerprint(paths.embeddingModel, this.options.resourceRoot), rerankModel: embeddingFingerprint(paths.rerankModel, this.options.resourceRoot),
58
- python: stamp(paths.python), venv: stamp(join(env, 'pyvenv.cfg')), dependencies: stamp(join(env, 'lib', 'python3.11', 'site-packages')), requirements: stamp(requirementsFile),
71
+ python: stamp(paths.python), venv: stamp(join(env, 'pyvenv.cfg')), dependencies: stamp(sitePackagesPath(paths.python)), requirements: stamp(requirementsFile),
59
72
  })).digest('hex')
60
73
  }
61
74
 
62
75
  view(): EnvironmentView {
63
76
  if (!this.checkPending && this.report.state === 'ready' && this.report.fingerprint !== this.fingerprint()) this.report = blankReport()
64
- return structuredClone({ report: this.report, ...(this.job ? { job: this.job } : {}) })
77
+ const selection = readModelSelection(this.options.resourceRoot)
78
+ return structuredClone({ report: this.report, selection, models: modelOptionsView(), ...(this.job ? { job: this.job } : {}) })
65
79
  }
66
80
  invalidate(): void { if (!this.checkPending) this.report = blankReport() }
67
81
 
@@ -94,20 +108,6 @@ export class KnowledgeEnvironment {
94
108
  const { config, paths } = this.options.snapshot()
95
109
  this.report = blankReport()
96
110
  this.report.state = 'checking'
97
- this.item('python', { state: 'checking', path: paths.python, message: '检查解释器与依赖' })
98
- let pythonReady = false
99
- try {
100
- const output = await (this.options.run ?? runProcess)(paths.python, ['-c', PYTHON_PROBE], { signal, timeoutMs: 60_000 })
101
- const line = output.split(/\r?\n/).filter(line => line.startsWith('{')).pop()
102
- const result = JSON.parse(line ?? '{}') as { version?: number[]; missing?: string[] }
103
- if (!result.version || result.version[0] !== 3 || result.version[1]! < 10 || result.version[1]! >= 14) throw new Error('需要 Python 3.10–3.13,默认安装 Python 3.11')
104
- if (!Array.isArray(result.missing)) throw new Error('Python 自检返回格式不完整')
105
- if (result.missing.length) throw new Error(`依赖缺失或无法导入:${result.missing.join(';')}`)
106
- pythonReady = true
107
- this.item('python', { state: 'ready', message: `Python ${result.version.join('.')} 与推理依赖可用` })
108
- } catch (cause) {
109
- this.item('python', { state: 'failed', message: cause instanceof Error ? cause.message : String(cause) })
110
- }
111
111
  for (const id of ['embedding', 'rerank'] as const) {
112
112
  if (signal.aborted || this.disposed) break
113
113
  const path = id === 'embedding' ? paths.embeddingModel : paths.rerankModel
@@ -116,7 +116,6 @@ export class KnowledgeEnvironment {
116
116
  const problem = disabled ? '此能力是必需项,请启用模型'
117
117
  : id === 'embedding' && config.embeddingProvider === 'ollama' ? undefined : modelFilesProblem(path)
118
118
  if (problem) { this.item(id, { state: 'missing', message: problem }); continue }
119
- if (!pythonReady) { this.item(id, { state: 'unchecked', message: '先修复 Python,再检查模型推理' }); continue }
120
119
  if (!deep) { this.item(id, { state: 'unchecked', message: '资源已找到,尚未完成实际推理自检' }); continue }
121
120
  this.item(id, { state: 'checking', message: '加载模型并运行推理自检,首次加载可能较慢' })
122
121
  try {
@@ -125,8 +124,12 @@ export class KnowledgeEnvironment {
125
124
  } catch (cause) { this.item(id, { state: 'failed', message: cause instanceof Error ? cause.message : String(cause) }) }
126
125
  }
127
126
  const changed = fingerprint !== this.fingerprint()
128
- if (changed) this.item('python', { state: 'unchecked', message: '自检期间配置或资源发生变化,请重新自检' })
129
- if (signal.aborted || this.disposed) this.item('python', { state: 'unchecked', message: '自检已停止,请重新运行' })
127
+ if (changed) this.report.items = this.report.items.map(item => item.state === 'ready'
128
+ ? { ...item, state: 'unchecked', message: '自检期间配置或资源发生变化,请重新自检' }
129
+ : item)
130
+ if (signal.aborted || this.disposed) this.report.items = this.report.items.map(item => item.state === 'checking'
131
+ ? { ...item, state: 'unchecked', message: '自检已停止,请重新运行' }
132
+ : item)
130
133
  this.report.state = this.report.items.every(item => item.state === 'ready') ? 'ready' : 'blocked'
131
134
  this.report.checkedAt = new Date().toISOString()
132
135
  this.report.fingerprint = fingerprint
@@ -134,9 +137,13 @@ export class KnowledgeEnvironment {
134
137
  return structuredClone(this.report)
135
138
  }
136
139
 
137
- start(action: 'check' | 'install'): EnvironmentView {
140
+ start(action: 'check' | 'install', requestedSelection?: unknown): EnvironmentView {
138
141
  if (this.disposed) throw new Error('环境服务已关闭')
139
142
  if (this.job?.state === 'running') return this.view()
143
+ const previousSelection = readModelSelection(this.options.resourceRoot)
144
+ const selection: ModelSelection = action === 'install'
145
+ ? parseModelSelection(requestedSelection, previousSelection)
146
+ : previousSelection
140
147
  this.checkController?.abort()
141
148
  this.controller = new AbortController()
142
149
  const controller = this.controller
@@ -154,9 +161,9 @@ export class KnowledgeEnvironment {
154
161
  await (this.options.install ?? installRequiredEnvironment)(this.options.resourceRoot, controller.signal, label => {
155
162
  job.stage = label
156
163
  for (const item of this.report.items) if (item.state === 'checking') this.item(item.id, { state: 'unchecked', message: '准备步骤已完成,等待实际自检' })
157
- const id = label.includes('向量') ? 'embedding' : label.includes('重排') ? 'rerank' : 'python'
158
- this.item(id, { state: 'checking', message: label })
159
- }, log)
164
+ const id = label.includes('向量') ? 'embedding' : label.includes('重排') ? 'rerank' : undefined
165
+ if (id) this.item(id, { state: 'checking', message: label })
166
+ }, log, this.options.run ?? runProcess, selection)
160
167
  this.options.reload()
161
168
  } else this.options.reload()
162
169
  job.stage = '运行实际推理自检'
@@ -167,6 +174,19 @@ export class KnowledgeEnvironment {
167
174
  await writeFile(join(this.options.resourceRoot, 'environment-last-check.json'), JSON.stringify(report, null, 2))
168
175
  job.state = 'succeeded'; job.stage = '环境已就绪'
169
176
  } catch (cause) {
177
+ if (action === 'install') {
178
+ const activeSelection = readModelSelection(this.options.resourceRoot)
179
+ if (activeSelection.embedding !== previousSelection.embedding || activeSelection.rerank !== previousSelection.rerank) {
180
+ try {
181
+ await writeModelSelection(this.options.resourceRoot, previousSelection)
182
+ this.options.reload()
183
+ this.report = blankReport()
184
+ log('所选模型未通过自检,已恢复此前启用的模型')
185
+ } catch (rollbackCause) {
186
+ log(`恢复此前模型选择失败:${rollbackCause instanceof Error ? rollbackCause.message : String(rollbackCause)}`)
187
+ }
188
+ }
189
+ }
170
190
  job.state = controller.signal.aborted ? 'cancelled' : 'failed'
171
191
  job.error = controller.signal.aborted ? '已停止,已下载的资源可在下次重试时复用' : cause instanceof Error ? cause.message : String(cause)
172
192
  job.stage = controller.signal.aborted ? '已停止' : '准备未完成'
@@ -1,5 +1,7 @@
1
1
  /** Installation state is deployment-wide; it is not knowledge or a build artifact. */
2
- export type EnvironmentItemId = 'python' | 'embedding' | 'rerank'
2
+ import type { ModelOptionView, ModelSelection } from './model-catalog.js'
3
+
4
+ export type EnvironmentItemId = 'embedding' | 'rerank'
3
5
  export type EnvironmentItemState = 'unchecked' | 'checking' | 'ready' | 'missing' | 'failed'
4
6
  export interface EnvironmentItem {
5
7
  id: EnvironmentItemId
@@ -27,4 +29,6 @@ export interface EnvironmentJob {
27
29
  export interface EnvironmentView {
28
30
  report: EnvironmentReport
29
31
  job?: EnvironmentJob
32
+ selection: ModelSelection
33
+ models: { embedding: ModelOptionView[]; rerank: ModelOptionView[] }
30
34
  }
package/src/images.ts ADDED
@@ -0,0 +1,203 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { readFile, stat } from 'node:fs/promises'
3
+ import { basename, extname, join } from 'node:path'
4
+ import { ASSETS_DIR, PUBLISHED_IMAGE_MARKER } from './layout.js'
5
+ import { writeBufferAtomic, writeTextAtomic } from './fs-atomic.js'
6
+
7
+ /** Maximum decoded image size accepted by `images.publish`. */
8
+ export const MAX_PUBLISHED_IMAGE_BYTES = 20 * 1024 * 1024
9
+
10
+ const IMAGE_FORMATS = [
11
+ { mimeType: 'image/png', extension: '.png' },
12
+ { mimeType: 'image/jpeg', extension: '.jpg' },
13
+ { mimeType: 'image/gif', extension: '.gif' },
14
+ { mimeType: 'image/webp', extension: '.webp' },
15
+ { mimeType: 'image/avif', extension: '.avif' },
16
+ { mimeType: 'image/bmp', extension: '.bmp' },
17
+ { mimeType: 'image/svg+xml', extension: '.svg' },
18
+ ] as const
19
+
20
+ export class ImagePublishError extends Error {
21
+ constructor(readonly code: string, message: string, readonly suggestion?: string) {
22
+ super(message)
23
+ this.name = 'ImagePublishError'
24
+ }
25
+ }
26
+
27
+ export interface PublishKnowledgeImageInput {
28
+ filePath?: unknown
29
+ contentBase64?: unknown
30
+ filename?: unknown
31
+ mimeType?: unknown
32
+ }
33
+
34
+ export interface PublishedKnowledgeImage {
35
+ kb: string
36
+ filename: string
37
+ path: string
38
+ url: string
39
+ markdown: string
40
+ mimeType: string
41
+ bytes: number
42
+ sha256: string
43
+ }
44
+
45
+ function invalid(message: string, suggestion?: string): never {
46
+ throw new ImagePublishError('IMAGE_INVALID', message, suggestion)
47
+ }
48
+
49
+ function normalizeMimeType(value: string): string {
50
+ const mimeType = value.split(';', 1)[0]!.trim().toLowerCase()
51
+ return mimeType === 'image/jpg' ? 'image/jpeg' : mimeType
52
+ }
53
+
54
+ function detectImageMimeType(bytes: Buffer): string | undefined {
55
+ if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'image/png'
56
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg'
57
+ if (bytes.length >= 6 && (bytes.toString('ascii', 0, 6) === 'GIF87a' || bytes.toString('ascii', 0, 6) === 'GIF89a')) return 'image/gif'
58
+ if (bytes.length >= 12 && bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP') return 'image/webp'
59
+ if (bytes.length >= 12 && bytes.toString('ascii', 0, 2) === 'BM') return 'image/bmp'
60
+ if (bytes.length >= 12 && bytes.toString('ascii', 4, 8) === 'ftyp') {
61
+ const brands = bytes.toString('ascii', 8, Math.min(bytes.length, 32))
62
+ if (/(?:avif|avis)/.test(brands)) return 'image/avif'
63
+ }
64
+ // SVG is text, so recognize its root element after an optional XML declaration.
65
+ const text = bytes.toString('utf8', 0, Math.min(bytes.length, 4096)).replace(/^\uFEFF/, '').trimStart()
66
+ if (/^(?:<\?xml\b[^>]*>\s*)?<svg(?:\s|>)/i.test(text)) return 'image/svg+xml'
67
+ return undefined
68
+ }
69
+
70
+ function decodeBase64(value: string): { bytes: Buffer; declaredMimeType?: string } {
71
+ let encoded = value.trim()
72
+ let declaredMimeType: string | undefined
73
+ if (encoded.startsWith('data:')) {
74
+ const match = /^data:([^;,]+);base64,([\s\S]*)$/i.exec(encoded)
75
+ if (!match) invalid('content_base64 的 Data URI 格式无效。', '传标准 Base64,或传 data:image/png;base64,... 形式。')
76
+ declaredMimeType = normalizeMimeType(match[1]!)
77
+ encoded = match[2]!
78
+ }
79
+ encoded = encoded.replace(/\s+/g, '')
80
+ if (encoded.length > Math.ceil(MAX_PUBLISHED_IMAGE_BYTES * 4 / 3) + 8) {
81
+ throw new ImagePublishError('IMAGE_TOO_LARGE', `图片超过 ${MAX_PUBLISHED_IMAGE_BYTES / 1024 / 1024} MiB 限制。`)
82
+ }
83
+ if (encoded.length % 4 === 1 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}={0,2}|[A-Za-z0-9+/]{3}=?)?$/.test(encoded)) {
84
+ invalid('content_base64 不是有效的 Base64。')
85
+ }
86
+ const bytes = Buffer.from(encoded, 'base64')
87
+ if (bytes.length === 0) invalid('图片内容不能为空。')
88
+ if (bytes.length > MAX_PUBLISHED_IMAGE_BYTES) {
89
+ throw new ImagePublishError('IMAGE_TOO_LARGE', `图片超过 ${MAX_PUBLISHED_IMAGE_BYTES / 1024 / 1024} MiB 限制。`)
90
+ }
91
+ if (bytes.toString('base64').replace(/=+$/, '') !== encoded.replace(/=+$/, '')) invalid('content_base64 不是有效的 Base64。')
92
+ return { bytes, ...(declaredMimeType ? { declaredMimeType } : {}) }
93
+ }
94
+
95
+ function safeFilename(value: unknown, fallback: string, extension: string): string {
96
+ const raw = typeof value === 'string' ? value.trim() : ''
97
+ const leaf = (raw.replace(/\\/g, '/').split('/').pop() ?? '').replace(/[\u0000-\u001f\u007f]/g, '')
98
+ const suppliedExtension = extname(leaf)
99
+ const stem = (suppliedExtension ? leaf.slice(0, -suppliedExtension.length) : leaf)
100
+ .replace(/[^\p{L}\p{N}._ -]/gu, '_')
101
+ .replace(/\s+/g, '-')
102
+ .replace(/^\.+$/, '')
103
+ .slice(0, 120)
104
+ return `${stem || fallback}${extension}`
105
+ }
106
+
107
+ async function readExisting(path: string): Promise<Buffer | undefined> {
108
+ try { return await readFile(path) } catch (cause) {
109
+ if ((cause as NodeJS.ErrnoException)?.code === 'ENOENT') return undefined
110
+ throw cause
111
+ }
112
+ }
113
+
114
+ /** Store one validated image using the knowledge assets content-hash bucket layout. */
115
+ export async function publishKnowledgeImage(
116
+ rootDir: string,
117
+ kb: string,
118
+ input: PublishKnowledgeImageInput,
119
+ ): Promise<PublishedKnowledgeImage> {
120
+ const filePath = typeof input.filePath === 'string' ? input.filePath.trim() : ''
121
+ const base64 = typeof input.contentBase64 === 'string' ? input.contentBase64 : ''
122
+ if (Boolean(filePath) === Boolean(base64)) {
123
+ invalid('file_path 和 content_base64 必须且只能提供一个。')
124
+ }
125
+
126
+ let bytes: Buffer
127
+ let sourceName = ''
128
+ let dataUriMimeType: string | undefined
129
+ if (filePath) {
130
+ let details
131
+ try { details = await stat(filePath) } catch (cause) {
132
+ throw new ImagePublishError('IMAGE_SOURCE_NOT_FOUND', `图片文件无法读取:${cause instanceof Error ? cause.message : String(cause)}`)
133
+ }
134
+ if (!details.isFile()) throw new ImagePublishError('IMAGE_SOURCE_NOT_FILE', 'file_path 必须指向一个文件。')
135
+ if (details.size > MAX_PUBLISHED_IMAGE_BYTES) {
136
+ throw new ImagePublishError('IMAGE_TOO_LARGE', `图片超过 ${MAX_PUBLISHED_IMAGE_BYTES / 1024 / 1024} MiB 限制。`)
137
+ }
138
+ try { bytes = await readFile(filePath) } catch (cause) {
139
+ throw new ImagePublishError('IMAGE_SOURCE_NOT_FOUND', `图片文件无法读取:${cause instanceof Error ? cause.message : String(cause)}`)
140
+ }
141
+ sourceName = basename(filePath)
142
+ } else {
143
+ const decoded = decodeBase64(base64)
144
+ bytes = decoded.bytes
145
+ dataUriMimeType = decoded.declaredMimeType
146
+ }
147
+
148
+ if (bytes.length === 0) invalid('图片内容不能为空。')
149
+ if (bytes.length > MAX_PUBLISHED_IMAGE_BYTES) {
150
+ throw new ImagePublishError('IMAGE_TOO_LARGE', `图片超过 ${MAX_PUBLISHED_IMAGE_BYTES / 1024 / 1024} MiB 限制。`)
151
+ }
152
+ const mimeType = detectImageMimeType(bytes)
153
+ if (!mimeType) invalid('文件内容不是支持的图片格式(PNG、JPEG、GIF、WebP、AVIF、BMP、SVG)。')
154
+ const declaredMimeType = typeof input.mimeType === 'string' && input.mimeType.trim()
155
+ ? normalizeMimeType(input.mimeType)
156
+ : dataUriMimeType
157
+ if (declaredMimeType && declaredMimeType !== mimeType) {
158
+ invalid(`声明的 MIME 类型 ${declaredMimeType} 与图片内容 ${mimeType} 不一致。`)
159
+ }
160
+
161
+ const format = IMAGE_FORMATS.find(item => item.mimeType === mimeType)!
162
+ const sha256 = createHash('sha256').update(bytes).digest('hex')
163
+ const bucket = sha256.slice(0, 12)
164
+ const filename = safeFilename(input.filename ?? sourceName, `image-${bucket}`, format.extension)
165
+ const assetDir = join(rootDir, ASSETS_DIR, bucket)
166
+ const assetPath = join(assetDir, filename)
167
+ let existing = await readExisting(assetPath)
168
+ let storedFilename = filename
169
+
170
+ // The established bucket uses only the first 12 hash characters. If that prefix and the
171
+ // supplied filename both collide with different bytes, add more hash characters instead of
172
+ // replacing the existing asset.
173
+ if (existing && !existing.equals(bytes)) {
174
+ const extension = format.extension
175
+ const stem = filename.slice(0, -extension.length)
176
+ storedFilename = `${stem}-${sha256.slice(12, 20)}${extension}`
177
+ existing = await readExisting(join(assetDir, storedFilename))
178
+ if (existing && !existing.equals(bytes)) {
179
+ storedFilename = `image-${sha256}${extension}`
180
+ existing = await readExisting(join(assetDir, storedFilename))
181
+ }
182
+ }
183
+
184
+ if (existing && !existing.equals(bytes)) {
185
+ throw new ImagePublishError('ASSET_NAME_COLLISION', '目标文件名已被不同内容占用,未覆盖现有图片。')
186
+ }
187
+ if (!existing?.equals(bytes)) await writeBufferAtomic(join(assetDir, storedFilename), bytes)
188
+ // Standalone hosted images have no SourceRaw or card by design. Keep that intent beside the
189
+ // asset so the orphan sweep does not mistake it for a disposable extraction artifact.
190
+ await writeTextAtomic(join(assetDir, PUBLISHED_IMAGE_MARKER), 'images.publish\n')
191
+ const relativePath = `${ASSETS_DIR}/${bucket}/${storedFilename}`
192
+ const url = `/knowledge/assets/${encodeURIComponent(kb)}/${relativePath.split('/').map(encodeURIComponent).join('/')}`
193
+ return {
194
+ kb,
195
+ filename: storedFilename,
196
+ path: relativePath,
197
+ url,
198
+ markdown: `![${storedFilename}](${url})`,
199
+ mimeType,
200
+ bytes: bytes.length,
201
+ sha256,
202
+ }
203
+ }
package/src/index.ts CHANGED
@@ -44,6 +44,7 @@ import type { DiagnosisRunRecord } from './diagnosis/types.js'
44
44
  import { KNOWLEDGE_SETTINGS_SCHEMA, SETTINGS_NAMESPACE } from './settings-schema.js'
45
45
  import { DEFAULT_KB_ID, KnowledgeBases } from './knowledge-bases.js'
46
46
  import type { KnowledgeQuery } from './knowledge.js'
47
+ import { ImagePublishError, publishKnowledgeImage } from './images.js'
47
48
  import {
48
49
  applyConvert, CONVERT_SETTINGS_NAMESPACE, CONVERT_SETTINGS_SCHEMA, MOUNT_PATH as CONVERT_MOUNT_PATH,
49
50
  type ConvertHost,
@@ -555,7 +556,7 @@ export function apply(ctx: Context, pluginConfig: KnowledgePluginConfig = {}): v
555
556
  // 0044:非默认库的 runtime 也要拿到模型接缝。只传 `config` 不够 —— 默认库走上面
556
557
  // 那个 `new KnowledgeRuntime(... runtimeOptions())`,别的库走注册表这条支路,
557
558
  // 两边必须都带上,否则「新建一个库 → 导入文档」静默变成规则抽取。
558
- runtimeOptions: () => ({ modelSeam: modelSeamFor, environmentGuard: () => environment.requireReady() }),
559
+ runtimeOptions: () => ({ resourceRoot: resourceRoot(config.agentRoot), modelSeam: modelSeamFor, environmentGuard: () => environment.requireReady() }),
559
560
  })
560
561
  compileModelForBase = id => bases.compileModelOf(id)
561
562
  runtime.baseId = bases.defaultId
@@ -776,7 +777,7 @@ export function apply(ctx: Context, pluginConfig: KnowledgePluginConfig = {}): v
776
777
  applyIntent(ctx, { intentRoot: () => intentRoot(config.agentRoot) })
777
778
 
778
779
  /**
779
- * 需要本轮用户审批的写工具。
780
+ * 需要本轮用户审批的知识写工具。`images.publish` 只写入本地素材目录,按用户要求免审批。
780
781
  *
781
782
  * 0044 换过一批名字:`write_knowledge_card`(只接受已确认提议)退役了,因为正文现在
782
783
  * 只读、卡片由编译直接写入;`list_card_proposals` 也退役了,因为待办队列不存在了。
@@ -823,6 +824,31 @@ export function apply(ctx: Context, pluginConfig: KnowledgePluginConfig = {}): v
823
824
  })),
824
825
  })))
825
826
 
827
+ ctx.tools.register(tool(
828
+ 'images.publish',
829
+ 'Publish one local image into a knowledge base assets bucket without user approval. Pass exactly one of `file_path` or `content_base64` (standard Base64 or a data URI); optional `filename` overrides the stored name, and optional `mime_type` must match the file bytes. Supports PNG, JPEG, GIF, WebP, AVIF, BMP and SVG up to 20 MiB. Returns the local asset URL and Markdown image reference.',
830
+ {
831
+ file_path: { type: 'string', description: '宿主可读取的本地图片文件路径;与 content_base64 二选一' },
832
+ content_base64: { type: 'string', description: '图片的标准 Base64,或 data:image/...;base64,...;与 file_path 二选一' },
833
+ filename: { type: 'string', description: '可选文件名;默认使用 file_path 的文件名,Base64 输入默认 image-<hash>.<ext>' },
834
+ mime_type: { type: 'string', description: '可选 MIME 类型;必须与图片二进制内容一致' },
835
+ kb: { type: 'string', description: '知识库 id;省略时写入默认库' },
836
+ },
837
+ async args => withRuntime(args.kb, async target => {
838
+ try {
839
+ return success(await publishKnowledgeImage(target.rootDir, target.baseId, {
840
+ filePath: args.file_path,
841
+ contentBase64: args.content_base64,
842
+ filename: args.filename,
843
+ mimeType: args.mime_type,
844
+ }))
845
+ } catch (cause) {
846
+ if (cause instanceof ImagePublishError) return failure({}, cause.code, cause.message, false, cause.suggestion)
847
+ return failure({}, 'IMAGE_PUBLISH_FAILED', `图片写入失败:${cause instanceof Error ? cause.message : String(cause)}`, true)
848
+ }
849
+ }),
850
+ ))
851
+
826
852
  ctx.tools.register(tool('add_source', 'Add or update a SourceRaw document (原始可追溯文档). DSH requests one user approval before execution.', { title: { type: 'string' }, content: { type: 'string', required: true }, external_id: { type: 'string' }, metadata: { type: 'json' }, source_id: { type: 'string' }, allow_duplicate_title: { type: 'boolean', description: '默认 false:标题与已有来源重复时拒收(SOURCE_TITLE_DUPLICATED),要求改标题。传 true 才允许并存。' }, kb: { type: 'string' }, session_key: { type: 'string' }, task_id: { type: 'string' }, subtask_id: { type: 'string' }, run_id: { type: 'string' }, turn_id: { type: 'string' }, trace_id: { type: 'string' }, domain: { type: 'string' } }, async (args, exec) => withRuntime(args.kb, target => target.addSource({ title: args.title, content: args.content, externalId: args.external_id, metadata: args.metadata, sourceId: args.source_id, ...(args.allow_duplicate_title === true ? { allowDuplicateTitle: true } : {}), lineage: lineageOf(args, exec) })), {
827
853
  call: addSourceCallView,
828
854
  meta: (args, value) => addSourceMetaOf(args, dataOf(value)),
@@ -1022,10 +1048,10 @@ export function apply(ctx: Context, pluginConfig: KnowledgePluginConfig = {}): v
1022
1048
  { kind: 'exact' as const, path: '/knowledge/environment/prepare', handler: async (req: IncomingMessage, res: ServerResponse) => {
1023
1049
  if (req.method !== 'POST') return send(res, 405, envelopeError('METHOD_NOT_ALLOWED', '只支持 POST'))
1024
1050
  try {
1025
- const body = await readBody(req, 4000) as { action?: string } | null
1051
+ const body = await readBody(req, 4000) as { action?: string; selection?: unknown } | null
1026
1052
  if (body?.action === 'cancel') { environment.cancel(); return send(res, 200, success(environment.view())) }
1027
1053
  if (body?.action !== 'check' && body?.action !== 'install') return send(res, 400, failure({}, 'ACTION_INVALID', 'action 必须是 check、install 或 cancel', false))
1028
- return send(res, 200, success(environment.start(body.action)))
1054
+ return send(res, 200, success(environment.start(body.action, body.selection)))
1029
1055
  } catch (cause) { return send(res, 400, envelopeError('REQUEST_INVALID', cause instanceof Error ? cause.message : String(cause))) }
1030
1056
  } },
1031
1057
  { kind: 'exact' as const, path: '/knowledge/embedding/health', handler: async (req: IncomingMessage, res: ServerResponse) => {
package/src/layout.ts CHANGED
@@ -381,6 +381,8 @@ export function intentRoot(agentRoot: string): string {
381
381
  export const KNOWLEDGE_DB_FILE = 'knowledge.db'
382
382
  export const DERIVED_DB_FILE = 'derived.db'
383
383
  export const ASSETS_DIR = 'assets'
384
+ /** Hidden marker: standalone published images must survive source/card orphan sweeps. */
385
+ export const PUBLISHED_IMAGE_MARKER = '.dsh-published-image'
384
386
  /**
385
387
  * 原始输入的落点(0067)。与 {@link ASSETS_DIR} **并列且同构**:一个装内容里提取出来的
386
388
  * 图,一个装用户送进来的原件 —— 两者都是「从这个知识库里能直接拿到手的东西」,