forked from EduCraft/curriculum-project-hub
Compare commits
4 Commits
dc2d1c2f9e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5df1900ca8 | |||
| df66691d24 | |||
| 73cb0e5b47 | |||
| e21096c642 |
@@ -32,7 +32,7 @@ App ID 通常以 `cli_` 开头,可以写入交付单。App Secret 必须通过
|
|||||||
| 接收群聊中 @ 机器人的消息 | `im:message.group_at_msg:readonly` |
|
| 接收群聊中 @ 机器人的消息 | `im:message.group_at_msg:readonly` |
|
||||||
| 以应用身份发送消息 | `im:message:send_as_bot` |
|
| 以应用身份发送消息 | `im:message:send_as_bot` |
|
||||||
| 读取触发消息和线程上下文 | `im:message:readonly` |
|
| 读取触发消息和线程上下文 | `im:message:readonly` |
|
||||||
| 获取与上传图片或文件 | `im:resource` |
|
| 获取消息中的图片/文件,并向飞书上传图片或文件(含 Agent 回答中的图片发送) | `im:resource` |
|
||||||
| 添加、删除消息表情回复 | `im:message.reactions:write_only` |
|
| 添加、删除消息表情回复 | `im:message.reactions:write_only` |
|
||||||
| 获取用户基本信息 | `contact:user.base:readonly` |
|
| 获取用户基本信息 | `contact:user.base:readonly` |
|
||||||
| 获取用户基本资料 | `contact:user.basic_profile:readonly` |
|
| 获取用户基本资料 | `contact:user.basic_profile:readonly` |
|
||||||
@@ -66,6 +66,9 @@ Educraft 机器人以应用身份调用上述 API,因此这些 scope 全部放
|
|||||||
|
|
||||||
如果 API 调试台提示缺少更细粒度权限,请把错误提示和发生时间截图给部署人员。不要自行开通通讯录全量读取等超出本表的权限。
|
如果 API 调试台提示缺少更细粒度权限,请把错误提示和发生时间截图给部署人员。不要自行开通通讯录全量读取等超出本表的权限。
|
||||||
|
|
||||||
|
说明:`im:resource` 既用于下载用户发来的图片/文件,也用于 Agent 回复时把本地或远程图片上传为飞书 `image_key` 后嵌入消息卡片。缺少该权限时,带图回答会发送失败或降级为无图文本。已开通该 scope 的存量应用一般无需新增权限,但若权限尚未随最新版本发布,请创建新版本并审核发布。
|
||||||
|
|
||||||
|
|
||||||
## 4. 配置事件与卡片回调
|
## 4. 配置事件与卡片回调
|
||||||
|
|
||||||
进入“事件与回调”。
|
进入“事件与回调”。
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ dist/
|
|||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
.secrets/
|
.secrets/
|
||||||
|
.dev-keyring.json
|
||||||
|
.dev-workspaces/
|
||||||
|
.dev-skills/
|
||||||
admin-web/node_modules/
|
admin-web/node_modules/
|
||||||
admin-web/build/
|
admin-web/build/
|
||||||
admin-web/.svelte-kit/
|
admin-web/.svelte-kit/
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@paradigm/hub",
|
"name": "@paradigm/hub",
|
||||||
"version": "0.0.35",
|
"version": "0.0.36",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@paradigm/hub",
|
"name": "@paradigm/hub",
|
||||||
"version": "0.0.35",
|
"version": "0.0.36",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alicloud/credentials": "^2.4.5",
|
"@alicloud/credentials": "^2.4.5",
|
||||||
"@alicloud/docmind-api20220711": "^1.4.15",
|
"@alicloud/docmind-api20220711": "^1.4.15",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@paradigm/hub",
|
"name": "@paradigm/hub",
|
||||||
"version": "0.0.35",
|
"version": "0.0.36",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# src/database/
|
||||||
|
|
||||||
|
`/database/*` HTTP 面。代码写在这个目录里,`hub.ts` 通过 `plugin.ts` 挂载它,
|
||||||
|
所以服务器启动时能正确识别这些路由。
|
||||||
|
|
||||||
|
页面:
|
||||||
|
|
||||||
|
- `/database/admin` —— 飞书登录页(唯一登录方式)
|
||||||
|
- `/database/dashboard` —— 左菜单 + 右内容的后台,未登录会跳回 `/database/admin`
|
||||||
|
|
||||||
|
登录走平台既有的飞书 OAuth:登录页的按钮指向 `/auth/feishu/<orgSlug>`,
|
||||||
|
回调由 `src/admin/routes/authRoutes.ts` 处理并种下 session cookie。
|
||||||
|
|
||||||
|
## 开发模式:用环境变量开启一键登录
|
||||||
|
|
||||||
|
本地开发没有真实飞书 app 时,可以用环境变量开启一键登录,跳过飞书 OAuth,
|
||||||
|
直接以现有 OWNER/ADMIN 身份登入后台。**仅限开发,不是生产登录路径。**
|
||||||
|
|
||||||
|
### 怎么开
|
||||||
|
|
||||||
|
在 `hub/.env` 里设:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
HUB_DEV_LOGIN_BYPASS="true"
|
||||||
|
```
|
||||||
|
|
||||||
|
改完重启服务(`npm run dev`,或本地手动 `npx tsx src/server.ts`)。启动日志会
|
||||||
|
打印一行 `DEV login bypass enabled: /database/dev-login ...` 作为确认。
|
||||||
|
|
||||||
|
开启后:
|
||||||
|
|
||||||
|
- `/database/admin` 登录页显示「⚡ 一键登录管理员」按钮
|
||||||
|
- 后端注册 `/database/dev-login` 端点:按钮就是打它,它签发一个和飞书 OAuth
|
||||||
|
回调完全一样的 session,然后跳到 `/database/dashboard`
|
||||||
|
|
||||||
|
### 怎么关
|
||||||
|
|
||||||
|
把值设成 `false`(或 `0` / `no` / `off`),或删掉这一行。关闭后按钮消失、
|
||||||
|
`/database/dev-login` 返回 404 —— 按钮和端点同进同退。
|
||||||
|
|
||||||
|
### 双重门禁(重要)
|
||||||
|
|
||||||
|
真正的开关是两个条件的**与**(判断在 `plugin.ts`):
|
||||||
|
|
||||||
|
```
|
||||||
|
allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真
|
||||||
|
```
|
||||||
|
|
||||||
|
即:**只要 `NODE_ENV=production`,无论 `HUB_DEV_LOGIN_BYPASS` 设成什么,一键登录
|
||||||
|
都强制关闭。** 生产始终只能走真实飞书 OAuth。
|
||||||
|
|
||||||
|
> 提醒:`HUB_DEV_LOGIN_BYPASS` 是敏感开关,别把开着它的 `.env` 带到任何联网 /
|
||||||
|
> 共享环境。整个旁路逻辑自包含在本目录(`plugin.ts` + `routes/databaseRoutes.ts`),
|
||||||
|
> `src/admin` 的登录路由未受影响。
|
||||||
|
|
||||||
|
## 文件
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `plugin.ts` | 模块对外入口,`hub.ts` 调 `registerDatabasePlugin()` |
|
||||||
|
| `routes/databaseRoutes.ts` | 路由 + 页面渲染,**你主要在这里加内容** |
|
||||||
|
|
||||||
|
新增一类端点时:要么直接往 `databaseRoutes.ts` 加 `app.get("/database/...")`,
|
||||||
|
要么新建 `routes/xxxRoutes.ts` 并在 `databaseRoutes.ts` 里 `registerXxxRoutes(app, {...})`
|
||||||
|
注册一次。
|
||||||
|
|
||||||
|
## 约定(与 admin 面一致)
|
||||||
|
|
||||||
|
1. 路由用**绝对路径** `"/database/..."`,不用 Fastify prefix —— 每条路由 grep 得到。
|
||||||
|
2. **guard 前置、fail closed**:凡碰数据的端点第一行先跑
|
||||||
|
`requireSession` / `requireOrgRole` / `requireProjectPermission`
|
||||||
|
(都在 `../admin/auth/guards.js`)。
|
||||||
|
3. **租户隔离**(ADR-0020):每个 Prisma 查询都 scope 到 `auth.organization.id`,
|
||||||
|
不得跨 org。禁止无鉴权的数据路由。
|
||||||
|
4. 数据库通过传入的 `config.prisma` 访问(全进程单例,见 `../db.ts`);
|
||||||
|
不要在这里 `new PrismaClient()`。
|
||||||
|
|
||||||
|
## 为什么代码在 `src/` 下
|
||||||
|
|
||||||
|
`tsconfig.json` 固定 `rootDir: "src"` 且 `include: ["src/**/*.ts"]`。只有
|
||||||
|
`src/` 下的 `.ts` 会被 `tsc` 编译、被 `tsx watch`(`npm run dev`)加载。放在
|
||||||
|
`src/` 之外的目录不会被构建,外部识别不到。
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Registers the `/database/*` HTTP surface onto the Fastify app.
|
||||||
|
*
|
||||||
|
* Single public entry point (mirrors src/admin/plugin.ts). hub.ts calls
|
||||||
|
* registerDatabasePlugin() so the routes are recognized at startup.
|
||||||
|
*
|
||||||
|
* Register AFTER registerAdminPlugin: it relies on the @fastify/cookie parser
|
||||||
|
* and the /auth/feishu/* login routes that the admin plugin adds.
|
||||||
|
*
|
||||||
|
* The dev login bypass is self-contained in THIS module: the flag is read here
|
||||||
|
* (not threaded from hub.ts) and only ever enables the `/database/dev-login`
|
||||||
|
* route below. Double-gated: NODE_ENV must not be production AND
|
||||||
|
* HUB_DEV_LOGIN_BYPASS must be truthy. Production always requires real Feishu
|
||||||
|
* OAuth.
|
||||||
|
*/
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import type { PrismaClient } from "@prisma/client";
|
||||||
|
import { registerDatabaseRoutes } from "./routes/databaseRoutes.js";
|
||||||
|
|
||||||
|
export interface DatabasePluginConfig {
|
||||||
|
readonly prisma: PrismaClient;
|
||||||
|
readonly sessionSecret: string;
|
||||||
|
readonly siloOrganizationSlug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FALSY = new Set(["", "0", "false", "no", "off"]);
|
||||||
|
|
||||||
|
export async function registerDatabasePlugin(
|
||||||
|
app: FastifyInstance,
|
||||||
|
config: DatabasePluginConfig,
|
||||||
|
): Promise<void> {
|
||||||
|
const allowDevLoginBypass =
|
||||||
|
process.env["NODE_ENV"] !== "production" &&
|
||||||
|
!FALSY.has((process.env["HUB_DEV_LOGIN_BYPASS"] ?? "").trim().toLowerCase());
|
||||||
|
if (allowDevLoginBypass) {
|
||||||
|
app.log.warn("DEV login bypass enabled: /database/dev-login mints a session without Feishu OAuth");
|
||||||
|
}
|
||||||
|
|
||||||
|
await registerDatabaseRoutes(app, {
|
||||||
|
prisma: config.prisma,
|
||||||
|
sessionSecret: config.sessionSecret,
|
||||||
|
siloOrganizationSlug: config.siloOrganizationSlug,
|
||||||
|
allowDevLoginBypass,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
/**
|
||||||
|
* `/database/*` route aggregator.
|
||||||
|
*
|
||||||
|
* Owns the `/database` HTTP surface (single place new sub-routes get wired in).
|
||||||
|
* Handlers use ABSOLUTE paths (no Fastify prefix) so every route greps as the
|
||||||
|
* literal string it serves.
|
||||||
|
*
|
||||||
|
* /database/admin — Feishu-only login page (+ dev one-click button)
|
||||||
|
* /database/dashboard — sidebar + content admin shell, session-gated
|
||||||
|
* /database/dev-login — DEV ONLY bypass, registered only when the flag is on
|
||||||
|
*
|
||||||
|
* The dev bypass (button + /database/dev-login) is self-contained here and
|
||||||
|
* gated by allowDevLoginBypass (computed in ./plugin.ts from HUB_DEV_LOGIN_BYPASS
|
||||||
|
* + NODE_ENV). Production requires real Feishu OAuth.
|
||||||
|
*/
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import type { PrismaClient } from "@prisma/client";
|
||||||
|
import { SESSION_COOKIE_NAME, signSession, verifySession } from "../../admin/auth/session.js";
|
||||||
|
|
||||||
|
export interface DatabaseRouteConfig {
|
||||||
|
readonly prisma: PrismaClient;
|
||||||
|
/** HMAC secret for the signed session cookie — reused from the admin plane. */
|
||||||
|
readonly sessionSecret: string;
|
||||||
|
/** Silo Organization slug — builds the org-scoped Feishu login link. */
|
||||||
|
readonly siloOrganizationSlug: string;
|
||||||
|
/** DEV ONLY. Enables the one-click button and the /database/dev-login route. */
|
||||||
|
readonly allowDevLoginBypass: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerDatabaseRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
config: DatabaseRouteConfig,
|
||||||
|
): Promise<void> {
|
||||||
|
app.get("/database/admin", async (request, reply) => {
|
||||||
|
// Already signed in → straight to the dashboard.
|
||||||
|
if ((await resolveUser(request.cookies[SESSION_COOKIE_NAME], config)) !== null) {
|
||||||
|
return reply.redirect("/database/dashboard");
|
||||||
|
}
|
||||||
|
return reply.type("text/html").send(renderLoginPage(config));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/database/dashboard", async (request, reply) => {
|
||||||
|
const user = await resolveUser(request.cookies[SESSION_COOKIE_NAME], config);
|
||||||
|
if (user === null) return reply.redirect("/database/admin");
|
||||||
|
return reply.type("text/html").send(renderDashboard(user.displayName));
|
||||||
|
});
|
||||||
|
|
||||||
|
// DEV ONLY bypass — self-contained here, registered only when the flag is on
|
||||||
|
// (see ./plugin.ts). Mints a session for an existing OWNER/ADMIN, reusing the
|
||||||
|
// scoped SessionIdentity shape the Feishu OAuth callback produces so the
|
||||||
|
// session guard behaves identically. Never registered in production.
|
||||||
|
if (config.allowDevLoginBypass) {
|
||||||
|
app.get("/database/dev-login", async (_request, reply) => {
|
||||||
|
const membership = await config.prisma.organizationMembership.findFirst({
|
||||||
|
where: {
|
||||||
|
revokedAt: null,
|
||||||
|
role: { in: ["OWNER", "ADMIN"] },
|
||||||
|
organization: { status: "ACTIVE" },
|
||||||
|
},
|
||||||
|
select: { userId: true, organizationId: true },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
});
|
||||||
|
if (membership === null) {
|
||||||
|
return reply.status(404).send({ error: { code: "no_admin", message: "no active OWNER/ADMIN to impersonate" } });
|
||||||
|
}
|
||||||
|
const identity = await config.prisma.feishuUserIdentity.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: membership.userId,
|
||||||
|
connection: { organizationId: membership.organizationId, status: "ACTIVE" },
|
||||||
|
},
|
||||||
|
select: { id: true, connectionId: true, connection: { select: { organizationId: true } } },
|
||||||
|
});
|
||||||
|
if (identity === null) {
|
||||||
|
return reply.status(404).send({ error: { code: "no_identity", message: "admin has no active scoped Feishu identity" } });
|
||||||
|
}
|
||||||
|
const token = signSession(
|
||||||
|
{
|
||||||
|
userId: membership.userId,
|
||||||
|
feishuIdentityId: identity.id,
|
||||||
|
feishuConnectionId: identity.connectionId,
|
||||||
|
feishuOrganizationId: identity.connection.organizationId,
|
||||||
|
},
|
||||||
|
config.sessionSecret,
|
||||||
|
);
|
||||||
|
// Local dev is http://127.0.0.1, so secure:false. This route only ever
|
||||||
|
// runs outside production (double-gated in ./plugin.ts).
|
||||||
|
reply.setCookie(SESSION_COOKIE_NAME, token, {
|
||||||
|
path: "/",
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: false,
|
||||||
|
maxAge: 7 * 24 * 60 * 60,
|
||||||
|
});
|
||||||
|
reply.log.warn({ userId: membership.userId, orgId: membership.organizationId }, "DEV database login bypass used");
|
||||||
|
return reply.redirect("/database/dashboard");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add more /database/* routes here. Guard data routes with requireSession /
|
||||||
|
// requireOrgRole (../../admin/auth/guards.js) and scope every query to the
|
||||||
|
// caller's org (ADR-0020). Access the DB via config.prisma.
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verify the session cookie and load the user, or null if not signed in. */
|
||||||
|
async function resolveUser(
|
||||||
|
rawCookie: string | undefined,
|
||||||
|
config: DatabaseRouteConfig,
|
||||||
|
): Promise<{ displayName: string } | null> {
|
||||||
|
if (rawCookie === undefined || rawCookie === "") return null;
|
||||||
|
const session = verifySession(rawCookie, config.sessionSecret);
|
||||||
|
if (session === null) return null;
|
||||||
|
const user = await config.prisma.user.findUnique({
|
||||||
|
where: { id: session.userId },
|
||||||
|
select: { displayName: true },
|
||||||
|
});
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared document head: Tailwind CDN + a small design system (fonts, keyframes
|
||||||
|
* for the aurora background, fade-in, shimmer). Both pages import it so the
|
||||||
|
* look stays consistent.
|
||||||
|
*/
|
||||||
|
function pageHead(title: string): string {
|
||||||
|
return `<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<title>${title}</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"/>
|
||||||
|
<style>
|
||||||
|
:root { font-family: 'Inter', system-ui, sans-serif; }
|
||||||
|
@keyframes aurora {
|
||||||
|
0% { transform: translate(0,0) scale(1); }
|
||||||
|
33% { transform: translate(6%, -8%) scale(1.15); }
|
||||||
|
66% { transform: translate(-8%, 6%) scale(0.9); }
|
||||||
|
100% { transform: translate(0,0) scale(1); }
|
||||||
|
}
|
||||||
|
@keyframes rise {
|
||||||
|
from { opacity: 0; transform: translateY(16px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: -200% 0; }
|
||||||
|
100% { background-position: 200% 0; }
|
||||||
|
}
|
||||||
|
.aurora { filter: blur(80px); animation: aurora 18s ease-in-out infinite; }
|
||||||
|
.rise { animation: rise .7s cubic-bezier(.16,1,.3,1) both; }
|
||||||
|
.rise-2 { animation: rise .7s cubic-bezier(.16,1,.3,1) .1s both; }
|
||||||
|
.rise-3 { animation: rise .7s cubic-bezier(.16,1,.3,1) .2s both; }
|
||||||
|
.grad-text {
|
||||||
|
background: linear-gradient(120deg,#7c3aed,#0891b2,#4f46e5);
|
||||||
|
background-size: 200% auto;
|
||||||
|
-webkit-background-clip: text; background-clip: text; color: transparent;
|
||||||
|
animation: shimmer 6s linear infinite;
|
||||||
|
}
|
||||||
|
.glass { background: rgba(255,255,255,.7); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); }
|
||||||
|
.glow-btn { box-shadow: 0 12px 32px -10px rgba(124,58,237,.45); }
|
||||||
|
</style>
|
||||||
|
</head>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The animated aurora background blobs, shared by both pages (soft pastels on light). */
|
||||||
|
const AURORA = `
|
||||||
|
<div class="pointer-events-none fixed inset-0 overflow-hidden">
|
||||||
|
<div class="aurora absolute -left-32 -top-32 h-96 w-96 rounded-full bg-violet-300/50"></div>
|
||||||
|
<div class="aurora absolute right-0 top-1/4 h-96 w-96 rounded-full bg-cyan-300/40" style="animation-delay:-6s"></div>
|
||||||
|
<div class="aurora absolute bottom-0 left-1/3 h-96 w-96 rounded-full bg-indigo-300/40" style="animation-delay:-12s"></div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
function renderLoginPage(config: DatabaseRouteConfig): string {
|
||||||
|
const feishuHref = `/auth/feishu/${encodeURIComponent(config.siloOrganizationSlug)}`;
|
||||||
|
const devButton = config.allowDevLoginBypass
|
||||||
|
? `
|
||||||
|
<div class="relative my-6 rise-3">
|
||||||
|
<div class="absolute inset-0 flex items-center"><div class="w-full border-t border-slate-200"></div></div>
|
||||||
|
<div class="relative flex justify-center"><span class="bg-white/70 px-3 text-[11px] font-medium uppercase tracking-[0.2em] text-slate-400">开发模式</span></div>
|
||||||
|
</div>
|
||||||
|
<a href="/database/dev-login"
|
||||||
|
class="rise-3 group flex w-full items-center justify-center gap-2 rounded-xl border border-amber-300 bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-700 transition hover:border-amber-400 hover:bg-amber-100">
|
||||||
|
<span>⚡</span> 一键登录管理员
|
||||||
|
</a>
|
||||||
|
<p class="rise-3 mt-2 text-center text-xs text-slate-400">仅开发环境可见 · 跳过飞书 OAuth</p>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
${pageHead("Database Admin · 登录")}
|
||||||
|
<body class="relative min-h-screen overflow-hidden bg-gradient-to-br from-slate-50 via-white to-indigo-50 text-slate-800 flex items-center justify-center p-4">
|
||||||
|
${AURORA}
|
||||||
|
<main class="rise glass relative w-full max-w-sm rounded-3xl border border-white/80 p-8 shadow-2xl shadow-indigo-200/50">
|
||||||
|
<div class="mb-7 text-center">
|
||||||
|
<div class="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-violet-500 to-cyan-400 text-2xl font-bold text-white glow-btn">D</div>
|
||||||
|
<h1 class="text-2xl font-bold tracking-tight grad-text">Database Admin</h1>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">使用飞书登录以管理数据库</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="${feishuHref}"
|
||||||
|
class="rise-2 glow-btn group flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-violet-500 to-indigo-500 px-4 py-3.5 text-sm font-semibold text-white transition hover:from-violet-400 hover:to-indigo-400">
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Zm0 2.3 6.5 3.6L12 11.5 5.5 7.9 12 4.3Z"/></svg>
|
||||||
|
使用飞书登录
|
||||||
|
</a>
|
||||||
|
${devButton}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sidebar nav items. `active` marks the current page. `href` "#" = placeholder. */
|
||||||
|
const NAV_ITEMS: ReadonlyArray<{ label: string; icon: string; href: string; active: boolean }> = [
|
||||||
|
{ label: "概览", icon: "M4 13h6V4H4v9Zm0 7h6v-5H4v5Zm10 0h6V11h-6v9Zm0-16v5h6V4h-6Z", href: "/database/dashboard", active: true },
|
||||||
|
{ label: "数据表", icon: "M4 5h16v4H4V5Zm0 6h16v4H4v-4Zm0 6h16v2H4v-2Z", href: "#", active: false },
|
||||||
|
{ label: "查询", icon: "m21 21-4.3-4.3M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14Z", href: "#", active: false },
|
||||||
|
{ label: "设置", icon: "M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm7-3 2 1-2 3-2-1a7 7 0 0 1-2 1l-1 2h-4l-1-2a7 7 0 0 1-2-1l-2 1-2-3 2-1a7 7 0 0 1 0-2l-2-1 2-3 2 1a7 7 0 0 1 2-1l1-2h4l1 2a7 7 0 0 1 2 1l2-1 2 3-2 1a7 7 0 0 1 0 2Z", href: "#", active: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
function renderDashboard(displayName: string): string {
|
||||||
|
const nav = NAV_ITEMS.map((item) => {
|
||||||
|
const cls = item.active
|
||||||
|
? "group flex items-center gap-3 rounded-xl bg-gradient-to-r from-violet-500 to-indigo-500 px-3 py-2.5 text-sm font-semibold text-white shadow-lg shadow-indigo-300/50"
|
||||||
|
: "group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-slate-500 transition hover:bg-slate-100 hover:text-slate-900";
|
||||||
|
return `<a href="${item.href}" class="${cls}">
|
||||||
|
<svg class="h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="${item.icon}"/></svg>
|
||||||
|
${item.label}
|
||||||
|
</a>`;
|
||||||
|
}).join("\n ");
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ label: "数据表", value: "—", accent: "from-violet-200/60 to-transparent" },
|
||||||
|
{ label: "记录数", value: "—", accent: "from-cyan-200/60 to-transparent" },
|
||||||
|
{ label: "最近查询", value: "—", accent: "from-indigo-200/60 to-transparent" },
|
||||||
|
].map((s, i) => `
|
||||||
|
<div class="rise-${i + 1} group relative overflow-hidden rounded-2xl border border-white/80 glass p-5 shadow-lg shadow-slate-200/50 transition hover:-translate-y-0.5 hover:shadow-xl hover:shadow-indigo-200/50">
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-br ${s.accent} opacity-0 transition group-hover:opacity-100"></div>
|
||||||
|
<p class="relative text-sm text-slate-500">${s.label}</p>
|
||||||
|
<p class="relative mt-2 text-3xl font-bold text-slate-900">${s.value}</p>
|
||||||
|
</div>`).join("");
|
||||||
|
|
||||||
|
const initial = escapeHtml(displayName.slice(0, 1) || "U");
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
${pageHead("Database Admin · 概览")}
|
||||||
|
<body class="relative min-h-screen overflow-hidden bg-gradient-to-br from-slate-50 via-white to-indigo-50 text-slate-700">
|
||||||
|
${AURORA}
|
||||||
|
<div class="relative flex min-h-screen">
|
||||||
|
<!-- 左侧菜单栏 -->
|
||||||
|
<aside class="flex w-64 shrink-0 flex-col border-r border-slate-200/80 glass">
|
||||||
|
<div class="flex items-center gap-3 px-5 py-6">
|
||||||
|
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500 to-cyan-400 text-lg font-bold text-white glow-btn">D</div>
|
||||||
|
<span class="text-base font-bold grad-text">Database Admin</span>
|
||||||
|
</div>
|
||||||
|
<nav class="flex flex-1 flex-col gap-1.5 px-3 py-2">
|
||||||
|
${nav}
|
||||||
|
</nav>
|
||||||
|
<div class="m-3 flex items-center gap-3 rounded-xl border border-slate-200 bg-white/60 px-3 py-3">
|
||||||
|
<div class="flex h-9 w-9 items-center justify-center rounded-full bg-gradient-to-br from-violet-500 to-indigo-500 text-sm font-semibold text-white">${initial}</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-[11px] uppercase tracking-wider text-slate-400">已登录</p>
|
||||||
|
<p class="truncate text-sm font-medium text-slate-700">${escapeHtml(displayName)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- 右侧内容 -->
|
||||||
|
<div class="flex flex-1 flex-col">
|
||||||
|
<header class="flex items-center justify-between border-b border-slate-200/80 glass px-8 py-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-lg font-bold text-slate-900">概览</h1>
|
||||||
|
<p class="text-xs text-slate-400">欢迎回来,这里是数据库管理台</p>
|
||||||
|
</div>
|
||||||
|
<button id="logout"
|
||||||
|
class="rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-600 transition hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900">
|
||||||
|
退出登录
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="flex-1 p-8">
|
||||||
|
<div class="grid grid-cols-1 gap-5 sm:grid-cols-3">
|
||||||
|
${stats}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rise-3 mt-6 flex h-64 items-center justify-center rounded-2xl border border-dashed border-slate-300 glass text-sm text-slate-400">
|
||||||
|
内容区占位 · 在 src/database/routes/databaseRoutes.ts 里继续搭建
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById("logout").addEventListener("click", async () => {
|
||||||
|
await fetch("/auth/logout", { method: "POST" });
|
||||||
|
window.location.href = "/database/admin";
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ToolUseTraceStep } from "./trace-store.js";
|
import type { ToolUseTraceStep } from "./trace-store.js";
|
||||||
|
import type { CardContentSegment } from "../outboundImages.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -58,6 +59,7 @@ function toolIcon(toolName: string): string {
|
|||||||
export function buildAgentCard(params: {
|
export function buildAgentCard(params: {
|
||||||
phase: CardPhase;
|
phase: CardPhase;
|
||||||
text: string;
|
text: string;
|
||||||
|
contentSegments?: readonly CardContentSegment[] | undefined;
|
||||||
reasoningText: string | undefined;
|
reasoningText: string | undefined;
|
||||||
toolUseSteps: ToolUseTraceStep[];
|
toolUseSteps: ToolUseTraceStep[];
|
||||||
toolUseElapsedMs: number | undefined;
|
toolUseElapsedMs: number | undefined;
|
||||||
@@ -65,19 +67,19 @@ export function buildAgentCard(params: {
|
|||||||
interrupted: boolean | undefined;
|
interrupted: boolean | undefined;
|
||||||
runId: string | undefined;
|
runId: string | undefined;
|
||||||
}): Record<string, unknown> {
|
}): Record<string, unknown> {
|
||||||
const { phase, text, reasoningText, toolUseSteps, toolUseElapsedMs, isError, interrupted } = params;
|
const { phase, text, contentSegments, reasoningText, toolUseSteps, toolUseElapsedMs, isError, interrupted } = params;
|
||||||
const elements: unknown[] = [];
|
const elements: unknown[] = [];
|
||||||
|
|
||||||
// Tool-use panel (always present if there are steps)
|
// Tool-use panel (always present if there are steps)
|
||||||
if (toolUseSteps.length > 0) {
|
if (toolUseSteps.length > 0) {
|
||||||
elements.push(buildToolUsePanel(toolUseSteps, toolUseElapsedMs, phase !== "complete"));
|
elements.push(buildToolUsePanel(toolUseSteps, toolUseElapsedMs, phase !== "complete"));
|
||||||
} else if (phase === "thinking" || (phase === "streaming" && text === "")) {
|
} else if (phase === "thinking" || (phase === "streaming" && text === "" && (contentSegments === undefined || contentSegments.length === 0))) {
|
||||||
elements.push(buildPendingToolUsePanel());
|
elements.push(buildPendingToolUsePanel());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reasoning panel
|
// Reasoning panel
|
||||||
if (reasoningText !== undefined && reasoningText !== "") {
|
if (reasoningText !== undefined && reasoningText !== "") {
|
||||||
if (phase === "streaming" && text === "") {
|
if (phase === "streaming" && text === "" && (contentSegments === undefined || contentSegments.length === 0)) {
|
||||||
// Still thinking: show reasoning inline
|
// Still thinking: show reasoning inline
|
||||||
elements.push({
|
elements.push({
|
||||||
tag: "markdown",
|
tag: "markdown",
|
||||||
@@ -90,12 +92,11 @@ export function buildAgentCard(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main text content
|
// Main answer: either materialized segments (markdown + Feishu-hosted images)
|
||||||
if (text !== "") {
|
// or a single markdown block.
|
||||||
elements.push({
|
const answerElements = buildAnswerElements(text, contentSegments);
|
||||||
tag: "markdown",
|
if (answerElements.length > 0) {
|
||||||
content: truncateText(text, MAX_TEXT_LENGTH),
|
elements.push(...answerElements);
|
||||||
});
|
|
||||||
} else if (phase === "thinking" && toolUseSteps.length === 0 && (reasoningText === undefined || reasoningText === "")) {
|
} else if (phase === "thinking" && toolUseSteps.length === 0 && (reasoningText === undefined || reasoningText === "")) {
|
||||||
elements.push({
|
elements.push({
|
||||||
tag: "markdown",
|
tag: "markdown",
|
||||||
@@ -401,6 +402,43 @@ function escapeMarkdown(value: string): string {
|
|||||||
return value.replace(/\\/g, "\\\\").replace(/([`*_{}[\]<>])/g, "\\$1");
|
return value.replace(/\\/g, "\\\\").replace(/([`*_{}[\]<>])/g, "\\$1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildAnswerElements(
|
||||||
|
text: string,
|
||||||
|
contentSegments: readonly CardContentSegment[] | undefined,
|
||||||
|
): unknown[] {
|
||||||
|
if (contentSegments !== undefined && contentSegments.length > 0) {
|
||||||
|
const elements: unknown[] = [];
|
||||||
|
let remaining = MAX_TEXT_LENGTH;
|
||||||
|
for (const segment of contentSegments) {
|
||||||
|
if (segment.type === "image") {
|
||||||
|
elements.push({
|
||||||
|
tag: "img",
|
||||||
|
img_key: segment.imgKey,
|
||||||
|
alt: { tag: "plain_text", content: segment.alt },
|
||||||
|
mode: "fit_horizontal",
|
||||||
|
preview: true,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (segment.content === "" || remaining <= 0) continue;
|
||||||
|
const slice = segment.content.length <= remaining
|
||||||
|
? segment.content
|
||||||
|
: truncateText(segment.content, remaining);
|
||||||
|
remaining -= slice.length;
|
||||||
|
elements.push({
|
||||||
|
tag: "markdown",
|
||||||
|
content: slice,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return elements;
|
||||||
|
}
|
||||||
|
if (text === "") return [];
|
||||||
|
return [{
|
||||||
|
tag: "markdown",
|
||||||
|
content: truncateText(text, MAX_TEXT_LENGTH),
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
function truncateText(value: string, maxLength: number): string {
|
function truncateText(value: string, maxLength: number): string {
|
||||||
return value.length <= maxLength ? value : `${value.slice(0, maxLength - 3)}...`;
|
return value.length <= maxLength ? value : `${value.slice(0, maxLength - 3)}...`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,13 @@
|
|||||||
* 4. onToolEnd(name, id, input, result?, error?) — complete a tool step
|
* 4. onToolEnd(name, id, input, result?, error?) — complete a tool step
|
||||||
* 5. finish(finalText) — flush + transition to complete card
|
* 5. finish(finalText) — flush + transition to complete card
|
||||||
* 6. fail(errorText) — flush + transition to error card
|
* 6. fail(errorText) — flush + transition to error card
|
||||||
|
*
|
||||||
|
* On finish, markdown image references (``) are downloaded /
|
||||||
|
* read, uploaded to Feishu as message images, and embedded as native card
|
||||||
|
* `img` elements so external URLs never hit Feishu content-security checks.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { FeishuRuntime, SendMessageOptions } from "../client.js";
|
import type { FeishuRuntime, SendMessageOptions } from "../client.js";
|
||||||
import { sendCard, patchCard, sendText } from "../client.js";
|
import { sendCard, patchCard, sendText, sendLongText } from "../client.js";
|
||||||
import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "../textStream.js";
|
import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "../textStream.js";
|
||||||
import {
|
import {
|
||||||
startToolUseTraceRun,
|
startToolUseTraceRun,
|
||||||
@@ -31,6 +34,12 @@ import {
|
|||||||
getToolUseTraceSteps,
|
getToolUseTraceSteps,
|
||||||
} from "./trace-store.js";
|
} from "./trace-store.js";
|
||||||
import { buildAgentCard, type CardPhase } from "./builder.js";
|
import { buildAgentCard, type CardPhase } from "./builder.js";
|
||||||
|
import {
|
||||||
|
type CardContentSegment,
|
||||||
|
maskMarkdownImagesForStreaming,
|
||||||
|
materializeAnswerSegments,
|
||||||
|
sendImageMessage,
|
||||||
|
} from "../outboundImages.js";
|
||||||
|
|
||||||
export interface StreamingCardSink {
|
export interface StreamingCardSink {
|
||||||
readonly create: (card: Record<string, unknown>) => Promise<string | null>;
|
readonly create: (card: Record<string, unknown>) => Promise<string | null>;
|
||||||
@@ -44,6 +53,11 @@ export interface StreamingCardOptions {
|
|||||||
readonly sendOptions?: SendMessageOptions | undefined;
|
readonly sendOptions?: SendMessageOptions | undefined;
|
||||||
readonly patchIntervalMs: number | undefined;
|
readonly patchIntervalMs: number | undefined;
|
||||||
readonly maxMessageLength: number | undefined;
|
readonly maxMessageLength: number | undefined;
|
||||||
|
/** Project workspace root; required to resolve local image paths. */
|
||||||
|
readonly workspaceRoot?: string | undefined;
|
||||||
|
/** Project workspace directory; required to resolve local image paths. */
|
||||||
|
readonly workspaceDir?: string | undefined;
|
||||||
|
readonly maxImageBytes?: number | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_PATCH_INTERVAL_MS = 400;
|
const DEFAULT_PATCH_INTERVAL_MS = 400;
|
||||||
@@ -65,6 +79,9 @@ export class StreamingAgentCard {
|
|||||||
private readonly sendOptions: SendMessageOptions | undefined;
|
private readonly sendOptions: SendMessageOptions | undefined;
|
||||||
private readonly patchIntervalMs: number;
|
private readonly patchIntervalMs: number;
|
||||||
private readonly maxMessageLength: number;
|
private readonly maxMessageLength: number;
|
||||||
|
private readonly workspaceRoot: string | undefined;
|
||||||
|
private readonly workspaceDir: string | undefined;
|
||||||
|
private readonly maxImageBytes: number | undefined;
|
||||||
|
|
||||||
constructor(options: StreamingCardOptions) {
|
constructor(options: StreamingCardOptions) {
|
||||||
this.runId = options.runId;
|
this.runId = options.runId;
|
||||||
@@ -73,6 +90,9 @@ export class StreamingAgentCard {
|
|||||||
this.sendOptions = options.sendOptions;
|
this.sendOptions = options.sendOptions;
|
||||||
this.patchIntervalMs = options.patchIntervalMs ?? DEFAULT_PATCH_INTERVAL_MS;
|
this.patchIntervalMs = options.patchIntervalMs ?? DEFAULT_PATCH_INTERVAL_MS;
|
||||||
this.maxMessageLength = options.maxMessageLength ?? DEFAULT_MAX_MESSAGE_LENGTH;
|
this.maxMessageLength = options.maxMessageLength ?? DEFAULT_MAX_MESSAGE_LENGTH;
|
||||||
|
this.workspaceRoot = options.workspaceRoot;
|
||||||
|
this.workspaceDir = options.workspaceDir;
|
||||||
|
this.maxImageBytes = options.maxImageBytes;
|
||||||
startToolUseTraceRun(this.runId);
|
startToolUseTraceRun(this.runId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,23 +126,43 @@ export class StreamingAgentCard {
|
|||||||
recordToolUseEnd({ runId: this.runId, ...params });
|
recordToolUseEnd({ runId: this.runId, ...params });
|
||||||
this.scheduleFlush();
|
this.scheduleFlush();
|
||||||
}
|
}
|
||||||
async finish(fallbackText: string, options: { readonly interrupted?: boolean; readonly footerText?: string | undefined } = {}): Promise<void> {
|
|
||||||
|
async finish(
|
||||||
|
fallbackText: string,
|
||||||
|
options: { readonly interrupted?: boolean; readonly footerText?: string | undefined } = {},
|
||||||
|
): Promise<void> {
|
||||||
await this.flushChain;
|
await this.flushChain;
|
||||||
this.interrupted = options.interrupted === true;
|
this.interrupted = options.interrupted === true;
|
||||||
const footerText = options.footerText ?? "";
|
const footerText = options.footerText ?? "";
|
||||||
const fallbackWithFooter = appendFooter(fallbackText, footerText);
|
const fallbackWithFooter = appendFooter(fallbackText, footerText);
|
||||||
try {
|
try {
|
||||||
|
let answerText =
|
||||||
|
this.text.length > 0 ? appendFooter(this.text, footerText) : fallbackWithFooter;
|
||||||
|
this.text = answerText;
|
||||||
|
|
||||||
|
const { segments, unresolved } = await materializeAnswerSegments(answerText, {
|
||||||
|
rt: this.rt,
|
||||||
|
workspaceRoot: this.workspaceRoot,
|
||||||
|
workspaceDir: this.workspaceDir,
|
||||||
|
maxImageBytes: this.maxImageBytes,
|
||||||
|
});
|
||||||
|
if (unresolved.length > 0) {
|
||||||
|
this.rt.logger.warn(
|
||||||
|
{ runId: this.runId, unresolvedCount: unresolved.length, unresolved: unresolved.slice(0, 5) },
|
||||||
|
"some answer images could not be uploaded to Feishu",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let updated = true;
|
let updated = true;
|
||||||
if (this.text.length > 0) {
|
if (answerText.length > 0 || segments.length > 0) {
|
||||||
this.text = appendFooter(this.text, footerText);
|
updated = await this.flushCard("complete", answerText, false, segments);
|
||||||
updated = await this.flushCard("complete", this.text);
|
|
||||||
} else if (this.currentMessageId === null && fallbackWithFooter.length > 0) {
|
|
||||||
// No streaming text was sent. If we never created a card, send one now.
|
|
||||||
this.text = fallbackWithFooter;
|
|
||||||
updated = await this.flushCard("complete", this.text);
|
|
||||||
} else if (this.currentMessageId !== null) {
|
} else if (this.currentMessageId !== null) {
|
||||||
// Patch the existing card with the final text.
|
updated = await this.flushCard("complete", "", false, []);
|
||||||
updated = await this.flushCard("complete", fallbackWithFooter);
|
}
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
// Card path failed (e.g. residual content policy). Deliver text + standalone images.
|
||||||
|
updated = await this.deliverPlainFallback(segments, answerText);
|
||||||
}
|
}
|
||||||
if (!updated && this.interrupted) {
|
if (!updated && this.interrupted) {
|
||||||
await sendText(this.rt, this.chatId, "\u5DF2\u4E2D\u65AD\u5F53\u524D\u8FD0\u884C\u3002", this.sendOptions);
|
await sendText(this.rt, this.chatId, "\u5DF2\u4E2D\u65AD\u5F53\u524D\u8FD0\u884C\u3002", this.sendOptions);
|
||||||
@@ -166,15 +206,30 @@ export class StreamingAgentCard {
|
|||||||
return this.flushCard(this.currentPhase(), this.text);
|
return this.flushCard(this.currentPhase(), this.text);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async flushCard(phase: CardPhase, text: string, isError = false): Promise<boolean> {
|
private async flushCard(
|
||||||
const chunks = splitAtBoundary(text, this.maxMessageLength);
|
phase: CardPhase,
|
||||||
const firstChunk = chunks[0];
|
text: string,
|
||||||
if (firstChunk === undefined) return true;
|
isError = false,
|
||||||
|
contentSegments?: readonly CardContentSegment[],
|
||||||
|
): Promise<boolean> {
|
||||||
|
// During live streaming, strip image URLs so Feishu never fetches remote
|
||||||
|
// ranks mid-run. Materialized segments are only used on the complete pass.
|
||||||
|
const displayText =
|
||||||
|
phase === "complete" && contentSegments !== undefined
|
||||||
|
? text
|
||||||
|
: maskMarkdownImagesForStreaming(text);
|
||||||
|
|
||||||
|
const chunks = splitAtBoundary(displayText, this.maxMessageLength);
|
||||||
|
const firstChunk = chunks[0] ?? "";
|
||||||
|
// When we have segments (complete+images), keep first-card complete content
|
||||||
|
// on segments only; overflow text (rare) falls back to plain chunked cards.
|
||||||
const toolUseSteps = getToolUseTraceSteps(this.runId);
|
const toolUseSteps = getToolUseTraceSteps(this.runId);
|
||||||
const card = buildAgentCard({
|
const card = buildAgentCard({
|
||||||
phase,
|
phase,
|
||||||
text: firstChunk,
|
text: contentSegments !== undefined && contentSegments.length > 0 ? "" : firstChunk,
|
||||||
|
contentSegments: contentSegments !== undefined && contentSegments.length > 0
|
||||||
|
? contentSegments
|
||||||
|
: undefined,
|
||||||
reasoningText: this.reasoningText || undefined,
|
reasoningText: this.reasoningText || undefined,
|
||||||
toolUseSteps,
|
toolUseSteps,
|
||||||
toolUseElapsedMs: this.toolUseElapsedMs,
|
toolUseElapsedMs: this.toolUseElapsedMs,
|
||||||
@@ -186,43 +241,89 @@ export class StreamingAgentCard {
|
|||||||
if (this.currentMessageId === null) {
|
if (this.currentMessageId === null) {
|
||||||
this.currentMessageId = await sendCard(this.rt, this.chatId, card, this.sendOptions);
|
this.currentMessageId = await sendCard(this.rt, this.chatId, card, this.sendOptions);
|
||||||
let updated = this.currentMessageId !== null;
|
let updated = this.currentMessageId !== null;
|
||||||
// Send overflow chunks as new messages (rare for agent output)
|
// Send overflow chunks as new messages (rare for agent output). Segments
|
||||||
for (const chunk of chunks.slice(1)) {
|
// already include the whole answer; only plain text overflows.
|
||||||
const overflowCard = buildAgentCard({
|
if (contentSegments === undefined || contentSegments.length === 0) {
|
||||||
phase,
|
for (const chunk of chunks.slice(1)) {
|
||||||
text: chunk,
|
const overflowCard = buildAgentCard({
|
||||||
reasoningText: undefined,
|
phase,
|
||||||
toolUseSteps: [],
|
text: chunk,
|
||||||
toolUseElapsedMs: undefined,
|
reasoningText: undefined,
|
||||||
isError,
|
toolUseSteps: [],
|
||||||
interrupted: this.interrupted,
|
toolUseElapsedMs: undefined,
|
||||||
runId: undefined,
|
isError,
|
||||||
});
|
interrupted: this.interrupted,
|
||||||
const overflowMessageId = await sendCard(this.rt, this.chatId, overflowCard, this.sendOptions);
|
runId: undefined,
|
||||||
updated = updated && overflowMessageId !== null;
|
});
|
||||||
this.currentMessageId = overflowMessageId;
|
const overflowMessageId = await sendCard(this.rt, this.chatId, overflowCard, this.sendOptions);
|
||||||
}
|
updated = updated && overflowMessageId !== null;
|
||||||
return updated;
|
this.currentMessageId = overflowMessageId;
|
||||||
} else {
|
}
|
||||||
let updated = await patchCard(this.rt, this.currentMessageId, card);
|
|
||||||
// For overflow, create new messages
|
|
||||||
for (const chunk of chunks.slice(1)) {
|
|
||||||
const overflowCard = buildAgentCard({
|
|
||||||
phase,
|
|
||||||
text: chunk,
|
|
||||||
reasoningText: undefined,
|
|
||||||
toolUseSteps: [],
|
|
||||||
toolUseElapsedMs: undefined,
|
|
||||||
isError,
|
|
||||||
interrupted: this.interrupted,
|
|
||||||
runId: undefined,
|
|
||||||
});
|
|
||||||
const overflowMessageId = await sendCard(this.rt, this.chatId, overflowCard, this.sendOptions);
|
|
||||||
updated = updated && overflowMessageId !== null;
|
|
||||||
this.currentMessageId = overflowMessageId;
|
|
||||||
}
|
}
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let updated = await patchCard(this.rt, this.currentMessageId, card);
|
||||||
|
if (contentSegments === undefined || contentSegments.length === 0) {
|
||||||
|
for (const chunk of chunks.slice(1)) {
|
||||||
|
const overflowCard = buildAgentCard({
|
||||||
|
phase,
|
||||||
|
text: chunk,
|
||||||
|
reasoningText: undefined,
|
||||||
|
toolUseSteps: [],
|
||||||
|
toolUseElapsedMs: undefined,
|
||||||
|
isError,
|
||||||
|
interrupted: this.interrupted,
|
||||||
|
runId: undefined,
|
||||||
|
});
|
||||||
|
const overflowMessageId = await sendCard(this.rt, this.chatId, overflowCard, this.sendOptions);
|
||||||
|
updated = updated && overflowMessageId !== null;
|
||||||
|
this.currentMessageId = overflowMessageId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deliverPlainFallback(
|
||||||
|
segments: readonly CardContentSegment[],
|
||||||
|
answerText: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const textParts: string[] = [];
|
||||||
|
const imageKeys: string[] = [];
|
||||||
|
if (segments.length > 0) {
|
||||||
|
for (const segment of segments) {
|
||||||
|
if (segment.type === "markdown") {
|
||||||
|
if (segment.content.trim() !== "") textParts.push(segment.content);
|
||||||
|
} else {
|
||||||
|
imageKeys.push(segment.imgKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (answerText.trim() !== "") {
|
||||||
|
textParts.push(maskMarkdownImagesForStreaming(answerText));
|
||||||
|
}
|
||||||
|
|
||||||
|
let any = false;
|
||||||
|
if (textParts.length > 0) {
|
||||||
|
const messageId = await sendLongText(
|
||||||
|
this.rt,
|
||||||
|
this.chatId,
|
||||||
|
textParts.join("\n\n"),
|
||||||
|
this.sendOptions,
|
||||||
|
);
|
||||||
|
any = messageId !== null;
|
||||||
|
}
|
||||||
|
for (const imageKey of imageKeys) {
|
||||||
|
try {
|
||||||
|
const messageId = await sendImageMessage(this.rt, this.chatId, imageKey, this.sendOptions);
|
||||||
|
any = any || messageId !== null;
|
||||||
|
} catch (error) {
|
||||||
|
this.rt.logger.warn(
|
||||||
|
{ runId: this.runId, err: error instanceof Error ? error.message : String(error) },
|
||||||
|
"standalone image fallback failed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return any;
|
||||||
}
|
}
|
||||||
|
|
||||||
private currentPhase(): CardPhase {
|
private currentPhase(): CardPhase {
|
||||||
@@ -235,5 +336,5 @@ export class StreamingAgentCard {
|
|||||||
function appendFooter(text: string, footerText: string): string {
|
function appendFooter(text: string, footerText: string): string {
|
||||||
if (footerText === "") return text;
|
if (footerText === "") return text;
|
||||||
if (text === "") return footerText;
|
if (text === "") return footerText;
|
||||||
return `${text.trimEnd()}\n\n${footerText}`;
|
return `${text}\n\n${footerText}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -316,7 +316,8 @@ export async function sendCard(
|
|||||||
{ msgType: "interactive", content: JSON.stringify(card) },
|
{ msgType: "interactive", content: JSON.stringify(card) },
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
rt.logger.warn({ chatId, err: errorText(e) }, "sendCard failed");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -334,7 +335,7 @@ export async function patchCard(rt: FeishuRuntime, messageId: string, card: Reco
|
|||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
rt.logger.warn({ messageId, err: e instanceof Error ? e.message : String(e) }, "patchCard failed");
|
rt.logger.warn({ messageId, err: errorText(e) }, "patchCard failed");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,389 @@
|
|||||||
|
/**
|
||||||
|
* Resolve markdown image references in agent answers into Feishu-hosted
|
||||||
|
* image_keys so cards can embed them without remote URLs (which trip Feishu
|
||||||
|
* content-security controls).
|
||||||
|
*/
|
||||||
|
import { isIP } from "node:net";
|
||||||
|
import type { FeishuRuntime } from "./client.js";
|
||||||
|
import { withRetry } from "./client.js";
|
||||||
|
import {
|
||||||
|
WorkspaceFileBoundaryError,
|
||||||
|
readWorkspaceFileNoFollow,
|
||||||
|
} from "../security/workspaceFiles.js";
|
||||||
|
|
||||||
|
export const FEISHU_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||||
|
export const DEFAULT_MAX_OUTBOUND_IMAGES = 10;
|
||||||
|
|
||||||
|
const IMAGE_MARKDOWN_RE = /!\[([^\]\n]*)\]\(([^)\n]+)\)/g;
|
||||||
|
const FENCED_CODE_RE = /```[\s\S]*?```/g;
|
||||||
|
|
||||||
|
export type CardContentSegment =
|
||||||
|
| { readonly type: "markdown"; readonly content: string }
|
||||||
|
| { readonly type: "image"; readonly imgKey: string; readonly alt: string };
|
||||||
|
|
||||||
|
export interface MarkdownImageRef {
|
||||||
|
readonly fullMatch: string;
|
||||||
|
readonly alt: string;
|
||||||
|
readonly src: string;
|
||||||
|
readonly index: number;
|
||||||
|
readonly length: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OutboundImageContext {
|
||||||
|
readonly rt: FeishuRuntime;
|
||||||
|
readonly workspaceRoot?: string | undefined;
|
||||||
|
readonly workspaceDir?: string | undefined;
|
||||||
|
readonly maxImageBytes?: number | undefined;
|
||||||
|
readonly maxImages?: number | undefined;
|
||||||
|
readonly fetchImpl?: typeof fetch | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImageCreateResponse = {
|
||||||
|
image_key?: string;
|
||||||
|
data?: { image_key?: string };
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
type MessageCreateResponse = {
|
||||||
|
message_id?: string;
|
||||||
|
data?: { message_id?: string };
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
/** Streaming-safe view: drop markdown image URLs so partial cards do not hit Feishu URL checks. */
|
||||||
|
export function maskMarkdownImagesForStreaming(text: string): string {
|
||||||
|
return rewriteMarkdownImagesOutsideCode(text, (ref) => {
|
||||||
|
const alt = ref.alt.trim();
|
||||||
|
return alt === "" ? "\u3010\u56fe\u7247\u3011" : alt;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findMarkdownImagesOutsideCode(text: string): MarkdownImageRef[] {
|
||||||
|
const blocked = blockedRanges(text);
|
||||||
|
const refs: MarkdownImageRef[] = [];
|
||||||
|
IMAGE_MARKDOWN_RE.lastIndex = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = IMAGE_MARKDOWN_RE.exec(text)) !== null) {
|
||||||
|
const index = match.index;
|
||||||
|
if (blocked.some((range) => index >= range.start && index < range.end)) continue;
|
||||||
|
const fullMatch = match[0];
|
||||||
|
const alt = match[1] ?? "";
|
||||||
|
const rawSrc = (match[2] ?? "").trim();
|
||||||
|
const src = stripUrlTitle(rawSrc);
|
||||||
|
if (src === "") continue;
|
||||||
|
refs.push({ fullMatch, alt, src, index, length: fullMatch.length });
|
||||||
|
}
|
||||||
|
return refs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload reachable markdown images and split the answer into card segments
|
||||||
|
* (markdown + Feishu img elements). Unresolved images become visible alt text.
|
||||||
|
*/
|
||||||
|
export async function materializeAnswerSegments(
|
||||||
|
text: string,
|
||||||
|
ctx: OutboundImageContext,
|
||||||
|
): Promise<{ segments: CardContentSegment[]; unresolved: string[] }> {
|
||||||
|
const refs = findMarkdownImagesOutsideCode(text);
|
||||||
|
if (refs.length === 0) {
|
||||||
|
return {
|
||||||
|
segments: text === "" ? [] : [{ type: "markdown", content: text }],
|
||||||
|
unresolved: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxImages = ctx.maxImages ?? DEFAULT_MAX_OUTBOUND_IMAGES;
|
||||||
|
const maxBytes = ctx.maxImageBytes ?? FEISHU_MAX_IMAGE_BYTES;
|
||||||
|
const keyBySrc = new Map<string, string>();
|
||||||
|
const unresolved: string[] = [];
|
||||||
|
const uniqueSrcs: string[] = [];
|
||||||
|
for (const ref of refs) {
|
||||||
|
if (!uniqueSrcs.includes(ref.src)) uniqueSrcs.push(ref.src);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const src of uniqueSrcs.slice(0, maxImages)) {
|
||||||
|
try {
|
||||||
|
const bytes = await resolveOutboundImageBytes(src, ctx, maxBytes);
|
||||||
|
if (bytes === null) {
|
||||||
|
unresolved.push(src);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const imageKey = await uploadMessageImage(ctx.rt, bytes);
|
||||||
|
keyBySrc.set(src, imageKey);
|
||||||
|
} catch (error) {
|
||||||
|
ctx.rt.logger.warn(
|
||||||
|
{ src, err: error instanceof Error ? error.message : String(error) },
|
||||||
|
"outbound image materialize failed",
|
||||||
|
);
|
||||||
|
unresolved.push(src);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const src of uniqueSrcs.slice(maxImages)) {
|
||||||
|
unresolved.push(src);
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments: CardContentSegment[] = [];
|
||||||
|
let cursor = 0;
|
||||||
|
for (const ref of refs) {
|
||||||
|
if (ref.index > cursor) {
|
||||||
|
pushMarkdown(segments, text.slice(cursor, ref.index));
|
||||||
|
}
|
||||||
|
const imageKey = keyBySrc.get(ref.src);
|
||||||
|
if (imageKey !== undefined) {
|
||||||
|
segments.push({
|
||||||
|
type: "image",
|
||||||
|
imgKey: imageKey,
|
||||||
|
alt: ref.alt.trim() === "" ? "\u56fe\u7247" : ref.alt.trim(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const alt = ref.alt.trim();
|
||||||
|
pushMarkdown(segments, alt === "" ? "\u3010\u56fe\u7247\u3011" : alt);
|
||||||
|
}
|
||||||
|
cursor = ref.index + ref.length;
|
||||||
|
}
|
||||||
|
if (cursor < text.length) {
|
||||||
|
pushMarkdown(segments, text.slice(cursor));
|
||||||
|
}
|
||||||
|
return { segments, unresolved };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadMessageImage(rt: FeishuRuntime, image: Buffer): Promise<string> {
|
||||||
|
if (image.byteLength === 0) {
|
||||||
|
throw new Error("image is empty");
|
||||||
|
}
|
||||||
|
if (image.byteLength > FEISHU_MAX_IMAGE_BYTES) {
|
||||||
|
throw new Error(`image exceeds Feishu limit of ${FEISHU_MAX_IMAGE_BYTES} bytes`);
|
||||||
|
}
|
||||||
|
const client = rt.client as unknown as {
|
||||||
|
im: { v1: { image: { create: (p: unknown) => Promise<ImageCreateResponse> } } };
|
||||||
|
};
|
||||||
|
const res = await withRetry(async () =>
|
||||||
|
client.im.v1.image.create({
|
||||||
|
data: { image_type: "message", image },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const imageKey = res?.image_key ?? res?.data?.image_key;
|
||||||
|
if (imageKey === undefined || imageKey === "") {
|
||||||
|
throw new Error("Feishu image upload response is missing image_key");
|
||||||
|
}
|
||||||
|
return imageKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send a standalone image message (fallback if card embed is unavailable). */
|
||||||
|
export async function sendImageMessage(
|
||||||
|
rt: FeishuRuntime,
|
||||||
|
chatId: string,
|
||||||
|
imageKey: string,
|
||||||
|
options?: { readonly replyToMessageId?: string | undefined },
|
||||||
|
): Promise<string | null> {
|
||||||
|
const client = rt.client as unknown as {
|
||||||
|
im: {
|
||||||
|
v1: {
|
||||||
|
message: {
|
||||||
|
create: (p: unknown) => Promise<MessageCreateResponse>;
|
||||||
|
reply: (p: unknown) => Promise<MessageCreateResponse>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const replyTo = options?.replyToMessageId;
|
||||||
|
if (replyTo !== undefined && replyTo !== "") {
|
||||||
|
const res = await client.im.v1.message.reply({
|
||||||
|
path: { message_id: replyTo },
|
||||||
|
data: { msg_type: "image", content: JSON.stringify({ image_key: imageKey }) },
|
||||||
|
});
|
||||||
|
return res?.data?.message_id ?? res?.message_id ?? null;
|
||||||
|
}
|
||||||
|
const res = await client.im.v1.message.create({
|
||||||
|
params: { receive_id_type: "chat_id" },
|
||||||
|
data: {
|
||||||
|
receive_id: chatId,
|
||||||
|
msg_type: "image",
|
||||||
|
content: JSON.stringify({ image_key: imageKey }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return res?.data?.message_id ?? res?.message_id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveOutboundImageBytes(
|
||||||
|
src: string,
|
||||||
|
ctx: OutboundImageContext,
|
||||||
|
maxBytes: number,
|
||||||
|
): Promise<Buffer | null> {
|
||||||
|
if (isRemoteUrl(src)) {
|
||||||
|
return fetchRemoteImage(src, ctx.fetchImpl ?? fetch, maxBytes);
|
||||||
|
}
|
||||||
|
const root = ctx.workspaceRoot?.trim();
|
||||||
|
const dir = ctx.workspaceDir?.trim();
|
||||||
|
if (root === undefined || root === "" || dir === undefined || dir === "") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const file = await readWorkspaceFileNoFollow(root, dir, src, maxBytes);
|
||||||
|
return file.data;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof WorkspaceFileBoundaryError && error.reason === "not_found") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteMarkdownImagesOutsideCode(
|
||||||
|
text: string,
|
||||||
|
replace: (ref: MarkdownImageRef) => string,
|
||||||
|
): string {
|
||||||
|
const refs = findMarkdownImagesOutsideCode(text);
|
||||||
|
if (refs.length === 0) return text;
|
||||||
|
let out = "";
|
||||||
|
let cursor = 0;
|
||||||
|
for (const ref of refs) {
|
||||||
|
out += text.slice(cursor, ref.index);
|
||||||
|
out += replace(ref);
|
||||||
|
cursor = ref.index + ref.length;
|
||||||
|
}
|
||||||
|
out += text.slice(cursor);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushMarkdown(segments: CardContentSegment[], content: string): void {
|
||||||
|
if (content === "") return;
|
||||||
|
const last = segments[segments.length - 1];
|
||||||
|
if (last !== undefined && last.type === "markdown") {
|
||||||
|
segments[segments.length - 1] = { type: "markdown", content: last.content + content };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
segments.push({ type: "markdown", content });
|
||||||
|
}
|
||||||
|
|
||||||
|
function blockedRanges(text: string): Array<{ start: number; end: number }> {
|
||||||
|
const ranges: Array<{ start: number; end: number }> = [];
|
||||||
|
FENCED_CODE_RE.lastIndex = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = FENCED_CODE_RE.exec(text)) !== null) {
|
||||||
|
ranges.push({ start: match.index, end: match.index + match[0].length });
|
||||||
|
}
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripUrlTitle(raw: string): string {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
// Markdown optional title: url "title" or url 'title'
|
||||||
|
const titled = /^(\S+)\s+(".*"|'.*')$/.exec(trimmed);
|
||||||
|
return (titled?.[1] ?? trimmed).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRemoteUrl(src: string): boolean {
|
||||||
|
try {
|
||||||
|
const url = new URL(src);
|
||||||
|
return url.protocol === "http:" || url.protocol === "https:";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRemoteImage(
|
||||||
|
src: string,
|
||||||
|
fetchImpl: typeof fetch,
|
||||||
|
maxBytes: number,
|
||||||
|
): Promise<Buffer | null> {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(src);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
||||||
|
if (!isPublicHttpHost(url.hostname)) return null;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), 15_000);
|
||||||
|
try {
|
||||||
|
const response = await fetchImpl(url, {
|
||||||
|
method: "GET",
|
||||||
|
redirect: "manual",
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: { accept: "image/*,*/*;q=0.8" },
|
||||||
|
});
|
||||||
|
// One safe redirect hop to another public http(s) host.
|
||||||
|
if (response.status >= 300 && response.status < 400) {
|
||||||
|
const location = response.headers.get("location");
|
||||||
|
if (location === null || location === "") return null;
|
||||||
|
let redirected: URL;
|
||||||
|
try {
|
||||||
|
redirected = new URL(location, url);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (redirected.protocol !== "http:" && redirected.protocol !== "https:") return null;
|
||||||
|
if (!isPublicHttpHost(redirected.hostname)) return null;
|
||||||
|
const second = await fetchImpl(redirected, {
|
||||||
|
method: "GET",
|
||||||
|
redirect: "manual",
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: { accept: "image/*,*/*;q=0.8" },
|
||||||
|
});
|
||||||
|
return readImageBody(second, maxBytes);
|
||||||
|
}
|
||||||
|
return readImageBody(response, maxBytes);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readImageBody(response: Response, maxBytes: number): Promise<Buffer | null> {
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const contentType = (response.headers.get("content-type") ?? "").toLowerCase();
|
||||||
|
if (
|
||||||
|
contentType !== "" &&
|
||||||
|
!contentType.startsWith("image/") &&
|
||||||
|
!contentType.includes("octet-stream") &&
|
||||||
|
(contentType.startsWith("text/") || contentType.includes("json") || contentType.includes("html"))
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const contentLength = Number(response.headers.get("content-length") ?? "NaN");
|
||||||
|
if (Number.isFinite(contentLength) && contentLength > maxBytes) return null;
|
||||||
|
const buf = Buffer.from(await response.arrayBuffer());
|
||||||
|
if (buf.byteLength === 0 || buf.byteLength > maxBytes) return null;
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPublicHttpHost(hostname: string): boolean {
|
||||||
|
const host = hostname.trim().toLowerCase().replace(/\.$/, "");
|
||||||
|
if (host === "" || host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (host === "0.0.0.0" || host === "::" || host === "[::]" || host === "::1" || host === "[::1]") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const unbracketed = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
||||||
|
const ipVersion = isIP(unbracketed);
|
||||||
|
if (ipVersion === 4) return !isPrivateIPv4(unbracketed);
|
||||||
|
if (ipVersion === 6) return !isPrivateIPv6(unbracketed);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrivateIPv4(ip: string): boolean {
|
||||||
|
const parts = ip.split(".").map((part) => Number(part));
|
||||||
|
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const a = parts[0]!;
|
||||||
|
const b = parts[1]!;
|
||||||
|
if (a === 10 || a === 127 || a === 0) return true;
|
||||||
|
if (a === 169 && b === 254) return true;
|
||||||
|
if (a === 172 && b >= 16 && b <= 31) return true;
|
||||||
|
if (a === 192 && b === 168) return true;
|
||||||
|
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
|
||||||
|
if (a >= 224) return true; // multicast / reserved
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrivateIPv6(ip: string): boolean {
|
||||||
|
const normalized = ip.toLowerCase();
|
||||||
|
if (normalized === "::1") return true;
|
||||||
|
if (normalized.startsWith("fc") || normalized.startsWith("fd")) return true; // unique local
|
||||||
|
if (normalized.startsWith("fe80:")) return true; // link-local
|
||||||
|
const mapped = /^:ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(normalized);
|
||||||
|
if (mapped?.[1] !== undefined) return isPrivateIPv4(mapped[1]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -496,6 +496,9 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
|||||||
sendOptions,
|
sendOptions,
|
||||||
patchIntervalMs: undefined,
|
patchIntervalMs: undefined,
|
||||||
maxMessageLength: undefined,
|
maxMessageLength: undefined,
|
||||||
|
workspaceRoot: projectWorkspaceRoot,
|
||||||
|
workspaceDir: project.workspaceDir,
|
||||||
|
maxImageBytes: deps.resourceLimits?.maxBytesPerFile,
|
||||||
});
|
});
|
||||||
const fileDeliveryMcpServer = createFileDeliveryMcpServer({
|
const fileDeliveryMcpServer = createFileDeliveryMcpServer({
|
||||||
rt,
|
rt,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Fastify from "fastify";
|
import Fastify from "fastify";
|
||||||
import { registerAdminPlugin } from "./admin/plugin.js";
|
import { registerAdminPlugin } from "./admin/plugin.js";
|
||||||
|
import { registerDatabasePlugin } from "./database/plugin.js";
|
||||||
import { prisma } from "./db.js";
|
import { prisma } from "./db.js";
|
||||||
import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js";
|
import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js";
|
||||||
import { archiveFeishuBindingForLifecycleEvent } from "./feishu/bindingLifecycle.js";
|
import { archiveFeishuBindingForLifecycleEvent } from "./feishu/bindingLifecycle.js";
|
||||||
@@ -143,6 +144,14 @@ export async function startHub(): Promise<void> {
|
|||||||
secretEnvelope,
|
secretEnvelope,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// `/database/*` surface. Registered after the admin plugin so the
|
||||||
|
// @fastify/cookie parser and /auth/* login routes are already available.
|
||||||
|
await registerDatabasePlugin(app, {
|
||||||
|
prisma,
|
||||||
|
sessionSecret,
|
||||||
|
siloOrganizationSlug: siloOrganization.slug,
|
||||||
|
});
|
||||||
|
|
||||||
const feishuListenerEnabled = booleanEnv("HUB_FEISHU_LISTENER_ENABLED", true);
|
const feishuListenerEnabled = booleanEnv("HUB_FEISHU_LISTENER_ENABLED", true);
|
||||||
if (feishuListenerEnabled) {
|
if (feishuListenerEnabled) {
|
||||||
const feishuConfig = {
|
const feishuConfig = {
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { FeishuRuntime } from "../../src/feishu/client.js";
|
||||||
|
import {
|
||||||
|
findMarkdownImagesOutsideCode,
|
||||||
|
maskMarkdownImagesForStreaming,
|
||||||
|
materializeAnswerSegments,
|
||||||
|
uploadMessageImage,
|
||||||
|
} from "../../src/feishu/outboundImages.js";
|
||||||
|
import { buildAgentCard } from "../../src/feishu/card/builder.js";
|
||||||
|
|
||||||
|
const temps: string[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(temps.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("outbound markdown image parsing", () => {
|
||||||
|
it("finds image refs outside fenced code blocks", () => {
|
||||||
|
const text = [
|
||||||
|
"See diagram:",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"```md",
|
||||||
|
"",
|
||||||
|
"```",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const refs = findMarkdownImagesOutsideCode(text);
|
||||||
|
expect(refs.map((ref) => ref.src)).toEqual([
|
||||||
|
"https://cdn.example.com/a.png",
|
||||||
|
"assets/x.png",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("masks image urls for streaming cards", () => {
|
||||||
|
expect(maskMarkdownImagesForStreaming("before  after")).toBe(
|
||||||
|
"before alt text after",
|
||||||
|
);
|
||||||
|
expect(maskMarkdownImagesForStreaming("")).toBe("【图片】");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("materializeAnswerSegments", () => {
|
||||||
|
it("uploads remote and workspace images and builds card segments", async () => {
|
||||||
|
const workspaceRoot = await mkdtemp(join(tmpdir(), "cph-img-root-"));
|
||||||
|
temps.push(workspaceRoot);
|
||||||
|
const workspaceDir = join(workspaceRoot, "project");
|
||||||
|
await mkdir(join(workspaceDir, "assets"), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
join(workspaceDir, "assets", "local.png"),
|
||||||
|
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const imageCreate = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ image_key: "img_remote_1" })
|
||||||
|
.mockResolvedValueOnce({ image_key: "img_local_1" });
|
||||||
|
const rt = mockRuntime({ imageCreate });
|
||||||
|
|
||||||
|
const fetchImpl = vi.fn(async () =>
|
||||||
|
new Response(Buffer.from("remote-bytes"), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "image/png" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = "Intro\n\n\n\nAnd local \nDone.";
|
||||||
|
const { segments, unresolved } = await materializeAnswerSegments(text, {
|
||||||
|
rt,
|
||||||
|
workspaceRoot,
|
||||||
|
workspaceDir,
|
||||||
|
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(unresolved).toEqual([]);
|
||||||
|
expect(imageCreate).toHaveBeenCalledTimes(2);
|
||||||
|
expect(segments).toEqual([
|
||||||
|
{ type: "markdown", content: "Intro\n\n" },
|
||||||
|
{ type: "image", imgKey: "img_remote_1", alt: "remote" },
|
||||||
|
{ type: "markdown", content: "\n\nAnd local " },
|
||||||
|
{ type: "image", imgKey: "img_local_1", alt: "local" },
|
||||||
|
{ type: "markdown", content: "\nDone." },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const card = buildAgentCard({
|
||||||
|
phase: "complete",
|
||||||
|
text: "",
|
||||||
|
contentSegments: segments,
|
||||||
|
reasoningText: undefined,
|
||||||
|
toolUseSteps: [],
|
||||||
|
toolUseElapsedMs: undefined,
|
||||||
|
isError: false,
|
||||||
|
interrupted: false,
|
||||||
|
runId: undefined,
|
||||||
|
});
|
||||||
|
expect(card.elements).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ tag: "img", img_key: "img_remote_1" }),
|
||||||
|
expect.objectContaining({ tag: "img", img_key: "img_local_1" }),
|
||||||
|
expect.objectContaining({ tag: "markdown", content: "Intro\n\n" }),
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects private remote hosts", async () => {
|
||||||
|
const imageCreate = vi.fn();
|
||||||
|
const rt = mockRuntime({ imageCreate });
|
||||||
|
const fetchImpl = vi.fn();
|
||||||
|
const { segments, unresolved } = await materializeAnswerSegments(
|
||||||
|
"",
|
||||||
|
{ rt, fetchImpl: fetchImpl as unknown as typeof fetch },
|
||||||
|
);
|
||||||
|
expect(fetchImpl).not.toHaveBeenCalled();
|
||||||
|
expect(imageCreate).not.toHaveBeenCalled();
|
||||||
|
expect(unresolved).toEqual(["http://127.0.0.1/secret.png"]);
|
||||||
|
expect(segments).toEqual([{ type: "markdown", content: "x" }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("uploadMessageImage", () => {
|
||||||
|
it("returns image_key from Feishu upload", async () => {
|
||||||
|
const imageCreate = vi.fn(async () => ({ data: { image_key: "img_nested" } }));
|
||||||
|
const rt = mockRuntime({ imageCreate });
|
||||||
|
await expect(uploadMessageImage(rt, Buffer.from("png"))).resolves.toBe("img_nested");
|
||||||
|
expect(imageCreate).toHaveBeenCalledWith({
|
||||||
|
data: { image_type: "message", image: Buffer.from("png") },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockRuntime(options: {
|
||||||
|
readonly imageCreate?: (payload: unknown) => Promise<unknown>;
|
||||||
|
}): FeishuRuntime {
|
||||||
|
return {
|
||||||
|
client: {
|
||||||
|
im: {
|
||||||
|
v1: {
|
||||||
|
image: {
|
||||||
|
create: options.imageCreate ?? vi.fn(),
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
create: vi.fn(),
|
||||||
|
reply: vi.fn(),
|
||||||
|
patch: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as FeishuRuntime["client"],
|
||||||
|
logger: {
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
child: vi.fn(),
|
||||||
|
fatal: vi.fn(),
|
||||||
|
trace: vi.fn(),
|
||||||
|
silent: vi.fn(),
|
||||||
|
level: "info",
|
||||||
|
} as unknown as FeishuRuntime["logger"],
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user