@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,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CORS Middleware
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const DEFAULT_ORIGIN = '*';
|
|
6
|
+
const DEFAULT_METHODS = 'GET,POST,PUT,PATCH,DELETE,OPTIONS';
|
|
7
|
+
const DEFAULT_HEADERS = 'Content-Type,Authorization,X-Taichu-Agent-Key,X-Taichu-Agent-Id';
|
|
8
|
+
const DEFAULT_MAX_AGE = '86400';
|
|
9
|
+
|
|
10
|
+
export function corsMiddleware(req, res) {
|
|
11
|
+
res.setHeader('Access-Control-Allow-Origin', DEFAULT_ORIGIN);
|
|
12
|
+
res.setHeader('Access-Control-Allow-Methods', DEFAULT_METHODS);
|
|
13
|
+
res.setHeader('Access-Control-Allow-Headers', DEFAULT_HEADERS);
|
|
14
|
+
res.setHeader('Access-Control-Max-Age', DEFAULT_MAX_AGE);
|
|
15
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error Handler — 统一错误响应
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { TaichuError } from '../../../core/src/errors.js';
|
|
6
|
+
|
|
7
|
+
export function errorHandler(res, err) {
|
|
8
|
+
// Already sent headers?
|
|
9
|
+
if (res.headersSent) {
|
|
10
|
+
if (!res.writableEnded) res.end();
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Body parser errors
|
|
15
|
+
if (err.code === 'PAYLOAD_TOO_LARGE') {
|
|
16
|
+
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
17
|
+
res.end(JSON.stringify({
|
|
18
|
+
error: 'PAYLOAD_TOO_LARGE',
|
|
19
|
+
message: `Request body exceeds limit of ${(err.maxSize / 1024 / 1024).toFixed(1)}MB`,
|
|
20
|
+
status: 413
|
|
21
|
+
}));
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (err.code === 'INVALID_JSON') {
|
|
26
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
27
|
+
res.end(JSON.stringify({
|
|
28
|
+
error: 'INVALID_JSON',
|
|
29
|
+
message: 'Request body must be valid JSON',
|
|
30
|
+
status: 400
|
|
31
|
+
}));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (err instanceof TaichuError) {
|
|
36
|
+
res.writeHead(err.status, { 'Content-Type': 'application/json' });
|
|
37
|
+
res.end(JSON.stringify(err.toJSON()));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Unknown error — log and return 500
|
|
42
|
+
console.error('[Taichu] Unhandled error:', err);
|
|
43
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
44
|
+
res.end(JSON.stringify({
|
|
45
|
+
error: 'INTERNAL_ERROR',
|
|
46
|
+
message: process.env.NODE_ENV === 'development' ? err.message : 'Internal server error',
|
|
47
|
+
status: 500
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate Limiter — Token Bucket 算法,零依赖
|
|
3
|
+
*
|
|
4
|
+
* 三种监控维度(按优先级):
|
|
5
|
+
* 1. API Key → 按 Agent 限制
|
|
6
|
+
* 2. JWT User → 按人类用户限制
|
|
7
|
+
* 3. IP → 匿名请求兜底
|
|
8
|
+
*
|
|
9
|
+
* 环境变量:
|
|
10
|
+
* TAICHU_RATE_LIMIT_WINDOW_MS — 时间窗口(默认 60000ms = 1分钟)
|
|
11
|
+
* TAICHU_RATE_LIMIT_MAX — 窗口内最大请求数(默认 100)
|
|
12
|
+
* TAICHU_RATE_LIMIT_AUTH_MAX — 认证用户窗口内最大请求数(默认 300)
|
|
13
|
+
* TAICHU_RATE_LIMIT_LOGIN_MAX — 登录端点窗口内最大请求数(默认 10)
|
|
14
|
+
*
|
|
15
|
+
* 响应:429 Too Many Requests + Retry-After 头
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const DEFAULT_WINDOW = parseInt(process.env.TAICHU_RATE_LIMIT_WINDOW_MS) || 60000;
|
|
19
|
+
const DEFAULT_MAX = parseInt(process.env.TAICHU_RATE_LIMIT_MAX) || 100;
|
|
20
|
+
const AUTH_MAX = parseInt(process.env.TAICHU_RATE_LIMIT_AUTH_MAX) || 300;
|
|
21
|
+
const LOGIN_MAX = parseInt(process.env.TAICHU_RATE_LIMIT_LOGIN_MAX) || 10;
|
|
22
|
+
|
|
23
|
+
/** @type {Map<string, { tokens: number, lastRefill: number }>} */
|
|
24
|
+
const buckets = new Map();
|
|
25
|
+
|
|
26
|
+
// Periodic cleanup (every 5 minutes)
|
|
27
|
+
setInterval(() => {
|
|
28
|
+
const now = Date.now();
|
|
29
|
+
for (const [key, b] of buckets) {
|
|
30
|
+
if (now - b.lastRefill > DEFAULT_WINDOW * 2) {
|
|
31
|
+
buckets.delete(key);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}, 300000).unref();
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Get client identifier for rate limiting.
|
|
38
|
+
* Priority: API Key → JWT User → IP
|
|
39
|
+
*/
|
|
40
|
+
function getClientId(ctx) {
|
|
41
|
+
// API Key header
|
|
42
|
+
const agentKey = ctx.req.headers['x-taichu-agent-key'];
|
|
43
|
+
if (agentKey) return `ak:${agentKey}`;
|
|
44
|
+
|
|
45
|
+
// JWT (via actor if already authenticated)
|
|
46
|
+
if (ctx.actor?.id) return `user:${ctx.actor.id}`;
|
|
47
|
+
|
|
48
|
+
// IP fallback
|
|
49
|
+
const ip = ctx.req.headers['x-forwarded-for']?.split(',')[0]?.trim()
|
|
50
|
+
|| ctx.req.socket?.remoteAddress
|
|
51
|
+
|| 'unknown';
|
|
52
|
+
return `ip:${ip}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Determine the max tokens for a given request.
|
|
57
|
+
*/
|
|
58
|
+
function getMaxTokens(ctx, clientId) {
|
|
59
|
+
// Login/register endpoints — strict limit
|
|
60
|
+
const path = ctx.url?.pathname || '';
|
|
61
|
+
if (path.startsWith('/api/auth/login') || path.startsWith('/api/auth/register')) {
|
|
62
|
+
return LOGIN_MAX;
|
|
63
|
+
}
|
|
64
|
+
// Authenticated users — higher limit
|
|
65
|
+
if (clientId.startsWith('ak:') || clientId.startsWith('user:')) {
|
|
66
|
+
return AUTH_MAX;
|
|
67
|
+
}
|
|
68
|
+
return DEFAULT_MAX;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Rate limit middleware. Returns true if allowed, false if blocked.
|
|
73
|
+
* If blocked, it will have already written the 429 response.
|
|
74
|
+
*
|
|
75
|
+
* @param {object} ctx — request context
|
|
76
|
+
* @returns {boolean} — true = allowed, false = blocked
|
|
77
|
+
*/
|
|
78
|
+
export function rateLimit(ctx) {
|
|
79
|
+
const clientId = getClientId(ctx);
|
|
80
|
+
const maxTokens = getMaxTokens(ctx, clientId);
|
|
81
|
+
const now = Date.now();
|
|
82
|
+
|
|
83
|
+
let bucket = buckets.get(clientId);
|
|
84
|
+
if (!bucket) {
|
|
85
|
+
bucket = { tokens: maxTokens, lastRefill: now };
|
|
86
|
+
buckets.set(clientId, bucket);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Refill tokens
|
|
90
|
+
const elapsed = now - bucket.lastRefill;
|
|
91
|
+
const refill = Math.floor((elapsed / DEFAULT_WINDOW) * maxTokens);
|
|
92
|
+
if (refill > 0) {
|
|
93
|
+
bucket.tokens = Math.min(maxTokens, bucket.tokens + refill);
|
|
94
|
+
bucket.lastRefill = now;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Consume a token
|
|
98
|
+
if (bucket.tokens > 0) {
|
|
99
|
+
bucket.tokens--;
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Rate limited — write 429 response
|
|
104
|
+
const retryAfter = Math.ceil((DEFAULT_WINDOW - elapsed) / 1000);
|
|
105
|
+
ctx.res.writeHead(429, {
|
|
106
|
+
'Content-Type': 'application/json',
|
|
107
|
+
'Retry-After': String(retryAfter),
|
|
108
|
+
'X-RateLimit-Limit': String(maxTokens),
|
|
109
|
+
'X-RateLimit-Remaining': '0',
|
|
110
|
+
'X-RateLimit-Reset': String(Math.ceil((bucket.lastRefill + DEFAULT_WINDOW) / 1000))
|
|
111
|
+
});
|
|
112
|
+
ctx.res.end(JSON.stringify({
|
|
113
|
+
error: 'RATE_LIMITED',
|
|
114
|
+
message: `Too many requests. Try again in ${retryAfter} seconds.`,
|
|
115
|
+
retryAfter
|
|
116
|
+
}));
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multipart Parser — 零依赖的文件上传解析
|
|
3
|
+
*
|
|
4
|
+
* 支持 multipart/form-data,解析文件字段和普通字段。
|
|
5
|
+
* 仅解析第一个文件(单文件上传场景),返回 Buffer + metadata。
|
|
6
|
+
*
|
|
7
|
+
* 限制:单文件最大 50MB,请求体最大 55MB。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
11
|
+
|
|
12
|
+
const MAX_FILE_SIZE = parseInt(process.env.TAICHU_MAX_FILE_SIZE) || 50 * 1024 * 1024;
|
|
13
|
+
const MAX_TOTAL_SIZE = MAX_FILE_SIZE + 5 * 1024 * 1024;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {object} MultipartFile
|
|
17
|
+
* @property {string} fieldname — 表单字段名
|
|
18
|
+
* @property {string} filename — 原始文件名
|
|
19
|
+
* @property {string} mimetype — MIME 类型
|
|
20
|
+
* @property {Buffer} buffer — 文件内容
|
|
21
|
+
* @property {number} size — 文件大小(字节)
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parse a multipart/form-data request body.
|
|
26
|
+
* @param {import('node:http').IncomingMessage} req
|
|
27
|
+
* @returns {Promise<{ files: MultipartFile[], fields: Record<string,string> }>}
|
|
28
|
+
*/
|
|
29
|
+
export function parseMultipart(req) {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
const contentType = req.headers['content-type'] || '';
|
|
32
|
+
const boundaryMatch = contentType.match(/boundary=(?:"([^"]+)"|([^;]+))/i);
|
|
33
|
+
if (!boundaryMatch) {
|
|
34
|
+
reject(Object.assign(new Error('Missing boundary in multipart request'), { code: 'BAD_REQUEST', status: 400 }));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const boundary = boundaryMatch[1] || boundaryMatch[2];
|
|
39
|
+
const boundaryBuffer = Buffer.from(`--${boundary}`);
|
|
40
|
+
const finalBoundary = Buffer.from(`--${boundary}--`);
|
|
41
|
+
const crlf = Buffer.from('\r\n');
|
|
42
|
+
const doubleCrlf = Buffer.from('\r\n\r\n');
|
|
43
|
+
|
|
44
|
+
const chunks = [];
|
|
45
|
+
let totalSize = 0;
|
|
46
|
+
|
|
47
|
+
req.on('data', (chunk) => {
|
|
48
|
+
totalSize += chunk.length;
|
|
49
|
+
if (totalSize > MAX_TOTAL_SIZE) {
|
|
50
|
+
reject(Object.assign(new Error('Request too large'), { code: 'PAYLOAD_TOO_LARGE', status: 413 }));
|
|
51
|
+
req.destroy();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
chunks.push(chunk);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
req.on('end', () => {
|
|
58
|
+
try {
|
|
59
|
+
const buffer = Buffer.concat(chunks);
|
|
60
|
+
const parts = splitParts(buffer, boundaryBuffer, finalBoundary);
|
|
61
|
+
const files = [];
|
|
62
|
+
const fields = {};
|
|
63
|
+
|
|
64
|
+
for (const part of parts) {
|
|
65
|
+
const parsed = parsePart(part, doubleCrlf);
|
|
66
|
+
if (!parsed) continue;
|
|
67
|
+
|
|
68
|
+
if (parsed.filename) {
|
|
69
|
+
if (parsed.data.length > MAX_FILE_SIZE) {
|
|
70
|
+
reject(Object.assign(new Error(`File exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit`), { code: 'FILE_TOO_LARGE', status: 413 }));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
files.push({
|
|
74
|
+
fieldname: parsed.name,
|
|
75
|
+
filename: parsed.filename,
|
|
76
|
+
mimetype: parsed.contentType || 'application/octet-stream',
|
|
77
|
+
buffer: parsed.data,
|
|
78
|
+
size: parsed.data.length
|
|
79
|
+
});
|
|
80
|
+
} else {
|
|
81
|
+
fields[parsed.name] = parsed.data.toString('utf-8');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Generate IDs for files
|
|
86
|
+
for (const f of files) {
|
|
87
|
+
f.id = randomUUID();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
resolve({ files, fields });
|
|
91
|
+
} catch (err) {
|
|
92
|
+
reject(Object.assign(err, { code: 'PARSE_ERROR', status: 400 }));
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
req.on('error', reject);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function splitParts(buffer, boundary, finalBoundary) {
|
|
101
|
+
const parts = [];
|
|
102
|
+
let start = buffer.indexOf(boundary);
|
|
103
|
+
if (start === -1) return parts;
|
|
104
|
+
|
|
105
|
+
while (start !== -1) {
|
|
106
|
+
start += boundary.length;
|
|
107
|
+
// Skip the \r\n after boundary
|
|
108
|
+
if (buffer[start] === 0x0d && buffer[start + 1] === 0x0a) start += 2;
|
|
109
|
+
|
|
110
|
+
let end = buffer.indexOf(boundary, start);
|
|
111
|
+
if (end === -1) {
|
|
112
|
+
// Check for final boundary
|
|
113
|
+
end = buffer.indexOf(finalBoundary, start);
|
|
114
|
+
if (end === -1) break;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Trim trailing \r\n before boundary
|
|
118
|
+
let partEnd = end;
|
|
119
|
+
if (partEnd > start && buffer[partEnd - 2] === 0x0d && buffer[partEnd - 1] === 0x0a) {
|
|
120
|
+
partEnd -= 2;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
parts.push(buffer.subarray(start, partEnd));
|
|
124
|
+
start = end;
|
|
125
|
+
if (start + finalBoundary.length > buffer.length) break;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return parts;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function parsePart(part, doubleCrlf) {
|
|
132
|
+
const headerEnd = part.indexOf(doubleCrlf);
|
|
133
|
+
if (headerEnd === -1) return null;
|
|
134
|
+
|
|
135
|
+
const headerStr = part.subarray(0, headerEnd).toString('utf-8');
|
|
136
|
+
const data = part.subarray(headerEnd + doubleCrlf.length);
|
|
137
|
+
|
|
138
|
+
// Parse Content-Disposition
|
|
139
|
+
const cdMatch = headerStr.match(/Content-Disposition:\s*form-data;\s*name="([^"]+)"(?:;\s*filename="([^"]*)")?/i);
|
|
140
|
+
if (!cdMatch) return null;
|
|
141
|
+
|
|
142
|
+
const name = cdMatch[1];
|
|
143
|
+
const filename = cdMatch[2] || null;
|
|
144
|
+
|
|
145
|
+
// Parse Content-Type
|
|
146
|
+
const ctMatch = headerStr.match(/Content-Type:\s*([^\r\n]+)/i);
|
|
147
|
+
const contentType = ctMatch ? ctMatch[1].trim() : null;
|
|
148
|
+
|
|
149
|
+
return { name, filename, contentType, data };
|
|
150
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notification — 多渠道通知(飞书/钉钉/企业微信 + 邮件)
|
|
3
|
+
*
|
|
4
|
+
* 通过 Webhook 发送 IM 消息 + SMTP 发送邮件。
|
|
5
|
+
* 环境变量:
|
|
6
|
+
* TAICHU_NOTIFY_FEISHU — 飞书机器人 Webhook URL
|
|
7
|
+
* TAICHU_NOTIFY_DINGTALK — 钉钉机器人 Webhook URL
|
|
8
|
+
* TAICHU_NOTIFY_WECOM — 企业微信机器人 Webhook URL
|
|
9
|
+
* TAICHU_SMTP_HOST — SMTP 服务器(启用邮件通知)
|
|
10
|
+
* TAICHU_SMTP_PORT — SMTP 端口(默认 587)
|
|
11
|
+
* TAICHU_SMTP_USER — SMTP 用户名
|
|
12
|
+
* TAICHU_SMTP_PASS — SMTP 密码
|
|
13
|
+
* TAICHU_SMTP_FROM — 发件人地址
|
|
14
|
+
* TAICHU_SMTP_TO — 默认收件人
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createLogger } from './logger.js';
|
|
18
|
+
import { sendEmail, buildEmailHtml } from './email.js';
|
|
19
|
+
|
|
20
|
+
const log = createLogger('notify');
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Send notification to all configured channels.
|
|
24
|
+
* @param {string} event — event type
|
|
25
|
+
* @param {object} data — event payload
|
|
26
|
+
*/
|
|
27
|
+
export async function notify(event, data) {
|
|
28
|
+
const promises = [];
|
|
29
|
+
|
|
30
|
+
if (process.env.TAICHU_NOTIFY_FEISHU) {
|
|
31
|
+
promises.push(sendFeishu(process.env.TAICHU_NOTIFY_FEISHU, event, data));
|
|
32
|
+
}
|
|
33
|
+
if (process.env.TAICHU_NOTIFY_DINGTALK) {
|
|
34
|
+
promises.push(sendDingTalk(process.env.TAICHU_NOTIFY_DINGTALK, event, data));
|
|
35
|
+
}
|
|
36
|
+
if (process.env.TAICHU_NOTIFY_WECOM) {
|
|
37
|
+
promises.push(sendWecom(process.env.TAICHU_NOTIFY_WECOM, event, data));
|
|
38
|
+
}
|
|
39
|
+
if (process.env.TAICHU_SMTP_HOST) {
|
|
40
|
+
promises.push(sendEmailNotify(event, data));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
await Promise.allSettled(promises);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Send email notification via configured SMTP.
|
|
48
|
+
*/
|
|
49
|
+
async function sendEmailNotify(event, data) {
|
|
50
|
+
const { title } = normalize(data);
|
|
51
|
+
const html = buildEmailHtml(event, data);
|
|
52
|
+
|
|
53
|
+
const result = await sendEmail({
|
|
54
|
+
subject: `[Taichu] ${eventLabel(event)}: ${title}`,
|
|
55
|
+
html
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (result.success) {
|
|
59
|
+
log.info(`Email sent: ${event} — ${title}`);
|
|
60
|
+
} else {
|
|
61
|
+
log.warn(`Email failed: ${result.error}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 飞书消息卡片 */
|
|
66
|
+
async function sendFeishu(webhook, event, data) {
|
|
67
|
+
const { title, url, actor, summary } = normalize(data);
|
|
68
|
+
const body = {
|
|
69
|
+
msg_type: 'interactive',
|
|
70
|
+
card: {
|
|
71
|
+
header: { title: { tag: 'plain_text', content: `${icon(event)} ${eventLabel(event)}` }, template: 'green' },
|
|
72
|
+
elements: [
|
|
73
|
+
{ tag: 'div', text: { tag: 'lark_md', content: `**${escapeMd(title)}**\n${summary}` } },
|
|
74
|
+
{ tag: 'note', elements: [{ tag: 'plain_text', content: `${actor} · ${new Date().toLocaleString('zh-CN')}` }] }
|
|
75
|
+
]
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
if (url) body.card.elements.push({ tag: 'action', actions: [{ tag: 'button', text: { tag: 'plain_text', content: '查看详情' }, type: 'default', url }] });
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
await fetch(webhook, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
82
|
+
} catch (err) { log.error(`Feishu notify failed: ${err.message}`); }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 钉钉 Markdown 消息 */
|
|
86
|
+
async function sendDingTalk(webhook, event, data) {
|
|
87
|
+
const { title, url, actor, summary } = normalize(data);
|
|
88
|
+
let text = `## ${icon(event)} ${eventLabel(event)}\n\n**${title}**\n${summary}\n\n> ${actor}`;
|
|
89
|
+
if (url) text += `\n\n[查看详情](${url})`;
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
await fetch(webhook, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ msgtype: 'markdown', markdown: { title: `${eventLabel(event)}: ${title}`, text } }) });
|
|
93
|
+
} catch (err) { log.error(`DingTalk notify failed: ${err.message}`); }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 企业微信 Markdown 消息 */
|
|
97
|
+
async function sendWecom(webhook, event, data) {
|
|
98
|
+
const { title, url, actor, summary } = normalize(data);
|
|
99
|
+
let content = `**${icon(event)} ${eventLabel(event)}**\n> ${title}\n${summary}\n> ${actor} · ${new Date().toLocaleString('zh-CN')}`;
|
|
100
|
+
if (url) content += `\n[查看详情](${url})`;
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
await fetch(webhook, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ msgtype: 'markdown', markdown: { content } }) });
|
|
104
|
+
} catch (err) { log.error(`WeCom notify failed: ${err.message}`); }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function normalize(data) {
|
|
108
|
+
return {
|
|
109
|
+
title: data.doc?.data?.title || data.doc?.id || 'Untitled',
|
|
110
|
+
url: data.url || '',
|
|
111
|
+
actor: data.actor || data.doc?._meta?.createdBy?.label || 'System',
|
|
112
|
+
summary: data.summary || (typeof data.doc?.data?.body === 'string' ? data.doc.data.body.substring(0, 100) : '')
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function eventLabel(e) {
|
|
117
|
+
return { content_created: '内容创建', content_updated: '内容更新', content_deleted: '内容删除', review_requested: '请求审核', agent_action: 'Agent 操作' }[e] || e;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function icon(e) {
|
|
121
|
+
return { content_created: '📝', content_updated: '✏️', content_deleted: '🗑️', review_requested: '👀', agent_action: '🤖' }[e] || '📌';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function escapeMd(text) {
|
|
125
|
+
return String(text).replace(/[*_~`>#[\]()\\]/g, '\\$&');
|
|
126
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pipeline Engine — Agent 内容编排管道
|
|
3
|
+
*
|
|
4
|
+
* 声明式定义多步骤内容操作(翻译→润色→SEO→审核→发布)。
|
|
5
|
+
* 基于现有 Hook 系统构建。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createLogger } from './logger.js';
|
|
9
|
+
|
|
10
|
+
const log = createLogger('pipeline');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {object} PipelineStep
|
|
14
|
+
* @property {string} name — step name
|
|
15
|
+
* @property {string} action — "translate" | "polish" | "seo" | "review" | "publish" | custom
|
|
16
|
+
* @property {object} [config] — step-specific config
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {object} PipelineTemplate
|
|
21
|
+
* @property {string} name
|
|
22
|
+
* @property {string} label
|
|
23
|
+
* @property {PipelineStep[]} steps
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Built-in pipeline templates */
|
|
27
|
+
const TEMPLATES = {
|
|
28
|
+
translation: {
|
|
29
|
+
name: 'translation',
|
|
30
|
+
label: '翻译管道',
|
|
31
|
+
steps: [
|
|
32
|
+
{ name: 'detect-language', action: 'detect' },
|
|
33
|
+
{ name: 'translate', action: 'translate', config: { targetLang: 'en' } },
|
|
34
|
+
{ name: 'polish', action: 'polish' },
|
|
35
|
+
{ name: 'publish', action: 'publish' }
|
|
36
|
+
]
|
|
37
|
+
},
|
|
38
|
+
seo: {
|
|
39
|
+
name: 'seo',
|
|
40
|
+
label: 'SEO 优化管道',
|
|
41
|
+
steps: [
|
|
42
|
+
{ name: 'analyze-keywords', action: 'seo_analyze' },
|
|
43
|
+
{ name: 'optimize-title', action: 'seo_title' },
|
|
44
|
+
{ name: 'optimize-body', action: 'seo_body' },
|
|
45
|
+
{ name: 'add-meta', action: 'seo_meta' },
|
|
46
|
+
{ name: 'publish', action: 'publish' }
|
|
47
|
+
]
|
|
48
|
+
},
|
|
49
|
+
review: {
|
|
50
|
+
name: 'review',
|
|
51
|
+
label: '审核发布管道',
|
|
52
|
+
steps: [
|
|
53
|
+
{ name: 'ai-review', action: 'review', config: { autoApprove: false } },
|
|
54
|
+
{ name: 'publish', action: 'publish' }
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
class PipelineEngine {
|
|
60
|
+
constructor(store, hooks) {
|
|
61
|
+
this.store = store;
|
|
62
|
+
this.hooks = hooks;
|
|
63
|
+
/** @type {Map<string, PipelineTemplate>} */
|
|
64
|
+
this.templates = new Map(Object.entries(TEMPLATES));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Register a custom pipeline template.
|
|
69
|
+
*/
|
|
70
|
+
registerTemplate(template) {
|
|
71
|
+
this.templates.set(template.name, template);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* List available templates.
|
|
76
|
+
*/
|
|
77
|
+
listTemplates() {
|
|
78
|
+
return Array.from(this.templates.values());
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Execute a pipeline on a document.
|
|
83
|
+
* @param {string} templateName
|
|
84
|
+
* @param {object} doc — the document to process
|
|
85
|
+
* @returns {Promise<{ steps: Array<{ name: string, status: string, result?: any }> }>}
|
|
86
|
+
*/
|
|
87
|
+
async execute(templateName, doc) {
|
|
88
|
+
const template = this.templates.get(templateName);
|
|
89
|
+
if (!template) throw new Error(`Pipeline template "${templateName}" not found`);
|
|
90
|
+
|
|
91
|
+
const results = [];
|
|
92
|
+
const currentDoc = doc;
|
|
93
|
+
|
|
94
|
+
for (const step of template.steps) {
|
|
95
|
+
try {
|
|
96
|
+
// Fire before-step hook
|
|
97
|
+
await this.hooks.run(`pipeline:before:${step.action}`, { doc: currentDoc, step });
|
|
98
|
+
|
|
99
|
+
// Execute step (delegated to hook handlers or built-in logic)
|
|
100
|
+
const stepResult = await this.hooks.run(`pipeline:${step.action}`, {
|
|
101
|
+
doc: currentDoc,
|
|
102
|
+
config: step.config || {},
|
|
103
|
+
stepName: step.name
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
results.push({ name: step.name, status: 'completed', result: stepResult });
|
|
107
|
+
|
|
108
|
+
// Fire after-step hook
|
|
109
|
+
await this.hooks.run(`pipeline:after:${step.action}`, { doc: currentDoc, result: stepResult });
|
|
110
|
+
|
|
111
|
+
log.info(`Pipeline step "${step.name}" completed for doc ${doc.id}`);
|
|
112
|
+
} catch (err) {
|
|
113
|
+
results.push({ name: step.name, status: 'failed', error: err.message });
|
|
114
|
+
log.error(`Pipeline step "${step.name}" failed: ${err.message}`);
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { steps: results };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── Agent Metadata ─────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Attach agent metadata to a content document.
|
|
127
|
+
* Called automatically when an Agent (API Key) creates or modifies content.
|
|
128
|
+
*
|
|
129
|
+
* @param {object} doc — document being created/updated
|
|
130
|
+
* @param {object} actor — from auth middleware
|
|
131
|
+
* @param {string} action — "create" | "update"
|
|
132
|
+
* @returns {object} doc with metadata attached
|
|
133
|
+
*/
|
|
134
|
+
export function attachAgentMeta(doc, actor, action = 'create') {
|
|
135
|
+
if (actor?.type !== 'agent') return doc;
|
|
136
|
+
|
|
137
|
+
const meta = doc._meta || (doc._meta = {});
|
|
138
|
+
meta.createdBy = meta.createdBy || {
|
|
139
|
+
type: 'agent',
|
|
140
|
+
agentId: actor.keyPrefix || actor.id,
|
|
141
|
+
label: actor.label || 'Unknown Agent',
|
|
142
|
+
timestamp: new Date().toISOString()
|
|
143
|
+
};
|
|
144
|
+
meta.lastModifiedBy = {
|
|
145
|
+
type: 'agent',
|
|
146
|
+
agentId: actor.keyPrefix || actor.id,
|
|
147
|
+
label: actor.label || 'Unknown Agent',
|
|
148
|
+
action,
|
|
149
|
+
timestamp: new Date().toISOString()
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
return doc;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── Review Policy ─────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Review policy for Agent-generated content.
|
|
159
|
+
*/
|
|
160
|
+
class ReviewPolicy {
|
|
161
|
+
constructor(config = {}) {
|
|
162
|
+
this.requireHumanReview = config.requireHumanReview ?? true;
|
|
163
|
+
this.autoApproveThreshold = config.autoApproveThreshold ?? null;
|
|
164
|
+
this.blockedPatterns = config.blockedPatterns || [];
|
|
165
|
+
this.minConfidence = config.minConfidence ?? 0.7;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Evaluate content against the review policy.
|
|
170
|
+
* @returns {{ approved: boolean, reason?: string, score?: number }}
|
|
171
|
+
*/
|
|
172
|
+
evaluate(doc) {
|
|
173
|
+
// Check blocked patterns
|
|
174
|
+
const text = JSON.stringify(doc.data || {}).toLowerCase();
|
|
175
|
+
for (const pattern of this.blockedPatterns) {
|
|
176
|
+
if (text.includes(pattern.toLowerCase())) {
|
|
177
|
+
return { approved: false, reason: `Blocked pattern: "${pattern}"` };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Check if marked as AI-generated → require review
|
|
182
|
+
const isAgentContent = doc._meta?.createdBy?.type === 'agent';
|
|
183
|
+
if (isAgentContent && this.requireHumanReview) {
|
|
184
|
+
return { approved: false, reason: 'Agent-generated content requires human review' };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return { approved: true };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Serialize to JSON for storage */
|
|
191
|
+
toJSON() {
|
|
192
|
+
return {
|
|
193
|
+
requireHumanReview: this.requireHumanReview,
|
|
194
|
+
autoApproveThreshold: this.autoApproveThreshold,
|
|
195
|
+
blockedPatterns: this.blockedPatterns,
|
|
196
|
+
minConfidence: this.minConfidence
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Create from stored JSON */
|
|
201
|
+
static fromJSON(json) {
|
|
202
|
+
return new ReviewPolicy(json);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export { PipelineEngine, ReviewPolicy, TEMPLATES };
|