@claudelaw/taichu 0.6.0
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/.dockerignore +13 -0
- package/Dockerfile +51 -0
- package/LICENSE +21 -0
- package/README.md +208 -0
- package/docker-compose.yml +42 -0
- package/docs/ROADMAP.md +101 -0
- package/docs/api/README.md +102 -0
- package/docs/architecture/001-zero-dependency-core.md +61 -0
- package/docs/architecture/002-structured-content-model.md +70 -0
- package/docs/architecture/003-hook-based-extension.md +82 -0
- package/docs/architecture/004-api-first-architecture.md +122 -0
- package/docs/architecture/README.md +24 -0
- package/docs/logo.svg +40 -0
- package/docs/research/ai-era-cms-user-research.md +247 -0
- package/docs/zh/README.md +81 -0
- package/docs/zh/guides/deploy.md +75 -0
- package/docs/zh/guides/mcp.md +84 -0
- package/docs/zh/guides/promotion.md +51 -0
- package/marketplace.json +78 -0
- package/package.json +60 -0
- package/packages/core/src/auth.js +158 -0
- package/packages/core/src/content-type.js +244 -0
- package/packages/core/src/core.test.js +406 -0
- package/packages/core/src/errors.js +60 -0
- package/packages/core/src/hooks.js +104 -0
- package/packages/core/src/index.js +16 -0
- package/packages/core/src/server.test.js +149 -0
- package/packages/core/src/sm-crypto.js +31 -0
- package/packages/core/src/sqlite-store.js +354 -0
- package/packages/core/src/store.js +174 -0
- package/packages/core/src/tokenizer.js +89 -0
- package/packages/core/src/vector-index.js +131 -0
- package/packages/llm-providers/src/index.js +181 -0
- package/packages/mcp/src/index.js +355 -0
- package/packages/server/public/admin/assets/index-DApxOVTx.js +191 -0
- package/packages/server/public/admin/assets/index-DtMvdQm9.css +1 -0
- package/packages/server/public/admin/index.html +28 -0
- package/packages/server/public/aurora/style.css +1173 -0
- package/packages/server/public/favicon.svg +46 -0
- package/packages/server/public/theme/index.html +288 -0
- package/packages/server/public/theme/style.css +133 -0
- package/packages/server/public/theme-minimal/index.html +223 -0
- package/packages/server/public/theme-minimal/style.css +109 -0
- package/packages/server/public/ws-test.html +106 -0
- package/packages/server/src/activitypub.js +228 -0
- package/packages/server/src/audit.js +104 -0
- package/packages/server/src/auth-provider.js +76 -0
- package/packages/server/src/body-parser.js +52 -0
- package/packages/server/src/bootstrap.js +272 -0
- package/packages/server/src/collab.js +154 -0
- package/packages/server/src/config.js +136 -0
- package/packages/server/src/context.js +86 -0
- package/packages/server/src/email.js +317 -0
- package/packages/server/src/index.js +195 -0
- package/packages/server/src/logger.js +78 -0
- package/packages/server/src/media-store.js +213 -0
- package/packages/server/src/middleware/auth.js +203 -0
- package/packages/server/src/middleware/cors.js +15 -0
- package/packages/server/src/middleware/error-handler.js +49 -0
- package/packages/server/src/middleware/rate-limit.js +118 -0
- package/packages/server/src/multipart.js +150 -0
- package/packages/server/src/notify.js +126 -0
- package/packages/server/src/pipeline.js +206 -0
- package/packages/server/src/plugin-installer.js +139 -0
- package/packages/server/src/plugin-manager.js +165 -0
- package/packages/server/src/relationships.js +217 -0
- package/packages/server/src/revisions.js +114 -0
- package/packages/server/src/router.js +194 -0
- package/packages/server/src/routes/activitypub.js +140 -0
- package/packages/server/src/routes/api.js +363 -0
- package/packages/server/src/routes/audit.js +222 -0
- package/packages/server/src/routes/auth.js +205 -0
- package/packages/server/src/routes/collab.js +90 -0
- package/packages/server/src/routes/export.js +77 -0
- package/packages/server/src/routes/graphql.js +344 -0
- package/packages/server/src/routes/media.js +169 -0
- package/packages/server/src/routes/plugin-marketplace.js +171 -0
- package/packages/server/src/routes/relationships.js +133 -0
- package/packages/server/src/routes/rss.js +92 -0
- package/packages/server/src/routes/sso.js +211 -0
- package/packages/server/src/routes/theme.js +119 -0
- package/packages/server/src/routes/webhook.js +94 -0
- package/packages/server/src/routes/wechat.js +115 -0
- package/packages/server/src/routes/workflow.js +157 -0
- package/packages/server/src/scheduler.js +96 -0
- package/packages/server/src/search.js +100 -0
- package/packages/server/src/server.test.js +295 -0
- package/packages/server/src/sso-analytics.js +78 -0
- package/packages/server/src/static.js +70 -0
- package/packages/server/src/theme-engine.js +119 -0
- package/packages/server/src/webhook.js +192 -0
- package/packages/server/src/websocket.js +308 -0
- package/scripts/cli.js +90 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLiteStore — 基于 sql.js (WASM) 的持久化存储
|
|
3
|
+
*
|
|
4
|
+
* 特点:
|
|
5
|
+
* - 纯 JavaScript + WASM,零原生编译依赖
|
|
6
|
+
* - 数据持久化到文件,重启不丢失
|
|
7
|
+
* - 支持 JSON 字段查询(SQLite JSON1 扩展)
|
|
8
|
+
* - 与 MemoryStore 共享同一接口
|
|
9
|
+
*
|
|
10
|
+
* sql.js 是 SQLite 编译为 WebAssembly,跨平台,无需 node-gyp。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { randomUUID } from 'node:crypto';
|
|
14
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
15
|
+
import { existsSync } from 'node:fs';
|
|
16
|
+
import { dirname, join } from 'node:path';
|
|
17
|
+
|
|
18
|
+
const SCHEMA_SQL = `
|
|
19
|
+
CREATE TABLE IF NOT EXISTS documents (
|
|
20
|
+
id TEXT PRIMARY KEY,
|
|
21
|
+
type TEXT NOT NULL,
|
|
22
|
+
data TEXT NOT NULL DEFAULT '{}',
|
|
23
|
+
status TEXT NOT NULL DEFAULT 'draft',
|
|
24
|
+
published_at TEXT,
|
|
25
|
+
tenant_id TEXT NOT NULL DEFAULT 'default',
|
|
26
|
+
created_by TEXT,
|
|
27
|
+
created_at TEXT NOT NULL,
|
|
28
|
+
updated_at TEXT NOT NULL,
|
|
29
|
+
meta TEXT NOT NULL DEFAULT '{}'
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
CREATE INDEX IF NOT EXISTS idx_documents_type ON documents(type);
|
|
33
|
+
CREATE INDEX IF NOT EXISTS idx_documents_status ON documents(status);
|
|
34
|
+
CREATE INDEX IF NOT EXISTS idx_documents_updated_at ON documents(updated_at);
|
|
35
|
+
CREATE INDEX IF NOT EXISTS idx_documents_type_status ON documents(type, status);
|
|
36
|
+
CREATE INDEX IF NOT EXISTS idx_documents_scheduled ON documents(status, published_at);
|
|
37
|
+
CREATE INDEX IF NOT EXISTS idx_documents_tenant ON documents(tenant_id);
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
const MIGRATION_SQL = [
|
|
41
|
+
`ALTER TABLE documents ADD COLUMN published_at TEXT;`,
|
|
42
|
+
`ALTER TABLE documents ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default';`
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const INDEX_MIGRATIONS = [
|
|
46
|
+
'CREATE INDEX IF NOT EXISTS idx_documents_scheduled ON documents(status, published_at)',
|
|
47
|
+
'CREATE INDEX IF NOT EXISTS idx_documents_tenant ON documents(tenant_id)'
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Create a SQLiteStore instance.
|
|
52
|
+
*
|
|
53
|
+
* @param {object} config
|
|
54
|
+
* @param {string} [config.dataDir] — 数据目录路径,默认 `.taichu/data`
|
|
55
|
+
* @param {string} [config.dbPath] — 数据库文件路径,默认 `{dataDir}/taichu.db`
|
|
56
|
+
* @returns {Promise<Store>}
|
|
57
|
+
*/
|
|
58
|
+
export async function createSQLiteStore(config = {}) {
|
|
59
|
+
// Lazy-load sql.js — only when SQLiteStore is actually used
|
|
60
|
+
const sqlModule = await import('sql.js');
|
|
61
|
+
|
|
62
|
+
let SQL = null;
|
|
63
|
+
let db = null;
|
|
64
|
+
let dbPath = null;
|
|
65
|
+
let dirty = false;
|
|
66
|
+
let flushTimer = null;
|
|
67
|
+
const FLUSH_INTERVAL = process.env.TAICHU_SQLITE_FLUSH_MS ? parseInt(process.env.TAICHU_SQLITE_FLUSH_MS) : 5000;
|
|
68
|
+
|
|
69
|
+
async function init() {
|
|
70
|
+
SQL = await sqlModule.default();
|
|
71
|
+
const dataDir = config.dataDir || join(process.cwd(), '.taichu', 'data');
|
|
72
|
+
dbPath = config.dbPath || join(dataDir, 'taichu.db');
|
|
73
|
+
|
|
74
|
+
// Ensure data directory exists
|
|
75
|
+
if (!existsSync(dataDir)) {
|
|
76
|
+
await mkdir(dataDir, { recursive: true });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Load existing database or create new one
|
|
80
|
+
if (existsSync(dbPath)) {
|
|
81
|
+
const buffer = await readFile(dbPath);
|
|
82
|
+
db = new SQL.Database(buffer);
|
|
83
|
+
} else {
|
|
84
|
+
db = new SQL.Database();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Run schema migration
|
|
88
|
+
db.run(SCHEMA_SQL);
|
|
89
|
+
|
|
90
|
+
// Run column migrations for existing databases
|
|
91
|
+
for (const mig of MIGRATION_SQL) {
|
|
92
|
+
try {
|
|
93
|
+
db.run(mig);
|
|
94
|
+
} catch (e) {
|
|
95
|
+
if (!e.message?.includes('duplicate column')) throw e;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Ensure indices exist (idempotent for new + migrated DBs)
|
|
100
|
+
for (const idx of INDEX_MIGRATIONS) {
|
|
101
|
+
try { db.run(idx); } catch (e) { /* ignore */ }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
await saveToDisk();
|
|
105
|
+
|
|
106
|
+
return db;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function saveToDisk() {
|
|
110
|
+
const data = db.export();
|
|
111
|
+
const buffer = Buffer.from(data);
|
|
112
|
+
await writeFile(dbPath, buffer);
|
|
113
|
+
dirty = false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Mark database as dirty and schedule a debounced flush */
|
|
117
|
+
function markDirty() {
|
|
118
|
+
dirty = true;
|
|
119
|
+
if (flushTimer) clearTimeout(flushTimer);
|
|
120
|
+
flushTimer = setTimeout(async () => {
|
|
121
|
+
if (dirty) await saveToDisk();
|
|
122
|
+
}, FLUSH_INTERVAL);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Flush on process exit
|
|
126
|
+
process.on('beforeExit', async () => {
|
|
127
|
+
if (dirty && db) await saveToDisk();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
function rowToDoc(row) {
|
|
131
|
+
if (!row) return null;
|
|
132
|
+
return {
|
|
133
|
+
id: row[0],
|
|
134
|
+
type: row[1],
|
|
135
|
+
data: JSON.parse(row[2]),
|
|
136
|
+
status: row[3],
|
|
137
|
+
publishedAt: row[4] || null,
|
|
138
|
+
tenantId: row[5] || 'default',
|
|
139
|
+
createdBy: row[6],
|
|
140
|
+
createdAt: row[7],
|
|
141
|
+
updatedAt: row[8],
|
|
142
|
+
meta: JSON.parse(row[9])
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Escape SQL string value (simple implementation)
|
|
148
|
+
*/
|
|
149
|
+
function esc(val) {
|
|
150
|
+
if (val === null || val === undefined) return 'NULL';
|
|
151
|
+
if (typeof val === 'number') return String(val);
|
|
152
|
+
return `'${String(val).replace(/'/g, "''")}'`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Initialize the database
|
|
156
|
+
await init();
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
async create(doc) {
|
|
160
|
+
const now = new Date().toISOString();
|
|
161
|
+
const id = doc.id || randomUUID();
|
|
162
|
+
const type = doc.type || 'default';
|
|
163
|
+
const data = JSON.stringify(doc.data || {});
|
|
164
|
+
const status = doc.status || 'draft';
|
|
165
|
+
const publishedAt = doc.publishedAt || null;
|
|
166
|
+
const tenantId = doc.tenantId || 'default';
|
|
167
|
+
const createdBy = doc.createdBy || null;
|
|
168
|
+
const createdAt = doc.createdAt || now;
|
|
169
|
+
const meta = JSON.stringify(doc.meta || {});
|
|
170
|
+
|
|
171
|
+
db.run(
|
|
172
|
+
`INSERT INTO documents (id, type, data, status, published_at, tenant_id, created_by, created_at, updated_at, meta)
|
|
173
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
174
|
+
[id, type, data, status, publishedAt, tenantId, createdBy, createdAt, now, meta]
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
markDirty();
|
|
178
|
+
|
|
179
|
+
return { id, type, data: JSON.parse(data), status, publishedAt, tenantId, createdBy, createdAt, updatedAt: now, meta: JSON.parse(meta) };
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
async get(id) {
|
|
183
|
+
const stmt = db.prepare('SELECT * FROM documents WHERE id = ?');
|
|
184
|
+
stmt.bind([id]);
|
|
185
|
+
|
|
186
|
+
if (stmt.step()) {
|
|
187
|
+
const row = stmt.getAsObject();
|
|
188
|
+
stmt.free();
|
|
189
|
+
return rowToDocFromObj(row);
|
|
190
|
+
}
|
|
191
|
+
stmt.free();
|
|
192
|
+
return null;
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
async list(options = {}) {
|
|
196
|
+
const conditions = [];
|
|
197
|
+
const params = [];
|
|
198
|
+
|
|
199
|
+
if (options.type) {
|
|
200
|
+
conditions.push('type = ?');
|
|
201
|
+
params.push(options.type);
|
|
202
|
+
}
|
|
203
|
+
if (options.status) {
|
|
204
|
+
conditions.push('status = ?');
|
|
205
|
+
params.push(options.status);
|
|
206
|
+
}
|
|
207
|
+
if (options.tenantId) {
|
|
208
|
+
conditions.push('tenant_id = ?');
|
|
209
|
+
params.push(options.tenantId);
|
|
210
|
+
}
|
|
211
|
+
if (options.search) {
|
|
212
|
+
conditions.push("(data LIKE ? OR type LIKE ?)");
|
|
213
|
+
const q = `%${options.search}%`;
|
|
214
|
+
params.push(q, q);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
218
|
+
|
|
219
|
+
// Whitelist validation for ORDER BY columns — prevents SQL injection
|
|
220
|
+
const ALLOWED_ORDER_COLUMNS = new Set(['id', 'type', 'status', 'created_at', 'updated_at']);
|
|
221
|
+
const orderBy = ALLOWED_ORDER_COLUMNS.has(options.orderBy) ? options.orderBy : 'updated_at';
|
|
222
|
+
const order = options.order === 'asc' ? 'ASC' : 'DESC';
|
|
223
|
+
// LIMIT/OFFSET must be integers
|
|
224
|
+
const limit = Math.min(Math.max(1, parseInt(options.limit) || 50), 1000);
|
|
225
|
+
const offset = Math.max(0, parseInt(options.offset) || 0);
|
|
226
|
+
|
|
227
|
+
const sql = `SELECT * FROM documents ${where} ORDER BY ${orderBy} ${order} LIMIT ? OFFSET ?`;
|
|
228
|
+
const allParams = [...params, limit, offset];
|
|
229
|
+
const stmt = db.prepare(sql);
|
|
230
|
+
stmt.bind(allParams);
|
|
231
|
+
|
|
232
|
+
const results = [];
|
|
233
|
+
while (stmt.step()) {
|
|
234
|
+
results.push(rowToDocFromObj(stmt.getAsObject()));
|
|
235
|
+
}
|
|
236
|
+
stmt.free();
|
|
237
|
+
|
|
238
|
+
return results;
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
async update(id, patch) {
|
|
242
|
+
const existing = await this.get(id);
|
|
243
|
+
if (!existing) return null;
|
|
244
|
+
|
|
245
|
+
const now = new Date().toISOString();
|
|
246
|
+
|
|
247
|
+
// Batch all updates in a single transaction
|
|
248
|
+
db.run('BEGIN TRANSACTION');
|
|
249
|
+
|
|
250
|
+
if (patch.data) {
|
|
251
|
+
const merged = { ...existing.data, ...patch.data };
|
|
252
|
+
db.run('UPDATE documents SET data = ?, updated_at = ? WHERE id = ?', [
|
|
253
|
+
JSON.stringify(merged), now, id
|
|
254
|
+
]);
|
|
255
|
+
existing.data = merged;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (patch.status !== undefined) {
|
|
259
|
+
db.run('UPDATE documents SET status = ?, updated_at = ? WHERE id = ?', [
|
|
260
|
+
patch.status, now, id
|
|
261
|
+
]);
|
|
262
|
+
existing.status = patch.status;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// publishedAt: explicit null clears it, string value sets it
|
|
266
|
+
if (patch.publishedAt !== undefined) {
|
|
267
|
+
const val = patch.publishedAt || null;
|
|
268
|
+
db.run('UPDATE documents SET published_at = ?, updated_at = ? WHERE id = ?', [
|
|
269
|
+
val, now, id
|
|
270
|
+
]);
|
|
271
|
+
existing.publishedAt = val;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (patch.meta) {
|
|
275
|
+
const merged = { ...existing.meta, ...patch.meta };
|
|
276
|
+
db.run('UPDATE documents SET meta = ?, updated_at = ? WHERE id = ?', [
|
|
277
|
+
JSON.stringify(merged), now, id
|
|
278
|
+
]);
|
|
279
|
+
existing.meta = merged;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
db.run('UPDATE documents SET updated_at = ? WHERE id = ?', [now, id]);
|
|
283
|
+
db.run('COMMIT');
|
|
284
|
+
|
|
285
|
+
existing.updatedAt = now;
|
|
286
|
+
markDirty();
|
|
287
|
+
return existing;
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
async delete(id) {
|
|
291
|
+
const existing = await this.get(id);
|
|
292
|
+
if (!existing) return false;
|
|
293
|
+
|
|
294
|
+
db.run('DELETE FROM documents WHERE id = ?', [id]);
|
|
295
|
+
markDirty();
|
|
296
|
+
return true;
|
|
297
|
+
},
|
|
298
|
+
|
|
299
|
+
async count(options = {}) {
|
|
300
|
+
const conditions = [];
|
|
301
|
+
const params = [];
|
|
302
|
+
|
|
303
|
+
if (options.type) {
|
|
304
|
+
conditions.push('type = ?');
|
|
305
|
+
params.push(options.type);
|
|
306
|
+
}
|
|
307
|
+
if (options.status) {
|
|
308
|
+
conditions.push('status = ?');
|
|
309
|
+
params.push(options.status);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
313
|
+
const stmt = db.prepare(`SELECT COUNT(*) as cnt FROM documents ${where}`);
|
|
314
|
+
stmt.bind(params);
|
|
315
|
+
|
|
316
|
+
let count = 0;
|
|
317
|
+
if (stmt.step()) {
|
|
318
|
+
count = stmt.getAsObject().cnt;
|
|
319
|
+
}
|
|
320
|
+
stmt.free();
|
|
321
|
+
return count;
|
|
322
|
+
},
|
|
323
|
+
|
|
324
|
+
/** Close database connection */
|
|
325
|
+
async close() {
|
|
326
|
+
if (dirty && db) await saveToDisk();
|
|
327
|
+
if (db) {
|
|
328
|
+
db.close();
|
|
329
|
+
db = null;
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
/** Get database path */
|
|
334
|
+
getDbPath() {
|
|
335
|
+
return dbPath;
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function rowToDocFromObj(row) {
|
|
341
|
+
if (!row) return null;
|
|
342
|
+
return {
|
|
343
|
+
id: row.id,
|
|
344
|
+
type: row.type,
|
|
345
|
+
data: JSON.parse(row.data),
|
|
346
|
+
status: row.status,
|
|
347
|
+
publishedAt: row.published_at || null,
|
|
348
|
+
tenantId: row.tenant_id || 'default',
|
|
349
|
+
createdBy: row.created_by || null,
|
|
350
|
+
createdAt: row.created_at,
|
|
351
|
+
updatedAt: row.updated_at,
|
|
352
|
+
meta: JSON.parse(row.meta || '{}')
|
|
353
|
+
};
|
|
354
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Store — 存储抽象层
|
|
3
|
+
*
|
|
4
|
+
* 提供统一的 CRUD 接口,后端可以置换不同的存储引擎:
|
|
5
|
+
* - MemoryStore — 开发/测试用,内存存储
|
|
6
|
+
* - SQLiteStore — 生产环境,基于 better-sqlite3
|
|
7
|
+
* - 未来扩展:PostgresStore, FileStore, etc.
|
|
8
|
+
*
|
|
9
|
+
* 所有 Store 实现同一个接口,确保上层代码零修改。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {object} Document
|
|
14
|
+
* @property {string} id — 唯一标识符(UUID)
|
|
15
|
+
* @property {string} type — 内容类型名称
|
|
16
|
+
* @property {object} data — 结构化内容数据
|
|
17
|
+
* @property {string} status — 'draft' | 'scheduled' | 'published' | 'archived'
|
|
18
|
+
* @property {string|null} publishedAt — 定时发布时间 (ISO 8601)
|
|
19
|
+
* @property {string} tenantId — 租户 ID (默认 'default')
|
|
20
|
+
* @property {string} createdAt — ISO 8601
|
|
21
|
+
* @property {string} updatedAt — ISO 8601
|
|
22
|
+
* @property {string} [createdBy] — 创建者 ID(人类或 Agent)
|
|
23
|
+
* @property {object} [meta] — 额外元数据
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {object} QueryOptions
|
|
28
|
+
* @property {string} [type] — 按内容类型过滤
|
|
29
|
+
* @property {string} [status] — 按状态过滤
|
|
30
|
+
* @property {string} [tenantId] — 按租户 ID 过滤
|
|
31
|
+
* @property {string} [search] — 全文搜索关键词
|
|
32
|
+
* @property {number} [limit] — 返回数量上限
|
|
33
|
+
* @property {number} [offset] — 分页偏移
|
|
34
|
+
* @property {string} [orderBy] — 排序字段
|
|
35
|
+
* @property {'asc'|'desc'} [order] — 排序方向
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @typedef {object} Store
|
|
40
|
+
* @property {function(Document): Promise<Document>} create
|
|
41
|
+
* @property {function(string): Promise<Document|null>} get
|
|
42
|
+
* @property {function(QueryOptions): Promise<Document[]>} list
|
|
43
|
+
* @property {function(string, object): Promise<Document>} update
|
|
44
|
+
* @property {function(string): Promise<boolean>} delete
|
|
45
|
+
* @property {function(): Promise<number>} count
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { randomUUID } from 'node:crypto';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* MemoryStore — 内存存储实现
|
|
52
|
+
* 适合开发、测试、和小规模单机部署
|
|
53
|
+
*/
|
|
54
|
+
export function createMemoryStore() {
|
|
55
|
+
/** @type {Map<string, Document>} */
|
|
56
|
+
const docs = new Map();
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
async create(doc) {
|
|
60
|
+
const now = new Date().toISOString();
|
|
61
|
+
const document = {
|
|
62
|
+
id: doc.id || randomUUID(),
|
|
63
|
+
type: doc.type || 'default',
|
|
64
|
+
data: doc.data || {},
|
|
65
|
+
status: doc.status || 'draft',
|
|
66
|
+
publishedAt: doc.publishedAt || null,
|
|
67
|
+
tenantId: doc.tenantId || 'default',
|
|
68
|
+
createdAt: doc.createdAt || now,
|
|
69
|
+
updatedAt: now,
|
|
70
|
+
createdBy: doc.createdBy || null,
|
|
71
|
+
meta: doc.meta || {}
|
|
72
|
+
};
|
|
73
|
+
docs.set(document.id, document);
|
|
74
|
+
return { ...document, data: { ...document.data } };
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
async get(id) {
|
|
78
|
+
const doc = docs.get(id);
|
|
79
|
+
if (!doc) return null;
|
|
80
|
+
return { ...doc, data: { ...doc.data } };
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
async list(options = {}) {
|
|
84
|
+
let results = Array.from(docs.values());
|
|
85
|
+
|
|
86
|
+
if (options.type) {
|
|
87
|
+
results = results.filter(d => d.type === options.type);
|
|
88
|
+
}
|
|
89
|
+
if (options.status) {
|
|
90
|
+
results = results.filter(d => d.status === options.status);
|
|
91
|
+
}
|
|
92
|
+
if (options.tenantId) {
|
|
93
|
+
results = results.filter(d => d.tenantId === options.tenantId);
|
|
94
|
+
}
|
|
95
|
+
if (options.search) {
|
|
96
|
+
const q = options.search.toLowerCase();
|
|
97
|
+
results = results.filter(d =>
|
|
98
|
+
JSON.stringify(d.data).toLowerCase().includes(q) ||
|
|
99
|
+
d.type.toLowerCase().includes(q)
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Sort by updatedAt descending by default
|
|
104
|
+
const orderBy = options.orderBy || 'updatedAt';
|
|
105
|
+
const order = options.order || 'desc';
|
|
106
|
+
results.sort((a, b) => {
|
|
107
|
+
const va = a[orderBy] || '';
|
|
108
|
+
const vb = b[orderBy] || '';
|
|
109
|
+
return order === 'desc' ? vb.localeCompare(va) : va.localeCompare(vb);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const offset = options.offset || 0;
|
|
113
|
+
const limit = options.limit || 50;
|
|
114
|
+
return results.slice(offset, offset + limit).map(d => ({
|
|
115
|
+
...d, data: { ...d.data }
|
|
116
|
+
}));
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
async update(id, patch) {
|
|
120
|
+
const doc = docs.get(id);
|
|
121
|
+
if (!doc) return null;
|
|
122
|
+
const updated = {
|
|
123
|
+
...doc,
|
|
124
|
+
data: patch.data ? { ...doc.data, ...patch.data } : doc.data,
|
|
125
|
+
status: patch.status !== undefined ? patch.status : doc.status,
|
|
126
|
+
publishedAt: patch.publishedAt !== undefined ? patch.publishedAt : doc.publishedAt,
|
|
127
|
+
meta: patch.meta ? { ...doc.meta, ...patch.meta } : doc.meta,
|
|
128
|
+
updatedAt: new Date().toISOString()
|
|
129
|
+
};
|
|
130
|
+
docs.set(id, updated);
|
|
131
|
+
return { ...updated, data: { ...updated.data } };
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
async delete(id) {
|
|
135
|
+
return docs.delete(id);
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
async count(options = {}) {
|
|
139
|
+
let results = Array.from(docs.values());
|
|
140
|
+
if (options.type) results = results.filter(d => d.type === options.type);
|
|
141
|
+
if (options.status) results = results.filter(d => d.status === options.status);
|
|
142
|
+
return results.length;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Create a store instance based on the engine configuration.
|
|
149
|
+
*
|
|
150
|
+
* Supported engines:
|
|
151
|
+
* - 'memory' — in-memory store (default, zero dependencies)
|
|
152
|
+
* - 'sqlite' — SQLite via sql.js WASM (persistent, requires sql.js package)
|
|
153
|
+
*
|
|
154
|
+
* @param {object} config
|
|
155
|
+
* @param {string} [config.engine] — 'memory' | 'sqlite'
|
|
156
|
+
* @param {string} [config.dataDir] — data directory for file-based stores
|
|
157
|
+
* @returns {Promise<Store>}
|
|
158
|
+
*/
|
|
159
|
+
export async function createStore(config = {}) {
|
|
160
|
+
const engine = (config.engine || process.env.TAICHU_STORAGE || 'memory').toLowerCase();
|
|
161
|
+
|
|
162
|
+
switch (engine) {
|
|
163
|
+
case 'memory':
|
|
164
|
+
return createMemoryStore();
|
|
165
|
+
|
|
166
|
+
case 'sqlite': {
|
|
167
|
+
const { createSQLiteStore } = await import('./sqlite-store.js');
|
|
168
|
+
return createSQLiteStore(config);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
default:
|
|
172
|
+
throw new Error(`Unknown storage engine: "${engine}". Supported: memory, sqlite`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tokenizer — 可插拔分词器
|
|
3
|
+
*
|
|
4
|
+
* 优先级:
|
|
5
|
+
* 1. nodejieba(中文词级分词,可选安装)
|
|
6
|
+
* 2. 内置 n-gram(零依赖回退)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
let _cutter = null;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Try to load jieba tokenizer (optional dependency).
|
|
13
|
+
* Returns null if not installed.
|
|
14
|
+
*/
|
|
15
|
+
export async function tryLoadJieba() {
|
|
16
|
+
try {
|
|
17
|
+
const jieba = await import('nodejieba');
|
|
18
|
+
// nodejieba exports: { cut, tag, extract, ... }
|
|
19
|
+
const cut = (text) => {
|
|
20
|
+
return jieba.cut(text);
|
|
21
|
+
};
|
|
22
|
+
return cut;
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Set a custom tokenizer.
|
|
30
|
+
*/
|
|
31
|
+
export function setTokenizer(fn) {
|
|
32
|
+
_cutter = fn;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const STOPWORDS = new Set([
|
|
36
|
+
'的','了','在','是','我','有','和','就','不','人','都','一','一个',
|
|
37
|
+
'上','也','很','到','说','要','去','你','会','着','没有','看','好',
|
|
38
|
+
'自己','这','他','她','它','们','那','些','什么','怎么','哪','为什么',
|
|
39
|
+
'吗','吧','啊','呢','哦','哈','嗯','呀','the','a','an','is','are',
|
|
40
|
+
'was','were','be','been','in','on','at','to','for','of','with',
|
|
41
|
+
'by','from','and','or','but','not','this','that','it','as','its'
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Tokenize text. Uses jieba if available, otherwise n-gram fallback.
|
|
46
|
+
*/
|
|
47
|
+
export function tokenize(text, cutter = null) {
|
|
48
|
+
if (!text || typeof text !== 'string') return [];
|
|
49
|
+
const cut = cutter || _cutter;
|
|
50
|
+
|
|
51
|
+
if (cut) return _jiebaTokens(text, cut);
|
|
52
|
+
return _ngramTokens(text);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function _jiebaTokens(text, cut) {
|
|
56
|
+
const cleaned = text
|
|
57
|
+
.replace(/[^\w\u4e00-\u9fff\s]/g, ' ')
|
|
58
|
+
.replace(/\s+/g, ' ')
|
|
59
|
+
.trim();
|
|
60
|
+
if (!cleaned) return [];
|
|
61
|
+
const words = cut(cleaned);
|
|
62
|
+
return words.filter(w => w.length > 1 && !STOPWORDS.has(w));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function _ngramTokens(text) {
|
|
66
|
+
const cleaned = text.toLowerCase()
|
|
67
|
+
.replace(/[,。!?、;:""'()【】《》\n\r\t]+/g, ' ')
|
|
68
|
+
.replace(/[^\w\u4e00-\u9fff\s]/g, ' ')
|
|
69
|
+
.replace(/\s+/g, ' ')
|
|
70
|
+
.trim();
|
|
71
|
+
if (!cleaned) return [];
|
|
72
|
+
|
|
73
|
+
const tokens = [];
|
|
74
|
+
// Split Chinese segments and English words
|
|
75
|
+
for (let i = 0; i < cleaned.length; i++) {
|
|
76
|
+
const ch = cleaned[i];
|
|
77
|
+
if (/[\u4e00-\u9fff]/.test(ch)) {
|
|
78
|
+
// Chinese: 2-gram
|
|
79
|
+
if (i + 1 < cleaned.length && /[\u4e00-\u9fff]/.test(cleaned[i + 1])) {
|
|
80
|
+
tokens.push(cleaned.substring(i, i + 2));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// English words
|
|
85
|
+
const words = cleaned.match(/[a-z]{2,}/g) || [];
|
|
86
|
+
tokens.push(...words);
|
|
87
|
+
|
|
88
|
+
return tokens.filter(t => !STOPWORDS.has(t));
|
|
89
|
+
}
|