superagent-ai-agent 0.1.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/.env.example +27 -0
- package/LICENSE +21 -0
- package/README.md +147 -0
- package/README.zh-CN.md +147 -0
- package/agent-config.json +4 -0
- package/agent-persona.md +67 -0
- package/bin/postinstall.js +26 -0
- package/bin/superagent.js +283 -0
- package/package.json +43 -0
- package/src/agent.js +114 -0
- package/src/config.js +103 -0
- package/src/public/index.html +1889 -0
- package/src/query.js +239 -0
- package/src/server.js +303 -0
- package/src/web-tool.js +174 -0
package/src/query.js
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/query.js — SDK 交互层
|
|
3
|
+
*
|
|
4
|
+
* 封装与 @anthropic-ai/claude-agent-sdk 的所有交互细节:
|
|
5
|
+
* - 底层 query 调用(runQuery)
|
|
6
|
+
* - 流式消费 + 状态收集(consumeQuery)
|
|
7
|
+
* - 内容审查降级包装器(withFallback)
|
|
8
|
+
* - Plan JSON 解析(parsePlan)
|
|
9
|
+
* - 辅助工具函数
|
|
10
|
+
*
|
|
11
|
+
* 对上层(agent.js)只暴露 3 个接口:withFallback、phaseModel、parsePlan
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
17
|
+
import { webMcpServer } from './web-tool.js';
|
|
18
|
+
import {
|
|
19
|
+
MODEL_TEXT, MODEL_VISION, MODEL_FALLBACK,
|
|
20
|
+
EXECUTOR_TOOLS, INSPECTION_RE,
|
|
21
|
+
PERSONA_PATH, PROJECT_SKILLS_DIR,
|
|
22
|
+
} from './config.js';
|
|
23
|
+
|
|
24
|
+
// ═══════════════════════════════════════════════
|
|
25
|
+
// 辅助函数
|
|
26
|
+
// ═══════════════════════════════════════════════
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 根据是否有图片选择模型
|
|
30
|
+
* 有图片 → MODEL_VISION(多模态),无图片 → MODEL_TEXT(纯文本)
|
|
31
|
+
*/
|
|
32
|
+
export function phaseModel(images) {
|
|
33
|
+
return images && images.length ? MODEL_VISION : MODEL_TEXT;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 读取 agent-persona.md(每次调用都重读,改完即生效) */
|
|
37
|
+
function loadPersona() {
|
|
38
|
+
try {
|
|
39
|
+
return readFileSync(PERSONA_PATH, 'utf8').trim();
|
|
40
|
+
} catch (e) {
|
|
41
|
+
if (e.code !== 'ENOENT') console.warn('[persona] 读取失败:', e.message);
|
|
42
|
+
return '';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 扫描 .claude/skills/ 目录,提取已注册的 skill 名称列表 */
|
|
47
|
+
function listProjectSkills() {
|
|
48
|
+
try {
|
|
49
|
+
const entries = readdirSync(PROJECT_SKILLS_DIR, { withFileTypes: true });
|
|
50
|
+
const names = [];
|
|
51
|
+
for (const e of entries) {
|
|
52
|
+
// symlink 处理:readdirSync 的 Dirent 走 lstat,需 statSync 跟随
|
|
53
|
+
let full;
|
|
54
|
+
try { full = statSync(path.join(PROJECT_SKILLS_DIR, e.name)); } catch { continue; }
|
|
55
|
+
if (!full.isDirectory()) continue;
|
|
56
|
+
try {
|
|
57
|
+
const content = readFileSync(path.join(PROJECT_SKILLS_DIR, e.name, 'SKILL.md'), 'utf8');
|
|
58
|
+
const m = content.match(/^---[\s\S]*?\nname:\s*([^\n]+)/);
|
|
59
|
+
names.push((m?.[1]?.trim()) || e.name);
|
|
60
|
+
} catch { /* SKILL.md 缺失,跳过 */ }
|
|
61
|
+
}
|
|
62
|
+
return names;
|
|
63
|
+
} catch (e) {
|
|
64
|
+
if (e.code !== 'ENOENT') console.warn('[skills] 扫描失败:', e.message);
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 构建 prompt:纯文本直接返回字符串,带图片则包装为异步可迭代 SDK 用户消息 */
|
|
70
|
+
function buildPrompt(text, images) {
|
|
71
|
+
if (!images || images.length === 0) return text;
|
|
72
|
+
const content = [
|
|
73
|
+
...images.map((img) => ({
|
|
74
|
+
type: 'image',
|
|
75
|
+
source: { type: 'base64', media_type: img.mediaType, data: img.base64 },
|
|
76
|
+
})),
|
|
77
|
+
{ type: 'text', text: text || '' },
|
|
78
|
+
];
|
|
79
|
+
return (async function* () {
|
|
80
|
+
yield { type: 'user', message: { role: 'user', content }, parent_tool_use_id: null };
|
|
81
|
+
})();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 从各种错误结构中提取可读文本 */
|
|
85
|
+
function pickErrorText(msg, err) {
|
|
86
|
+
return [err?.message, err?.stack, msg?.message, msg?.error?.message,
|
|
87
|
+
typeof msg?.error === 'string' ? msg.error : null,
|
|
88
|
+
msg?.data, typeof msg?.data === 'string' ? msg.data : null]
|
|
89
|
+
.filter(Boolean).join('\n');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ═══════════════════════════════════════════════
|
|
93
|
+
// SDK 调用封装
|
|
94
|
+
// ═══════════════════════════════════════════════
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 底层 SDK query 调用
|
|
98
|
+
* 将业务参数转换为 claude-agent-sdk 的 options 格式
|
|
99
|
+
*/
|
|
100
|
+
function runQuery({ prompt, images, model, sessionId, abortController, tools = EXECUTOR_TOOLS, roleAppend = '' }) {
|
|
101
|
+
const persona = loadPersona();
|
|
102
|
+
const projectSkills = listProjectSkills();
|
|
103
|
+
const append = [persona, roleAppend].filter(Boolean).join('\n');
|
|
104
|
+
return query({
|
|
105
|
+
prompt: buildPrompt(prompt, images),
|
|
106
|
+
options: {
|
|
107
|
+
permissionMode: 'bypassPermissions',
|
|
108
|
+
allowDangerouslySkipPermissions: true,
|
|
109
|
+
tools,
|
|
110
|
+
mcpServers: { web: webMcpServer },
|
|
111
|
+
cwd: process.cwd(),
|
|
112
|
+
settingSources: ['project'],
|
|
113
|
+
skills: projectSkills,
|
|
114
|
+
// 关闭扩展思考:模型不再产出 thinking 块,UI 也就没有 💭 思考卡片
|
|
115
|
+
thinking: { type: 'disabled' },
|
|
116
|
+
env: {
|
|
117
|
+
...process.env,
|
|
118
|
+
ANTHROPIC_API_KEY: process.env.ANTHROPIC_AUTH_TOKEN,
|
|
119
|
+
ANTHROPIC_AUTH_TOKEN: undefined,
|
|
120
|
+
API_TIMEOUT_MS: '300000000',
|
|
121
|
+
ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL,
|
|
122
|
+
ANTHROPIC_MODEL: model,
|
|
123
|
+
},
|
|
124
|
+
systemPrompt: {
|
|
125
|
+
type: 'preset',
|
|
126
|
+
preset: 'claude_code',
|
|
127
|
+
...(append ? { append } : {}),
|
|
128
|
+
},
|
|
129
|
+
...(sessionId ? { resume: sessionId } : {}),
|
|
130
|
+
...(abortController ? { abortController } : {}),
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 流式消费一次 query,透传所有 msg 给上层
|
|
137
|
+
*
|
|
138
|
+
* 返回的 generator 附带 .state 属性,包含:
|
|
139
|
+
* - text: 累积的 assistant 文本
|
|
140
|
+
* - sessionId: SDK 返回的会话 ID
|
|
141
|
+
* - inspectionHit: 是否命中内容审查
|
|
142
|
+
* - yieldedContent: 是否已产出有效内容
|
|
143
|
+
* - failures: 失败的 tool_result 错误文本数组
|
|
144
|
+
*/
|
|
145
|
+
function consumeQuery({ prompt, images, model, sessionId, abortController, tools, roleAppend }) {
|
|
146
|
+
const state = {
|
|
147
|
+
text: '',
|
|
148
|
+
sessionId: sessionId ?? null,
|
|
149
|
+
inspectionHit: false,
|
|
150
|
+
yieldedContent: false,
|
|
151
|
+
failures: [],
|
|
152
|
+
};
|
|
153
|
+
const it = runQuery({ prompt, images, model, sessionId, abortController, tools, roleAppend });
|
|
154
|
+
const gen = (async function* () {
|
|
155
|
+
for await (const msg of it) {
|
|
156
|
+
// 命中审查的 error 吞掉,交给上层 withFallback 切兜底模型
|
|
157
|
+
if (msg?.type === 'error' && INSPECTION_RE.test(pickErrorText(msg))) {
|
|
158
|
+
state.inspectionHit = true;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (msg?.type !== 'error' && msg?.type !== 'result') state.yieldedContent = true;
|
|
162
|
+
if (msg?.type === 'assistant') {
|
|
163
|
+
for (const b of msg.message?.content ?? []) {
|
|
164
|
+
if (b.type === 'text' && b.text) state.text += b.text;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (msg?.type === 'user') {
|
|
168
|
+
for (const b of msg.message?.content ?? []) {
|
|
169
|
+
if (b.type === 'tool_result' && b.is_error) {
|
|
170
|
+
const t = typeof b.content === 'string'
|
|
171
|
+
? b.content
|
|
172
|
+
: (b.content ?? []).map((c) => c.text ?? JSON.stringify(c)).join('\n');
|
|
173
|
+
state.failures.push(t);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (msg?.type === 'result' && msg.session_id) state.sessionId = msg.session_id;
|
|
178
|
+
yield msg;
|
|
179
|
+
}
|
|
180
|
+
})();
|
|
181
|
+
gen.state = state;
|
|
182
|
+
return gen;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ═══════════════════════════════════════════════
|
|
186
|
+
// 公开接口
|
|
187
|
+
// ═══════════════════════════════════════════════
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 统一的内容审查降级包装器
|
|
191
|
+
*
|
|
192
|
+
* 先用 primaryModel 跑,若命中审查且无有效输出,自动切 MODEL_FALLBACK 重跑。
|
|
193
|
+
* 返回最终的 state 对象(text / sessionId / failures 等)。
|
|
194
|
+
*/
|
|
195
|
+
export async function* withFallback({ prompt, images, model, sessionId, abortController, tools, roleAppend }) {
|
|
196
|
+
const gen = consumeQuery({ prompt, images, model, sessionId, abortController, tools, roleAppend });
|
|
197
|
+
for await (const msg of gen) yield msg;
|
|
198
|
+
if (gen.state.inspectionHit && !gen.state.yieldedContent) {
|
|
199
|
+
console.warn(`[agent] ${model} 命中内容审查,切 ${MODEL_FALLBACK}`);
|
|
200
|
+
const fb = consumeQuery({ prompt, images, model: MODEL_FALLBACK, sessionId: gen.state.sessionId ?? sessionId, abortController, tools, roleAppend });
|
|
201
|
+
for await (const msg of fb) yield msg;
|
|
202
|
+
return fb.state;
|
|
203
|
+
}
|
|
204
|
+
return gen.state;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ═══════════════════════════════════════════════
|
|
208
|
+
// Plan JSON 解析
|
|
209
|
+
// ═══════════════════════════════════════════════
|
|
210
|
+
|
|
211
|
+
/** 校验 plan 对象结构是否合法 */
|
|
212
|
+
function validatePlan(obj) {
|
|
213
|
+
if (obj && (typeof obj.needPlan === 'boolean' || Array.isArray(obj.steps))) return obj;
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* 从 Planner 输出的文本中提取 plan JSON
|
|
219
|
+
*
|
|
220
|
+
* 支持 ```plan / ```json 代码块包裹,或裸 JSON。
|
|
221
|
+
* 严格解析失败后会做宽松重试(去尾逗号、修中文标点)。
|
|
222
|
+
*/
|
|
223
|
+
export function parsePlan(text) {
|
|
224
|
+
if (!text) return null;
|
|
225
|
+
const block = text.match(/```(?:plan|json)?\s*(\{[\s\S]*?\})\s*```/);
|
|
226
|
+
const raw = block ? block[1] : text;
|
|
227
|
+
const m = raw.match(/\{[\s\S]*\}/);
|
|
228
|
+
if (!m) return null;
|
|
229
|
+
let json = m[0];
|
|
230
|
+
// 先严格解析
|
|
231
|
+
try { return validatePlan(JSON.parse(json)); } catch { /* strict failed */ }
|
|
232
|
+
// 宽松重试:去尾逗号、修中文标点
|
|
233
|
+
json = json
|
|
234
|
+
.replace(/,\s*([}\]])/g, '$1')
|
|
235
|
+
.replace(/[\u201c\u201d]/g, '"')
|
|
236
|
+
.replace(/\uFF1A/g, ':');
|
|
237
|
+
try { return validatePlan(JSON.parse(json)); } catch { /* lenient failed */ }
|
|
238
|
+
return null;
|
|
239
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { runAgent } from './agent.js';
|
|
5
|
+
|
|
6
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const PORT = Number(process.env.PORT ?? 3000);
|
|
8
|
+
|
|
9
|
+
for (const key of ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN']) {
|
|
10
|
+
if (!process.env[key]) {
|
|
11
|
+
console.error(`[fatal] 缺少环境变量 ${key},请在 .env 里配置后再启动`);
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const app = express();
|
|
17
|
+
// 图片走 base64 走 JSON,放宽到 30MB(单图建议 ≤5MB,base64 后约 7MB)
|
|
18
|
+
app.use(express.json({ limit: '30mb' }));
|
|
19
|
+
app.use(express.static(path.join(__dirname, 'public'), {
|
|
20
|
+
// HTML 入口强制 no-cache,确保前端代码改动后浏览器每次都拿最新版
|
|
21
|
+
// (JS/CSS 资源可继续走默认缓存,因为内联在 HTML 里随 HTML 一起更新)
|
|
22
|
+
setHeaders: (res, filePath) => {
|
|
23
|
+
if (filePath.endsWith('.html')) {
|
|
24
|
+
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
// 把 marked 当 vendor 静态资源对外暴露
|
|
30
|
+
app.get('/vendor/marked.umd.js', (_req, res) => {
|
|
31
|
+
res.sendFile(path.resolve(__dirname, '../node_modules/marked/lib/marked.umd.js'));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// agent-config.json:Web UI 显示的 agent/user 名称等。每次请求重读,文件改完立刻生效
|
|
35
|
+
const CONFIG_PATH = path.resolve(process.cwd(), 'agent-config.json');
|
|
36
|
+
const DEFAULT_CONFIG = { agentName: '个人助理', userName: '你' };
|
|
37
|
+
app.get('/api/config', async (_req, res) => {
|
|
38
|
+
try {
|
|
39
|
+
const fs = await import('node:fs/promises');
|
|
40
|
+
const text = await fs.readFile(CONFIG_PATH, 'utf8');
|
|
41
|
+
res.json({ ...DEFAULT_CONFIG, ...JSON.parse(text) });
|
|
42
|
+
} catch {
|
|
43
|
+
res.json(DEFAULT_CONFIG);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Server 启动时间戳,用于前端自愈:发现变化就 reload
|
|
48
|
+
const SERVER_START_TIME = Date.now();
|
|
49
|
+
app.get('/api/version', (_req, res) => {
|
|
50
|
+
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
51
|
+
res.json({ startTime: SERVER_START_TIME });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ---------- 重启后自动自测 ----------
|
|
55
|
+
// Luna 改完代码重启前会写 .self-test-pending.json,server 启动时读到就暴露给前端,
|
|
56
|
+
// 前端 init 时拉取,有 pending 就自动发一条测试消息验证工具是否工作,完成后上报清除
|
|
57
|
+
const SELF_TEST_FILE = path.resolve(process.cwd(), '.self-test-pending.json');
|
|
58
|
+
app.get('/api/self-test', async (_req, res) => {
|
|
59
|
+
try {
|
|
60
|
+
const fs = await import('node:fs/promises');
|
|
61
|
+
const raw = await fs.readFile(SELF_TEST_FILE, 'utf8');
|
|
62
|
+
res.json(JSON.parse(raw));
|
|
63
|
+
} catch (e) {
|
|
64
|
+
if (e?.code === 'ENOENT') return res.json({ pending: false });
|
|
65
|
+
res.status(500).json({ error: String(e?.message ?? e) });
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
app.post('/api/self-test/done', async (_req, res) => {
|
|
69
|
+
try {
|
|
70
|
+
const fs = await import('node:fs/promises');
|
|
71
|
+
await fs.unlink(SELF_TEST_FILE);
|
|
72
|
+
res.json({ ok: true });
|
|
73
|
+
} catch (e) {
|
|
74
|
+
if (e?.code === 'ENOENT') return res.json({ ok: true });
|
|
75
|
+
res.status(500).json({ error: String(e?.message ?? e) });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// 头像图片路由:如果项目根有 luna-avatar.png 之类的图片就 serve 出去
|
|
80
|
+
app.get('/avatar', async (_req, res) => {
|
|
81
|
+
try {
|
|
82
|
+
const fs = await import('node:fs/promises');
|
|
83
|
+
const cfg = JSON.parse(await fs.readFile(CONFIG_PATH, 'utf8'));
|
|
84
|
+
if (!cfg.avatar) return res.status(404).end();
|
|
85
|
+
const filePath = path.resolve(process.cwd(), cfg.avatar);
|
|
86
|
+
const buf = await fs.readFile(filePath);
|
|
87
|
+
const ext = (cfg.avatar.split('.').pop() || 'png').toLowerCase();
|
|
88
|
+
const mime = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif', svg: 'image/svg+xml' }[ext] || 'application/octet-stream';
|
|
89
|
+
res.setHeader('Content-Type', mime);
|
|
90
|
+
res.setHeader('Cache-Control', 'public, max-age=300');
|
|
91
|
+
res.send(buf);
|
|
92
|
+
} catch (e) {
|
|
93
|
+
res.status(404).end();
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// 动态 SVG favicon:取 agentName 第一个字,珊瑚红渐变背景
|
|
98
|
+
function escapeXml(s) {
|
|
99
|
+
return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
100
|
+
}
|
|
101
|
+
function firstChar(s) {
|
|
102
|
+
return [...String(s || '')][0] || '';
|
|
103
|
+
}
|
|
104
|
+
// 某些浏览器无视 <link rel="icon"> 仍然请求 /favicon.ico,给它返回 204 静默掉
|
|
105
|
+
app.get('/favicon.ico', (_req, res) => res.status(204).end());
|
|
106
|
+
|
|
107
|
+
app.get('/favicon.svg', async (_req, res) => {
|
|
108
|
+
let agentName = DEFAULT_CONFIG.agentName;
|
|
109
|
+
try {
|
|
110
|
+
const fs = await import('node:fs/promises');
|
|
111
|
+
const cfg = JSON.parse(await fs.readFile(CONFIG_PATH, 'utf8'));
|
|
112
|
+
if (cfg.agentName) agentName = cfg.agentName;
|
|
113
|
+
} catch { /* 用默认 */ }
|
|
114
|
+
const ch = escapeXml(firstChar(agentName) || '助');
|
|
115
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
|
116
|
+
<defs>
|
|
117
|
+
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
|
118
|
+
<stop offset="0%" stop-color="#9b87d4"/>
|
|
119
|
+
<stop offset="100%" stop-color="#f0c2dc"/>
|
|
120
|
+
</linearGradient>
|
|
121
|
+
</defs>
|
|
122
|
+
<rect width="64" height="64" rx="14" fill="url(#g)"/>
|
|
123
|
+
<text x="32" y="44" text-anchor="middle"
|
|
124
|
+
font-family="-apple-system,'PingFang SC','Hiragino Sans GB','Microsoft YaHei',system-ui,sans-serif"
|
|
125
|
+
font-size="36" font-weight="700" fill="#fff">${ch}</text>
|
|
126
|
+
</svg>`;
|
|
127
|
+
res.setHeader('Content-Type', 'image/svg+xml');
|
|
128
|
+
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
129
|
+
res.send(svg);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ---------- 对话历史归档:./sessions/{sessionId}.json ----------
|
|
133
|
+
const SESSIONS_DIR = path.resolve(process.cwd(), 'sessions');
|
|
134
|
+
// sessionId 只允许字母数字、横线、下划线,防路径穿越
|
|
135
|
+
const SESSION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
136
|
+
|
|
137
|
+
async function ensureSessionsDir() {
|
|
138
|
+
const fs = await import('node:fs/promises');
|
|
139
|
+
await fs.mkdir(SESSIONS_DIR, { recursive: true });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
app.get('/api/sessions', async (_req, res) => {
|
|
143
|
+
try {
|
|
144
|
+
await ensureSessionsDir();
|
|
145
|
+
const fs = await import('node:fs/promises');
|
|
146
|
+
const files = (await fs.readdir(SESSIONS_DIR)).filter((f) => f.endsWith('.json'));
|
|
147
|
+
const items = [];
|
|
148
|
+
for (const f of files) {
|
|
149
|
+
try {
|
|
150
|
+
const raw = await fs.readFile(path.join(SESSIONS_DIR, f), 'utf8');
|
|
151
|
+
const obj = JSON.parse(raw);
|
|
152
|
+
items.push({
|
|
153
|
+
sessionId: obj.sessionId,
|
|
154
|
+
title: obj.title || '(无标题)',
|
|
155
|
+
createdAt: obj.createdAt,
|
|
156
|
+
updatedAt: obj.updatedAt,
|
|
157
|
+
});
|
|
158
|
+
} catch { /* 跳过坏文件 */ }
|
|
159
|
+
}
|
|
160
|
+
// 按 updatedAt 倒序
|
|
161
|
+
items.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
|
162
|
+
res.json(items);
|
|
163
|
+
} catch (e) {
|
|
164
|
+
res.status(500).json({ error: String(e?.message ?? e) });
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
app.get('/api/sessions/:id', async (req, res) => {
|
|
169
|
+
const { id } = req.params;
|
|
170
|
+
if (!SESSION_ID_RE.test(id)) return res.status(400).json({ error: 'bad id' });
|
|
171
|
+
try {
|
|
172
|
+
const fs = await import('node:fs/promises');
|
|
173
|
+
const raw = await fs.readFile(path.join(SESSIONS_DIR, `${id}.json`), 'utf8');
|
|
174
|
+
res.type('application/json').send(raw);
|
|
175
|
+
} catch (e) {
|
|
176
|
+
if (e?.code === 'ENOENT') return res.status(404).json({ error: 'not found' });
|
|
177
|
+
res.status(500).json({ error: String(e?.message ?? e) });
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
app.post('/api/sessions', async (req, res) => {
|
|
182
|
+
const { sessionId, title, history, createdAt } = req.body ?? {};
|
|
183
|
+
if (!sessionId || !SESSION_ID_RE.test(sessionId)) {
|
|
184
|
+
return res.status(400).json({ error: 'bad sessionId' });
|
|
185
|
+
}
|
|
186
|
+
if (!Array.isArray(history) || history.length === 0) {
|
|
187
|
+
return res.status(400).json({ error: 'history 不能为空' });
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
await ensureSessionsDir();
|
|
191
|
+
const fs = await import('node:fs/promises');
|
|
192
|
+
const now = Date.now();
|
|
193
|
+
const filePath = path.join(SESSIONS_DIR, `${sessionId}.json`);
|
|
194
|
+
// 同一 sessionId 已存在 → 保留旧 createdAt
|
|
195
|
+
let prevCreatedAt = null;
|
|
196
|
+
try {
|
|
197
|
+
const old = JSON.parse(await fs.readFile(filePath, 'utf8'));
|
|
198
|
+
if (typeof old?.createdAt === 'number') prevCreatedAt = old.createdAt;
|
|
199
|
+
} catch { /* 文件不存在或损坏,忽略 */ }
|
|
200
|
+
const payload = {
|
|
201
|
+
sessionId,
|
|
202
|
+
title: (typeof title === 'string' && title.trim()) || '(无标题)',
|
|
203
|
+
createdAt: prevCreatedAt ?? (typeof createdAt === 'number' ? createdAt : now),
|
|
204
|
+
updatedAt: now,
|
|
205
|
+
history,
|
|
206
|
+
};
|
|
207
|
+
await fs.writeFile(filePath, JSON.stringify(payload, null, 2), 'utf8');
|
|
208
|
+
res.json({ ok: true, sessionId });
|
|
209
|
+
} catch (e) {
|
|
210
|
+
res.status(500).json({ error: String(e?.message ?? e) });
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
app.delete('/api/sessions/:id', async (req, res) => {
|
|
215
|
+
const { id } = req.params;
|
|
216
|
+
if (!SESSION_ID_RE.test(id)) return res.status(400).json({ error: 'bad id' });
|
|
217
|
+
try {
|
|
218
|
+
const fs = await import('node:fs/promises');
|
|
219
|
+
await fs.unlink(path.join(SESSIONS_DIR, `${id}.json`));
|
|
220
|
+
res.json({ ok: true });
|
|
221
|
+
} catch (e) {
|
|
222
|
+
if (e?.code === 'ENOENT') return res.status(404).json({ error: 'not found' });
|
|
223
|
+
res.status(500).json({ error: String(e?.message ?? e) });
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const ALLOWED_IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
|
|
228
|
+
const MAX_IMAGES = 8;
|
|
229
|
+
|
|
230
|
+
app.post('/api/chat', async (req, res) => {
|
|
231
|
+
const { prompt, sessionId, images } = req.body ?? {};
|
|
232
|
+
const text = typeof prompt === 'string' ? prompt : '';
|
|
233
|
+
const imgs = Array.isArray(images) ? images : [];
|
|
234
|
+
|
|
235
|
+
if (!text.trim() && imgs.length === 0) {
|
|
236
|
+
res.status(400).json({ error: 'prompt 和 images 至少要有一个' });
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (imgs.length > MAX_IMAGES) {
|
|
240
|
+
res.status(400).json({ error: `图片最多 ${MAX_IMAGES} 张` });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
for (const img of imgs) {
|
|
244
|
+
if (!img || !ALLOWED_IMAGE_TYPES.has(img.mediaType) || typeof img.base64 !== 'string') {
|
|
245
|
+
res.status(400).json({ error: `图片格式不合法,支持 ${[...ALLOWED_IMAGE_TYPES].join('/')}` });
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
251
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
252
|
+
res.setHeader('Connection', 'keep-alive');
|
|
253
|
+
res.flushHeaders?.();
|
|
254
|
+
|
|
255
|
+
const send = (event, data) => {
|
|
256
|
+
res.write(`event: ${event}\n`);
|
|
257
|
+
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const abortController = new AbortController();
|
|
261
|
+
// 注意:req.on('close') 是「请求体读完」就触发(express.json 一读完就 fire),不能用
|
|
262
|
+
// 真正的「客户端断开」用 res.on('close'),并用 writableEnded 排除"我们自己 end 掉"的正常完成
|
|
263
|
+
res.on('close', () => {
|
|
264
|
+
if (!res.writableEnded && !abortController.signal.aborted) {
|
|
265
|
+
console.log('[chat] 客户端断开 → 中止 agent');
|
|
266
|
+
abortController.abort();
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
for await (const msg of runAgent({ prompt: text, sessionId, images: imgs, abortController })) {
|
|
272
|
+
// plan-execute 阶段事件单独走专用 SSE event,前端渲染成独立卡片
|
|
273
|
+
if (msg?.type === 'plan') send('plan', msg);
|
|
274
|
+
else if (msg?.type === 'reflection') send('reflection', msg);
|
|
275
|
+
else send('message', msg);
|
|
276
|
+
if (msg?.type === 'result') {
|
|
277
|
+
send('done', { sessionId: msg.session_id });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
try { res.end(); } catch {}
|
|
281
|
+
} catch (err) {
|
|
282
|
+
if (abortController.signal.aborted) {
|
|
283
|
+
console.log('[chat] 已中止');
|
|
284
|
+
try { res.end(); } catch {}
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
console.error('[chat] agent 异常:', err);
|
|
288
|
+
try { send('error', { message: err?.message ?? String(err) }); res.end(); } catch {}
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
app
|
|
293
|
+
.listen(PORT, () => {
|
|
294
|
+
console.log(`Listening on http://localhost:${PORT}`);
|
|
295
|
+
})
|
|
296
|
+
.on('error', (err) => {
|
|
297
|
+
if (err.code === 'EADDRINUSE') {
|
|
298
|
+
console.error(`[fatal] 端口 ${PORT} 被占用,换一个:PORT=${PORT + 1} npm start`);
|
|
299
|
+
} else {
|
|
300
|
+
console.error('[fatal]', err);
|
|
301
|
+
}
|
|
302
|
+
process.exit(1);
|
|
303
|
+
});
|