@turingflow/agent-kit 0.0.0-stage → 0.1.1
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/README.md +592 -2
- package/dist/agent.d.ts +27 -0
- package/dist/agent.js +31 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +105 -0
- package/dist/handlers.d.ts +92 -0
- package/dist/handlers.js +211 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +10 -0
- package/dist/sdk.d.ts +106 -0
- package/dist/sdk.js +309 -0
- package/dist/server/agent.d.ts +27 -0
- package/dist/server/agent.js +31 -0
- package/dist/server/artifact-hooks.d.ts +6 -0
- package/dist/server/artifact-hooks.js +37 -0
- package/dist/server/artifact-tool-tracker.d.ts +24 -0
- package/dist/server/artifact-tool-tracker.js +347 -0
- package/dist/server/artifacts.d.ts +227 -0
- package/dist/server/artifacts.js +1061 -0
- package/dist/server/auth/config.d.ts +34 -0
- package/dist/server/auth/config.js +82 -0
- package/dist/server/auth/db.d.ts +159 -0
- package/dist/server/auth/db.js +680 -0
- package/dist/server/auth/handlers.d.ts +27 -0
- package/dist/server/auth/handlers.js +297 -0
- package/dist/server/auth/index.d.ts +15 -0
- package/dist/server/auth/index.js +14 -0
- package/dist/server/auth/login-options.d.ts +14 -0
- package/dist/server/auth/login-options.js +50 -0
- package/dist/server/auth/providers/dingtalk.d.ts +16 -0
- package/dist/server/auth/providers/dingtalk.js +177 -0
- package/dist/server/auth/providers/feishu.d.ts +15 -0
- package/dist/server/auth/providers/feishu.js +100 -0
- package/dist/server/auth/providers/index.d.ts +13 -0
- package/dist/server/auth/providers/index.js +24 -0
- package/dist/server/auth/session.d.ts +48 -0
- package/dist/server/auth/session.js +108 -0
- package/dist/server/auth/types.d.ts +118 -0
- package/dist/server/auth/types.js +7 -0
- package/dist/server/browser-agent-mcp.d.ts +67 -0
- package/dist/server/browser-agent-mcp.js +608 -0
- package/dist/server/cli.d.ts +6 -0
- package/dist/server/cli.js +112 -0
- package/dist/server/debug-log.d.ts +25 -0
- package/dist/server/debug-log.js +86 -0
- package/dist/server/handlers.d.ts +141 -0
- package/dist/server/handlers.js +523 -0
- package/dist/server/index.d.ts +30 -0
- package/dist/server/index.js +28 -0
- package/dist/server/kit-api.d.ts +23 -0
- package/dist/server/kit-api.js +153 -0
- package/dist/server/model-sources.d.ts +32 -0
- package/dist/server/model-sources.js +58 -0
- package/dist/server/oa.d.ts +87 -0
- package/dist/server/oa.js +397 -0
- package/dist/server/providers/openrouter.d.ts +51 -0
- package/dist/server/providers/openrouter.js +283 -0
- package/dist/server/render-markdown.d.ts +2 -0
- package/dist/server/render-markdown.js +50 -0
- package/dist/server/sdk.d.ts +269 -0
- package/dist/server/sdk.js +2025 -0
- package/dist/server/session-access.d.ts +5 -0
- package/dist/server/session-access.js +68 -0
- package/dist/server/session-fork.d.ts +10 -0
- package/dist/server/session-fork.js +142 -0
- package/dist/server/session-store.d.ts +56 -0
- package/dist/server/session-store.js +148 -0
- package/dist/server/sessions.d.ts +24 -0
- package/dist/server/sessions.js +541 -0
- package/dist/server/share.d.ts +15 -0
- package/dist/server/share.js +433 -0
- package/dist/server/tool-activity.d.ts +2 -0
- package/dist/server/tool-activity.js +26 -0
- package/dist/server/transcription/xiaomi-mimo.d.ts +8 -0
- package/dist/server/transcription/xiaomi-mimo.js +86 -0
- package/dist/server/upload.d.ts +13 -0
- package/dist/server/upload.js +81 -0
- package/dist/server/user-files.d.ts +28 -0
- package/dist/server/user-files.js +311 -0
- package/dist/server/voice.d.ts +25 -0
- package/dist/server/voice.js +61 -0
- package/dist/server/web-fetch.d.ts +71 -0
- package/dist/server/web-fetch.js +330 -0
- package/dist/styles.css +3354 -0
- package/dist/transcription/xiaomi-mimo.d.ts +8 -0
- package/dist/transcription/xiaomi-mimo.js +86 -0
- package/dist/ui/index.d.ts +358 -0
- package/dist/ui/index.js +7730 -0
- package/dist/upload.d.ts +13 -0
- package/dist/upload.js +81 -0
- package/dist/voice.d.ts +25 -0
- package/dist/voice.js +61 -0
- package/package.json +74 -4
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** 用户文件区在产物根下的目录名(点开头 = 中间产物,不会被产物收集扫到) */
|
|
2
|
+
export declare const USER_FILES_DIR_NAME = "users";
|
|
3
|
+
/** 目录名派生:sub(provider:unionId)→ 安全的单层目录名。
|
|
4
|
+
* ':' 在 Windows 非法 → 换成 '_';再叠 8 位 hash 防不同 sub 清洗后撞名。 */
|
|
5
|
+
export declare function userKeyFor(sub: string): string;
|
|
6
|
+
/** 用户文件区根目录:<产物根>/users/<userKey>/ */
|
|
7
|
+
export declare function userFilesRoot(sub: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* 把用户传入的相对路径解析为绝对路径,并强制落在 root 内。
|
|
10
|
+
* 返回 null 表示非法(穿越 / 绝对路径 / 空路径 / 越界)。
|
|
11
|
+
* 与产物读取同一套思路:先清洗再复核,不信任任何客户端输入。
|
|
12
|
+
*/
|
|
13
|
+
export declare function safeUserFilePath(root: string, rel: unknown): string | null;
|
|
14
|
+
/** 当前请求者:登录用户 sub(主体键)。未登录返回 null。
|
|
15
|
+
* 目录名只由此派生,前端无法指定他人目录。 */
|
|
16
|
+
export declare function currentUserSub(request: Request): string | null;
|
|
17
|
+
export interface UserFileEntry {
|
|
18
|
+
name: string;
|
|
19
|
+
path: string;
|
|
20
|
+
isDir: boolean;
|
|
21
|
+
size: number;
|
|
22
|
+
mtimeMs: number;
|
|
23
|
+
}
|
|
24
|
+
export declare function createUserFilesHandlers(): {
|
|
25
|
+
GET: (request: Request) => Promise<Response>;
|
|
26
|
+
POST: (request: Request) => Promise<Response>;
|
|
27
|
+
DELETE: (request: Request) => Promise<Response>;
|
|
28
|
+
};
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-kit · 用户文件区(User Files):以登录用户 id 为目录名的**用户可操作区**
|
|
3
|
+
*
|
|
4
|
+
* 背景:产物(artifact)是「模型写给用户看的」,落<产物根>/<会话id>/,随会话隔离。
|
|
5
|
+
* 但用户往往需要一块**自己能改**的地方:上传原始资料、下载成品、清理中间件、
|
|
6
|
+
* 沉淀跨会话复用的资料。这类需求此前没有落点——模型只能写会话目录,用户只能看。
|
|
7
|
+
*
|
|
8
|
+
* 目录:<产物根>/users/<userKey>/,userKey = 由主体键 sub(`provider:unionId`)派生:
|
|
9
|
+
* - 同一用户跨会话共享同一份文件(不按会话再分层,避免会话堆积导致文件失联);
|
|
10
|
+
* - 用户之间天然隔离:目录名由服务端从登录态派生,**不接受前端传入**。
|
|
11
|
+
*
|
|
12
|
+
* 权限边界(本模块是"放开"的口子,必须收紧):
|
|
13
|
+
* - 入口必须登录,且目录名只从 verifySession 的 sub 派生(伪造不了他人目录);
|
|
14
|
+
* - 所有路径经 safeUserFilePath 归一 + 边界复核,`..`、绝对路径、符号链接一律拒绝;
|
|
15
|
+
* - 只在本模块自己的 users/ 子树内活动,够不到会话产物、服务端代码、其他用户数据;
|
|
16
|
+
* - 不与模型沙箱混用:模型的可写 allowRoots 不包含用户目录(除非显式开启),
|
|
17
|
+
* 用户对自己目录的操作也不经过模型,因此与「防模型乱改服务」的目标不冲突。
|
|
18
|
+
*
|
|
19
|
+
* 端点(挂载见 kit-api.ts 的 /api/user-files/*):
|
|
20
|
+
* GET /api/user-files?path=<相对路径> 列目录(path 为空 = 根)
|
|
21
|
+
* POST /api/user-files 上传(multipart,字段名 file;可选 path 指定子目录)
|
|
22
|
+
* GET /api/user-files/raw?path=<相对> 下载 / 预览单个文件内容
|
|
23
|
+
* DELETE /api/user-files?path=<相对> 删除文件或空目录
|
|
24
|
+
* POST /api/user-files/mkdir 新建子目录 { path }
|
|
25
|
+
* POST /api/user-files/rename 重命名/移动 { from, to }
|
|
26
|
+
*/
|
|
27
|
+
import fs from "node:fs/promises";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
import { artifactRoot, mimeFor, sanitizeArtifactName } from "./artifacts.js";
|
|
30
|
+
import { verifySession } from "./auth/handlers.js";
|
|
31
|
+
import { loadAuthConfig } from "./auth/config.js";
|
|
32
|
+
/** 用户文件区在产物根下的目录名(点开头 = 中间产物,不会被产物收集扫到) */
|
|
33
|
+
export const USER_FILES_DIR_NAME = "users";
|
|
34
|
+
/** 单文件上传上限(默认 50MB,比附件的 20MB 宽:这里放的是资料/成品,不是聊天图) */
|
|
35
|
+
const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
|
|
36
|
+
/** 目录名派生:sub(provider:unionId)→ 安全的单层目录名。
|
|
37
|
+
* ':' 在 Windows 非法 → 换成 '_';再叠 8 位 hash 防不同 sub 清洗后撞名。 */
|
|
38
|
+
export function userKeyFor(sub) {
|
|
39
|
+
const cleaned = String(sub || "").replace(/[^a-zA-Z0-9_-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 64);
|
|
40
|
+
let h = 5381;
|
|
41
|
+
for (let i = 0; i < sub.length; i++)
|
|
42
|
+
h = ((h << 5) + h + sub.charCodeAt(i)) | 0;
|
|
43
|
+
const hash = (h >>> 0).toString(16).padStart(8, "0").slice(0, 8);
|
|
44
|
+
return cleaned ? `${cleaned}_${hash}` : `user_${hash}`;
|
|
45
|
+
}
|
|
46
|
+
/** 用户文件区根目录:<产物根>/users/<userKey>/ */
|
|
47
|
+
export function userFilesRoot(sub) {
|
|
48
|
+
return path.join(artifactRoot(), USER_FILES_DIR_NAME, userKeyFor(sub));
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* 把用户传入的相对路径解析为绝对路径,并强制落在 root 内。
|
|
52
|
+
* 返回 null 表示非法(穿越 / 绝对路径 / 空路径 / 越界)。
|
|
53
|
+
* 与产物读取同一套思路:先清洗再复核,不信任任何客户端输入。
|
|
54
|
+
*/
|
|
55
|
+
export function safeUserFilePath(root, rel) {
|
|
56
|
+
const raw = String(rel ?? "").trim();
|
|
57
|
+
// 绝对路径、盘符、UNC 一律拒绝——用户区只接受相对路径
|
|
58
|
+
if (!raw)
|
|
59
|
+
return root;
|
|
60
|
+
if (path.isAbsolute(raw) || /^[a-zA-Z]:/.test(raw) || raw.includes("\0"))
|
|
61
|
+
return null;
|
|
62
|
+
const cleaned = sanitizeArtifactName(raw.replace(/\\/g, "/"));
|
|
63
|
+
const abs = path.resolve(root, cleaned);
|
|
64
|
+
const base = path.resolve(root);
|
|
65
|
+
if (abs !== base && !abs.startsWith(base + path.sep))
|
|
66
|
+
return null;
|
|
67
|
+
return abs;
|
|
68
|
+
}
|
|
69
|
+
function json(data, status = 200) {
|
|
70
|
+
return new Response(JSON.stringify(data), {
|
|
71
|
+
status,
|
|
72
|
+
headers: { "Content-Type": "application/json; charset=utf-8" },
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
const err = (msg, status) => json({ error: msg }, status);
|
|
76
|
+
/** 当前请求者:登录用户 sub(主体键)。未登录返回 null。
|
|
77
|
+
* 目录名只由此派生,前端无法指定他人目录。 */
|
|
78
|
+
export function currentUserSub(request) {
|
|
79
|
+
const user = verifySession(request, loadAuthConfig());
|
|
80
|
+
return user?.sub ?? null;
|
|
81
|
+
}
|
|
82
|
+
/** 列目录(不递归)。path 为空返回根。 */
|
|
83
|
+
async function listDir(root, rel) {
|
|
84
|
+
const dir = safeUserFilePath(root, rel);
|
|
85
|
+
if (!dir)
|
|
86
|
+
return { error: "非法路径", status: 400 };
|
|
87
|
+
let dirents;
|
|
88
|
+
try {
|
|
89
|
+
dirents = await fs.readdir(dir, { withFileTypes: true });
|
|
90
|
+
}
|
|
91
|
+
catch (e) {
|
|
92
|
+
const code = e.code;
|
|
93
|
+
if (code === "ENOENT")
|
|
94
|
+
return { entries: [] };
|
|
95
|
+
if (code === "ENOTDIR")
|
|
96
|
+
return { error: "不是目录", status: 400 };
|
|
97
|
+
return { error: "读取目录失败", status: 500 };
|
|
98
|
+
}
|
|
99
|
+
const entries = [];
|
|
100
|
+
for (const d of dirents) {
|
|
101
|
+
// 隐藏项(点开头)不对用户暴露,避免与内部元数据混淆
|
|
102
|
+
if (d.name.startsWith("."))
|
|
103
|
+
continue;
|
|
104
|
+
const abs = path.join(dir, d.name);
|
|
105
|
+
try {
|
|
106
|
+
const st = await fs.stat(abs);
|
|
107
|
+
entries.push({
|
|
108
|
+
name: d.name,
|
|
109
|
+
path: path.relative(root, abs).split(path.sep).join("/"),
|
|
110
|
+
isDir: st.isDirectory(),
|
|
111
|
+
size: st.isDirectory() ? 0 : st.size,
|
|
112
|
+
mtimeMs: st.mtimeMs,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
/* 并发删除等:跳过该条 */
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// 目录在前;同类按名称自然序
|
|
120
|
+
entries.sort((a, b) => (a.isDir === b.isDir ? a.name.localeCompare(b.name) : a.isDir ? -1 : 1));
|
|
121
|
+
return { entries };
|
|
122
|
+
}
|
|
123
|
+
export function createUserFilesHandlers() {
|
|
124
|
+
/** 取 root + 校验登录;未登录返回 401 */
|
|
125
|
+
async function resolve(request) {
|
|
126
|
+
const sub = currentUserSub(request);
|
|
127
|
+
if (!sub)
|
|
128
|
+
return err("未登录", 401);
|
|
129
|
+
const root = userFilesRoot(sub);
|
|
130
|
+
try {
|
|
131
|
+
await fs.mkdir(root, { recursive: true });
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return err("用户目录不可用", 500);
|
|
135
|
+
}
|
|
136
|
+
return { root, sub };
|
|
137
|
+
}
|
|
138
|
+
async function GET(request) {
|
|
139
|
+
const ctx = await resolve(request);
|
|
140
|
+
if (ctx instanceof Response)
|
|
141
|
+
return ctx;
|
|
142
|
+
const url = new URL(request.url);
|
|
143
|
+
const raw = url.searchParams.get("path") ?? "";
|
|
144
|
+
// /raw = 下载/预览单个文件
|
|
145
|
+
if (url.searchParams.get("raw") !== null) {
|
|
146
|
+
const abs = safeUserFilePath(ctx.root, raw);
|
|
147
|
+
if (!abs)
|
|
148
|
+
return err("非法路径", 400);
|
|
149
|
+
let st;
|
|
150
|
+
try {
|
|
151
|
+
st = await fs.stat(abs);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return err("文件不存在", 404);
|
|
155
|
+
}
|
|
156
|
+
if (st.isDirectory())
|
|
157
|
+
return err("目标是目录", 400);
|
|
158
|
+
const buf = await fs.readFile(abs).catch(() => null);
|
|
159
|
+
if (!buf)
|
|
160
|
+
return err("读取失败", 500);
|
|
161
|
+
const headers = {
|
|
162
|
+
"Content-Type": mimeFor(raw),
|
|
163
|
+
"Cache-Control": "private, no-store",
|
|
164
|
+
"X-Content-Type-Options": "nosniff",
|
|
165
|
+
};
|
|
166
|
+
if (url.searchParams.get("download") !== null) {
|
|
167
|
+
headers["Content-Disposition"] =
|
|
168
|
+
`attachment; filename*=UTF-8''${encodeURIComponent(path.basename(abs))}`;
|
|
169
|
+
}
|
|
170
|
+
else if (mimeFor(raw).startsWith("text/html")) {
|
|
171
|
+
// HTML 预览沙箱化,与产物预览一致
|
|
172
|
+
headers["Content-Security-Policy"] = "sandbox";
|
|
173
|
+
}
|
|
174
|
+
return new Response(new Uint8Array(buf), { headers });
|
|
175
|
+
}
|
|
176
|
+
const res = await listDir(ctx.root, raw);
|
|
177
|
+
if ("error" in res)
|
|
178
|
+
return err(res.error, res.status);
|
|
179
|
+
return json({ path: sanitizeArtifactName(raw.replace(/\\/g, "/")), entries: res.entries });
|
|
180
|
+
}
|
|
181
|
+
async function POST(request) {
|
|
182
|
+
const ctx = await resolve(request);
|
|
183
|
+
if (ctx instanceof Response)
|
|
184
|
+
return ctx;
|
|
185
|
+
const url = new URL(request.url);
|
|
186
|
+
const op = url.pathname.endsWith("/mkdir") ? "mkdir" : url.pathname.endsWith("/rename") ? "rename" : "upload";
|
|
187
|
+
if (op === "mkdir" || op === "rename") {
|
|
188
|
+
let body;
|
|
189
|
+
try {
|
|
190
|
+
body = await request.json();
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return err("请求格式错误", 400);
|
|
194
|
+
}
|
|
195
|
+
if (op === "mkdir") {
|
|
196
|
+
const abs = safeUserFilePath(ctx.root, body.path);
|
|
197
|
+
if (!abs)
|
|
198
|
+
return err("非法路径", 400);
|
|
199
|
+
if (abs === path.resolve(ctx.root))
|
|
200
|
+
return err("不能创建根目录", 400);
|
|
201
|
+
try {
|
|
202
|
+
await fs.mkdir(abs, { recursive: false });
|
|
203
|
+
}
|
|
204
|
+
catch (e) {
|
|
205
|
+
const code = e.code;
|
|
206
|
+
if (code === "EEXIST")
|
|
207
|
+
return err("同名目录已存在", 409);
|
|
208
|
+
if (code === "ENOENT")
|
|
209
|
+
return err("父目录不存在", 404);
|
|
210
|
+
return err("创建失败", 500);
|
|
211
|
+
}
|
|
212
|
+
return json({ ok: true, path: path.relative(ctx.root, abs).split(path.sep).join("/") });
|
|
213
|
+
}
|
|
214
|
+
// rename:from/to 都要在自己的目录内,且目标不存在(不做隐式覆盖)
|
|
215
|
+
const from = safeUserFilePath(ctx.root, body.from);
|
|
216
|
+
const to = safeUserFilePath(ctx.root, body.to);
|
|
217
|
+
if (!from || !to)
|
|
218
|
+
return err("非法路径", 400);
|
|
219
|
+
if (from === path.resolve(ctx.root) || to === path.resolve(ctx.root))
|
|
220
|
+
return err("不能重命名根目录", 400);
|
|
221
|
+
const toStat = await fs.stat(to).catch(() => null);
|
|
222
|
+
if (toStat)
|
|
223
|
+
return err("目标已存在", 409);
|
|
224
|
+
try {
|
|
225
|
+
await fs.rename(from, to);
|
|
226
|
+
}
|
|
227
|
+
catch (e) {
|
|
228
|
+
const code = e.code;
|
|
229
|
+
if (code === "ENOENT")
|
|
230
|
+
return err("源不存在", 404);
|
|
231
|
+
return err("重命名失败", 500);
|
|
232
|
+
}
|
|
233
|
+
return json({ ok: true, path: path.relative(ctx.root, to).split(path.sep).join("/") });
|
|
234
|
+
}
|
|
235
|
+
// upload:multipart,字段名 file;可选 query path = 目标子目录
|
|
236
|
+
let form;
|
|
237
|
+
try {
|
|
238
|
+
form = await request.formData();
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return err("请求格式错误", 400);
|
|
242
|
+
}
|
|
243
|
+
const file = form.get("file");
|
|
244
|
+
if (!file || typeof file === "string")
|
|
245
|
+
return err("没有收到文件", 400);
|
|
246
|
+
const buf = Buffer.from(await file.arrayBuffer());
|
|
247
|
+
if (buf.length > MAX_UPLOAD_BYTES) {
|
|
248
|
+
return err(`文件超过 ${Math.floor(MAX_UPLOAD_BYTES / 1024 / 1024)}MB 上限`, 400);
|
|
249
|
+
}
|
|
250
|
+
const dir = safeUserFilePath(ctx.root, url.searchParams.get("path") ?? "");
|
|
251
|
+
if (!dir)
|
|
252
|
+
return err("非法路径", 400);
|
|
253
|
+
// 保留原文件名(去路径成分 + 清洗),同名自动加 (n) 后缀,不静默覆盖
|
|
254
|
+
const rawName = path.basename(String(file.name || "file")).replace(/[\\/:*?"<>|\s]+/g, "_");
|
|
255
|
+
const base = sanitizeArtifactName(rawName);
|
|
256
|
+
let name = base;
|
|
257
|
+
for (let i = 1; i < 1000; i++) {
|
|
258
|
+
const exists = await fs.stat(path.join(dir, name)).then(() => true).catch(() => false);
|
|
259
|
+
if (!exists)
|
|
260
|
+
break;
|
|
261
|
+
const dot = base.lastIndexOf(".");
|
|
262
|
+
name = dot > 0 ? `${base.slice(0, dot)}(${i})${base.slice(dot)}` : `${base}(${i})`;
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
await fs.mkdir(dir, { recursive: true });
|
|
266
|
+
await fs.writeFile(path.join(dir, name), buf);
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return err("保存失败", 500);
|
|
270
|
+
}
|
|
271
|
+
return json({
|
|
272
|
+
ok: true,
|
|
273
|
+
name,
|
|
274
|
+
size: buf.length,
|
|
275
|
+
path: path.relative(ctx.root, path.join(dir, name)).split(path.sep).join("/"),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
async function DELETE(request) {
|
|
279
|
+
const ctx = await resolve(request);
|
|
280
|
+
if (ctx instanceof Response)
|
|
281
|
+
return ctx;
|
|
282
|
+
const url = new URL(request.url);
|
|
283
|
+
const abs = safeUserFilePath(ctx.root, url.searchParams.get("path") ?? "");
|
|
284
|
+
if (!abs)
|
|
285
|
+
return err("非法路径", 400);
|
|
286
|
+
if (abs === path.resolve(ctx.root))
|
|
287
|
+
return err("不能删除根目录", 400);
|
|
288
|
+
let st;
|
|
289
|
+
try {
|
|
290
|
+
st = await fs.stat(abs);
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
return err("目标不存在", 404);
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
// 目录必须为空才删——递归删除风险太高,宁可让用户自己一层层清
|
|
297
|
+
if (st.isDirectory())
|
|
298
|
+
await fs.rmdir(abs);
|
|
299
|
+
else
|
|
300
|
+
await fs.unlink(abs);
|
|
301
|
+
}
|
|
302
|
+
catch (e) {
|
|
303
|
+
const code = e.code;
|
|
304
|
+
if (["ENOTEMPTY", "EEXIST"].includes(code ?? ""))
|
|
305
|
+
return err("目录非空,请先清空", 409);
|
|
306
|
+
return err("删除失败", 500);
|
|
307
|
+
}
|
|
308
|
+
return json({ ok: true });
|
|
309
|
+
}
|
|
310
|
+
return { GET, POST, DELETE };
|
|
311
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface VoiceUser {
|
|
2
|
+
userId?: string;
|
|
3
|
+
name?: string;
|
|
4
|
+
sub?: string;
|
|
5
|
+
nick?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function createVoiceToken(user: VoiceUser, { ttlSeconds }?: {
|
|
8
|
+
ttlSeconds?: number;
|
|
9
|
+
}): {
|
|
10
|
+
token: string;
|
|
11
|
+
tokenType: string;
|
|
12
|
+
expiresAt: number;
|
|
13
|
+
};
|
|
14
|
+
export declare function verifyVoiceToken(token: string | null | undefined): {
|
|
15
|
+
purpose?: string;
|
|
16
|
+
exp?: number;
|
|
17
|
+
} | null;
|
|
18
|
+
export declare function transcribeVoice({ bytes, mimeType, fileName, }: {
|
|
19
|
+
bytes: Buffer;
|
|
20
|
+
mimeType: string;
|
|
21
|
+
fileName: string;
|
|
22
|
+
}): Promise<{
|
|
23
|
+
text: string;
|
|
24
|
+
provider: string;
|
|
25
|
+
}>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-kit · 语音输入服务端(Next.js App Router 通用 handler)
|
|
3
|
+
*
|
|
4
|
+
* 短时令牌(HMAC-SHA256 签名,默认 10 分钟有效)+ ASR 转写。
|
|
5
|
+
* 前端录音前先取 token,再带 Bearer token 调 transcribe 接口。
|
|
6
|
+
* 默认转写后端:BrandAgent / 小米 MiMo(见 transcription/xiaomi-mimo.ts)。
|
|
7
|
+
*/
|
|
8
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
9
|
+
import { transcribeWithXiaomiMimo } from "./transcription/xiaomi-mimo.js";
|
|
10
|
+
const DEFAULT_TTL_MS = 10 * 60 * 1000;
|
|
11
|
+
function tokenSecret() {
|
|
12
|
+
return (process.env.AGENT_KIT_VOICE_TOKEN_SECRET ||
|
|
13
|
+
process.env.HOTLINE_ASR_TOKEN_SECRET ||
|
|
14
|
+
process.env.HOTLINE_SESSION_SECRET ||
|
|
15
|
+
"agent-kit-2026");
|
|
16
|
+
}
|
|
17
|
+
function sign(value) {
|
|
18
|
+
return createHmac("sha256", tokenSecret()).update(value).digest("base64url");
|
|
19
|
+
}
|
|
20
|
+
export function createVoiceToken(user, { ttlSeconds } = {}) {
|
|
21
|
+
const ttlMs = Math.min(DEFAULT_TTL_MS, Math.max(60_000, (Number(ttlSeconds) || 600) * 1000));
|
|
22
|
+
const payload = Buffer.from(JSON.stringify({
|
|
23
|
+
purpose: "agent-kit-voice",
|
|
24
|
+
sub: user.userId || user.sub || "user",
|
|
25
|
+
name: user.name || user.nick || "",
|
|
26
|
+
exp: Date.now() + ttlMs,
|
|
27
|
+
})).toString("base64url");
|
|
28
|
+
return { token: `${payload}.${sign(payload)}`, tokenType: "Bearer", expiresAt: Date.now() + ttlMs };
|
|
29
|
+
}
|
|
30
|
+
export function verifyVoiceToken(token) {
|
|
31
|
+
if (!token || !token.includes("."))
|
|
32
|
+
return null;
|
|
33
|
+
const [payload, signature] = token.split(".");
|
|
34
|
+
if (!payload || !signature)
|
|
35
|
+
return null;
|
|
36
|
+
const expected = Buffer.from(sign(payload));
|
|
37
|
+
const provided = Buffer.from(signature);
|
|
38
|
+
if (provided.length !== expected.length || !timingSafeEqual(provided, expected))
|
|
39
|
+
return null;
|
|
40
|
+
try {
|
|
41
|
+
const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
42
|
+
if (data.purpose !== "agent-kit-voice")
|
|
43
|
+
return null;
|
|
44
|
+
if (typeof data.exp !== "number" || data.exp <= Date.now())
|
|
45
|
+
return null;
|
|
46
|
+
return data;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// 按环境变量选择转写后端(默认小米 MiMo,经 BrandAgent 平台)。
|
|
53
|
+
export async function transcribeVoice({ bytes, mimeType, fileName, }) {
|
|
54
|
+
const provider = (process.env.AGENT_KIT_ASR_PROVIDER || process.env.HOTLINE_ASR_PROVIDER || "xiaomi")
|
|
55
|
+
.trim()
|
|
56
|
+
.toLowerCase();
|
|
57
|
+
if (provider === "tencent" || provider === "tencent-cloud") {
|
|
58
|
+
throw new Error("腾讯云转写未接入(agent-kit 默认提供 BrandAgent / 小米 MiMo)");
|
|
59
|
+
}
|
|
60
|
+
return transcribeWithXiaomiMimo({ bytes, mimeType, fileName });
|
|
61
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-kit · web-fetch MCP 工具(方案B:真实浏览器抓取,正文 + 图片)
|
|
3
|
+
*
|
|
4
|
+
* 背景:CodeBuddy 内置网页抓取工具能渲染正文,但内容清洗管线会剥离 <img>;
|
|
5
|
+
* 而今日头条等反爬站图片 CDN 对冷 HTTP 请求一律 403,图片必须在页面会话内抓取。
|
|
6
|
+
*
|
|
7
|
+
* 本模块用 Playwright 持久浏览器上下文(launchPersistentContext)实现:
|
|
8
|
+
* - 真实 Chromium 执行页面 JS(自动过 __ac_signature 类签名挑战);
|
|
9
|
+
* - 图片在页面上下文内 fetch(携带会话),绕开图片 CDN 403;
|
|
10
|
+
* - 持久 userDataDir 复用登录态/会话,降低反复建连开销。
|
|
11
|
+
*
|
|
12
|
+
* 对外暴露一个 MCP 工具:fetch_webpage(url, maxImages?, maxTextChars?)
|
|
13
|
+
* 返回:{ title, text, images: [{ url, mediaType, bytes, base64 }] }
|
|
14
|
+
*/
|
|
15
|
+
import { type Page } from "playwright";
|
|
16
|
+
import { type SdkMcpServerResult } from "@tencent-ai/agent-sdk";
|
|
17
|
+
export interface WebFetchOptions {
|
|
18
|
+
/** 最大返回图片数(0 = 不抓图),默认 3 */
|
|
19
|
+
maxImages?: number;
|
|
20
|
+
/** 正文最大字符数,默认 60000 */
|
|
21
|
+
maxTextChars?: number;
|
|
22
|
+
/** 单张图片最大字节数,默认 800KB,超限跳过 */
|
|
23
|
+
maxBytesPerImage?: number;
|
|
24
|
+
/** 页面渲染等待上限(ms),默认 25s */
|
|
25
|
+
navigationTimeoutMs?: number;
|
|
26
|
+
/** 持久上下文 userDataDir(默认 <cwd>/.web-fetch-profile) */
|
|
27
|
+
profileDir?: string;
|
|
28
|
+
/** 是否无头模式,默认 true */
|
|
29
|
+
headless?: boolean;
|
|
30
|
+
}
|
|
31
|
+
export interface WebFetchImage {
|
|
32
|
+
url: string;
|
|
33
|
+
mediaType: string;
|
|
34
|
+
bytes: number;
|
|
35
|
+
base64: string;
|
|
36
|
+
}
|
|
37
|
+
export interface WebFetchResult {
|
|
38
|
+
url: string;
|
|
39
|
+
title: string;
|
|
40
|
+
text: string;
|
|
41
|
+
images: WebFetchImage[];
|
|
42
|
+
imageSkipped: number;
|
|
43
|
+
truncated: boolean;
|
|
44
|
+
}
|
|
45
|
+
/** 是否头条系图片 URL(仅用于头条页图片去重范围;普通页面取 article 内全部图片) */
|
|
46
|
+
export declare function isMediaUrl(u: string): boolean;
|
|
47
|
+
export declare function collectImageUrls(page: Page): Promise<string[]>;
|
|
48
|
+
/**
|
|
49
|
+
* WebFetchService:单例浏览器(持久上下文),串行处理请求。
|
|
50
|
+
* 浏览器实例跨请求复用;每个请求独立 page,处理完即关。
|
|
51
|
+
*/
|
|
52
|
+
export declare class WebFetchService {
|
|
53
|
+
private static instance;
|
|
54
|
+
private contextPromise;
|
|
55
|
+
private queue;
|
|
56
|
+
private readonly opts;
|
|
57
|
+
private constructor();
|
|
58
|
+
static get(opts?: WebFetchOptions): WebFetchService;
|
|
59
|
+
private context;
|
|
60
|
+
/** 串行执行(同一浏览器上下文同一时刻只跑一个请求) */
|
|
61
|
+
private run;
|
|
62
|
+
/** 抓取单个网页:正文 + 图片(base64) */
|
|
63
|
+
fetchWebpage(url: string, opts?: WebFetchOptions): Promise<WebFetchResult>;
|
|
64
|
+
private doFetch;
|
|
65
|
+
/** 释放浏览器(进程退出时调用) */
|
|
66
|
+
close(): Promise<void>;
|
|
67
|
+
}
|
|
68
|
+
/** 每次调用创建独立 McpServer 实例(绝不共享):agent-sdk 每个 query 的 transport 都会对
|
|
69
|
+
* 传入实例调用 server.connect(),而 MCP Protocol 实例只允许 connect 一次;共享实例在并发
|
|
70
|
+
* 或先后复用时抛 "Already connected to a transport"。薄壳开销可忽略(WebFetchService 为单例)。 */
|
|
71
|
+
export declare function createWebFetchMcpServer(opts?: WebFetchOptions): SdkMcpServerResult;
|