@turingflow/agent-kit 0.0.0-stage → 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/README.md +546 -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 +259 -0
- package/dist/server/sdk.js +1991 -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 +352 -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,433 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-kit · 会话分享 API(外部只读访问 + 发送到 OA)
|
|
3
|
+
*
|
|
4
|
+
* 路由接线(宿主 app,basePath=/agent):
|
|
5
|
+
* // app/api/share/route.js -> POST 创建分享链接(登录态)
|
|
6
|
+
* // app/api/share/jsapi-sign/route.js -> POST 返回钉钉/飞书 H5 JSAPI 前端鉴权参数(登录态)
|
|
7
|
+
* // app/api/share/[token]/route.js -> GET 公开只读页(自包含 HTML,无需登录)
|
|
8
|
+
* // -> POST 发送到 OA 会话(登录态,仅创建者可发)
|
|
9
|
+
*
|
|
10
|
+
* 设计要点:
|
|
11
|
+
* - 分享链接不可枚举(token 为 24 字节随机 hex),公开页只读、不渲染产物内容;
|
|
12
|
+
* - 「发送到 OA」:钉钉走 chooseConversationByCorpId 一次性 cid + message/send_to_conversation,
|
|
13
|
+
* 飞书走 chooseChat open_chat_id + im/v1/messages(见 oa.ts);
|
|
14
|
+
* - 钉钉 JSAPI 鉴权需要 agentId(企业内部应用微应用 ID,env DD_AGENT_ID)。
|
|
15
|
+
*/
|
|
16
|
+
import { randomBytes } from "node:crypto";
|
|
17
|
+
import { verifySession } from "./auth/handlers.js";
|
|
18
|
+
import { loadAuthConfig } from "./auth/config.js";
|
|
19
|
+
import { parseSessionKey } from "./auth/session.js";
|
|
20
|
+
import { getAgentSession, getShareByToken, createShareRecord, getSessionTitleBySessionId, listAgentMessagesPublic, getUserCodeByKey, getUserOauthTokenByKey, updateUserOauthToken, isDbEnabled, } from "./auth/db.js";
|
|
21
|
+
import { dingTalkJsapiConfig, resolveDingTalkUserIdByUnionId, sendDingTalkMessage, feishuJsapiConfig, sendFeishuMessage, sendFeishuMessageAsUser, refreshFeishuUserToken, } from "./oa.js";
|
|
22
|
+
import { renderMarkdownHtml } from "./render-markdown.js";
|
|
23
|
+
/**
|
|
24
|
+
* 生成分享卡片用的聊天摘要:最近 5 条非空消息,每条一行「我/AI:内容」。
|
|
25
|
+
* 与 view() 同一清洗口径:assistant 剥离限流提示、user 剥离附件首行。
|
|
26
|
+
*/
|
|
27
|
+
async function buildChatSummary(sessionId) {
|
|
28
|
+
const messages = await listAgentMessagesPublic(sessionId);
|
|
29
|
+
const lines = [];
|
|
30
|
+
for (const m of messages) {
|
|
31
|
+
const raw = String(m.content || "").trim();
|
|
32
|
+
if (!raw)
|
|
33
|
+
continue;
|
|
34
|
+
const clean = m.role === "assistant" ? stripRateLimitText(raw) : m.attachment ? stripAttachmentLine(raw) : raw;
|
|
35
|
+
if (!clean.trim())
|
|
36
|
+
continue;
|
|
37
|
+
lines.push(`${m.role === "assistant" ? "AI" : "我"}:${clean.trim().slice(0, 60)}`);
|
|
38
|
+
if (lines.length >= 5)
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
return lines.join("\n");
|
|
42
|
+
}
|
|
43
|
+
function json(data, status = 200) {
|
|
44
|
+
return new Response(JSON.stringify(data), {
|
|
45
|
+
status,
|
|
46
|
+
headers: { "Content-Type": "application/json; charset=utf-8" },
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/** 解析对外 origin:优先 env(APP_PUBLIC_URL),否则用请求头(x-forwarded-host / host)推断。
|
|
50
|
+
* 注意:不能依赖 `new URL(request.url).origin`——反代/Next dev 场景下 request.url 的 host
|
|
51
|
+
* 可能是代理内部地址(如 localhost),导致分享链接 host 错误;请求头才是用户实际访问的地址。 */
|
|
52
|
+
function publicOrigin(request) {
|
|
53
|
+
const cfg = loadAuthConfig();
|
|
54
|
+
if (cfg.publicUrl)
|
|
55
|
+
return cfg.publicUrl;
|
|
56
|
+
const proto = request.headers.get("x-forwarded-proto") || "http";
|
|
57
|
+
const host = request.headers.get("x-forwarded-host") || request.headers.get("host");
|
|
58
|
+
if (host)
|
|
59
|
+
return `${proto}://${host}`;
|
|
60
|
+
try {
|
|
61
|
+
return new URL(request.url).origin;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return `${proto}://localhost`;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function escapeHtml(s) {
|
|
68
|
+
return s
|
|
69
|
+
.replace(/&/g, "&")
|
|
70
|
+
.replace(/</g, "<")
|
|
71
|
+
.replace(/>/g, ">")
|
|
72
|
+
.replace(/"/g, """)
|
|
73
|
+
.replace(/'/g, "'");
|
|
74
|
+
}
|
|
75
|
+
// 以下两个清洗函数与 ui/ChatWindow.tsx 保持一致(分享页渲染与聊天区域看齐):
|
|
76
|
+
// - 用户消息:剥离消息开头的 "[附件] 文件名" 行(附件已在气泡上方独立展示);
|
|
77
|
+
// - 助手消息:剥离 CLI transcript 的 trace 前缀、丢弃 429 限流错误整段。
|
|
78
|
+
function stripAttachmentLine(content) {
|
|
79
|
+
return String(content).replace(/^\[附件\] [^\n]*\n?/, "");
|
|
80
|
+
}
|
|
81
|
+
function stripRateLimitText(text) {
|
|
82
|
+
if (!text)
|
|
83
|
+
return text || "";
|
|
84
|
+
const out = [];
|
|
85
|
+
for (const rawLine of String(text).split("\n")) {
|
|
86
|
+
const line = rawLine.trim();
|
|
87
|
+
if (!line) {
|
|
88
|
+
out.push("");
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
// trace 前缀:形如 (32hex/uuid) 开头的行,剥离前缀保留正文
|
|
92
|
+
const rest = line.replace(/^\(\s*[0-9a-fA-F]{8,}\s*\/\s*[0-9a-fA-F-]{8,}[^)\n]*\)\s*/, "");
|
|
93
|
+
if (/429/.test(rest) && /频率限制|rate\s*[- ]?limit/i.test(rest))
|
|
94
|
+
continue;
|
|
95
|
+
if (!rest.trim())
|
|
96
|
+
continue;
|
|
97
|
+
out.push(rest.replace(/429[^\n]*?频率限制[^\n]*$/i, ""));
|
|
98
|
+
}
|
|
99
|
+
return out.join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
100
|
+
}
|
|
101
|
+
export function createShareHandlers() {
|
|
102
|
+
const cfg = loadAuthConfig();
|
|
103
|
+
async function currentUser(request) {
|
|
104
|
+
const user = verifySession(request, cfg);
|
|
105
|
+
if (!user || !user.sub)
|
|
106
|
+
return null;
|
|
107
|
+
return { userId: user.sub, provider: user.provider || cfg.method || "" };
|
|
108
|
+
}
|
|
109
|
+
/** POST /api/share:创建当前会话的分享链接。 */
|
|
110
|
+
async function create(request) {
|
|
111
|
+
const user = await currentUser(request);
|
|
112
|
+
if (!user)
|
|
113
|
+
return json({ error: "unauthorized" }, 401);
|
|
114
|
+
if (!isDbEnabled())
|
|
115
|
+
return json({ error: "database_unavailable" }, 503);
|
|
116
|
+
const body = (await request.json().catch(() => ({})));
|
|
117
|
+
const sessionId = typeof body.sessionId === "string" && body.sessionId ? body.sessionId : "";
|
|
118
|
+
if (!sessionId)
|
|
119
|
+
return json({ error: "missing_session_id" }, 400);
|
|
120
|
+
// 会话必须归属当前用户
|
|
121
|
+
const session = await getAgentSession(user.userId, sessionId);
|
|
122
|
+
if (!session)
|
|
123
|
+
return json({ error: "not_found" }, 404);
|
|
124
|
+
const token = randomBytes(24).toString("hex");
|
|
125
|
+
const ok = await createShareRecord(token, sessionId, user.userId);
|
|
126
|
+
if (!ok)
|
|
127
|
+
return json({ error: "share_create_failed" }, 500);
|
|
128
|
+
const origin = publicOrigin(request);
|
|
129
|
+
const url = `${origin}${cfg.cookiePath.replace(/\/+$/, "")}/api/share/${token}`;
|
|
130
|
+
const sessionTitle = (await getSessionTitleBySessionId(sessionId)) || "AI 对话";
|
|
131
|
+
// 聊天摘要:最近 5 条非空消息(与飞书分享卡片同口径),供钉钉原生分享面板的正文使用
|
|
132
|
+
const summary = await buildChatSummary(sessionId);
|
|
133
|
+
return json({
|
|
134
|
+
url,
|
|
135
|
+
token,
|
|
136
|
+
provider: user.provider,
|
|
137
|
+
// 分享卡片(用户身份发送)展示用:标题 / 正文 / 聊天摘要 / 跳转链接
|
|
138
|
+
title: `AI 对话分享:${sessionTitle}`,
|
|
139
|
+
text: `来自「${sessionTitle}」的 AI 对话,点击查看完整内容。`,
|
|
140
|
+
summary,
|
|
141
|
+
// 钉钉 H5 JSAPI 前端鉴权需要 agentId + corpId(agentId 来自 env DD_AGENT_ID)
|
|
142
|
+
oa: {
|
|
143
|
+
dingtalk: user.provider === "dingtalk"
|
|
144
|
+
? { agentId: cfg.dingtalk.agentId, corpId: cfg.dingtalk.corpId }
|
|
145
|
+
: undefined,
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
/** POST /api/share/jsapi-sign:返回当前登录方式的 H5 JSAPI 前端鉴权参数。 */
|
|
150
|
+
async function jsapiSign(request) {
|
|
151
|
+
const user = await currentUser(request);
|
|
152
|
+
if (!user)
|
|
153
|
+
return json({ error: "unauthorized" }, 401);
|
|
154
|
+
const body = (await request.json().catch(() => ({})));
|
|
155
|
+
const pageUrl = typeof body.url === "string" && body.url ? body.url : "";
|
|
156
|
+
if (!pageUrl)
|
|
157
|
+
return json({ error: "missing_url" }, 400);
|
|
158
|
+
if (user.provider === "dingtalk") {
|
|
159
|
+
if (!cfg.dingtalk.clientId || !cfg.dingtalk.clientSecret || !cfg.dingtalk.corpId) {
|
|
160
|
+
return json({ error: "dingtalk_not_configured" }, 400);
|
|
161
|
+
}
|
|
162
|
+
// agentId 是钉钉企业内部应用的微应用 AgentId,不是 clientId;
|
|
163
|
+
// 缺失时明确报错(否则钉钉返回「无效的agentid」,难以排查)
|
|
164
|
+
if (!cfg.dingtalk.agentId) {
|
|
165
|
+
return json({ error: "dingtalk_agent_id_missing", message: "缺少 DD_AGENT_ID 配置" }, 400);
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
const config = await dingTalkJsapiConfig(cfg.dingtalk.clientId, cfg.dingtalk.clientSecret, cfg.dingtalk.corpId, cfg.dingtalk.agentId, pageUrl);
|
|
169
|
+
// 用户身份发送互动卡片(sendMessageToGroup)需要 clientId + 卡片模板 ID
|
|
170
|
+
return json({
|
|
171
|
+
provider: "dingtalk",
|
|
172
|
+
config: {
|
|
173
|
+
...config,
|
|
174
|
+
clientId: cfg.dingtalk.clientId,
|
|
175
|
+
cardTemplateId: cfg.dingtalk.cardTemplateId || "",
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
catch (e) {
|
|
180
|
+
return json({ error: "sign_failed", message: e instanceof Error ? e.message : String(e) }, 502);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (user.provider === "feishu") {
|
|
184
|
+
if (!cfg.feishu.appId || !cfg.feishu.appSecret) {
|
|
185
|
+
return json({ error: "feishu_not_configured" }, 400);
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
const config = await feishuJsapiConfig(cfg.feishu.appId, cfg.feishu.appSecret, cfg.feishu.baseUrl, pageUrl);
|
|
189
|
+
return json({ provider: "feishu", config });
|
|
190
|
+
}
|
|
191
|
+
catch (e) {
|
|
192
|
+
return json({ error: "sign_failed", message: e instanceof Error ? e.message : String(e) }, 502);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return json({ error: "unsupported_provider" }, 400);
|
|
196
|
+
}
|
|
197
|
+
/** GET /api/share/[token]:公开只读页(自包含 HTML,与聊天区域同一套黑白灰风格)。 */
|
|
198
|
+
async function view(request, ctx) {
|
|
199
|
+
const params = (ctx && ctx.params) || {};
|
|
200
|
+
const resolved = params instanceof Promise ? await params : params;
|
|
201
|
+
const token = String(resolved.token || "");
|
|
202
|
+
if (!token)
|
|
203
|
+
return new Response("Not Found", { status: 404 });
|
|
204
|
+
const share = await getShareByToken(token);
|
|
205
|
+
if (!share)
|
|
206
|
+
return new Response("分享不存在或已失效", { status: 404 });
|
|
207
|
+
const title = (await getSessionTitleBySessionId(share.sessionId)) || "AI 对话";
|
|
208
|
+
const messages = await listAgentMessagesPublic(share.sessionId);
|
|
209
|
+
// 摘要:钉钉等 IM 分享链接卡片时以页面 meta(og:description)为内容来源(而非 JSAPI 参数),
|
|
210
|
+
// 这里与 create() 同口径生成摘要并写入 meta,保证分享出去的卡片正文展示会话内容
|
|
211
|
+
const summary = await buildChatSummary(share.sessionId);
|
|
212
|
+
const shareUrl = `${publicOrigin(request)}${cfg.cookiePath.replace(/\/+$/, "")}/api/share/${token}`;
|
|
213
|
+
// 不做分享图预览:卡片只展示标题/摘要/域名(og:title / og:description / og:url),
|
|
214
|
+
// 无 og:image(IM 抓取器缩略图依赖公网静态图,维护成本高且兼容性差)
|
|
215
|
+
const metaDesc = escapeHtml((summary ? `${summary.slice(0, 200)}…` : `来自「${title}」的 AI 对话,点击查看完整内容。`).slice(0, 300));
|
|
216
|
+
const rows = [];
|
|
217
|
+
for (const m of messages) {
|
|
218
|
+
const text = String(m.content || "");
|
|
219
|
+
if (m.role === "assistant") {
|
|
220
|
+
const clean = stripRateLimitText(text);
|
|
221
|
+
if (!clean)
|
|
222
|
+
continue;
|
|
223
|
+
// 服务端 markdown 渲染(与聊天区域同一管线);失败时回退纯文本
|
|
224
|
+
const html = await renderMarkdownHtml(clean);
|
|
225
|
+
rows.push(`<div class="msg bot"><div class="bubble">${html ? `<div class="md">${html}</div>` : escapeHtml(clean).split("\n").join("<br>")}</div></div>`);
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
// 与聊天区 UserBubble 一致:仅当消息带附件时剥离首行 "[附件] 文件名"(附件已在气泡上方独立展示)
|
|
229
|
+
const clean = m.attachment ? stripAttachmentLine(text) : text;
|
|
230
|
+
rows.push(`<div class="msg user"><div class="bubble">${escapeHtml(clean || "…").split("\n").join("<br>")}</div></div>`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const rowsHtml = rows.join("\n");
|
|
234
|
+
const html = `<!DOCTYPE html>
|
|
235
|
+
<html lang="zh-CN">
|
|
236
|
+
<head>
|
|
237
|
+
<meta charset="utf-8">
|
|
238
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
239
|
+
<meta name="description" content="${metaDesc}">
|
|
240
|
+
<meta property="og:title" content="${escapeHtml(title)} · AI 对话分享">
|
|
241
|
+
<meta property="og:description" content="${metaDesc}">
|
|
242
|
+
<meta property="og:url" content="${escapeHtml(shareUrl)}">
|
|
243
|
+
<meta property="og:type" content="website">
|
|
244
|
+
<meta property="og:site_name" content="Agent">
|
|
245
|
+
<title>${escapeHtml(title)} · AI 对话分享</title>
|
|
246
|
+
<style>
|
|
247
|
+
:root { --bg:#f4f6fb; --ink:#1f2733; --ink-soft:#5b6573; --line:#e3e8f0; --brand:#2f6bff; }
|
|
248
|
+
* { box-sizing: border-box; }
|
|
249
|
+
body { margin:0; background:var(--bg); color:var(--ink); font:14px/1.6 -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; }
|
|
250
|
+
.share-wrap { min-height:100vh; display:flex; flex-direction:column; }
|
|
251
|
+
.share-head { width:100%; max-width:900px; margin:0 auto; padding:22px 26px 10px 18px; border-bottom:1px solid var(--line); }
|
|
252
|
+
.share-head h1 { margin:0; font-size:17px; font-weight:700; color:var(--ink); }
|
|
253
|
+
.share-head p { margin:5px 0 0; font-size:12px; color:var(--ink-soft); }
|
|
254
|
+
.chat-body-inner { width:100%; max-width:900px; margin:0 auto; padding:20px 26px 20px 18px; display:flex; flex-direction:column; gap:14px; flex:1; }
|
|
255
|
+
.msg { display:flex; max-width:86%; min-width:0; }
|
|
256
|
+
.msg.user { align-self:flex-end; flex-direction:column; align-items:flex-end; }
|
|
257
|
+
.msg.bot { align-self:flex-start; flex-direction:column; width:100%; }
|
|
258
|
+
.msg .bubble { padding:11px 14px; border-radius:14px; font-size:14.5px; line-height:1.65; white-space:pre-wrap; word-break:break-word; overflow-wrap:anywhere; min-width:0; max-width:100%; }
|
|
259
|
+
.msg.bot .bubble { align-self:flex-start; background:transparent; color:var(--ink); padding:2px 0; border-bottom-left-radius:4px; white-space:normal; }
|
|
260
|
+
.msg.user .bubble { background:#f0f0f0; color:var(--ink); border-bottom-right-radius:4px; }
|
|
261
|
+
/* Markdown(与聊天区域 .md 一致) */
|
|
262
|
+
.bubble .md { font-size:14.5px; line-height:1.7; color:var(--ink); white-space:normal; overflow-wrap:anywhere; }
|
|
263
|
+
.bubble .md > *:first-child { margin-top:0; }
|
|
264
|
+
.bubble .md > *:last-child { margin-bottom:0; }
|
|
265
|
+
.bubble .md p { margin:0 0 8px; }
|
|
266
|
+
.bubble .md h1, .bubble .md h2, .bubble .md h3, .bubble .md h4, .bubble .md h5, .bubble .md h6 { margin:12px 0 6px; line-height:1.35; font-weight:700; }
|
|
267
|
+
.bubble .md h1 { font-size:19px; } .bubble .md h2 { font-size:17px; } .bubble .md h3 { font-size:15.5px; }
|
|
268
|
+
.bubble .md h4, .bubble .md h5, .bubble .md h6 { font-size:14.5px; }
|
|
269
|
+
.bubble .md ul, .bubble .md ol { margin:0 0 8px; padding-left:22px; }
|
|
270
|
+
.bubble .md li { margin:2px 0; }
|
|
271
|
+
.bubble .md a { color:var(--brand); text-decoration:none; border-bottom:1px solid rgba(47,107,255,.35); }
|
|
272
|
+
.bubble .md strong { font-weight:700; }
|
|
273
|
+
.bubble .md em { font-style:italic; }
|
|
274
|
+
.bubble .md del { color:var(--ink-soft); }
|
|
275
|
+
.bubble .md :not(pre) > code { background:#eef2f8; border:1px solid #dfe6f0; border-radius:5px; padding:1px 5px; font-size:12.5px; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; color:#c0392b; }
|
|
276
|
+
.bubble .md pre { background:#0f172a; color:#e2e8f0; border-radius:10px; padding:12px 14px; overflow-x:auto; margin:0 0 10px; font-size:12.5px; line-height:1.55; }
|
|
277
|
+
.bubble .md pre code { font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; white-space:pre; background:none; border:none; padding:0; color:inherit; }
|
|
278
|
+
.bubble .md table { border-collapse:collapse; margin:0 0 12px; font-size:13.5px; width:100%; display:block; overflow-x:auto; }
|
|
279
|
+
.bubble .md th, .bubble .md td { border:1px solid var(--line); padding:6px 10px; text-align:left; }
|
|
280
|
+
.bubble .md th { background:#eef2f8; font-weight:700; }
|
|
281
|
+
.bubble .md tr:nth-child(2n) td { background:rgba(0,0,0,.02); }
|
|
282
|
+
.bubble .md blockquote { margin:0 0 10px; padding:8px 12px; border-left:3px solid var(--brand); background:#eef4ff; border-radius:0 8px 8px 0; color:#334155; }
|
|
283
|
+
.bubble .md blockquote > *:last-child { margin-bottom:0; }
|
|
284
|
+
.bubble .md hr { border:none; border-top:1px solid var(--line); margin:12px 0; }
|
|
285
|
+
.bubble .md img { max-width:100%; border-radius:8px; }
|
|
286
|
+
/* rehype-highlight 代码高亮:GitHub Dark 精简版(同聊天区域) */
|
|
287
|
+
.bubble .md .hljs-comment, .bubble .md .hljs-quote { color:#8b949e; font-style:italic; }
|
|
288
|
+
.bubble .md .hljs-keyword, .bubble .md .hljs-selector-tag, .bubble .md .hljs-literal, .bubble .md .hljs-doctag, .bubble .md .hljs-type { color:#ff7b72; }
|
|
289
|
+
.bubble .md .hljs-string, .bubble .md .hljs-regexp, .bubble .md .hljs-addition, .bubble .md .hljs-attribute, .bubble .md .hljs-meta .hljs-string { color:#a5d6ff; }
|
|
290
|
+
.bubble .md .hljs-number, .bubble .md .hljs-symbol, .bubble .md .hljs-bullet, .bubble .md .hljs-variable, .bubble .md .hljs-template-variable { color:#79c0ff; }
|
|
291
|
+
.bubble .md .hljs-title, .bubble .md .hljs-section, .bubble .md .hljs-name { color:#d2a8ff; }
|
|
292
|
+
.bubble .md .hljs-built_in, .bubble .md .hljs-builtin-name, .bubble .md .hljs-params { color:#ffa657; }
|
|
293
|
+
.bubble .md .hljs-emphasis { font-style:italic; }
|
|
294
|
+
.bubble .md .hljs-strong { font-weight:700; }
|
|
295
|
+
.share-foot { width:100%; max-width:900px; margin:0 auto; padding:8px 26px 26px 18px; text-align:center; font-size:12px; color:var(--ink-soft); }
|
|
296
|
+
</style>
|
|
297
|
+
</head>
|
|
298
|
+
<body>
|
|
299
|
+
<div class="share-wrap">
|
|
300
|
+
<div class="share-head">
|
|
301
|
+
<h1>${escapeHtml(title)}</h1>
|
|
302
|
+
<p>AI 对话分享 · ${messages.length} 条消息</p>
|
|
303
|
+
</div>
|
|
304
|
+
<div class="chat-body-inner">
|
|
305
|
+
${rowsHtml || '<div class="msg bot"><div class="bubble">该会话暂无消息</div></div>'}
|
|
306
|
+
</div>
|
|
307
|
+
<div class="share-foot">内容由分享者通过 AI 助手生成,仅供查看</div>
|
|
308
|
+
</div>
|
|
309
|
+
</body>
|
|
310
|
+
</html>`;
|
|
311
|
+
return new Response(html, {
|
|
312
|
+
status: 200,
|
|
313
|
+
headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" },
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
/** POST /api/share/[token]/send:把分享链接发送到指定 OA 会话(仅创建者)。 */
|
|
317
|
+
async function send(request, ctx) {
|
|
318
|
+
const user = await currentUser(request);
|
|
319
|
+
if (!user)
|
|
320
|
+
return json({ error: "unauthorized" }, 401);
|
|
321
|
+
const params = (ctx && ctx.params) || {};
|
|
322
|
+
const resolved = params instanceof Promise ? await params : params;
|
|
323
|
+
const token = String(resolved.token || "");
|
|
324
|
+
if (!token)
|
|
325
|
+
return json({ error: "missing_token" }, 400);
|
|
326
|
+
const share = await getShareByToken(token);
|
|
327
|
+
if (!share)
|
|
328
|
+
return json({ error: "not_found" }, 404);
|
|
329
|
+
if (share.createdBy !== user.userId)
|
|
330
|
+
return json({ error: "forbidden" }, 403);
|
|
331
|
+
const body = (await request.json().catch(() => ({})));
|
|
332
|
+
const provider = body.provider === "dingtalk" ? "dingtalk" : body.provider === "feishu" ? "feishu" : "";
|
|
333
|
+
if (!provider)
|
|
334
|
+
return json({ error: "missing_provider" }, 400);
|
|
335
|
+
const sessionTitle = (await getSessionTitleBySessionId(share.sessionId)) || "AI 对话";
|
|
336
|
+
const origin = publicOrigin(request);
|
|
337
|
+
const url = `${origin}${cfg.cookiePath.replace(/\/+$/, "")}/api/share/${token}`;
|
|
338
|
+
// 链接预览:钉钉发 markdown 卡片(title + 链接)、飞书发 interactive 卡片(整卡点击跳转)
|
|
339
|
+
const title = `AI 对话分享:${sessionTitle}`;
|
|
340
|
+
const text = `来自「${sessionTitle}」的 AI 对话,点击查看完整内容。`;
|
|
341
|
+
try {
|
|
342
|
+
if (provider === "dingtalk") {
|
|
343
|
+
const cid = typeof body.cid === "string" && body.cid ? body.cid : "";
|
|
344
|
+
if (!cid)
|
|
345
|
+
return json({ error: "missing_cid" }, 400);
|
|
346
|
+
if (!cfg.dingtalk.clientId || !cfg.dingtalk.clientSecret) {
|
|
347
|
+
return json({ error: "dingtalk_not_configured" }, 400);
|
|
348
|
+
}
|
|
349
|
+
// 以用户身份发送到会话(旧接口 send_to_conversation:sender = 钉钉 userid + cid)。
|
|
350
|
+
// sender 优先取用户表 user_code(免登登录会落库);为空时用主体键里的 unionId 实时解析(OAuth 登录兜底)。
|
|
351
|
+
let sender = await getUserCodeByKey(user.userId);
|
|
352
|
+
if (!sender) {
|
|
353
|
+
const unionId = parseSessionKey(user.userId).unionId;
|
|
354
|
+
if (unionId) {
|
|
355
|
+
sender = await resolveDingTalkUserIdByUnionId(cfg.dingtalk.clientId, cfg.dingtalk.clientSecret, unionId);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (!sender) {
|
|
359
|
+
return json({ error: "dingtalk_sender_missing", message: "未获取到钉钉 userid(请确认应用已申请“成员信息读权限”)" }, 400);
|
|
360
|
+
}
|
|
361
|
+
await sendDingTalkMessage(cfg.dingtalk.clientId, cfg.dingtalk.clientSecret, cid, sender, text, { title, url });
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
const chatId = typeof body.chatId === "string" && body.chatId ? body.chatId : "";
|
|
365
|
+
if (!chatId)
|
|
366
|
+
return json({ error: "missing_chat_id" }, 400);
|
|
367
|
+
if (!cfg.feishu.appId || !cfg.feishu.appSecret) {
|
|
368
|
+
return json({ error: "feishu_not_configured" }, 400);
|
|
369
|
+
}
|
|
370
|
+
// 优先以当前用户身份发送(无需机器人进群,用户本就在自己的会话里):
|
|
371
|
+
// DB 有 user_access_token 则用用户身份;失效时用 refresh_token 刷新一次重发;
|
|
372
|
+
// 无令牌或用户身份全部失败时,回退应用身份(需机器人已在群内)。
|
|
373
|
+
// 聊天摘要:最近 5 条非空消息直接展示进飞书分享卡片(豆包分享同款样式)
|
|
374
|
+
const chatSummary = await buildChatSummary(share.sessionId);
|
|
375
|
+
const oauth = await getUserOauthTokenByKey(user.userId);
|
|
376
|
+
let lastErr;
|
|
377
|
+
if (oauth?.accessToken) {
|
|
378
|
+
try {
|
|
379
|
+
await sendFeishuMessageAsUser(oauth.accessToken, cfg.feishu.baseUrl, chatId, text, {
|
|
380
|
+
title,
|
|
381
|
+
url,
|
|
382
|
+
chatSummary,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
catch (e) {
|
|
386
|
+
lastErr = e;
|
|
387
|
+
if (oauth.refreshToken) {
|
|
388
|
+
try {
|
|
389
|
+
const refreshed = await refreshFeishuUserToken(cfg.feishu.appId, cfg.feishu.appSecret, cfg.feishu.baseUrl, oauth.refreshToken);
|
|
390
|
+
await updateUserOauthToken(user.userId, {
|
|
391
|
+
accessToken: refreshed.accessToken,
|
|
392
|
+
refreshToken: refreshed.refreshToken ?? undefined,
|
|
393
|
+
});
|
|
394
|
+
await sendFeishuMessageAsUser(refreshed.accessToken, cfg.feishu.baseUrl, chatId, text, {
|
|
395
|
+
title,
|
|
396
|
+
url,
|
|
397
|
+
chatSummary,
|
|
398
|
+
});
|
|
399
|
+
lastErr = undefined;
|
|
400
|
+
}
|
|
401
|
+
catch (e2) {
|
|
402
|
+
lastErr = e2;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (lastErr) {
|
|
408
|
+
try {
|
|
409
|
+
await sendFeishuMessage(cfg.feishu.appId, cfg.feishu.appSecret, cfg.feishu.baseUrl, chatId, text, { title, url });
|
|
410
|
+
lastErr = undefined;
|
|
411
|
+
}
|
|
412
|
+
catch (e3) {
|
|
413
|
+
lastErr = new Error(`用户身份发送失败:${lastErr instanceof Error ? lastErr.message : String(lastErr)};` +
|
|
414
|
+
`应用身份回退失败:${e3 instanceof Error ? e3.message : String(e3)}`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (lastErr)
|
|
418
|
+
throw lastErr;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
catch (e) {
|
|
422
|
+
console.error(`[agent-kit share] OA 发送失败 token=${token} provider=${provider}:`, e instanceof Error ? e.message : e);
|
|
423
|
+
return json({ error: "send_failed", message: e instanceof Error ? e.message : String(e) }, 502);
|
|
424
|
+
}
|
|
425
|
+
return json({ ok: true });
|
|
426
|
+
}
|
|
427
|
+
return {
|
|
428
|
+
create: { POST: create },
|
|
429
|
+
jsapiSign: { POST: jsapiSign },
|
|
430
|
+
view: { GET: view },
|
|
431
|
+
send: { POST: send },
|
|
432
|
+
};
|
|
433
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** A display detail from the SDK's complete tool input, before JSON/log truncation. */
|
|
2
|
+
export function toolActivitySummary(name, rawInput) {
|
|
3
|
+
let input = rawInput;
|
|
4
|
+
if (typeof input === "string") {
|
|
5
|
+
const text = input;
|
|
6
|
+
try {
|
|
7
|
+
input = JSON.parse(text);
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return text.trim();
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
if (!input || typeof input !== "object")
|
|
14
|
+
return "";
|
|
15
|
+
const values = input;
|
|
16
|
+
const base = (name.split("__").pop() ?? name).toLowerCase();
|
|
17
|
+
const shell = /bash|powershell|command|shell/.test(base) || base === "run";
|
|
18
|
+
const keys = shell
|
|
19
|
+
? ["command", "cmd", "script", "description"]
|
|
20
|
+
: ["description", "file_path", "notebook_path", "query", "url", "pattern", "path", "prompt"];
|
|
21
|
+
for (const key of keys) {
|
|
22
|
+
if (typeof values[key] === "string" && values[key].trim())
|
|
23
|
+
return values[key];
|
|
24
|
+
}
|
|
25
|
+
return "";
|
|
26
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-kit · 小米 MiMo 语音识别 —— 复用 @summersmile/voice-dictation SDK 通道。
|
|
3
|
+
* 真正的识别与留痕/运营都在 BrandAgent 平台侧完成。
|
|
4
|
+
*
|
|
5
|
+
* 链路:本 provider 仅作为 SDK 的 VoiceTranscriber 通道,把音频经
|
|
6
|
+
* POST <AGENT_KIT_ASR_HOST>/api/external/asr/transcribe(multipart file)
|
|
7
|
+
* 递交到平台;平台负责 ffmpeg 归一化 + mimo 主源 + 兜底链 + 留痕。
|
|
8
|
+
* 令牌由 <AGENT_KIT_ASR_HOST>/api/external/asr/token 用长期 ASR_API_KEY 换取,
|
|
9
|
+
* 短时有效(默认 600s),401 时 SDK 会自动强刷重试。
|
|
10
|
+
*
|
|
11
|
+
* 环境变量(兼容旧名 BRANDAGENT_*):
|
|
12
|
+
* AGENT_KIT_ASR_HOST 平台地址,如 https://agent.aotsea.com
|
|
13
|
+
* AGENT_KIT_ASR_API_KEY 长期密钥
|
|
14
|
+
*/
|
|
15
|
+
import { createDictationTranscriber } from "@summersmile/voice-dictation";
|
|
16
|
+
const REQUEST_TIMEOUT_MS = 120_000;
|
|
17
|
+
const ASR_SOURCE = "asr_xiaomi_mimo";
|
|
18
|
+
function brandAgentHost() {
|
|
19
|
+
const v = process.env.AGENT_KIT_ASR_HOST?.trim() || process.env.BRANDAGENT_ASR_HOST?.trim();
|
|
20
|
+
const clean = v?.replace(/\/$/, "");
|
|
21
|
+
if (!clean)
|
|
22
|
+
throw new Error("未配置 AGENT_KIT_ASR_HOST / BRANDAGENT_ASR_HOST(ASR 平台地址)");
|
|
23
|
+
return clean;
|
|
24
|
+
}
|
|
25
|
+
function brandAgentApiKey() {
|
|
26
|
+
const v = process.env.AGENT_KIT_ASR_API_KEY?.trim() || process.env.BRANDAGENT_ASR_API_KEY?.trim();
|
|
27
|
+
if (!v)
|
|
28
|
+
throw new Error("未配置 AGENT_KIT_ASR_API_KEY / BRANDAGENT_ASR_API_KEY(ASR 长期密钥)");
|
|
29
|
+
return v;
|
|
30
|
+
}
|
|
31
|
+
// 短时令牌缓存:平台令牌默认 600s,留 30s 余量再刷新
|
|
32
|
+
let tokenCache = { token: null, expiresAt: 0 };
|
|
33
|
+
async function getPlatformToken({ forceRefresh } = {}) {
|
|
34
|
+
if (!forceRefresh && tokenCache.token && tokenCache.expiresAt > Date.now() + 30_000) {
|
|
35
|
+
return tokenCache.token;
|
|
36
|
+
}
|
|
37
|
+
const host = brandAgentHost();
|
|
38
|
+
const apiKey = brandAgentApiKey();
|
|
39
|
+
const res = await fetch(`${host}/api/external/asr/token`, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: {
|
|
42
|
+
Authorization: `Bearer ${apiKey}`,
|
|
43
|
+
"Content-Type": "application/json",
|
|
44
|
+
},
|
|
45
|
+
body: JSON.stringify({ ttlSeconds: 600 }),
|
|
46
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
47
|
+
});
|
|
48
|
+
if (!res.ok) {
|
|
49
|
+
throw new Error(`BrandAgent 转写令牌获取失败 (${res.status})`);
|
|
50
|
+
}
|
|
51
|
+
const j = (await res.json().catch(() => ({})));
|
|
52
|
+
if (!j?.token)
|
|
53
|
+
throw new Error("BrandAgent 转写令牌响应异常");
|
|
54
|
+
tokenCache = { token: j.token, expiresAt: j.expiresAt || Date.now() + 600_000 };
|
|
55
|
+
return j.token;
|
|
56
|
+
}
|
|
57
|
+
let _transcriber = null;
|
|
58
|
+
function getTranscriber() {
|
|
59
|
+
if (!_transcriber) {
|
|
60
|
+
_transcriber = createDictationTranscriber({
|
|
61
|
+
endpoint: `${brandAgentHost()}/api/external/asr/transcribe`,
|
|
62
|
+
getToken: getPlatformToken,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return _transcriber;
|
|
66
|
+
}
|
|
67
|
+
export async function transcribeWithXiaomiMimo({ bytes, mimeType, fileName }) {
|
|
68
|
+
try {
|
|
69
|
+
const blob = new Blob([Uint8Array.from(bytes)], { type: mimeType || "audio/wav" });
|
|
70
|
+
const ext = (fileName || "").split(".").pop()?.toLowerCase() || "wav";
|
|
71
|
+
const text = await getTranscriber()(blob, {
|
|
72
|
+
filename: fileName || "audio.wav",
|
|
73
|
+
extension: ext,
|
|
74
|
+
mime: mimeType || "audio/wav",
|
|
75
|
+
});
|
|
76
|
+
const clean = typeof text === "string" ? text.trim() : "";
|
|
77
|
+
if (!clean) {
|
|
78
|
+
throw new Error("未检测到说话内容");
|
|
79
|
+
}
|
|
80
|
+
return { text: clean, provider: "xiaomi-mimo" };
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
console.error(`[${ASR_SOURCE}] 转写异常:`, e instanceof Error ? e.message : e);
|
|
84
|
+
throw e;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface UploadOptions {
|
|
2
|
+
/** 保存目录(默认 <cwd>/public/uploads) */
|
|
3
|
+
dir?: string;
|
|
4
|
+
/** 单文件大小上限字节(默认 20MB) */
|
|
5
|
+
maxBytes?: number;
|
|
6
|
+
/** 允许的 MIME → 扩展名映射(默认覆盖图片/PDF/Office/文本/压缩包) */
|
|
7
|
+
allowedTypes?: Record<string, string>;
|
|
8
|
+
/** 返回给前端的 URL 前缀(默认 /uploads,配合 public 目录静态访问) */
|
|
9
|
+
urlPrefix?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function createUploadHandler(options?: UploadOptions): {
|
|
12
|
+
POST: (request: Request) => Promise<Response>;
|
|
13
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-kit · 附件上传端点(Next.js App Router 通用 handler)
|
|
3
|
+
*
|
|
4
|
+
* 默认把文件保存到 <cwd>/public/uploads/ 并返回可访问的相对 URL,
|
|
5
|
+
* 与前端 ChatWindow 的「+ 上传 / 拖拽添加」流程对接。
|
|
6
|
+
* 第三方 Next.js 工程只需在 app/api/upload/route.js 里:
|
|
7
|
+
* import { createUploadHandler } from "agent-kit";
|
|
8
|
+
* export const { POST } = createUploadHandler();
|
|
9
|
+
*/
|
|
10
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import crypto from "node:crypto";
|
|
13
|
+
const DEFAULT_ALLOWED = {
|
|
14
|
+
"image/jpeg": "jpg",
|
|
15
|
+
"image/jpg": "jpg",
|
|
16
|
+
"image/png": "png",
|
|
17
|
+
"image/webp": "webp",
|
|
18
|
+
"image/gif": "gif",
|
|
19
|
+
"application/pdf": "pdf",
|
|
20
|
+
"text/plain": "txt",
|
|
21
|
+
"application/msword": "doc",
|
|
22
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
|
|
23
|
+
"application/vnd.ms-excel": "xls",
|
|
24
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
|
|
25
|
+
"application/zip": "zip",
|
|
26
|
+
};
|
|
27
|
+
function extFromName(name = "") {
|
|
28
|
+
const i = name.lastIndexOf(".");
|
|
29
|
+
return i >= 0 ? name.slice(i + 1).toLowerCase() : "";
|
|
30
|
+
}
|
|
31
|
+
/** 文件名安全化:替换 Windows/URL 非法字符与空白,保留中文等可读字符,截断防超长 */
|
|
32
|
+
function safeName(name = "") {
|
|
33
|
+
return name.replace(/[\\/:*?"<>|\s]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 80);
|
|
34
|
+
}
|
|
35
|
+
export function createUploadHandler(options = {}) {
|
|
36
|
+
const dir = options.dir ?? path.join(process.cwd(), "public", "uploads");
|
|
37
|
+
const maxBytes = options.maxBytes ?? 20 * 1024 * 1024;
|
|
38
|
+
const allowed = options.allowedTypes ?? DEFAULT_ALLOWED;
|
|
39
|
+
const urlPrefix = (options.urlPrefix ?? "/uploads").replace(/\/+$/, "");
|
|
40
|
+
async function POST(request) {
|
|
41
|
+
let form;
|
|
42
|
+
try {
|
|
43
|
+
form = await request.formData();
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return Response.json({ ok: false, error: "请求格式错误" }, { status: 400 });
|
|
47
|
+
}
|
|
48
|
+
const file = form.get("file");
|
|
49
|
+
if (!file || typeof file === "string" || !file.size) {
|
|
50
|
+
return Response.json({ ok: false, error: "没有收到文件" }, { status: 400 });
|
|
51
|
+
}
|
|
52
|
+
const ext = allowed[String(file.type).toLowerCase()] || extFromName(file.name);
|
|
53
|
+
if (!ext) {
|
|
54
|
+
return Response.json({ ok: false, error: "不支持的文件类型(支持图片/PDF/Office/文本/压缩包)" }, { status: 400 });
|
|
55
|
+
}
|
|
56
|
+
const buf = Buffer.from(await file.arrayBuffer());
|
|
57
|
+
if (buf.length > maxBytes) {
|
|
58
|
+
return Response.json({ ok: false, error: `文件超过 ${Math.floor(maxBytes / 1024 / 1024)}MB 上限` }, { status: 400 });
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
await mkdir(dir, { recursive: true });
|
|
62
|
+
// 保留原文件名(uuid 前缀防冲突):模型/用户能对得上文件名,
|
|
63
|
+
// 配合 chat handler 注入的磁盘路径即可直接 Read。
|
|
64
|
+
// 去掉原名扩展名再追加规范扩展名,避免 "光伏设备.txt.txt"。
|
|
65
|
+
const nameBase = safeName(file.name).replace(/\.[^./\\]+$/, "") || "file";
|
|
66
|
+
const filename = `${crypto.randomUUID()}_${nameBase}.${ext}`;
|
|
67
|
+
await writeFile(path.join(dir, filename), buf);
|
|
68
|
+
return Response.json({
|
|
69
|
+
ok: true,
|
|
70
|
+
url: `${urlPrefix}/${filename}`,
|
|
71
|
+
name: file.name,
|
|
72
|
+
size: buf.length,
|
|
73
|
+
mime: file.type,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
return Response.json({ ok: false, error: error instanceof Error ? error.message : String(error) }, { status: 500 });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { POST };
|
|
81
|
+
}
|