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.
- package/README.md +15 -7
- package/lib/client.js +64 -7
- package/lib/types/environment/install.d.ts +2 -1
- package/lib/types/environment/install.d.ts.map +1 -1
- package/lib/types/environment/install.js +20 -11
- package/lib/types/environment/install.js.map +1 -1
- package/lib/types/environment/manifest.d.ts +203 -12
- package/lib/types/environment/manifest.d.ts.map +1 -1
- package/lib/types/environment/manifest.js +23 -21
- package/lib/types/environment/manifest.js.map +1 -1
- package/lib/types/environment/model-catalog.d.ts +191 -0
- package/lib/types/environment/model-catalog.d.ts.map +1 -0
- package/lib/types/environment/model-catalog.js +91 -0
- package/lib/types/environment/model-catalog.js.map +1 -0
- package/lib/types/environment/selection.d.ts +9 -0
- package/lib/types/environment/selection.d.ts.map +1 -0
- package/lib/types/environment/selection.js +29 -0
- package/lib/types/environment/selection.js.map +1 -0
- package/lib/types/environment/service.d.ts +1 -1
- package/lib/types/environment/service.d.ts.map +1 -1
- package/lib/types/environment/service.js +54 -33
- package/lib/types/environment/service.js.map +1 -1
- package/lib/types/environment/types.d.ts +7 -1
- package/lib/types/environment/types.d.ts.map +1 -1
- package/lib/types/images.d.ts +26 -0
- package/lib/types/images.d.ts.map +1 -0
- package/lib/types/images.js +196 -0
- package/lib/types/images.js.map +1 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +25 -3
- package/lib/types/index.js.map +1 -1
- package/lib/types/layout.d.ts +2 -0
- package/lib/types/layout.d.ts.map +1 -1
- package/lib/types/layout.js +2 -0
- package/lib/types/layout.js.map +1 -1
- package/lib/types/runtime.d.ts +9 -3
- package/lib/types/runtime.d.ts.map +1 -1
- package/lib/types/runtime.js +73 -16
- package/lib/types/runtime.js.map +1 -1
- package/lib/types/store/migrate.d.ts.map +1 -1
- package/lib/types/store/migrate.js +5 -4
- package/lib/types/store/migrate.js.map +1 -1
- package/lib/types/types.d.ts +1 -1
- package/lib/types/types.d.ts.map +1 -1
- package/lib/types/types.js +3 -4
- package/lib/types/types.js.map +1 -1
- package/package.json +1 -1
- package/python/prepare-models.py +1 -1
- package/scripts/setup-embedding.sh +11 -2
- package/src/client/EnvironmentSetup.tsx +54 -8
- package/src/environment/install.ts +22 -10
- package/src/environment/manifest.ts +25 -22
- package/src/environment/model-catalog.ts +105 -0
- package/src/environment/selection.ts +29 -0
- package/src/environment/service.ts +46 -26
- package/src/environment/types.ts +5 -1
- package/src/images.ts +203 -0
- package/src/index.ts +30 -4
- package/src/layout.ts +2 -0
- package/src/runtime.ts +68 -16
- package/src/store/migrate.ts +4 -4
- 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 {
|
|
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 = {
|
|
23
|
+
const labels = { embedding: '向量模型', rerank: '重排模型' }
|
|
22
24
|
function blankReport(): EnvironmentReport {
|
|
23
|
-
return { state: 'unchecked', required: true, items: (['
|
|
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(
|
|
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
|
-
|
|
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
|
|
129
|
-
|
|
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' :
|
|
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 ? '已停止' : '准备未完成'
|
package/src/environment/types.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/** Installation state is deployment-wide; it is not knowledge or a build artifact. */
|
|
2
|
-
|
|
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: ``,
|
|
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
|
-
|
|
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
|
* 图,一个装用户送进来的原件 —— 两者都是「从这个知识库里能直接拿到手的东西」,
|