@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,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WeChat MCP Tool Routes — 微信生态桥接
|
|
3
|
+
*
|
|
4
|
+
* 通过 MCP Tool 暴露微信 API,Agent 可操作公众号/小程序内容。
|
|
5
|
+
* 实际对接微信开放平台 API,需要用户自行申请 AppID/AppSecret。
|
|
6
|
+
*
|
|
7
|
+
* 环境变量:
|
|
8
|
+
* TAICHU_WECHAT_APPID — 微信公众号 AppID
|
|
9
|
+
* TAICHU_WECHAT_SECRET — 公众号 AppSecret
|
|
10
|
+
* TAICHU_WECHAT_TOKEN — 服务器配置 Token(消息推送验证)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { requireAuth } from '../middleware/auth.js';
|
|
14
|
+
import { createLogger } from '../logger.js';
|
|
15
|
+
|
|
16
|
+
const log = createLogger('wechat');
|
|
17
|
+
|
|
18
|
+
let _accessToken = null;
|
|
19
|
+
let _tokenExpiry = 0;
|
|
20
|
+
|
|
21
|
+
async function getAccessToken() {
|
|
22
|
+
if (_accessToken && Date.now() < _tokenExpiry) return _accessToken;
|
|
23
|
+
|
|
24
|
+
const appId = process.env.TAICHU_WECHAT_APPID;
|
|
25
|
+
const secret = process.env.TAICHU_WECHAT_SECRET;
|
|
26
|
+
if (!appId || !secret) throw new Error('WeChat not configured (TAICHU_WECHAT_APPID/SECRET)');
|
|
27
|
+
|
|
28
|
+
const res = await fetch(`https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${appId}&secret=${secret}`);
|
|
29
|
+
const data = await res.json();
|
|
30
|
+
if (data.errcode) throw new Error(`WeChat token error: ${data.errmsg}`);
|
|
31
|
+
|
|
32
|
+
_accessToken = data.access_token;
|
|
33
|
+
_tokenExpiry = Date.now() + (data.expires_in - 300) * 1000;
|
|
34
|
+
return _accessToken;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Push article draft to WeChat Official Account.
|
|
39
|
+
*/
|
|
40
|
+
async function pushToDraft(title, content, thumbMediaId = '') {
|
|
41
|
+
const token = await getAccessToken();
|
|
42
|
+
const res = await fetch(`https://api.weixin.qq.com/cgi-bin/draft/add?access_token=${token}`, {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
body: JSON.stringify({
|
|
45
|
+
articles: [{
|
|
46
|
+
title,
|
|
47
|
+
content,
|
|
48
|
+
thumb_media_id: thumbMediaId,
|
|
49
|
+
need_open_comment: 0,
|
|
50
|
+
show_cover_pic: 0
|
|
51
|
+
}]
|
|
52
|
+
})
|
|
53
|
+
});
|
|
54
|
+
return res.json();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get WeChat user list (followers).
|
|
59
|
+
*/
|
|
60
|
+
async function getUserList() {
|
|
61
|
+
const token = await getAccessToken();
|
|
62
|
+
const res = await fetch(`https://api.weixin.qq.com/cgi-bin/user/get?access_token=${token}`);
|
|
63
|
+
return res.json();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function wechatRoutes(ctx) {
|
|
67
|
+
const { pathname } = ctx.url;
|
|
68
|
+
const method = ctx.req.method;
|
|
69
|
+
|
|
70
|
+
const authResult = await requireAuth(ctx);
|
|
71
|
+
if (!authResult.authenticated) {
|
|
72
|
+
ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
|
|
73
|
+
ctx.res.end(JSON.stringify({ error: authResult.error, message: authResult.message }));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
// GET /api/wechat/status
|
|
79
|
+
if (pathname === '/api/wechat/status' && method === 'GET') {
|
|
80
|
+
const configured = !!(process.env.TAICHU_WECHAT_APPID && process.env.TAICHU_WECHAT_SECRET);
|
|
81
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
82
|
+
ctx.res.end(JSON.stringify({ configured, appId: process.env.TAICHU_WECHAT_APPID?.substring(0, 8) + '...' || '' }));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// POST /api/wechat/push — sync article to WeChat draft
|
|
87
|
+
if (pathname === '/api/wechat/push' && method === 'POST') {
|
|
88
|
+
const { title, content, thumbMediaId } = ctx.body || {};
|
|
89
|
+
if (!title || !content) {
|
|
90
|
+
ctx.res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
91
|
+
ctx.res.end(JSON.stringify({ error: 'title and content required' }));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const result = await pushToDraft(title, content, thumbMediaId);
|
|
95
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
96
|
+
ctx.res.end(JSON.stringify(result));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// GET /api/wechat/followers
|
|
101
|
+
if (pathname === '/api/wechat/followers' && method === 'GET') {
|
|
102
|
+
const result = await getUserList();
|
|
103
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
104
|
+
ctx.res.end(JSON.stringify(result));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
} catch (err) {
|
|
108
|
+
ctx.res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
109
|
+
ctx.res.end(JSON.stringify({ error: err.message }));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
114
|
+
ctx.res.end(JSON.stringify({ error: 'NOT_FOUND' }));
|
|
115
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Engine — 内容审核工作流
|
|
3
|
+
*
|
|
4
|
+
* 状态机驱动的审核流程。
|
|
5
|
+
* 内置流程:draft → review → approved → published
|
|
6
|
+
* 自定义流程通过 WorkflowTemplate 内容类型定义。
|
|
7
|
+
*
|
|
8
|
+
* API:
|
|
9
|
+
* POST /api/workflow/request — 提交审核
|
|
10
|
+
* POST /api/workflow/approve/:id — 通过审核
|
|
11
|
+
* POST /api/workflow/reject/:id — 驳回
|
|
12
|
+
* GET /api/workflow/status/:id — 查看状态
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { getStore } from '../context.js';
|
|
16
|
+
import { requireAuth } from '../middleware/auth.js';
|
|
17
|
+
import { record as auditRecord } from '../audit.js';
|
|
18
|
+
import { notify } from '../notify.js';
|
|
19
|
+
import { createLogger } from '../logger.js';
|
|
20
|
+
|
|
21
|
+
const log = createLogger('workflow');
|
|
22
|
+
|
|
23
|
+
const DEFAULT_STATES = ['draft', 'scheduled', 'pending_review', 'approved', 'published', 'rejected', 'archived'];
|
|
24
|
+
const VALID_TRANSITIONS = {
|
|
25
|
+
'draft': ['scheduled', 'pending_review', 'published', 'archived'],
|
|
26
|
+
'scheduled': ['draft', 'published'],
|
|
27
|
+
'pending_review': ['approved', 'rejected'],
|
|
28
|
+
'approved': ['published', 'rejected'],
|
|
29
|
+
'published': ['archived'],
|
|
30
|
+
'rejected': ['draft', 'pending_review'],
|
|
31
|
+
'archived': ['draft']
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Request a review.
|
|
36
|
+
*/
|
|
37
|
+
export async function requestReview(docId, ctx) {
|
|
38
|
+
const store = getStore();
|
|
39
|
+
const doc = await store.get(docId);
|
|
40
|
+
if (!doc) throw { status: 404, message: 'Document not found' };
|
|
41
|
+
|
|
42
|
+
const current = doc.data?.workflowState || doc.status;
|
|
43
|
+
if (!VALID_TRANSITIONS[current]?.includes('pending_review')) {
|
|
44
|
+
throw { status: 400, message: `Cannot review from "${current}" state` };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
await store.update(docId, { data: { ...doc.data, workflowState: 'pending_review', reviewRequestedBy: ctx.actor?.id, reviewRequestedAt: new Date().toISOString() } });
|
|
48
|
+
auditRecord({ actorId: ctx.actor?.id, actorType: ctx.actor?.type || 'human', action: 'review_requested', resourceType: doc.type, resourceId: doc.id }).catch(() => {});
|
|
49
|
+
notify('review_requested', { doc, actor: ctx.actor?.username || 'User' }).catch(() => {});
|
|
50
|
+
|
|
51
|
+
return { success: true, state: 'pending_review' };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Approve a review.
|
|
56
|
+
*/
|
|
57
|
+
export async function approve(docId, ctx, comment = '') {
|
|
58
|
+
const store = getStore();
|
|
59
|
+
const doc = await store.get(docId);
|
|
60
|
+
if (!doc) throw { status: 404, message: 'Document not found' };
|
|
61
|
+
|
|
62
|
+
const current = doc.data?.workflowState || doc.status;
|
|
63
|
+
if (current !== 'pending_review' && current !== 'approved') {
|
|
64
|
+
throw { status: 400, message: `Cannot approve from "${current}" state` };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const newState = 'approved';
|
|
68
|
+
await store.update(docId, {
|
|
69
|
+
data: { ...doc.data, workflowState: newState, reviewApprovedBy: ctx.actor?.id, reviewApprovedAt: new Date().toISOString(), reviewComment: comment },
|
|
70
|
+
status: 'published'
|
|
71
|
+
});
|
|
72
|
+
auditRecord({ actorId: ctx.actor?.id, actorType: ctx.actor?.type || 'human', action: 'approved', resourceType: doc.type, resourceId: doc.id }).catch(() => {});
|
|
73
|
+
notify('content_published', { doc, actor: ctx.actor?.username || 'User' }).catch(() => {});
|
|
74
|
+
|
|
75
|
+
return { success: true, state: newState };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Reject a review.
|
|
80
|
+
*/
|
|
81
|
+
export async function reject(docId, ctx, reason = '') {
|
|
82
|
+
const store = getStore();
|
|
83
|
+
const doc = await store.get(docId);
|
|
84
|
+
if (!doc) throw { status: 404, message: 'Document not found' };
|
|
85
|
+
|
|
86
|
+
await store.update(docId, { data: { ...doc.data, workflowState: 'rejected', reviewRejectedBy: ctx.actor?.id, reviewRejectedAt: new Date().toISOString(), reviewRejectReason: reason } });
|
|
87
|
+
auditRecord({ actorId: ctx.actor?.id, actorType: ctx.actor?.type || 'human', action: 'rejected', resourceType: doc.type, resourceId: doc.id }).catch(() => {});
|
|
88
|
+
|
|
89
|
+
return { success: true, state: 'rejected' };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Routes ─────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
export async function workflowRoutes(ctx) {
|
|
95
|
+
const { pathname } = ctx.url;
|
|
96
|
+
const method = ctx.req.method;
|
|
97
|
+
|
|
98
|
+
const authResult = await requireAuth(ctx);
|
|
99
|
+
if (!authResult.authenticated) {
|
|
100
|
+
ctx.res.writeHead(authResult.status, { 'Content-Type': 'application/json' });
|
|
101
|
+
ctx.res.end(JSON.stringify({ error: authResult.error, message: authResult.message }));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
ctx.actor = authResult.actor;
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
// POST /api/workflow/request/:docId
|
|
108
|
+
const reqMatch = pathname.match(/^\/api\/workflow\/request\/([\w-]+)$/);
|
|
109
|
+
if (reqMatch && method === 'POST') {
|
|
110
|
+
const result = await requestReview(reqMatch[1], ctx);
|
|
111
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
112
|
+
ctx.res.end(JSON.stringify(result));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// POST /api/workflow/approve/:docId
|
|
117
|
+
const approveMatch = pathname.match(/^\/api\/workflow\/approve\/([\w-]+)$/);
|
|
118
|
+
if (approveMatch && method === 'POST') {
|
|
119
|
+
const result = await approve(approveMatch[1], ctx, (ctx.body || {}).comment || '');
|
|
120
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
121
|
+
ctx.res.end(JSON.stringify(result));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// POST /api/workflow/reject/:docId
|
|
126
|
+
const rejectMatch = pathname.match(/^\/api\/workflow\/reject\/([\w-]+)$/);
|
|
127
|
+
if (rejectMatch && method === 'POST') {
|
|
128
|
+
const result = await reject(rejectMatch[1], ctx, (ctx.body || {}).reason || '');
|
|
129
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
130
|
+
ctx.res.end(JSON.stringify(result));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// GET /api/workflow/status/:docId
|
|
135
|
+
const statusMatch = pathname.match(/^\/api\/workflow\/status\/([\w-]+)$/);
|
|
136
|
+
if (statusMatch && method === 'GET') {
|
|
137
|
+
const store = getStore();
|
|
138
|
+
const doc = await store.get(statusMatch[1]);
|
|
139
|
+
if (!doc) { ctx.res.writeHead(404, { 'Content-Type': 'application/json' }); ctx.res.end(JSON.stringify({ error: 'NOT_FOUND' })); return; }
|
|
140
|
+
ctx.res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
141
|
+
ctx.res.end(JSON.stringify({
|
|
142
|
+
state: doc.data?.workflowState || doc.status,
|
|
143
|
+
transitions: VALID_TRANSITIONS[doc.data?.workflowState || doc.status] || [],
|
|
144
|
+
reviewRequestedBy: doc.data?.reviewRequestedBy,
|
|
145
|
+
reviewRequestedAt: doc.data?.reviewRequestedAt
|
|
146
|
+
}));
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
} catch (err) {
|
|
150
|
+
ctx.res.writeHead(err.status || 500, { 'Content-Type': 'application/json' });
|
|
151
|
+
ctx.res.end(JSON.stringify({ error: err.message || 'Internal error' }));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
ctx.res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
156
|
+
ctx.res.end(JSON.stringify({ error: 'NOT_FOUND' }));
|
|
157
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheduler — 定时发布调度器
|
|
3
|
+
*
|
|
4
|
+
* 轮询数据库中 status='scheduled' 且 published_at <= now 的文档,
|
|
5
|
+
* 自动将状态更新为 'published'。
|
|
6
|
+
*
|
|
7
|
+
* 配置:
|
|
8
|
+
* TAICHU_SCHEDULE_INTERVAL_MS — 轮询间隔,默认 30000 (30s)
|
|
9
|
+
* TAICHU_SCHEDULE_BATCH_SIZE — 单次最多处理条数,默认 50
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { getStore } from './context.js';
|
|
13
|
+
import { createLogger } from './logger.js';
|
|
14
|
+
import { notify } from './notify.js';
|
|
15
|
+
|
|
16
|
+
const log = createLogger('scheduler');
|
|
17
|
+
|
|
18
|
+
let timer = null;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Start the scheduled publishing loop.
|
|
22
|
+
* @param {object} [hooks] — hook system instance for firing afterUpdate
|
|
23
|
+
*/
|
|
24
|
+
export function startScheduler(hooks) {
|
|
25
|
+
const interval = parseInt(process.env.TAICHU_SCHEDULE_INTERVAL_MS) || 30000;
|
|
26
|
+
const batchSize = parseInt(process.env.TAICHU_SCHEDULE_BATCH_SIZE) || 50;
|
|
27
|
+
|
|
28
|
+
log.info(`Scheduler started — interval: ${interval}ms, batch: ${batchSize}`);
|
|
29
|
+
|
|
30
|
+
// Run immediately on start
|
|
31
|
+
tick(hooks, batchSize);
|
|
32
|
+
|
|
33
|
+
timer = setInterval(() => tick(hooks, batchSize), interval);
|
|
34
|
+
|
|
35
|
+
// Don't prevent process exit
|
|
36
|
+
if (timer.unref) timer.unref();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Stop the scheduler (for graceful shutdown).
|
|
41
|
+
*/
|
|
42
|
+
export function stopScheduler() {
|
|
43
|
+
if (timer) {
|
|
44
|
+
clearInterval(timer);
|
|
45
|
+
timer = null;
|
|
46
|
+
log.info('Scheduler stopped');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Single tick — find and publish due documents.
|
|
52
|
+
*/
|
|
53
|
+
async function tick(hooks, batchSize) {
|
|
54
|
+
try {
|
|
55
|
+
const store = getStore();
|
|
56
|
+
if (!store) return;
|
|
57
|
+
|
|
58
|
+
// Query documents with status='scheduled'
|
|
59
|
+
const scheduled = await store.list({ status: 'scheduled', limit: batchSize });
|
|
60
|
+
if (!scheduled.length) return;
|
|
61
|
+
|
|
62
|
+
const now = new Date();
|
|
63
|
+
const due = scheduled.filter(doc => {
|
|
64
|
+
const pubAt = doc.publishedAt || doc.data?.publishedAt;
|
|
65
|
+
if (!pubAt) return false; // no publish time set — skip
|
|
66
|
+
return new Date(pubAt) <= now;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
if (!due.length) return;
|
|
70
|
+
|
|
71
|
+
log.info(`Publishing ${due.length} scheduled document(s)`);
|
|
72
|
+
|
|
73
|
+
for (const doc of due) {
|
|
74
|
+
try {
|
|
75
|
+
const updated = await store.update(doc.id, {
|
|
76
|
+
status: 'published',
|
|
77
|
+
publishedAt: doc.publishedAt || doc.data?.publishedAt || new Date().toISOString(),
|
|
78
|
+
data: { ...doc.data, publishedAt: doc.publishedAt || doc.data?.publishedAt || new Date().toISOString() }
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Fire afterUpdate hook (same as normal publish flow)
|
|
82
|
+
if (hooks && updated) {
|
|
83
|
+
await hooks.run('afterUpdate', updated, { actor: { id: 'scheduler', type: 'system' } }).catch(() => {});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
notify('content_published', { doc: updated, actor: 'scheduler' }).catch(() => {});
|
|
87
|
+
|
|
88
|
+
log.info(`Published: ${doc.id} (${doc.type})`);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
log.error(`Failed to publish ${doc.id}: ${err.message}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
} catch (err) {
|
|
94
|
+
log.error(`Scheduler tick error: ${err.message}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search Service — 向量检索集成
|
|
3
|
+
*
|
|
4
|
+
* 在 Taichu 启动时初始化 TF-IDF 索引,自动索引已有内容,
|
|
5
|
+
* 并 hook 到内容生命周期保持索引同步。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { TFIDFIndex } from '../../core/src/vector-index.js';
|
|
9
|
+
import { tryLoadJieba, setTokenizer } from '../../core/src/tokenizer.js';
|
|
10
|
+
|
|
11
|
+
let index = null;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Initialize the search index.
|
|
15
|
+
* @param {import('../context.js').Store} store
|
|
16
|
+
* @param {import('../context.js').HookSystem} hooks
|
|
17
|
+
*/
|
|
18
|
+
export async function initSearch(store, hooks) {
|
|
19
|
+
index = new TFIDFIndex();
|
|
20
|
+
|
|
21
|
+
// Try to load jieba for Chinese tokenization (optional)
|
|
22
|
+
const jieba = await tryLoadJieba();
|
|
23
|
+
if (jieba) {
|
|
24
|
+
setTokenizer(jieba);
|
|
25
|
+
console.log(' Search: jieba Chinese tokenizer loaded');
|
|
26
|
+
} else {
|
|
27
|
+
console.log(' Search: using n-gram tokenizer (install nodejieba for better Chinese search)');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Index all existing documents
|
|
31
|
+
const allTypes = ['article', 'page', 'category', 'media', 'author'];
|
|
32
|
+
for (const type of allTypes) {
|
|
33
|
+
try {
|
|
34
|
+
const docs = await store.list({ type, limit: 1000 });
|
|
35
|
+
for (const doc of docs) {
|
|
36
|
+
indexDoc(doc);
|
|
37
|
+
}
|
|
38
|
+
} catch (e) {
|
|
39
|
+
// Skip types that don't exist yet
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
console.log(` Search: indexed ${index.size} documents`);
|
|
44
|
+
|
|
45
|
+
// Hook: index new content
|
|
46
|
+
hooks.on('afterCreate', (doc) => {
|
|
47
|
+
indexDoc(doc);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Hook: re-index updated content
|
|
51
|
+
hooks.on('afterUpdate', (doc) => {
|
|
52
|
+
index.remove(doc.id);
|
|
53
|
+
indexDoc(doc);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Hook: remove deleted content
|
|
57
|
+
hooks.on('afterDelete', ({ id }) => {
|
|
58
|
+
index.remove(id);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function indexDoc(doc) {
|
|
63
|
+
if (!doc || !doc.data) return;
|
|
64
|
+
|
|
65
|
+
// Build searchable text from document data
|
|
66
|
+
const parts = [];
|
|
67
|
+
for (const [key, value] of Object.entries(doc.data)) {
|
|
68
|
+
if (typeof value === 'string') {
|
|
69
|
+
parts.push(value);
|
|
70
|
+
} else if (typeof value === 'object' && value !== null) {
|
|
71
|
+
// Flatten objects one level deep
|
|
72
|
+
for (const v of Object.values(value)) {
|
|
73
|
+
if (typeof v === 'string') parts.push(v);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const text = parts.join(' ');
|
|
79
|
+
if (text.trim()) {
|
|
80
|
+
index.add(doc.id, text);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Search for documents matching a query.
|
|
86
|
+
* @param {string} query
|
|
87
|
+
* @param {number} [topK=10]
|
|
88
|
+
* @returns {Array<{ docId: string, score: number }>}
|
|
89
|
+
*/
|
|
90
|
+
export function search(query, topK = 10) {
|
|
91
|
+
if (!index) return [];
|
|
92
|
+
return index.search(query, topK);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Get the search index for direct access.
|
|
97
|
+
*/
|
|
98
|
+
export function getIndex() {
|
|
99
|
+
return index;
|
|
100
|
+
}
|