@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.
Files changed (93) hide show
  1. package/.dockerignore +13 -0
  2. package/Dockerfile +51 -0
  3. package/LICENSE +21 -0
  4. package/README.md +208 -0
  5. package/docker-compose.yml +42 -0
  6. package/docs/ROADMAP.md +101 -0
  7. package/docs/api/README.md +102 -0
  8. package/docs/architecture/001-zero-dependency-core.md +61 -0
  9. package/docs/architecture/002-structured-content-model.md +70 -0
  10. package/docs/architecture/003-hook-based-extension.md +82 -0
  11. package/docs/architecture/004-api-first-architecture.md +122 -0
  12. package/docs/architecture/README.md +24 -0
  13. package/docs/logo.svg +40 -0
  14. package/docs/research/ai-era-cms-user-research.md +247 -0
  15. package/docs/zh/README.md +81 -0
  16. package/docs/zh/guides/deploy.md +75 -0
  17. package/docs/zh/guides/mcp.md +84 -0
  18. package/docs/zh/guides/promotion.md +51 -0
  19. package/marketplace.json +78 -0
  20. package/package.json +60 -0
  21. package/packages/core/src/auth.js +158 -0
  22. package/packages/core/src/content-type.js +244 -0
  23. package/packages/core/src/core.test.js +406 -0
  24. package/packages/core/src/errors.js +60 -0
  25. package/packages/core/src/hooks.js +104 -0
  26. package/packages/core/src/index.js +16 -0
  27. package/packages/core/src/server.test.js +149 -0
  28. package/packages/core/src/sm-crypto.js +31 -0
  29. package/packages/core/src/sqlite-store.js +354 -0
  30. package/packages/core/src/store.js +174 -0
  31. package/packages/core/src/tokenizer.js +89 -0
  32. package/packages/core/src/vector-index.js +131 -0
  33. package/packages/llm-providers/src/index.js +181 -0
  34. package/packages/mcp/src/index.js +355 -0
  35. package/packages/server/public/admin/assets/index-DApxOVTx.js +191 -0
  36. package/packages/server/public/admin/assets/index-DtMvdQm9.css +1 -0
  37. package/packages/server/public/admin/index.html +28 -0
  38. package/packages/server/public/aurora/style.css +1173 -0
  39. package/packages/server/public/favicon.svg +46 -0
  40. package/packages/server/public/theme/index.html +288 -0
  41. package/packages/server/public/theme/style.css +133 -0
  42. package/packages/server/public/theme-minimal/index.html +223 -0
  43. package/packages/server/public/theme-minimal/style.css +109 -0
  44. package/packages/server/public/ws-test.html +106 -0
  45. package/packages/server/src/activitypub.js +228 -0
  46. package/packages/server/src/audit.js +104 -0
  47. package/packages/server/src/auth-provider.js +76 -0
  48. package/packages/server/src/body-parser.js +52 -0
  49. package/packages/server/src/bootstrap.js +272 -0
  50. package/packages/server/src/collab.js +154 -0
  51. package/packages/server/src/config.js +136 -0
  52. package/packages/server/src/context.js +86 -0
  53. package/packages/server/src/email.js +317 -0
  54. package/packages/server/src/index.js +195 -0
  55. package/packages/server/src/logger.js +78 -0
  56. package/packages/server/src/media-store.js +213 -0
  57. package/packages/server/src/middleware/auth.js +203 -0
  58. package/packages/server/src/middleware/cors.js +15 -0
  59. package/packages/server/src/middleware/error-handler.js +49 -0
  60. package/packages/server/src/middleware/rate-limit.js +118 -0
  61. package/packages/server/src/multipart.js +150 -0
  62. package/packages/server/src/notify.js +126 -0
  63. package/packages/server/src/pipeline.js +206 -0
  64. package/packages/server/src/plugin-installer.js +139 -0
  65. package/packages/server/src/plugin-manager.js +165 -0
  66. package/packages/server/src/relationships.js +217 -0
  67. package/packages/server/src/revisions.js +114 -0
  68. package/packages/server/src/router.js +194 -0
  69. package/packages/server/src/routes/activitypub.js +140 -0
  70. package/packages/server/src/routes/api.js +363 -0
  71. package/packages/server/src/routes/audit.js +222 -0
  72. package/packages/server/src/routes/auth.js +205 -0
  73. package/packages/server/src/routes/collab.js +90 -0
  74. package/packages/server/src/routes/export.js +77 -0
  75. package/packages/server/src/routes/graphql.js +344 -0
  76. package/packages/server/src/routes/media.js +169 -0
  77. package/packages/server/src/routes/plugin-marketplace.js +171 -0
  78. package/packages/server/src/routes/relationships.js +133 -0
  79. package/packages/server/src/routes/rss.js +92 -0
  80. package/packages/server/src/routes/sso.js +211 -0
  81. package/packages/server/src/routes/theme.js +119 -0
  82. package/packages/server/src/routes/webhook.js +94 -0
  83. package/packages/server/src/routes/wechat.js +115 -0
  84. package/packages/server/src/routes/workflow.js +157 -0
  85. package/packages/server/src/scheduler.js +96 -0
  86. package/packages/server/src/search.js +100 -0
  87. package/packages/server/src/server.test.js +295 -0
  88. package/packages/server/src/sso-analytics.js +78 -0
  89. package/packages/server/src/static.js +70 -0
  90. package/packages/server/src/theme-engine.js +119 -0
  91. package/packages/server/src/webhook.js +192 -0
  92. package/packages/server/src/websocket.js +308 -0
  93. 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 };