chore: initial commit

Laimiao agent v2 service: agent orchestration, sessions, billing,
gateway client, skills catalog, auth, config, plus tests.
This commit is contained in:
light committed 2026-11-07 10:40:07 +08:00
commit 6ec9778d45
53 files changed
+12340

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
node_modules
dist
data
.env
.env.*
!.env.example
.git
.gitignore
tests
*.log
.vscode
.idea
+53
View File
@@ -0,0 +1,53 @@
NODE_ENV=development
HOST=0.0.0.0
PORT=8100
CORS_ORIGIN=*
APP_ROOT=
DATA_DIR=data
LOG_LEVEL=info
FILE_FETCH_ALLOWED_HOSTS=
BILLING_MODE=live
BILLING_CURRENCY=credits
DEFAULT_SKILL_PRICE=10
DEFAULT_SKILL_MODEL=gpt-image-2
DEFAULT_SKILL_SIZE=1024x1024
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_USER=root
MYSQL_PASSWORD=
MYSQL_DATABASE=laimiaodb
MYSQL_CONNECTION_LIMIT=10
MYSQL_CONNECT_TIMEOUT_MS=10000
GATEWAY_CN_URL=https://cn.gateway.laimiaoai.com
GATEWAY_GLOBAL_URL=https://gateway.laimiaoai.com
GATEWAY_FORCE_URL=
GATEWAY_MOCK=false
GATEWAY_REQUEST_TIMEOUT_MS=180000
GATEWAY_POLL_INTERVAL_MS=2000
GATEWAY_POLL_TIMEOUT_MS=900000
LAIMIAO_LLM_PROVIDER=laimiao
LAIMIAO_LLM_BASE_URL=
LAIMIAO_LLM_API_KEY=
LAIMIAO_LLM_MODEL=deepseek-chat
LAIMIAO_LLM_CONTEXT_WINDOW=131072
LAIMIAO_LLM_MAX_TOKENS=8192
LAIMIAO_LLM_REASONING=false
LAIMIAO_LLM_THINKING=low
JWT_KEY=
JWT_ALGORITHM=HS256
# 工具确认(通过 SSE 返回前端)
TOOL_CONFIRMATION_ENABLED=true
TOOL_CONFIRMATION_TOOLS=generate_image
TOOL_CONFIRMATION_TIMEOUT_MS=300000
# NODE_ENV=production 时启动会强制校验:
# JWT_KEY 必须非空,否则拒绝启动
# CORS_ORIGIN 不能是 *,必须是逗号分隔的显式来源(如 https://app.example.com)
# FILE_FETCH_ALLOWED_HOSTS 必须非空(文档/参考图只允许从这些域名下载)
# FILE_FETCH_ALLOWED_HOSTS 示例:cdn.example.com,*.oss.example.com
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
data/
.env
*.log
+1
View File
@@ -0,0 +1 @@
24
+29
View File
@@ -0,0 +1,29 @@
# syntax=docker/dockerfile:1
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
COPY tsconfig.json tsconfig.build.json ./
COPY src ./src
RUN npm run build
FROM node:22-alpine AS runtime
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=8100 \
DATA_DIR=/app/data \
NPM_CONFIG_UPDATE_NOTIFIER=false
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
RUN mkdir -p /app/data && chown -R node:node /app
USER node
EXPOSE 8100
CMD ["node", "dist/index.js"]
+39
View File
@@ -0,0 +1,39 @@
services:
agent:
build: .
image: laimiao-agent:latest
init: true
restart: unless-stopped
env_file:
- .env
environment:
NODE_ENV: production
HOST: 0.0.0.0
PORT: "8100"
DATA_DIR: /app/data
ports:
- "127.0.0.1:8100:8100"
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,nodev,size=64m
volumes:
- agent-data:/app/data
user: "1000:1000"
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
pids_limit: 256
healthcheck:
test:
- CMD
- node
- -e
- "fetch('http://127.0.0.1:8100/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
volumes:
agent-data:
+4486
View File
File diff suppressed because it is too large. Load diff
+38
View File
@@ -0,0 +1,38 @@
{
"name": "laimiao-skill-agent",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "来秒AI 技能付费 Agent:pi agent SDK + skill catalog + per-image billing",
"engines": {
"node": ">=22.19.0"
},
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"build": "rm -rf dist && tsc -p tsconfig.build.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@earendil-works/pi-ai": "^1.4.0",
"@earendil-works/pi-coding-agent": "^1.4.0",
"@fastify/cors": "^11.0.1",
"dotenv": "^17.2.3",
"fastify": "^5.6.1",
"mammoth": "^1.13.0",
"mysql2": "^3.24.6",
"pdfjs-dist": "^5.6.205",
"typebox": "^1.1.24",
"unpdf": "^1.7.0",
"word-extractor": "^1.0.4",
"zod": "^4.1.12"
},
"devDependencies": {
"@types/node": "^24.10.1",
"tsx": "^4.20.6",
"typescript": "^5.9.3",
"vitest": "^3.2.4"
}
}
+45
View File
@@ -0,0 +1,45 @@
import "dotenv/config";
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { loadConfig } from "../src/config.js";
import { createLogger } from "../src/logger.js";
import { MySqlSessionContextStore } from "../src/sessions/context_store.js";
const config = loadConfig();
const logger = createLogger(config.logLevel);
const dryRun = process.argv.includes("--dry-run");
async function main(): Promise<void> {
const store = new MySqlSessionContextStore(config.mysql, logger);
await store.init();
let migrated = 0;
let skipped = 0;
if (!existsSync(config.piSessionsDir)) {
console.log("no pi session dir, nothing to migrate");
await store.close();
return;
}
for (const file of readdirSync(config.piSessionsDir)) {
if (!file.endsWith(".jsonl")) continue;
const match = file.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/);
const sessionId = match?.[1] ?? file.replace(/^.*?_/, "").replace(/\.jsonl$/, "");
if (!sessionId) {
skipped += 1;
continue;
}
const content = readFileSync(join(config.piSessionsDir, file));
if (!dryRun) await store.save(sessionId, content);
migrated += 1;
}
console.log(`${dryRun ? "[dry-run] " : ""}migrated contexts=${migrated} skipped=${skipped}`);
await store.close();
}
main().catch((error) => {
console.error("context migration failed:", error);
process.exit(1);
});
+73
View File
@@ -0,0 +1,73 @@
import "dotenv/config";
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { loadConfig } from "../src/config.js";
import { createLogger } from "../src/logger.js";
import { MySqlSessionStore } from "../src/sessions/mysql_store.js";
import type { SessionMessage, SessionMeta } from "../src/sessions/store.js";
interface SessionIndexFile {
sessions?: Record<string, SessionMeta>;
}
const config = loadConfig();
const logger = createLogger(config.logLevel);
const dryRun = process.argv.includes("--dry-run");
async function main(): Promise<void> {
const store = new MySqlSessionStore(config.mysql, logger);
await store.init();
let sessionCount = 0;
let messageCount = 0;
let skipped = 0;
if (existsSync(config.sessionsFile)) {
const parsed = JSON.parse(readFileSync(config.sessionsFile, "utf8")) as SessionIndexFile;
const sessions = Object.values(parsed.sessions ?? {});
for (const meta of sessions) {
if (!meta.session_id) {
skipped += 1;
continue;
}
if (!dryRun) await store.upsertSession(meta);
sessionCount += 1;
}
}
if (existsSync(config.messagesDir)) {
for (const file of readdirSync(config.messagesDir)) {
if (!file.endsWith(".jsonl")) continue;
const sessionId = file.slice(0, -".jsonl".length);
const lines = readFileSync(join(config.messagesDir, file), "utf8").split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
let message: SessionMessage;
try {
message = JSON.parse(trimmed) as SessionMessage;
} catch {
skipped += 1;
continue;
}
if (!message.id || !message.session_id) {
message.id = message.id || `${sessionId}-${messageCount}`;
message.session_id = message.session_id || sessionId;
}
if (!dryRun) await store.insertMessage(message);
messageCount += 1;
}
}
}
console.log(
`${dryRun ? "[dry-run] " : ""}migrated sessions=${sessionCount} messages=${messageCount} skipped=${skipped}`,
);
await store.close();
}
main().catch((error) => {
console.error("migration failed:", error);
process.exit(1);
});
+321
View File
@@ -0,0 +1,321 @@
import { defineTool } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import type { Logger } from "../logger.js";
import type { SkillCatalog } from "../skills/catalog.js";
import type { ToolConfirmationManager } from "./confirmations.js";
import type { AgentStreamEvent } from "./service.js";
interface ConfirmToolDeps {
catalog: SkillCatalog;
confirmations: ToolConfirmationManager;
sessionId: string;
logger: Logger;
emit: (event: AgentStreamEvent) => void;
hasStream: boolean;
onTravelStyleConfirmed?: (style: string) => void;
}
interface TravelStyleOption {
id: string;
label: string;
desc: string;
}
interface UserConfirmOption {
id: string;
label: string;
desc?: string;
}
const DEFAULT_TRAVEL_STYLES: TravelStyleOption[] = [
{ id: "travel-general", label: "通用版", desc: "多景点有机融合主视觉,适用于绝大多数线路" },
{ id: "travel-simple", label: "简约版", desc: "单一核心主形象、大量留白、清爽高级感" },
{ id: "travel-illustration", label: "插画版", desc: "插画化主视觉,艺术化表达旅行,目的地识别不丢失" },
];
type ConfirmResult = {
content: Array<{ type: "text"; text: string }>;
details: { ok: boolean; approved?: boolean; content?: string; error?: string };
isError?: boolean;
};
export function createConfirmTools(deps: ConfirmToolDeps) {
const confirmContent = defineTool({
name: "confirm_travel_content",
label: "确认行程内容",
description:
"向用户确认旅行线路摘要内容,等待用户确认或补充后返回最终结果。在 read_document 读取文档并整理出线路摘要后必须调用本工具:它会暂停流程,把摘要呈现给用户确认。用户可确认、修正或补充内容;工具返回用户确认结果后才可继续后续步骤。参数 summary 为线路摘要文本。返回 JSON: {approved, content},content 为用户最终确认/修改后的内容。",
parameters: Type.Object({
summary: Type.String({ description: "线路摘要文本,可直接把 read_document 返回的摘要原样传入" }),
}),
execute: async (
toolCallId: string,
params: { summary: string },
signal: AbortSignal | undefined,
): Promise<ConfirmResult> => {
if (!deps.hasStream) {
return errorResult("该工具需要以 stream:true 调用才能等待用户确认。");
}
const summary = String(params.summary || "").trim();
if (!summary) {
return errorResult("summary 不能为空。");
}
const decision = await deps.confirmations.request(
{
sessionId: deps.sessionId,
toolCallId,
toolName: "confirm_travel_content",
confirmType: "travel_content_confirm",
payload: { summary },
args: { summary },
},
(request) => {
deps.emit({
type: "content_confirmation_required",
confirmation_id: request.toolCallId,
session_id: request.sessionId,
tool_call_id: request.toolCallId,
confirm_type: "travel_content_confirm",
payload: request.payload,
expires_at: request.expiresAt,
});
},
signal,
);
const content = typeof decision.content === "string" ? decision.content.trim() : "";
const approved = decision.approved;
deps.emit({
type: "content_confirmation_resolved",
confirmation_id: toolCallId,
tool_call_id: toolCallId,
confirm_type: "travel_content_confirm",
approved,
content,
reason: decision.reason,
});
deps.logger.info(
{ sessionId: deps.sessionId, toolCallId, approved, contentChars: content.length },
"travel content confirmation resolved",
);
return jsonResult({ approved, content: approved ? content : "" });
},
});
const confirmStyle = defineTool({
name: "confirm_travel_style",
label: "选择旅游风格",
description:
"向用户列出当前可用的旅游风格/技能,等待用户选择后返回所选风格 id。内容确认后调用本工具会暂停流程,把技能库中全部可用旅游风格展示给用户。用户选定后必须使用返回的 content 作为 generate_image 的 skill_id,并先调用 read_skill 读取该风格规范。返回 JSON: {approved, content},content 为选中的风格 id。",
parameters: Type.Object({}),
execute: async (
toolCallId: string,
_params: Record<string, never>,
signal: AbortSignal | undefined,
): Promise<ConfirmResult> => {
if (!deps.hasStream) {
return errorResult("该工具需要以 stream:true 调用才能等待用户选择。");
}
const styles = loadTravelStyles(deps.catalog);
const decision = await deps.confirmations.request(
{
sessionId: deps.sessionId,
toolCallId,
toolName: "confirm_travel_style",
confirmType: "travel_style_confirm",
payload: { styles },
args: { styles },
},
(request) => {
deps.emit({
type: "content_confirmation_required",
confirmation_id: request.toolCallId,
session_id: request.sessionId,
tool_call_id: request.toolCallId,
confirm_type: "travel_style_confirm",
payload: request.payload,
expires_at: request.expiresAt,
});
},
signal,
);
const requested = typeof decision.content === "string" ? decision.content.trim() : "";
const selected = styles.find((style) => style.id === requested);
const approved = decision.approved && Boolean(selected);
const content = selected?.id ?? "";
deps.emit({
type: "content_confirmation_resolved",
confirmation_id: toolCallId,
tool_call_id: toolCallId,
confirm_type: "travel_style_confirm",
approved,
content,
reason: decision.reason,
});
if (approved && content) {
deps.onTravelStyleConfirmed?.(content);
}
deps.logger.info(
{ sessionId: deps.sessionId, toolCallId, approved, style: content },
"travel style confirmation resolved",
);
return jsonResult({ approved, content });
},
});
const confirmUser = defineTool({
name: "confirm_user",
label: "请求用户确认",
description:
"当技能缺少必填资料、需要用户确认关键信息、补充/修改文本或从选项中选择时调用。该工具会暂停流程,把内容展示给用户,等待用户确认、编辑或选择后返回。不要用文字直接提问后假设用户已回复。支持三种模式:confirm(确认/取消)、edit(编辑文本后提交)、select(从选项中选择);提供 options 时自动按 select 处理,edit 模式不要提供 options;edit 模式下把已知内容放入 default_value,缺失项只保留字段名并留空(例如「活动时间:」),不要写「请补充」「待填写」等需要用户删除后再输入的占位文字,也不得编造价格、团期、资质等事实信息。返回 JSON: {approved, content};confirm 模式 content 为空,edit 模式 content 为用户提交的文本,select 模式 content 为选中的选项 id。",
parameters: Type.Object({
message: Type.String({ description: "展示给用户的确认内容或问题,必须使用简体中文" }),
title: Type.Optional(Type.String({ description: "卡片标题,默认“请确认”,必须使用简体中文" })),
mode: Type.Optional(
Type.Union([Type.Literal("confirm"), Type.Literal("edit"), Type.Literal("select")], {
description: "confirm=确认/取消,edit=编辑文本,select=选择选项;提供 options 时自动使用 select",
}),
),
options: Type.Optional(
Type.Array(
Type.Union([
Type.String(),
Type.Object({
id: Type.String(),
label: Type.String(),
desc: Type.Optional(Type.String()),
}),
]),
{ description: "select 模式下的选项,支持字符串或 {id,label,desc};label/desc 必须使用简体中文" },
),
),
default_value: Type.Optional(Type.String({ description: "edit 模式下的默认文本" })),
}),
execute: async (
toolCallId: string,
params: {
message: string;
title?: string;
mode?: "confirm" | "edit" | "select";
options?: Array<string | { id: string; label: string; desc?: string }>;
default_value?: string;
},
signal: AbortSignal | undefined,
): Promise<ConfirmResult> => {
if (!deps.hasStream) {
return errorResult("该工具需要以 stream:true 调用才能等待用户确认。");
}
const message = String(params.message || "").trim();
if (!message) {
return errorResult("message 不能为空。");
}
const options = normalizeUserOptions(params.options);
const mode = options.length > 0 ? "select" : params.mode || "confirm";
if (mode === "select" && options.length === 0) {
return errorResult("select 模式需要提供 options。");
}
const payload = {
title: (params.title || "").trim() || "请确认",
message,
mode,
options,
default_value: params.default_value ?? "",
};
const decision = await deps.confirmations.request(
{
sessionId: deps.sessionId,
toolCallId,
toolName: "confirm_user",
confirmType: "user_confirm",
payload,
args: payload,
},
(request) => {
deps.emit({
type: "content_confirmation_required",
confirmation_id: request.toolCallId,
session_id: request.sessionId,
tool_call_id: request.toolCallId,
confirm_type: "user_confirm",
payload: request.payload,
expires_at: request.expiresAt,
});
},
signal,
);
let approved = decision.approved;
let content = typeof decision.content === "string" ? decision.content.trim() : "";
if (mode === "confirm") {
content = "";
} else if (mode === "select") {
if (!options.some((option) => option.id === content)) {
approved = false;
content = "";
}
}
deps.emit({
type: "content_confirmation_resolved",
confirmation_id: toolCallId,
tool_call_id: toolCallId,
confirm_type: "user_confirm",
approved,
content,
reason: decision.reason,
});
deps.logger.info(
{ sessionId: deps.sessionId, toolCallId, approved, mode, contentChars: content.length },
"user confirmation resolved",
);
return jsonResult({ approved, content });
},
});
return [confirmContent, confirmStyle, confirmUser];
}
function normalizeUserOptions(value: unknown): UserConfirmOption[] {
if (!Array.isArray(value)) return [];
const out: UserConfirmOption[] = [];
for (const item of value) {
if (typeof item === "string") {
const id = item.trim();
if (id) out.push({ id, label: id });
continue;
}
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const record = item as Record<string, unknown>;
const id = typeof record.id === "string" ? record.id.trim() : "";
if (!id) continue;
const label = typeof record.label === "string" && record.label.trim() ? record.label.trim() : id;
const desc = typeof record.desc === "string" ? record.desc.trim() : "";
out.push(desc ? { id, label, desc } : { id, label });
}
return out;
}
function loadTravelStyles(catalog: SkillCatalog): TravelStyleOption[] {
const styles = catalog
.list()
.filter((skill) => skill.manifest.industry === "旅游" && skill.manifest.enabled)
.map((skill) => ({
id: skill.key,
label: skill.displayName,
desc: skill.description,
}));
return styles.length > 0 ? styles : DEFAULT_TRAVEL_STYLES;
}
function jsonResult(value: { approved: boolean; content: string }): ConfirmResult {
return {
content: [{ type: "text", text: JSON.stringify(value) }],
details: { ok: true, approved: value.approved, content: value.content },
};
}
function errorResult(text: string): ConfirmResult {
return {
content: [{ type: "text", text }],
details: { ok: false, error: text },
isError: true,
};
}
+107
View File
@@ -0,0 +1,107 @@
import type { Quote } from "../types.js";
export interface ToolConfirmationRequest {
sessionId: string;
toolCallId: string;
toolName: string;
args: unknown;
quote?: Quote;
confirmType?: string;
payload?: unknown;
expiresAt: number;
}
export interface ToolConfirmationDecision {
approved: boolean;
content?: string;
reason?: string;
}
interface PendingConfirmation {
resolve: (decision: ToolConfirmationDecision) => void;
timer: NodeJS.Timeout;
cleanup: () => void;
}
function keyOf(sessionId: string, toolCallId: string): string {
return `${sessionId}:${toolCallId}`;
}
export class ToolConfirmationManager {
private readonly pending = new Map<string, PendingConfirmation>();
constructor(private readonly timeoutMs: number) {}
request(
input: Omit<ToolConfirmationRequest, "expiresAt">,
onRequest: (request: ToolConfirmationRequest) => void,
signal?: AbortSignal,
): Promise<ToolConfirmationDecision> {
if (signal?.aborted) {
return Promise.resolve({ approved: false, reason: "请求已中止" });
}
const key = keyOf(input.sessionId, input.toolCallId);
if (this.pending.has(key)) {
return Promise.resolve({ approved: false, reason: "该工具调用已在等待确认" });
}
const request: ToolConfirmationRequest = {
...input,
expiresAt: Date.now() + this.timeoutMs,
};
return new Promise((resolve) => {
let settled = false;
const finish = (decision: ToolConfirmationDecision): void => {
if (settled) return;
settled = true;
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
this.pending.delete(key);
resolve(decision);
};
const onAbort = (): void => finish({ approved: false, reason: "请求已中止" });
const timer = setTimeout(
() => finish({ approved: false, reason: "等待用户确认超时" }),
this.timeoutMs,
);
timer.unref?.();
this.pending.set(key, {
resolve: finish,
timer,
cleanup: () => finish({ approved: false, reason: "会话已结束" }),
});
signal?.addEventListener("abort", onAbort, { once: true });
try {
onRequest(request);
} catch (error) {
finish({ approved: false, reason: error instanceof Error ? error.message : String(error) });
}
});
}
resolve(
sessionId: string,
toolCallId: string,
decision: boolean | ToolConfirmationDecision,
reason?: string,
): boolean {
const pending = this.pending.get(keyOf(sessionId, toolCallId));
if (!pending) return false;
pending.resolve(typeof decision === "boolean" ? { approved: decision, reason } : decision);
return true;
}
clearSession(sessionId: string): void {
const prefix = `${sessionId}:`;
for (const [key, pending] of this.pending) {
if (key.startsWith(prefix)) pending.cleanup();
}
}
count(): number {
return this.pending.size;
}
}
+525
View File
@@ -0,0 +1,525 @@
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { defineTool } from "@earendil-works/pi-coding-agent";
import mammoth from "mammoth";
import { Type } from "typebox";
import { definePDFJSModule, extractText, getDocumentProxy } from "unpdf";
import type { AppConfig } from "../config.js";
import type { Logger } from "../logger.js";
import { safeFetch } from "../utils/net.js";
const require = createRequire(import.meta.url);
const WordExtractor = require("word-extractor") as new () => {
extract(input: Buffer): Promise<{ getBody(): string }>;
};
const PDFJS_ROOT = dirname(require.resolve("pdfjs-dist/package.json"));
const PDFJS_CMAPS = `${join(PDFJS_ROOT, "cmaps")}/`;
const PDFJS_STANDARD_FONTS = `${join(PDFJS_ROOT, "standard_fonts")}/`;
let pdfjsReady: Promise<void> | null = null;
function ensurePdfjs(): Promise<void> {
pdfjsReady ??= definePDFJSModule(async () => import("pdfjs-dist/legacy/build/pdf.mjs") as never);
return pdfjsReady;
}
const MAX_DOCUMENT_BYTES = 30 * 1024 * 1024;
const MAX_TEXT_CHARS = 60_000;
const MAX_OUTPUT_CHARS = 8_000;
const DOC_EXTS = new Set(["doc", "docx", "pdf"]);
const TEXT_EXTS = new Set(["txt", "md", "csv", "json", "html", "htm", "xml", "log", "yaml", "yml"]);
const IMAGE_EXTS = new Set(["jpg", "jpeg", "png", "webp", "gif", "bmp", "heic", "heif"]);
const OCR_SYSTEM_PROMPT = "你是一个文档 OCR 助手。请提取图片中的所有文字内容,逐行输出,保留原始顺序与排版,不要遗漏数字、价格、日期,不要添加解释。";
const OCR_USER_PROMPT = "请提取这张图片中的全部文字:";
const ELEMENTS_PROMPT = `你是一位旅游海报策划专家。请从下面提供的旅游行程文档文本中,
全面、充分提炼出制作旅游海报所需的关键要素,尽量多提取文档中的有效信息,并以 JSON 对象输出。
要求输出的字段:
1. title: 行程标题(如"山西+陕西双飞7日游")
2. subtitle: 副标题(提炼天数/交通/目的地定位的一句话,如"双飞直抵 · 7天6晚 · 漫游晋陕";无则 null)
3. departure: 出发地(如"深圳";未标注填 null)
4. destination: 目的地(如"山西、陕西")
5. transport: 出行方式,从以下类别中选择一个:"飞机"/"高铁"/"火车"/"巴士"
(如文档未注明交通方式,填 null,不要自行猜测)
6. duration: 游玩天数(如"7天6晚")
7. dates: 出发团期列表(array of strings,如 ["04-12", "04-26"];未标注填空数组)
8. price: 参考价格对象,包含字段:
- adult: 成人价(如 "4999")
- older_child: 大童价
- younger_child: 小童价
- elder: 长者价
文档未出现的价格档位对应字段填 null
9. attractions: 游玩景点列表(array of strings,尽量完整,含沿途小景点)
10. scenic_features: 核心景点特色列表(array of objects,取 3-6 个重点景点),每项字段:
- name: 景点名称
- feature: 该景点一句话特色/看点(如"壶口瀑布——黄河一壶收的磅礴瀑布")
11. highlights: 行程亮点/特色列表(array of strings,提炼 3-6 个,
如"壶口瀑布观涛""夜游大唐不夜城")
12. itinerary: 每日行程列表(array of objects),每项字段:
- day: 第几天(如 "D1")
- route: 当天核心路线(如"深圳→运城→关帝庙")
- meals: 当天餐饮安排(如"含中晚餐";无则 "")
- hotel: 当晚住宿(如"运城四钻酒店";无则 "")
文档没有每日行程时填空数组
13. hotel: 住宿标准(如"全程精选四钻酒店";未标注填 null)
14. meals: 餐饮标准(如"含5早9正,升级特色餐";未标注填 null)
15. badges: 服务标签列表(array of strings,如 ["纯玩无购物", "品质出行"])
16. included: 费用包含列表(array of strings,如 ["往返大交通", "行程住宿", "门票"])
17. excluded: 费用不含/自费项目列表(array of strings,如 ["景区内小交通", "个人消费"];无则空数组)
18. gifts: 赠送项目列表(array of strings,如 ["旅游意外险", "当地特产伴手礼"];无则空数组)
19. experience: 特色体验/活动列表(array of strings,如 ["观赏《黄河之水天上来》演出",
"亲子互动体验"];无则空数组)
20. recommendation: 推荐理由/适合人群(一句话,如"亲子出游、长者康养、摄影爱好者的
首选线路";无则 null)
21. slogan: 特色需求标语(基于行程实际卖点提炼的营销短句,如"双飞直抵 · 不虚此行")
注意:只提取文档中实际出现的信息,未出现的内容对应字段填 null 或空数组,
不要编造。请严格按 JSON 对象输出,不要输出任何额外文字或 Markdown 代码块标记。
行程文档文本如下:`;
interface DocumentToolDeps {
config: AppConfig;
logger: Logger;
}
interface DownloadedFile {
buffer: Buffer;
contentType: string;
}
export function createDocumentTools(deps: DocumentToolDeps) {
const readDocument = defineTool({
name: "read_document",
label: "读取旅游文档",
description:
"读取用户上传的行程文档,返回由线路要素拼成的摘要段落(纯文本,可直接修改)。当用户上传了旅行线路文件(doc/docx/Word、pdf、txt、图片文档)作为海报素材,需要了解线路信息时使用。返回一段用户友好的中文摘要(线路/天数/出发地目的地/出行/团期/价格/景点/标语),可直接呈现给用户确认并允许直接修改。",
parameters: Type.Object({
file_url: Type.String({ description: "用户上传文件的 URL(CDN 地址)" }),
}),
execute: async (
_toolCallId: string,
params: { file_url: string },
): Promise<{
content: Array<{ type: "text"; text: string }>;
details: { ok: boolean; kind?: string; chars?: number; error?: string };
isError?: boolean;
}> => {
const fileUrl = String(params.file_url || "").trim();
if (!/^https?:\/\//i.test(fileUrl)) {
return errorResult("file_url 必须是 http/https 地址");
}
try {
const loaded = await loadDocumentText(fileUrl, deps.config);
if (loaded.kind === "doc" || loaded.kind === "docx" || loaded.kind === "pdf") {
if (!loaded.text.trim()) {
return errorResult("未从文档中提取到文本内容。");
}
const summary = await summarizeTravelDocument(loaded.text, deps.config, deps.logger);
const output = (summary || loaded.text).slice(0, MAX_OUTPUT_CHARS);
return okResult(output, loaded.kind, output.length);
}
if (loaded.kind === "txt" || loaded.kind === "image") {
const text = loaded.text.slice(0, MAX_OUTPUT_CHARS).trim();
return okResult(text || "未从文档中提取到文本内容。", loaded.kind, text.length);
}
return errorResult(`暂不支持该文件类型(${loaded.kind || "未知"})。支持: doc/docx/pdf/txt/md/csv/json/html/xml/图片。`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
deps.logger.warn({ error: message, fileUrl }, "read_document failed");
return errorResult(`文档读取失败: ${message}`);
}
},
});
const readDocumentText = defineTool({
name: "read_document_text",
label: "读取文档文本",
description:
"读取用户上传的远程文档并返回提取后的纯文本,不做行业化总结。支持 doc/docx/pdf/txt/md/csv/json/html/xml 及图片 OCR。适合阅读合同、资料、说明书等通用文档;旅游行程文档请优先使用 read_document。长文档可通过 offset/limit 分页读取。",
parameters: Type.Object({
file_url: Type.String({ description: "用户上传文件的 URL(CDN 地址)" }),
offset: Type.Optional(Type.Integer({ minimum: 0, description: "从第几个字符开始读取,默认 0" })),
limit: Type.Optional(
Type.Integer({ minimum: 1, maximum: 20000, description: "本次最多返回多少字符,默认 8000" }),
),
}),
execute: async (
_toolCallId: string,
params: { file_url: string; offset?: number; limit?: number },
): Promise<{
content: Array<{ type: "text"; text: string }>;
details: {
ok: boolean;
kind?: string;
total_chars?: number;
offset?: number;
limit?: number;
has_more?: boolean;
error?: string;
};
isError?: boolean;
}> => {
const fileUrl = String(params.file_url || "").trim();
if (!/^https?:\/\//i.test(fileUrl)) {
return errorResult("file_url 必须是 http/https 地址");
}
try {
const loaded = await loadDocumentText(fileUrl, deps.config);
const fullText = loaded.text.trim();
if (!fullText) {
return errorResult("未从文档中提取到文本内容。");
}
const offset = Math.max(0, Math.floor(Number(params.offset) || 0));
const limit = Math.min(Math.max(1, Math.floor(Number(params.limit) || 8000)), 20000);
if (offset >= fullText.length) {
return errorResult(`offset ${offset} 已超过文档长度(${fullText.length} 字符)。`);
}
const chunk = fullText.slice(offset, offset + limit);
const end = offset + chunk.length;
const hasMore = end < fullText.length;
let output = chunk;
if (hasMore) {
output += `\n\n[文档共 ${fullText.length} 字符,当前显示 ${offset + 1}-${end}。继续阅读请传 offset=${end}]`;
}
return {
content: [{ type: "text" as const, text: output }],
details: {
ok: true,
kind: loaded.kind,
total_chars: fullText.length,
offset,
limit,
has_more: hasMore,
},
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
deps.logger.warn({ error: message, fileUrl }, "read_document_text failed");
return errorResult(`文档读取失败: ${message}`);
}
},
});
return [readDocument, readDocumentText];
}
interface LoadedDocument {
kind: string;
text: string;
}
async function loadDocumentText(fileUrl: string, config: AppConfig): Promise<LoadedDocument> {
const downloaded = await downloadFile(fileUrl, config);
const kind = resolveKind(fileUrl, downloaded.contentType);
if (kind === "doc" || kind === "docx" || kind === "pdf") {
return { kind, text: await extractDocumentText(downloaded.buffer, kind) };
}
if (kind === "txt") {
return { kind, text: downloaded.buffer.toString("utf8") };
}
if (kind === "image") {
return { kind, text: await ocrImage(downloaded, config) };
}
throw new Error(`暂不支持该文件类型(${kind || "未知"})。支持: doc/docx/pdf/txt/md/csv/json/html/xml/图片。`);
}
function okResult(text: string, kind: string, chars: number) {
return {
content: [{ type: "text" as const, text }],
details: { ok: true, kind, chars },
};
}
function errorResult(text: string) {
return {
content: [{ type: "text" as const, text }],
details: { ok: false, error: text },
isError: true,
};
}
async function downloadFile(url: string, config: AppConfig): Promise<DownloadedFile> {
const response = await safeFetch(
url,
{
signal: AbortSignal.timeout(120_000),
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
},
},
{ allowedHosts: config.fileFetchAllowedHosts },
);
if (!response.ok) {
throw new Error(`文件下载失败 HTTP ${response.status}`);
}
const declaredBytes = Number(response.headers.get("content-length") ?? "0");
if (Number.isFinite(declaredBytes) && declaredBytes > MAX_DOCUMENT_BYTES) {
throw new Error("文件过大(超过 30MB)");
}
const contentType = ((response.headers.get("content-type") || "").split(";")[0] ?? "").trim().toLowerCase();
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.byteLength === 0) {
throw new Error("文件内容为空");
}
if (buffer.byteLength > MAX_DOCUMENT_BYTES) {
throw new Error("文件过大(超过 30MB)");
}
return { buffer, contentType };
}
function resolveKind(url: string, contentType: string): string {
const suffix = suffixOf(url);
if (DOC_EXTS.has(suffix)) return suffix;
if (TEXT_EXTS.has(suffix)) return "txt";
if (IMAGE_EXTS.has(suffix)) return "image";
if (contentType === "application/pdf") return "pdf";
if (contentType === "application/msword") return "doc";
if (contentType.includes("wordprocessingml.document")) return "docx";
if (contentType.startsWith("text/")) return "txt";
if (contentType.startsWith("image/")) return "image";
return suffix;
}
function suffixOf(url: string): string {
try {
const pathname = new URL(url).pathname;
const index = pathname.lastIndexOf(".");
return index >= 0 ? pathname.slice(index + 1).toLowerCase() : "";
} catch {
return "";
}
}
async function extractDocumentText(buffer: Buffer, kind: string): Promise<string> {
if (kind === "docx") {
try {
const result = await mammoth.extractRawText({ buffer });
return normalizeLines(result.value);
} catch (error) {
return normalizeLines(await extractWithWordExtractor(buffer));
}
}
if (kind === "doc") {
return normalizeLines(await extractWithWordExtractor(buffer));
}
if (kind === "pdf") {
await ensurePdfjs();
const pdf = await getDocumentProxy(new Uint8Array(buffer), {
cMapUrl: PDFJS_CMAPS,
cMapPacked: true,
standardFontDataUrl: PDFJS_STANDARD_FONTS,
});
const result = await extractText(pdf, { mergePages: true });
return normalizeLines(Array.isArray(result.text) ? result.text.join("\n") : result.text);
}
return normalizeLines(buffer.toString("utf8"));
}
async function extractWithWordExtractor(buffer: Buffer): Promise<string> {
const extractor = new WordExtractor();
const document = await extractor.extract(buffer);
return document.getBody();
}
function normalizeLines(text: string): string {
return text
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.join("\n");
}
async function summarizeTravelDocument(text: string, config: AppConfig, logger: Logger): Promise<string> {
const source = text.slice(0, MAX_TEXT_CHARS);
try {
const content = await callChat(config, {
temperature: 0.1,
response_format: { type: "json_object" },
messages: [
{ role: "system", content: "你是一个严谨的结构化信息提取器,只输出合法 JSON。" },
{ role: "user", content: `${ELEMENTS_PROMPT}\n---\n${source}\n---` },
],
});
const elements = parseJsonObject(content);
if (!elements) return "";
return friendlySummary(elements);
} catch (error) {
logger.warn({ error }, "travel document element extraction failed");
return "";
}
}
async function ocrImage(downloaded: DownloadedFile, config: AppConfig): Promise<string> {
const mimeType = downloaded.contentType.startsWith("image/") ? downloaded.contentType : "image/png";
const dataUrl = `data:${mimeType};base64,${downloaded.buffer.toString("base64")}`;
return callChat(config, {
temperature: 0.1,
messages: [
{ role: "system", content: OCR_SYSTEM_PROMPT },
{
role: "user",
content: [
{ type: "text", text: OCR_USER_PROMPT },
{ type: "image_url", image_url: { url: dataUrl } },
],
},
],
});
}
async function callChat(config: AppConfig, body: Record<string, unknown>): Promise<string> {
const baseUrl = config.llm.baseUrl.replace(/\/+$/, "");
const response = await fetch(`${baseUrl}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.llm.apiKey}`,
},
body: JSON.stringify({ model: config.llm.modelId, ...body }),
signal: AbortSignal.timeout(120_000),
});
if (!response.ok) {
const detail = await response.text().catch(() => "");
throw new Error(`模型调用失败 HTTP ${response.status}${detail ? `: ${detail.slice(0, 300)}` : ""}`);
}
const data = (await response.json()) as {
choices?: Array<{ message?: { content?: string | null } }>;
};
const content = data.choices?.[0]?.message?.content;
if (!content || !content.trim()) {
throw new Error("模型返回为空");
}
return content;
}
function parseJsonObject(content: string): Record<string, unknown> | null {
const cleaned = content
.trim()
.replace(/^```(?:json)?\s*/i, "")
.replace(/\s*```$/, "");
try {
const parsed = JSON.parse(cleaned) as unknown;
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null;
} catch {
return null;
}
}
function friendlySummary(elements: Record<string, unknown>): string {
const lines: string[] = [];
const title = asText(elements.title);
if (title) lines.push(`线路:${title}`);
const departure = asText(elements.departure);
const destination = asText(elements.destination);
const duration = asText(elements.duration);
const transport = asText(elements.transport);
const segments: string[] = [];
if (departure) segments.push(`从${departure}出发`);
if (destination) segments.push(`前往${destination}`);
if (duration) segments.push(duration);
if (transport) segments.push(`${transport}往返`);
if (segments.length) lines.push(segments.join(","));
const dates = asTextList(elements.dates);
if (dates.length) lines.push(`团期:${dates.join("、")}`);
const price = asRecord(elements.price);
const priceParts: string[] = [];
for (const [key, label] of [
["adult", "成人"],
["older_child", "大童"],
["younger_child", "小童"],
["elder", "长者"],
] as const) {
const value = asText(price[key]);
if (value) priceParts.push(`${label} ${value}元/人`);
}
if (priceParts.length) lines.push(`价格:${priceParts.join(",")}`);
const attractions = asTextList(elements.attractions);
if (attractions.length) lines.push(`主要景点:${attractions.join("、")}`);
const scenicFeatures = asTextList(elements.scenic_features);
if (scenicFeatures.length) lines.push(`景点特色:${scenicFeatures.join(";")}`);
const subtitle = asText(elements.subtitle);
if (subtitle) lines.push(`副标题:${subtitle}`);
const highlights = asTextList(elements.highlights);
if (highlights.length) lines.push(`行程亮点:${highlights.join("、")}`);
const itinerary = Array.isArray(elements.itinerary) ? elements.itinerary : [];
const itineraryParts: string[] = [];
for (const item of itinerary) {
const record = asRecord(item);
const day = asText(record.day);
const route = asText(record.route);
const segment = `${day} ${route}`.trim();
if (segment) itineraryParts.push(segment);
}
if (itineraryParts.length) lines.push(`行程:${itineraryParts.join("|")}`);
const hotel = asText(elements.hotel);
if (hotel) lines.push(`住宿:${hotel}`);
const meals = asText(elements.meals);
if (meals) lines.push(`餐饮:${meals}`);
const badges = asTextList(elements.badges);
if (badges.length) lines.push(`服务:${badges.join("、")}`);
const included = asTextList(elements.included);
if (included.length) lines.push(`费用包含:${included.join("、")}`);
const excluded = asTextList(elements.excluded);
if (excluded.length) lines.push(`费用不含:${excluded.join("、")}`);
const gifts = asTextList(elements.gifts);
if (gifts.length) lines.push(`赠送:${gifts.join("、")}`);
const experience = asTextList(elements.experience);
if (experience.length) lines.push(`特色体验:${experience.join("、")}`);
const recommendation = asText(elements.recommendation);
if (recommendation) lines.push(`推荐:${recommendation}`);
const slogan = asText(elements.slogan);
if (slogan) lines.push(`标语:${slogan}`);
return lines.length ? lines.join("\n") : JSON.stringify(elements);
}
function asText(value: unknown): string {
return value === null || value === undefined ? "" : String(value).trim();
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function asTextList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value
.map((item) => {
if (item && typeof item === "object" && !Array.isArray(item)) {
const record = item as Record<string, unknown>;
const name = asText(record.name);
const feature = asText(record.feature);
if (name && feature) return `${name}——${feature}`;
return name || feature;
}
return asText(item);
})
.filter(Boolean);
}
+128
View File
@@ -0,0 +1,128 @@
import { MAX_IMAGES_PER_REQUEST } from "../skills/params.js";
import type { AppConfig } from "../config.js";
import type { SkillCatalog } from "../skills/catalog.js";
import type { SkillDefinition } from "../types.js";
export interface SystemPrompts {
systemPrompt: string;
appendSystemPrompt: string[];
}
export function buildSystemPrompts(
catalog: SkillCatalog,
config: AppConfig,
skill?: SkillDefinition,
): SystemPrompts {
if (!skill) {
return buildDirectPrompts(config);
}
const systemPrompt = [
"你是「来秒AI」的创作助手,通过技能(Skill)帮助用户生成商业级图片。",
"",
"工作流程:",
"1. 本次请求已经由前端用户选定技能,不要重新选择或切换技能。",
"2. 该技能的完整规范已附在本提示词中,必须严格按照其中的要求补全信息、构造提示词;如需重新读取可调用 read_skill。",
"3. 生成图片必须调用 generate_image,并传入本次选定的 skill_id;不要自己拼接网关请求。",
"4. 张数以技能正文规定为准:正文规定了固定张数(如「固定生成 8 张」)时,必须生成该数量的全部图片,不得拆成多轮或减少数量;正文只规定范围时,用户未说明则取范围下限,用户指定则按用户数量(不超过范围上限)。",
"4.1 生成套图(多张不同内容)时,必须调用 generate_images_batch,参数结构只有两部分:① style = 整套图唯一的总体风格;② images = 按交付顺序的每张内容(图1生成什么、图2生成什么、图3生成什么…)。工具会把每张以「style + 图N:内容」的提示词一次性全部并发提交给生图模型,整体耗时≈最慢的一张;严禁逐张调用 generate_image 等待上一张完成后再提交下一张。只有「同一提示词重复出多张」时才用 generate_image 的 n 参数。",
"4.2 技能正文里的「逐张生成/按顺序生成」只表示交付顺序与编号规则,不代表串行提交:仍然要一次性批量提交,返回后按编号展示。",
"4.3 风格统一:先从技能规范提炼整套图共用的总体风格(画风/摄影风格、色板与主色、光线氛围、质感材质、字体与排版规则、构图与留白),写入 style 只传一次;images 里每张只写该张生成什么(主体、场景、文案、版式差异),不要重复描述风格、色板或字体。整套图必须看起来像同一位设计师的同一套作品。",
"4.4 每张图片可以有独立尺寸:在 images 里按图分别写 size;未写的使用技能默认尺寸。",
"5. 用户提供参考图时,把图片 URL 放入 reference_images;若技能需要参考图但用户没给,先追问。",
"6. 生成后简短说明结果,不重复贴出全部提示词。",
"6.1 套图生成完成后,最终回复必须按提交顺序逐张展示:每张先写「图N:<该张内容/用途>」,紧接着下一行用 Markdown 图片 `![图N](该张图片URL)` 展示对应图片(N 与提交顺序一致)。不得把图片集中堆在末尾,也不要用表格或下载链接代替逐张展示。",
"生成参数(尺寸、张数、水印、最大张数)以技能正文中的规定为准;调用 generate_image 时必须显式传入正文规定的 size 与 n,正文未规定的使用默认值。",
"7. 如果用户消息中出现【用户上传了以下文档…】标记,必须先读取文档再处理:普通文档用 read_document_text 读取原文;旅游行程文档优先用 read_document 提取线路摘要。不要编造文档中没有的信息。",
"8. 生成前必须核对技能正文要求的输入资料(如必需的商品/实物图、目的地与行程、日期与价格、品牌与卖点、文案、规格尺寸、参考图/文档等)。缺少必填信息时,禁止编造、禁止用默认值或“合理推断”直接生成,必须调用 confirm_user 向用户询问并等待确认:文本类信息用 mode=edit(把已知信息整理成草案;缺失项只保留字段名并留空,例如「活动时间:」「活动地点:」,不要写「请补充」「待填写」等需要用户删除后再输入的占位文字;价格/团期/包含项/资质等事实性字段不得虚构);让用户从多个选项中挑选时必须用 mode=select 并传 options(不要用 mode=edit 代替选择);需要用户同意/授权时用 mode=confirm。",
"8.1 只有用户确认(approved=true)且必填信息齐全后,才能调用 generate_image / generate_images_batch;用户取消或未补齐时停止本次生成,并明确列出还缺少哪些资料。信息已齐全但需要用户过一遍关键内容时,用 mode=edit 让用户确认后再生成,不要用文字反问等待回复。",
...(skill.manifest.industry === "旅游"
? [
"9. 如果用户上传了行程文档(消息中有【用户上传了以下文档…】标记),先读取文档并调用 confirm_travel_content 让用户确认或修改;用户确认后直接使用本次选定的 skill_id 调用 generate_image。如果用户只是用文字提供行程信息,也先用 confirm_travel_content 生成可编辑的线路摘要让用户确认,再生成。不要再询问或选择海报风格。",
"10. confirm_travel_content 返回的 content 是用户最终确认/修改后的内容,生成时必须严格以此为准。",
]
: []),
"",
"沟通要求:使用简体中文,直接、专业、简洁。缺少关键信息时不要用文字反问等待用户回复,而是按上面的确认流程给出可编辑草案让用户修改确认。",
"所有面向用户的文字(包括确认卡片的标题、说明、选项文案、追问与结果总结)必须使用简体中文;只有生图提示词本身可以是英文。",
"不要在回复中提及积分、费用、单价或预估消耗;扣费由平台自动完成。",
"禁止在未调用 generate_image 的情况下声称图片已生成;如果工具返回用户取消、确认超时或被阻止,明确告知用户本次未生成图片。",
].join("\n");
const pricing = [
"## 本次技能",
`skill_id: ${skill.key}`,
`名称: ${skill.displayName}`,
`模型: ${skill.manifest.model}`,
`默认尺寸: ${skill.manifest.defaults.size ?? config.skillDefaults.size}`,
`单价: ${skill.manifest.price.per_image} ${skill.manifest.price.currency}/张`,
"",
"## 技能规范",
catalog.promptCatalog([skill]) || "(技能摘要为空)",
"",
skill.content || "(技能正文为空)",
"",
"## 计费说明",
`计费模式:${config.billing.mode};结算币种:${config.billing.currency};每张成功图片按该技能单价结算。`,
config.billing.mode === "live"
? "当前按技能预设的每张图片积分实际扣费(生成成功后由平台自动扣除),不要在回复中提及具体积分数量或扣费过程。"
: config.billing.mode === "dry_run"
? "当前为试点期:每次生成只记录用量,不实际扣减团队积分。回复中可提示“预计消耗”,不要声称已扣费。"
: "当前计费已关闭:不扣减费用,用量仍会记录用于估算与审计。",
"",
"## 生图通道",
"所有图片统一通过服务端配置的生图网关生成;无需向用户询问或切换模型。",
].join("\n");
return { systemPrompt, appendSystemPrompt: [pricing] };
}
function buildDirectPrompts(config: AppConfig): SystemPrompts {
const systemPrompt = [
"你是「来秒AI」的创作助手,帮助用户生成商业级图片。",
"",
"本次请求用户没有选择技能,因此:",
"0. 本次为「原样直连」模式:必须直接调用 generate_image 生成图片,prompt 必须逐字使用用户输入原文,禁止改写、扩写、翻译、精简或补充;不要反问、不要要求用户补充信息。",
"1. 普通直接生成不要读取技能规范;如果用户要生成旅游海报并通过 confirm_travel_style 选择了风格,则必须调用 read_skill 读取该风格规范。",
"2. 直接理解用户需求;仅在用户通过 confirm_travel_style 选择了风格时,才按该风格规范补充提示词。",
"3. 普通直接生成调用 generate_image 时省略 skill_id;如果用户通过 confirm_travel_style 选择了旅游风格,则必须把该风格 id 作为 skill_id。",
`4. 用户未说明张数时生成 1 张,最多 ${MAX_IMAGES_PER_REQUEST} 张。`,
"4.1 如果需要多张不同内容(套图),调用 generate_images_batch 一次性提交:给一个总体 style,再按顺序列出每张 images 内容,并发生成;不要逐张串行调用 generate_image。",
"5. 用户提供参考图时,把图片 URL 放入 reference_images。",
"6. 生成后简短说明结果。",
"7. 如果用户消息中出现【用户上传了以下文档…】标记,必须先读取文档再处理:普通文档用 read_document_text 读取原文;旅游行程文档优先用 read_document 提取线路摘要。不要编造文档中没有的信息。",
"8. 如果用户要生成旅游海报(提到旅游/行程/线路/海报等),先调用 confirm_travel_style 让用户选择风格;用户选择后,使用返回的风格 id 作为 skill_id,先调用 read_skill 读取该风格规范,再调用 generate_image。",
"9. 如果用户上传了旅游行程文档,先读取文档并调用 confirm_travel_content 确认内容,再进入风格选择;如果只是文字描述且信息足够,可直接进入风格选择。",
"10. 需要用户确认关键信息、补充/修改文本或做选择时,禁止用文字反问,必须调用 confirm_user:让用户从多个选项中挑选时用 mode=select 并传 options(不要用 mode=edit 代替选择);需要用户补充/修改文本时用 mode=edit 并把已知内容放入 default_value,缺失项只写字段名并留空(如「活动时间:」),不要写「请补充」「待填写」等需要用户删除的占位文字;仅需用户同意/拒绝时用 mode=confirm。普通直接生成(原样直连)无需确认,直接生图。",
"",
"沟通要求:使用简体中文,直接、专业、简洁。缺少关键信息时不要用文字反问等待用户回复,而是按上面的确认流程给出可编辑草案让用户修改确认。",
"所有面向用户的文字(包括确认卡片的标题、说明、选项文案、追问与结果总结)必须使用简体中文;只有生图提示词本身可以是英文。",
"不要在回复中提及积分、费用、单价或预估消耗;扣费由平台自动完成。",
"禁止在未调用 generate_image 的情况下声称图片已生成;如果工具返回用户取消、确认超时或被阻止,明确告知用户本次未生成图片。",
].join("\n");
const pricing = [
"## 直接生成模式",
"默认不加载技能规范;旅游海报通过 confirm_travel_style 选择风格后,加载所选风格规范并按该风格计费。",
`默认模型: ${config.skillDefaults.model}`,
`默认尺寸: ${config.skillDefaults.size}`,
`默认单价: ${config.billing.defaultSkillPrice} ${config.billing.currency}/张`,
"",
"## 计费说明",
`计费模式:${config.billing.mode};结算币种:${config.billing.currency};每张成功图片按所选风格(或默认价)结算。`,
config.billing.mode === "live"
? "当前按平台默认单价实际扣费(生成成功后由平台自动扣除),不要在回复中提及具体积分数量或扣费过程。"
: config.billing.mode === "dry_run"
? "当前为试点期:每次生成只记录用量,不实际扣减团队积分。回复中可提示“预计消耗”,不要声称已扣费。"
: "当前计费已关闭:不扣减费用,用量仍会记录用于估算与审计。",
"",
"普通直接生成调用 generate_image 时不要传 skill_id;通过 confirm_travel_style 选择了旅游风格后,必须传该风格 id。",
].join("\n");
return { systemPrompt, appendSystemPrompt: [pricing] };
}
export function buildUserPrompt(message: string, referenceImages: string[] = []): string {
if (referenceImages.length === 0) return message;
return [message, "", "参考图 URL:", ...referenceImages.map((url, index) => `${index + 1}. ${url}`)].join("\n");
}
+817
View File
@@ -0,0 +1,817 @@
import { stripUploadedDocuments } from "../utils/text.js";
import { safeFetch } from "../utils/net.js";
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import {
createAgentSession,
DefaultResourceLoader,
ModelRuntime,
SessionManager,
SettingsManager,
type AgentSessionEvent,
} from "@earendil-works/pi-coding-agent";
import type { ImageContent, Model, ThinkingLevel } from "@earendil-works/pi-ai";
import { ToolConfirmationManager, type ToolConfirmationDecision } from "./confirmations.js";
import { buildSystemPrompts, buildUserPrompt } from "./prompt.js";
import { createAgentTools } from "./tools.js";
import { createDocumentTools } from "./document_tools.js";
import { createConfirmTools } from "./confirm_tools.js";
import { quoteSkill } from "../billing/pricing.js";
import type { UsageLedger } from "../billing/ledger.js";
import type { AppConfig } from "../config.js";
import type { GatewayClient } from "../gateway/client.js";
import type { Logger } from "../logger.js";
import type { SessionStoreLike } from "../sessions/store.js";
import type { SessionContextStore } from "../sessions/context_store.js";
import type { SkillCatalog } from "../skills/catalog.js";
import { directSkill } from "../skills/direct.js";
import type { Identity, Quote, SkillDefinition } from "../types.js";
export class AgentError extends Error {
constructor(
message: string,
readonly statusCode = 400,
) {
super(message);
this.name = "AgentError";
}
}
export class AgentConfigError extends AgentError {
constructor(message: string) {
super(message, 503);
this.name = "AgentConfigError";
}
}
export interface MediaItem {
type: "image" | "video";
url: string;
}
export interface RunUsage {
input: number;
output: number;
total: number;
cost: number;
}
export type ContentConfirmationType =
| "travel_content_confirm"
| "travel_style_confirm"
| "user_confirm";
export type AgentStreamEvent =
| { type: "session"; session_id: string }
| { type: "text_delta"; text: string }
| { type: "thinking_delta"; text: string }
| { type: "tool_start"; tool_call_id: string; tool_name: string; args: unknown }
| { type: "tool_end"; tool_call_id: string; tool_name: string; ok: boolean; duration_ms: number }
| { type: "images"; images: string[]; quote: Quote }
| {
type: "tool_confirmation_required";
confirmation_id: string;
session_id: string;
tool_call_id: string;
tool_name: string;
args: unknown;
quote?: Quote;
expires_at: number;
}
| {
type: "tool_confirmation_resolved";
confirmation_id: string;
tool_call_id: string;
approved: boolean;
reason?: string;
}
| {
type: "content_confirmation_required";
confirmation_id: string;
session_id: string;
tool_call_id: string;
confirm_type: ContentConfirmationType;
payload: unknown;
expires_at: number;
}
| {
type: "content_confirmation_resolved";
confirmation_id: string;
tool_call_id: string;
confirm_type: ContentConfirmationType;
approved: boolean;
content?: string;
reason?: string;
}
| {
type: "done";
session_id: string;
message_id: string;
answer: string;
media: MediaItem[];
usage: RunUsage;
skill_key?: string;
}
| { type: "error"; message: string };
export interface RunInput {
identity: Identity;
sessionId: string;
message: string;
referenceImages?: string[];
skillKey?: string;
industry?: string;
directPrompt?: boolean;
signal?: AbortSignal;
onEvent?: (event: AgentStreamEvent) => void;
}
export interface RunResult {
sessionId: string;
messageId: string;
answer: string;
media: MediaItem[];
usage: RunUsage;
skill_key?: string;
}
export interface ConfirmToolInput {
identity: Identity;
sessionId: string;
toolCallId: string;
approved: boolean;
content?: string;
reason?: string;
}
export class AgentService {
private runtime?: Promise<ModelRuntime>;
private readonly locks = new Map<string, Promise<void>>();
private readonly confirmations: ToolConfirmationManager;
constructor(
private readonly config: AppConfig,
private readonly catalog: SkillCatalog,
private readonly ledger: UsageLedger,
private readonly gateway: GatewayClient,
private readonly store: SessionStoreLike,
private readonly contextStore: SessionContextStore,
private readonly logger: Logger,
) {
this.confirmations = new ToolConfirmationManager(config.toolConfirmation.timeoutMs);
mkdirSync(config.agentDir, { recursive: true });
mkdirSync(config.workspaceDir, { recursive: true });
}
isLlmConfigured(): boolean {
return Boolean(this.config.llm.baseUrl && this.config.llm.apiKey);
}
async run(input: RunInput): Promise<RunResult> {
if (!this.isLlmConfigured()) {
throw new AgentConfigError("LLM 未配置:请设置 LAIMIAO_LLM_BASE_URL 与 LAIMIAO_LLM_API_KEY 后重启服务");
}
return this.withSessionLock(input.sessionId, () => this.execute(input));
}
async confirmTool(input: ConfirmToolInput): Promise<{
ok: true;
tool_call_id: string;
approved: boolean;
content?: string;
}> {
const meta = await this.store.get(input.sessionId);
if (!meta) throw new AgentError(`session not found: ${input.sessionId}`, 404);
if (meta.uid !== input.identity.uid) throw new AgentError("session does not belong to the current user", 403);
if (input.identity.team_id && meta.team_id && input.identity.team_id !== meta.team_id) {
throw new AgentError("session does not belong to the current team", 403);
}
const decision = { approved: input.approved, content: input.content, reason: input.reason };
if (!this.confirmations.resolve(input.sessionId, input.toolCallId, decision)) {
throw new AgentError("工具确认不存在或已过期,请重新发起请求", 409);
}
this.logger.info(
{
sessionId: input.sessionId,
toolCallId: input.toolCallId,
approved: input.approved,
contentChars: input.content?.length ?? 0,
},
"tool confirmation resolved",
);
return {
ok: true,
tool_call_id: input.toolCallId,
approved: input.approved,
content: input.content,
};
}
private async execute(input: RunInput): Promise<RunResult> {
const meta = await this.store.get(input.sessionId);
if (!meta) throw new AgentError(`session not found: ${input.sessionId}`, 404);
if (meta.uid !== input.identity.uid) throw new AgentError("session does not belong to the current user", 403);
if (input.identity.team_id && meta.team_id && input.identity.team_id !== meta.team_id) {
throw new AgentError("session does not belong to the current team", 403);
}
const identity: Identity = { ...input.identity, team_id: meta.team_id || input.identity.team_id };
const emit = input.onEvent ?? (() => {});
const media: MediaItem[] = [];
if (input.skillKey) await this.catalog.refresh();
const selectedSkill = input.skillKey ? this.catalog.get(input.skillKey) : undefined;
if (input.skillKey && !selectedSkill) {
throw new AgentError(`skill 不存在:${input.skillKey}`, 404);
}
const skill = selectedSkill ?? directSkill(this.config);
const industry = (input.industry || selectedSkill?.manifest.industry || "").trim();
const isDirectTravel = !selectedSkill && industry === "旅游";
let travelStyleConfirmed = false;
let travelStyleKey = "";
const referenceImages = (input.referenceImages ?? []).filter((url) => /^https?:\/\//.test(url)).slice(0, 6);
// 入库时去掉前端为 Agent 追加的「【用户上传了以下文档…】」指令块,
// 历史会话里展示的应是用户真正输入的内容(附件另行展示)
const userMessage = await this.store.append(input.sessionId, {
role: "user",
mode: "agent",
content: stripUploadedDocuments(input.message),
media: referenceImages.map((url) => ({ type: "image" as const, url })),
status: "ok",
});
const prompts = buildSystemPrompts(this.catalog, this.config, selectedSkill);
const isTravel = selectedSkill?.manifest.industry === "旅游";
const travelSkillKeys = this.catalog
.list()
.filter((item) => item.manifest.industry === "旅游")
.map((item) => item.key);
const allowedSkillKeys = selectedSkill
? [skill.key]
: [...new Set([...travelSkillKeys, skill.key])];
const toolNames = selectedSkill
? [
"read_document",
"read_document_text",
"confirm_user",
...(isTravel ? ["confirm_travel_content"] : []),
"list_skills",
"read_skill",
"generate_image",
"generate_images_batch",
]
: [
"read_document",
"read_document_text",
"confirm_user",
"confirm_travel_content",
"confirm_travel_style",
"read_skill",
"generate_image",
"generate_images_batch",
];
const runtime = await this.getRuntime();
const model = this.resolveModel(runtime);
const settingsManager = SettingsManager.create(this.config.workspaceDir, this.config.agentDir);
const toolConfirmationExtension = (pi: {
on: (
event: "tool_call",
handler: (event: {
toolCallId: string;
toolName: string;
input: Record<string, unknown>;
}) => Promise<{ block?: boolean; reason?: string } | undefined>,
) => void;
}): void => {
pi.on("tool_call", async (event) => {
if (
(event.toolName === "generate_image" || event.toolName === "generate_images_batch") &&
isDirectTravel &&
!travelStyleConfirmed
) {
return {
block: true,
reason: "旅游海报需要先调用 confirm_travel_style 让用户选择风格,再生成图片。",
};
}
if (
!this.config.toolConfirmation.enabled ||
!this.config.toolConfirmation.tools.includes(event.toolName)
) {
return undefined;
}
const toolArgs = { ...event.input };
if (event.toolName === "generate_image" && isDirectTravel && travelStyleKey) {
toolArgs.skill_id = travelStyleKey;
}
const decision = await this.requestToolConfirmation({
sessionId: input.sessionId,
toolCallId: event.toolCallId,
toolName: event.toolName,
toolArgs,
skill,
emit,
hasStream: Boolean(input.onEvent),
signal: input.signal,
});
emit({
type: "tool_confirmation_resolved",
confirmation_id: event.toolCallId,
tool_call_id: event.toolCallId,
approved: decision.approved,
reason: decision.reason,
});
if (decision.approved) {
emit({
type: "tool_start",
tool_call_id: event.toolCallId,
tool_name: event.toolName,
args: sanitizeArgs(toolArgs),
});
return undefined;
}
return { block: true, reason: decision.reason ?? "用户拒绝执行该工具" };
});
};
const loader = new DefaultResourceLoader({
cwd: this.config.workspaceDir,
agentDir: this.config.agentDir,
settingsManager,
noExtensions: true,
noPromptTemplates: true,
noThemes: true,
noContextFiles: true,
noSkills: true,
extensionFactories: [toolConfirmationExtension],
systemPrompt: prompts.systemPrompt,
appendSystemPrompt: prompts.appendSystemPrompt,
});
await loader.reload();
const piSession = await this.openPiSession(input.sessionId);
const sessionManager = piSession.manager;
const customTools = [
...createAgentTools({
config: this.config,
catalog: this.catalog,
ledger: this.ledger,
gateway: this.gateway,
logger: this.logger,
identity,
sessionId: input.sessionId,
allowedSkillKeys,
directSkill: selectedSkill ? undefined : skill,
getForcedSkillKey: () => (isDirectTravel ? travelStyleKey || undefined : undefined),
rawPrompt: input.directPrompt ? input.message : undefined,
onImages: (urls, quote) => {
for (const url of urls) media.push({ type: "image", url });
emit({ type: "images", images: urls, quote });
},
}),
...createDocumentTools({ config: this.config, logger: this.logger }),
...createConfirmTools({
catalog: this.catalog,
confirmations: this.confirmations,
sessionId: input.sessionId,
logger: this.logger,
emit,
hasStream: Boolean(input.onEvent),
onTravelStyleConfirmed: (style) => {
travelStyleConfirmed = true;
travelStyleKey = style;
},
}),
];
const { session } = await createAgentSession({
cwd: this.config.workspaceDir,
agentDir: this.config.agentDir,
modelRuntime: runtime,
model,
thinkingLevel: this.config.llm.thinkingLevel as ThinkingLevel,
tools: toolNames,
resourceLoader: loader,
sessionManager,
customTools,
settingsManager,
});
const unsubscribe = session.subscribe((event) => this.forwardEvent(event, emit));
const onAbort = () => {
void session.abort();
};
input.signal?.addEventListener("abort", onAbort, { once: true });
let assistantMessageId = "";
try {
const assistantPlaceholder = await this.store.append(
input.sessionId,
{
role: "assistant",
mode: "agent",
content: "",
media: [],
status: "loading",
},
userMessage.add_time + 1,
);
assistantMessageId = assistantPlaceholder.id;
const referenceImageContents = await loadReferenceImages(
referenceImages,
this.config.fileFetchAllowedHosts,
this.logger,
);
await session.prompt(
buildUserPrompt(input.message, referenceImages),
referenceImageContents.length > 0 ? { images: referenceImageContents } : undefined,
);
const output = collectAssistantOutput(session.messages as unknown[]);
const answer = output.text.trim() || session.getLastAssistantText()?.trim() || (media.length ? "已为你生成图片。" : "");
const usage = extractUsage(session.messages as unknown[]);
await this.store.update(input.sessionId, assistantMessageId, {
mode: media.length ? "image" : "agent",
content: answer,
thinking: output.thinking.trim() || undefined,
media,
status: "ok",
error: "",
});
const result: RunResult = {
sessionId: input.sessionId,
messageId: assistantMessageId,
answer,
media,
usage,
skill_key: selectedSkill?.key,
};
emit({
type: "done",
session_id: result.sessionId,
message_id: assistantMessageId,
answer,
media,
usage,
skill_key: selectedSkill?.key,
});
return result;
} catch (error) {
const messageText = error instanceof Error ? error.message : String(error);
if (assistantMessageId) {
await this.store.update(input.sessionId, assistantMessageId, {
mode: media.length ? "image" : "agent",
content: messageText,
media,
status: "error",
error: messageText,
});
} else {
await this.store.append(input.sessionId, {
role: "assistant",
mode: "agent",
content: messageText,
media,
status: "error",
error: messageText,
});
}
emit({ type: "error", message: messageText });
throw error;
} finally {
input.signal?.removeEventListener("abort", onAbort);
this.confirmations.clearSession(input.sessionId);
unsubscribe();
session.dispose();
await this.persistPiSession(input.sessionId, piSession);
}
}
private async requestToolConfirmation(input: {
sessionId: string;
toolCallId: string;
toolName: string;
toolArgs: Record<string, unknown>;
skill: SkillDefinition;
emit: (event: AgentStreamEvent) => void;
hasStream: boolean;
signal?: AbortSignal;
}): Promise<ToolConfirmationDecision> {
const config = this.config.toolConfirmation;
if (!config.enabled || !config.tools.includes(input.toolName)) {
return { approved: true };
}
if (!input.hasStream) {
return {
approved: false,
reason: "该工具需要用户确认,请使用 stream:true 发起请求并在确认后继续",
};
}
const requestedSkillId =
typeof input.toolArgs.skill_id === "string" ? input.toolArgs.skill_id : "";
const requestedSkill = requestedSkillId ? this.catalog.get(requestedSkillId) : undefined;
const quote = this.quoteForConfirmation(input.toolName, input.toolArgs, requestedSkill ?? input.skill);
this.logger.info(
{ sessionId: input.sessionId, toolCallId: input.toolCallId, toolName: input.toolName },
"waiting for tool confirmation",
);
return this.confirmations.request(
{
sessionId: input.sessionId,
toolCallId: input.toolCallId,
toolName: input.toolName,
args: input.toolArgs,
quote,
},
(request) => {
input.emit({
type: "tool_confirmation_required",
confirmation_id: request.toolCallId,
session_id: request.sessionId,
tool_call_id: request.toolCallId,
tool_name: request.toolName,
args: request.args,
quote: request.quote,
expires_at: request.expiresAt,
});
},
input.signal,
);
}
private quoteForConfirmation(
toolName: string,
args: Record<string, unknown>,
skill: SkillDefinition,
): Quote | undefined {
if (toolName !== "generate_image") return undefined;
const size = typeof args.size === "string" ? args.size : undefined;
const n = typeof args.n === "number" && Number.isInteger(args.n) ? args.n : undefined;
try {
const defaults = this.catalog.defaultsFor(skill);
return quoteSkill(
skill,
{ size: defaults.size, quantity: defaults.n },
{ size, quantity: n },
this.config.billing.mode,
);
} catch {
return undefined;
}
}
private forwardEvent(event: AgentSessionEvent, emit: (event: AgentStreamEvent) => void): void {
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
emit({ type: "text_delta", text: event.assistantMessageEvent.delta });
return;
}
if (event.type === "message_update" && event.assistantMessageEvent.type === "thinking_delta") {
emit({ type: "thinking_delta", text: event.assistantMessageEvent.delta });
return;
}
if (event.type === "tool_execution_start") {
if (this.config.toolConfirmation.enabled && this.config.toolConfirmation.tools.includes(event.toolName)) {
return;
}
emit({
type: "tool_start",
tool_call_id: event.toolCallId,
tool_name: event.toolName,
args: sanitizeArgs(event.args),
});
return;
}
if (event.type === "tool_execution_end") {
const details = event.result?.details as { ok?: boolean } | undefined;
emit({
type: "tool_end",
tool_call_id: event.toolCallId,
tool_name: event.toolName,
ok: details?.ok ?? !event.isError,
duration_ms: event.durationMs ?? 0,
});
}
}
private resolveModel(runtime: ModelRuntime): Model<"openai-completions"> {
const { providerId, modelId } = this.config.llm;
const model = runtime.getModel(providerId, modelId);
if (!model) {
const available = runtime
.getModels()
.map((item) => `${item.provider}/${item.id}`)
.join(", ");
throw new AgentConfigError(
`模型未找到:${providerId}/${modelId}。可用模型:${available || "无"}。请检查 LAIMIAO_LLM_* 环境变量`,
);
}
return model as Model<"openai-completions">;
}
private async getRuntime(): Promise<ModelRuntime> {
if (!this.runtime) {
this.runtime = this.createRuntime().catch((error: unknown) => {
this.runtime = undefined;
throw error;
});
}
return this.runtime;
}
private async createRuntime(): Promise<ModelRuntime> {
const { providerId, baseUrl, modelId, contextWindow, maxTokens, reasoning } = this.config.llm;
const payload = {
providers: {
[providerId]: {
baseUrl,
api: "openai-completions",
apiKey: "${LAIMIAO_LLM_API_KEY}",
compat: { thinkingFormat: "qwen", supportsReasoningEffort: false },
models: [
{
id: modelId,
name: modelId,
contextWindow,
maxTokens,
reasoning,
input: ["text", "image"],
},
],
},
},
};
writeFileSync(this.config.modelsFile, JSON.stringify(payload, null, 2), "utf8");
try {
return await ModelRuntime.create({ modelsPath: this.config.modelsFile, allowModelNetwork: false });
} catch (error) {
throw new AgentConfigError(
`模型运行时初始化失败:${error instanceof Error ? error.message : String(error)}`,
);
}
}
private async openPiSession(
sessionId: string,
): Promise<{ manager: SessionManager; tempFile: string | null }> {
if (this.config.sessionContext !== "mysql") {
mkdirSync(this.config.piSessionsDir, { recursive: true });
const existing = SessionManager.findById(this.config.workspaceDir, sessionId, this.config.piSessionsDir);
const manager = existing
? SessionManager.open(existing, this.config.piSessionsDir, this.config.workspaceDir)
: SessionManager.create(this.config.workspaceDir, this.config.piSessionsDir, { id: sessionId });
return { manager, tempFile: null };
}
const tmpDir = this.config.contextTmpDir;
mkdirSync(tmpDir, { recursive: true });
const blob = await this.contextStore.load(sessionId);
if (blob) {
const file = join(tmpDir, `${sessionId}.jsonl`);
writeFileSync(file, blob);
return {
manager: SessionManager.open(file, tmpDir, this.config.workspaceDir),
tempFile: file,
};
}
const manager = SessionManager.create(this.config.workspaceDir, tmpDir, { id: sessionId });
return { manager, tempFile: null };
}
private async persistPiSession(
sessionId: string,
piSession: { manager: SessionManager; tempFile: string | null },
): Promise<void> {
if (this.config.sessionContext !== "mysql") return;
const file = piSession.manager.getSessionFile() ?? piSession.tempFile;
if (!file || !existsSync(file)) return;
try {
await this.contextStore.save(sessionId, readFileSync(file));
} catch (error) {
this.logger.error(
{ sessionId, error: error instanceof Error ? error.message : String(error) },
"failed to persist pi session context",
);
} finally {
try {
unlinkSync(file);
} catch (error) {
this.logger.warn(
{ sessionId, error: error instanceof Error ? error.message : String(error) },
"failed to remove temp pi session file",
);
}
}
}
private async withSessionLock<T>(sessionId: string, work: () => Promise<T>): Promise<T> {
const previous = this.locks.get(sessionId) ?? Promise.resolve();
let release!: () => void;
const gate = new Promise<void>((resolveGate) => {
release = resolveGate;
});
const tail = previous.then(() => gate);
this.locks.set(sessionId, tail);
await previous;
try {
return await work();
} finally {
release();
if (this.locks.get(sessionId) === tail) this.locks.delete(sessionId);
}
}
}
const MAX_REFERENCE_IMAGE_BYTES = 10 * 1024 * 1024;
const MAX_REFERENCE_IMAGE_TOTAL_BYTES = 20 * 1024 * 1024;
async function loadReferenceImages(
urls: string[],
allowedHosts: string[],
logger: Logger,
): Promise<ImageContent[]> {
const images: ImageContent[] = [];
let totalBytes = 0;
for (const url of urls) {
try {
const response = await safeFetch(
url,
{ signal: AbortSignal.timeout(15_000) },
{ allowedHosts },
);
if (!response.ok) {
logger.warn({ url, status: response.status }, "reference image fetch failed");
continue;
}
const mimeType = ((response.headers.get("content-type") || "").split(";")[0] ?? "").trim().toLowerCase();
if (!mimeType.startsWith("image/")) {
logger.warn({ url, mimeType }, "reference image has non-image content type");
continue;
}
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.byteLength === 0) {
logger.warn({ url }, "reference image is empty");
continue;
}
if (buffer.byteLength > MAX_REFERENCE_IMAGE_BYTES || totalBytes + buffer.byteLength > MAX_REFERENCE_IMAGE_TOTAL_BYTES) {
logger.warn({ url, bytes: buffer.byteLength, totalBytes }, "reference image skipped: too large");
continue;
}
totalBytes += buffer.byteLength;
images.push({ type: "image", data: buffer.toString("base64"), mimeType });
} catch (error) {
logger.warn({ error, url }, "reference image download failed");
}
}
return images;
}
function sanitizeArgs(args: unknown): unknown {
if (!args || typeof args !== "object") return args;
const record = { ...(args as Record<string, unknown>) };
if (typeof record.prompt === "string" && record.prompt.length > 200) {
record.prompt = `${record.prompt.slice(0, 200)}…`;
}
return record;
}
interface UsageMessage {
role: string;
usage?: { input: number; output: number; totalTokens: number; cost: { total: number } };
}
function collectAssistantOutput(messages: unknown[]): { text: string; thinking: string } {
const texts: string[] = [];
const thoughts: string[] = [];
for (const item of messages) {
if (!item || typeof item !== "object") continue;
const message = item as { role?: string; content?: unknown };
if (message.role !== "assistant" || !Array.isArray(message.content)) continue;
for (const raw of message.content) {
if (!raw || typeof raw !== "object") continue;
const block = raw as { type?: string; text?: string; thinking?: string };
if (block.type === "text" && block.text) texts.push(block.text);
else if (block.type === "thinking" && block.thinking) thoughts.push(block.thinking);
}
}
return { text: texts.join("\n\n"), thinking: thoughts.join("\n\n") };
}
function extractUsage(messages: unknown[]): RunUsage {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index] as UsageMessage | undefined;
if (!message) continue;
if (message.role === "assistant" && message.usage) {
return {
input: message.usage.input,
output: message.usage.output,
total: message.usage.totalTokens,
cost: message.usage.cost.total,
};
}
}
return { input: 0, output: 0, total: 0, cost: 0 };
}
+666
View File
@@ -0,0 +1,666 @@
import { MAX_IMAGES_PER_REQUEST } from "../skills/params.js";
import { defineTool } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import { PricingError, quoteSkill, repriceForQuantity } from "../billing/pricing.js";
import type { UsageLedger } from "../billing/ledger.js";
import type { AppConfig } from "../config.js";
import { GatewayError, type GatewayClient } from "../gateway/client.js";
import type { Logger } from "../logger.js";
import type { SkillCatalog } from "../skills/catalog.js";
import type { Identity, Quote, SkillDefinition, UsageRecord } from "../types.js";
export interface AgentToolDeps {
config: AppConfig;
catalog: SkillCatalog;
ledger: UsageLedger;
gateway: GatewayClient;
logger: Logger;
identity: Identity;
sessionId: string;
allowedSkillKeys?: string[];
directSkill?: SkillDefinition;
getForcedSkillKey?: () => string | undefined;
rawPrompt?: string;
onImages: (urls: string[], quote: Quote) => void;
}
export interface GeneratedImageDetails {
ok: boolean;
quote?: Quote;
task_id?: string;
images?: string[];
elapsed_ms?: number;
idempotent?: boolean;
error?: string;
}
export interface BatchImageDetails {
ok: boolean;
quote?: Quote;
images?: string[];
task_id?: string;
elapsed_ms?: number;
idempotent?: boolean;
error?: string;
}
export interface QuoteDetails {
ok: boolean;
quote?: Quote;
error?: string;
}
export function createAgentTools(deps: AgentToolDeps) {
const { catalog, ledger, gateway, config, logger, identity, sessionId, directSkill } = deps;
const allowed = deps.allowedSkillKeys ? new Set(deps.allowedSkillKeys) : undefined;
const isAllowed = (key: string): boolean => !allowed || allowed.has(key);
const resolveSkill = (skillId?: string): SkillDefinition | undefined => {
const effectiveKey = deps.getForcedSkillKey?.() || skillId;
return effectiveKey ? catalog.get(effectiveKey) : directSkill;
};
const listSkills = defineTool({
name: "list_skills",
label: "列出技能",
description: "列出当前可用的生图技能,包含每个技能的用途、默认尺寸、模型与每张价格。",
parameters: Type.Object({}),
execute: async () => {
const skills = catalog.publicSkills().filter((skill) => isAllowed(skill.key));
const text = skills
.map(
(skill) =>
`${skill.key} | ${skill.display_name} | ${skill.price.per_image} ${skill.price.currency}/张 | ${skill.model} | ${skill.defaults.size}\n${skill.description}`,
)
.join("\n\n");
return {
content: [{ type: "text" as const, text: text || "当前没有可用技能。" }],
details: { skills },
};
},
});
const readSkill = defineTool({
name: "read_skill",
label: "读取技能说明",
description: "读取指定技能的 SKILL.md 全文。构造提示词前必须先调用此工具,并严格遵循其中的规范。",
parameters: Type.Object({
skill_id: Type.Optional(Type.String({ description: "技能 key;直接生成模式下省略" })),
}),
execute: async (
_toolCallId: string,
params: { skill_id?: string },
): Promise<{
content: Array<{ type: "text"; text: string }>;
details: { ok: boolean; error?: string };
isError?: boolean;
}> => {
const skill = resolveSkill(params.skill_id);
if (!skill || !isAllowed(skill.key)) {
return {
content: [{ type: "text", text: unknownSkillMessage(catalog, params.skill_id, allowed, directSkill) }],
details: { ok: false, error: "unknown_skill" },
isError: true,
};
}
if (!skill.content) {
return {
content: [{ type: "text", text: "当前为直接生成模式,没有技能规范需要读取。" }],
details: { ok: true },
};
}
return {
content: [{ type: "text", text: skill.content }],
details: { ok: true },
};
},
});
const generateTool = defineTool({
name: "generate_image",
label: "生成图片",
description: directSkill
? "直接生成图片,不需要 skill_id。每张成功图片按平台默认单价计费,返回图片 URL。"
: "按指定技能生成图片。必须传入本次选定的 skill_id。调用前应先阅读该技能的 SKILL.md 并遵守其提示词规范。每张成功图片按技能单价计费,返回图片 URL。",
parameters: Type.Object({
skill_id: Type.Optional(Type.String({ description: "技能 key;直接生成模式下省略" })),
prompt: Type.String({ minLength: 2, maxLength: 6000, description: "最终生图提示词" }),
size: Type.Optional(Type.String({ description: "图片尺寸,缺省用技能默认值" })),
n: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_IMAGES_PER_REQUEST, description: "生成张数,缺省 1;按技能正文要求的张数一次生成" })),
reference_images: Type.Optional(
Type.Array(Type.String(), { maxItems: 6, description: "参考图 URL 列表,仅 img2img 类技能使用" }),
),
watermark: Type.Optional(Type.Boolean({ description: "是否加水印,缺省不加" })),
}),
execute: async (
toolCallId: string,
params: {
skill_id?: string;
prompt: string;
size?: string;
n?: number;
reference_images?: string[];
watermark?: boolean;
},
signal: AbortSignal,
): Promise<{
content: Array<{ type: "text"; text: string }>;
details: GeneratedImageDetails;
isError?: boolean;
}> => {
const previous = ledger.findSucceededToolCall(identity.uid, sessionId, toolCallId);
if (previous) {
const quote = quoteFromRecord(previous);
return {
content: [
{
type: "text",
text: `该工具调用已生成过图片,不重复计费。\n${links(previous.image_urls)}`,
},
],
details: {
ok: true,
quote,
task_id: previous.gateway_task_id,
images: previous.image_urls,
elapsed_ms: 0,
idempotent: true,
},
};
}
const skill = resolveSkill(params.skill_id);
if (!skill || !isAllowed(skill.key)) {
return {
content: [{ type: "text", text: unknownSkillMessage(catalog, params.skill_id, allowed, directSkill) }],
details: { ok: false, error: "unknown_skill" },
isError: true,
};
}
const defaults = catalog.defaultsFor(skill);
let quote: Quote;
try {
quote = quoteSkill(
skill,
{ size: defaults.size, quantity: defaults.n },
{ size: params.size, quantity: params.n },
config.billing.mode,
);
if (directSkill && skill.key === directSkill.key) {
const unit = Math.max(1, Math.ceil(quote.unit_credits * 1.3));
quote = { ...quote, unit_credits: unit, total_credits: unit * quote.quantity };
}
} catch (error) {
const message = error instanceof PricingError || error instanceof Error ? error.message : String(error);
return {
content: [{ type: "text", text: `参数不合法:${message}` }],
details: { ok: false, error: message },
isError: true,
};
}
const referenceImages = (params.reference_images ?? []).filter((url) => /^https?:\/\//.test(url)).slice(0, 6);
const finalPrompt = deps.rawPrompt && deps.rawPrompt.trim() ? deps.rawPrompt.trim() : params.prompt;
// 生成前按技能单价(直接生成按技能最低标准)校验团队积分
const isDirect = !!(directSkill && skill.key === directSkill.key);
const requiredCredits = isDirect ? lowestSkillPrice(catalog, config) : quote.total_credits;
const balance = await fetchTeamScore(config.teamApiUrl, identity.team_id);
if (balance !== null && balance < requiredCredits) {
return {
content: [
{
type: "text",
text: `团队积分不足:本次生成需要 ${requiredCredits} 积分,当前剩余 ${balance} 积分,请充值后再试。`,
},
],
details: { ok: false, error: "insufficient_score" },
isError: true,
};
}
const record = ledger.begin({
uid: identity.uid,
team_id: identity.team_id,
session_id: sessionId,
tool_call_id: toolCallId,
skill_id: skill.key,
skill_name: skill.displayName,
model: quote.model,
size: quote.size,
quantity: quote.quantity,
unit_credits: quote.unit_credits,
size_multiplier: quote.size_multiplier,
total_credits: quote.total_credits,
currency: quote.currency,
billing_mode: quote.billing_mode,
});
logger.info(
{
skill: skill.key,
model: quote.model,
size: quote.size,
quantity: quote.quantity,
total: quote.total_credits,
usageId: record.id,
},
"image generation started",
);
try {
const result = await gateway.generateImage({
uid: identity.uid,
team_id: identity.team_id,
model: quote.model,
prompt: finalPrompt,
size: quote.size,
n: quote.quantity,
images: referenceImages,
watermark: params.watermark ?? defaults.watermark,
metadata: {
skill_id: skill.key,
session_id: sessionId,
uid: identity.uid,
team_id: identity.team_id,
usage_id: record.id,
tool_call_id: toolCallId,
credits: quote.total_credits,
},
signal,
});
if (result.urls.length === 0) {
throw new Error("网关没有返回图片 URL");
}
const billedQuote = repriceForQuantity(quote, result.urls.length);
ledger.settle(record.id, "succeeded", {
gateway_task_id: result.taskId,
image_urls: result.urls,
quantity: billedQuote.quantity,
total_credits: billedQuote.total_credits,
});
deps.onImages(result.urls, billedQuote);
logger.info(
{ usageId: record.id, taskId: result.taskId, images: result.urls.length },
"image generation succeeded",
);
return {
content: [
{
type: "text",
text: `生成完成:${billedQuote.quantity} 张,技能 ${skill.displayName},模型 ${billedQuote.model},尺寸 ${billedQuote.size}${billingSuffix(billedQuote)}。\n${links(result.urls)}`,
},
],
details: {
ok: true,
quote: billedQuote,
task_id: result.taskId,
images: result.urls,
elapsed_ms: result.elapsedMs,
},
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
ledger.settle(record.id, "failed", { error: message });
logger.error(
{
usageId: record.id,
skill: skill.key,
error: message,
code: error instanceof GatewayError ? error.code : undefined,
status: error instanceof GatewayError ? error.status : undefined,
detail: error instanceof GatewayError ? error.detail : undefined,
},
"image generation failed",
);
return {
content: [
{
type: "text",
text: `图片生成失败:${message}。可以调整提示词或稍后重试。本次不计费。`,
},
],
details: { ok: false, quote, error: message },
isError: true,
};
}
},
});
interface BatchItemResult {
ok: boolean;
urls: string[];
quote?: Quote;
error?: string;
}
const generateBatchTool = defineTool({
name: "generate_images_batch",
label: "批量生成套图",
description:
"一次提交整套图片:先给整套图唯一的总体风格 style,再按顺序列出 images(图1生成什么、图2生成什么…)。每张图独立提交给生图模型,所有任务并发执行(整体耗时≈最慢的一张),统一返回整套图片。生成套图、详情图、主图组时必须用本工具一次提交,禁止逐张调用 generate_image 等待上一张完成。",
parameters: Type.Object({
skill_id: Type.Optional(Type.String({ description: "技能 key;直接生成模式下省略" })),
style: Type.Optional(
Type.String({
maxLength: 2000,
description:
"整套图唯一的总体风格(画风/摄影风格、色板与主色、光线氛围、质感材质、字体排版、构图与留白规则等)。只传一次,会应用到每一张,保证整套风格一致",
}),
),
images: Type.Array(
Type.Object({
content: Type.String({
minLength: 2,
maxLength: 3000,
description: "这一张生成什么(如「图1:头图,主视觉…」「图2:每日行程…」);只写该张独有内容与版式,不要重复风格",
}),
size: Type.Optional(Type.String({ description: "该张图片尺寸,缺省用技能默认值" })),
}),
{ minItems: 1, maxItems: MAX_IMAGES_PER_REQUEST, description: "按交付顺序排列的图片内容列表(图1、图2、图3…)" },
),
reference_images: Type.Optional(
Type.Array(Type.String(), { maxItems: 6, description: "参考图 URL 列表,仅 img2img 类技能使用" }),
),
watermark: Type.Optional(Type.Boolean({ description: "是否加水印,缺省不加" })),
}),
execute: async (
toolCallId: string,
params: {
skill_id?: string;
style?: string;
images: Array<{ content: string; size?: string }>;
reference_images?: string[];
watermark?: boolean;
},
signal: AbortSignal,
): Promise<{
content: Array<{ type: "text"; text: string }>;
details: BatchImageDetails;
isError?: boolean;
}> => {
const skill = resolveSkill(params.skill_id);
if (!skill || !isAllowed(skill.key)) {
return {
content: [{ type: "text" as const, text: unknownSkillMessage(catalog, params.skill_id, allowed, directSkill) }],
details: { ok: false, error: "unknown_skill" },
isError: true,
};
}
const images = (params.images ?? []).filter((item) => item && typeof item.content === "string" && item.content.trim());
if (!images.length) {
return {
content: [{ type: "text" as const, text: "批量生成至少需要一张图片的内容说明。" }],
details: { ok: false, error: "empty_items" },
isError: true,
};
}
if (images.length > skill.manifest.max_images) {
return {
content: [
{
type: "text" as const,
text: `本次提交 ${images.length} 张,超过技能「${skill.displayName}」单次上限 ${skill.manifest.max_images} 张,请减少数量后重试。`,
},
],
details: { ok: false, error: "too_many_items" },
isError: true,
};
}
const isDirect = !!(directSkill && skill.key === directSkill.key);
const sharedStyle = (params.style ?? "").trim().slice(0, 2000);
if (!isDirect && images.length > 1 && !sharedStyle) {
return {
content: [
{
type: "text" as const,
text: "生成套图时必须先提供整套图唯一的总体风格 style,再按顺序列出 images 的每张内容。",
},
],
details: { ok: false, error: "missing_style" },
isError: true,
};
}
const defaults = catalog.defaultsFor(skill);
let itemQuotes: Quote[];
try {
itemQuotes = images.map((item) =>
quoteSkill(
skill,
{ size: defaults.size, quantity: 1 },
{ size: item.size ?? defaults.size, quantity: 1 },
config.billing.mode,
),
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
content: [{ type: "text" as const, text: `参数不合法:${message}` }],
details: { ok: false, error: message },
isError: true,
};
}
const referenceImages = (params.reference_images ?? []).filter((url) => /^https?:\/\//.test(url)).slice(0, 6);
const composePrompt = (content: string, index: number): string => {
const raw = deps.rawPrompt && deps.rawPrompt.trim() ? deps.rawPrompt.trim() : `图${index + 1}:${content.trim()}`;
return sharedStyle ? `${sharedStyle}\n\n${raw}` : raw;
};
const pendingItems = images.map((item, index) => ({
index,
prompt: composePrompt(item.content, index),
quote: itemQuotes[index]!,
previous: ledger.findSucceededToolCall(identity.uid, sessionId, `${toolCallId}:${index}`),
}));
const requiredCredits = isDirect
? lowestSkillPrice(catalog, config) * pendingItems.filter((item) => !item.previous).length
: pendingItems
.filter((item) => !item.previous)
.reduce((sum, item) => sum + item.quote.total_credits, 0);
const balance = await fetchTeamScore(config.teamApiUrl, identity.team_id);
if (balance !== null && balance < requiredCredits) {
return {
content: [
{
type: "text" as const,
text: `团队积分不足:本次批量生成需要 ${requiredCredits} 积分,当前剩余 ${balance} 积分,请充值后再试。`,
},
],
details: { ok: false, error: "insufficient_score" },
isError: true,
};
}
logger.info(
{ skill: skill.key, mode: "batch_concurrent", count: pendingItems.length, total: requiredCredits },
"batch image generation started",
);
// 每张独立提交,全部并发执行:整体耗时≈最慢的一张,而不是逐张累加
const results: BatchItemResult[] = await Promise.all(
pendingItems.map(async (item): Promise<BatchItemResult> => {
if (item.previous) {
return { ok: true, urls: item.previous.image_urls, quote: quoteFromRecord(item.previous) };
}
const record = ledger.begin({
uid: identity.uid,
team_id: identity.team_id,
session_id: sessionId,
tool_call_id: `${toolCallId}:${item.index}`,
skill_id: skill.key,
skill_name: skill.displayName,
model: item.quote.model,
size: item.quote.size,
quantity: item.quote.quantity,
unit_credits: item.quote.unit_credits,
size_multiplier: item.quote.size_multiplier,
total_credits: item.quote.total_credits,
currency: item.quote.currency,
billing_mode: item.quote.billing_mode,
});
try {
const result = await gateway.generateImage({
uid: identity.uid,
team_id: identity.team_id,
model: item.quote.model,
prompt: item.prompt,
size: item.quote.size,
n: 1,
images: referenceImages,
watermark: params.watermark ?? defaults.watermark,
metadata: {
skill_id: skill.key,
session_id: sessionId,
uid: identity.uid,
team_id: identity.team_id,
usage_id: record.id,
tool_call_id: `${toolCallId}:${item.index}`,
batch_index: item.index,
credits: item.quote.total_credits,
},
signal,
});
if (!result.urls.length) throw new Error("网关没有返回图片 URL");
const billed = repriceForQuantity(item.quote, result.urls.length);
ledger.settle(record.id, "succeeded", {
gateway_task_id: result.taskId,
image_urls: result.urls,
quantity: billed.quantity,
total_credits: billed.total_credits,
});
return { ok: true, urls: result.urls, quote: billed };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
ledger.settle(record.id, "failed", { error: message });
logger.error(
{
usageId: record.id,
skill: skill.key,
batchIndex: item.index,
error: message,
code: error instanceof GatewayError ? error.code : undefined,
},
"batch image generation item failed",
);
return { ok: false, urls: [], quote: item.quote, error: message };
}
}),
);
const urls = results.flatMap((result) => result.urls);
const failures = results
.map((result, index) => ({ result, index }))
.filter((entry) => !entry.result.ok);
const successQuotes = results.map((result) => result.quote).filter((quote): quote is Quote => Boolean(quote));
if (urls.length) {
const first = successQuotes[0] ?? itemQuotes[0]!;
const aggregate: Quote = {
...first,
quantity: urls.length,
size_multiplier: 1,
total_credits: successQuotes.reduce((sum, quote) => sum + quote.total_credits, 0),
};
deps.onImages(urls, aggregate);
logger.info(
{ skill: skill.key, images: urls.length, failed: failures.length, total: aggregate.total_credits },
"batch image generation finished",
);
const failureText = failures.length
? `\n失败 ${failures.length} 张:` +
failures.map((entry) => `第 ${entry.index + 1} 张(${entry.result.error ?? "未知错误"})`).join(";")
: "";
return {
content: [
{
type: "text" as const,
text: `套图生成完成:成功 ${urls.length} 张${failureText}。\n${links(urls)}`,
},
],
details: { ok: true, quote: aggregate, images: urls, elapsed_ms: 0 },
};
}
const firstError = failures[0]?.result.error ?? "全部生成失败";
return {
content: [{ type: "text" as const, text: `套图生成失败:${firstError}。本次不计费。` }],
details: { ok: false, error: firstError },
isError: true,
};
},
});
return [listSkills, readSkill, generateTool, generateBatchTool];
}
export function quoteFromRecord(record: UsageRecord): Quote {
return {
skill_id: record.skill_id,
skill_name: record.skill_name,
model: record.model,
size: record.size,
quantity: record.quantity,
unit_credits: record.unit_credits,
size_multiplier: record.size_multiplier,
total_credits: record.total_credits,
currency: record.currency,
billing_mode: record.billing_mode,
};
}
async function fetchTeamScore(teamApiUrl: string, teamId: string): Promise<number | null> {
if (!teamApiUrl || !teamId) return null;
try {
const response = await fetch(`${teamApiUrl}/api/team/get_score?team_id=${encodeURIComponent(teamId)}`, {
signal: AbortSignal.timeout(4000),
});
if (!response.ok) return null;
const text = (await response.text()).trim();
const value = Number(text);
return Number.isFinite(value) ? value : null;
} catch {
return null;
}
}
function lowestSkillPrice(catalog: SkillCatalog, config: AppConfig): number {
const prices = catalog
.list()
.map((skill) => skill.manifest.price.per_image)
.filter((price) => Number.isFinite(price) && price > 0);
if (prices.length) return Math.min(...prices);
return Math.max(1, config.billing.defaultSkillPrice);
}
function links(urls: string[]): string {
return urls.map((url, index) => `${index + 1}. ${url}`).join("\n");
}
function billingSuffix(quote: Quote): string {
if (quote.billing_mode === "live") return "(已按技能单价扣费)";
if (quote.billing_mode === "dry_run") return `(试点期仅记账,不实际扣费,预计 ${quote.total_credits} ${quote.currency})`;
return "";
}
function unknownSkillMessage(
catalog: SkillCatalog,
skillId: string | undefined,
allowed?: Set<string>,
directSkill?: SkillDefinition,
): string {
if (!skillId && directSkill) return "当前为直接生成模式,请省略 skill_id。";
const available = catalog
.list()
.filter((skill) => !allowed || allowed.has(skill.key))
.map((skill) => skill.key)
.join(", ");
return `未知技能 "${skillId ?? ""}"。可用技能:${available || "无"}。`;
}
+105
View File
@@ -0,0 +1,105 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import type { IncomingHttpHeaders } from "node:http";
import type { AuthConfig } from "../config.js";
import type { Identity } from "../types.js";
export class AuthError extends Error {
readonly statusCode = 401;
constructor(message = "未登录或登录已过期") {
super(message);
this.name = "AuthError";
}
}
interface JwtPayload {
uid?: number | string;
nickname?: string;
avatar?: string;
mobile?: string;
exp?: number;
nbf?: number;
}
export interface IdentityRequest {
headers: IncomingHttpHeaders;
teamId?: string;
uid?: string;
}
export function resolveIdentity(auth: AuthConfig, request: IdentityRequest): Identity {
const authorization = firstHeader(request.headers.authorization);
if (auth.jwtKey) {
if (!authorization) throw new AuthError();
const token = authorization.replace(/^Bearer\s+/i, "").trim();
const payload = verifyJwt(token, auth.jwtKey, auth.jwtAlgorithm);
const uid = payload.uid === undefined || payload.uid === null ? "" : String(payload.uid);
if (!uid) throw new AuthError("token 缺少 uid");
return {
uid,
team_id: request.teamId ?? firstHeader(request.headers["x-team-id"]) ?? "",
nickname: payload.nickname,
avatar: payload.avatar,
mobile: payload.mobile,
};
}
const uid = request.uid ?? firstHeader(request.headers["x-uid"]) ?? "1";
return {
uid,
team_id: request.teamId ?? firstHeader(request.headers["x-team-id"]) ?? "",
nickname: firstHeader(request.headers["x-nickname"]),
};
}
export function verifyJwt(token: string, secret: string, algorithm: string): JwtPayload {
const parts = token.split(".");
if (parts.length !== 3) throw new AuthError("token 格式错误");
const [headerPart, payloadPart, signaturePart] = parts;
if (!headerPart || !payloadPart || !signaturePart) throw new AuthError("token 格式错误");
const header = decodeJson(headerPart) as { alg?: string };
if (header.alg !== algorithm) {
throw new AuthError(`不支持的签名算法:${header.alg ?? "unknown"}`);
}
if (algorithm !== "HS256") {
throw new AuthError(`JWT_ALGORITHM 仅支持 HS256,当前为 ${algorithm}`);
}
const expected = createHmac("sha256", secret).update(`${headerPart}.${payloadPart}`).digest("base64url");
const providedBuffer = Buffer.from(signaturePart);
const expectedBuffer = Buffer.from(expected);
if (providedBuffer.length !== expectedBuffer.length || !timingSafeEqual(providedBuffer, expectedBuffer)) {
throw new AuthError("token 签名无效");
}
const payload = decodeJson(payloadPart) as JwtPayload;
const now = Math.floor(Date.now() / 1000);
if (!payload.exp || payload.exp < now) throw new AuthError("token 已过期");
if (payload.nbf && payload.nbf > now + 60) throw new AuthError("token 尚未生效");
// team-api 签发的 uid 是不带引号的 19 位整数,JSON.parse 会按双精度截断
// (1953296379736625152 会变成 1953296379736625200),必须从原始 JSON 文本里按字符串取回
const rawUid = readRawUid(Buffer.from(payloadPart, "base64url").toString("utf8"));
if (rawUid) payload.uid = rawUid;
return payload;
}
function readRawUid(payloadJson: string): string | undefined {
const match = payloadJson.match(/"uid"\s*:\s*"?([0-9]+)"?/);
return match ? match[1] : undefined;
}
function decodeJson(part: string): unknown {
try {
return JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
} catch {
throw new AuthError("token 解析失败");
}
}
function firstHeader(value: string | string[] | undefined): string | undefined {
if (Array.isArray(value)) return value[0];
return value || undefined;
}
+179
View File
@@ -0,0 +1,179 @@
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
import { dirname } from "node:path";
import { randomUUID } from "node:crypto";
import type { Logger } from "../logger.js";
import type { LedgerStatus, UsageRecord } from "../types.js";
export interface BeginUsage
extends Omit<UsageRecord, "id" | "ts" | "status" | "gateway_task_id" | "image_urls" | "error"> {}
export interface UsageFilter {
uid?: string;
team_id?: string;
session_id?: string;
skill_id?: string;
status?: LedgerStatus;
tool_call_id?: string;
limit?: number;
}
export interface UsageSummary {
billing_mode: string;
currency: string;
totals: {
records: number;
images: number;
credits_succeeded: number;
credits_pending: number;
credits_failed: number;
};
by_skill: Array<{
skill_id: string;
skill_name: string;
records: number;
images: number;
credits: number;
}>;
}
type SettlePatch = Partial<
Pick<UsageRecord, "gateway_task_id" | "image_urls" | "error" | "quantity" | "total_credits" | "size_multiplier">
>;
export class UsageLedger {
private readonly records = new Map<string, UsageRecord>();
private readonly succeededByToolCall = new Map<string, string>();
constructor(
private readonly file: string,
private readonly logger: Logger,
) {
mkdirSync(dirname(file), { recursive: true });
this.load();
}
begin(input: BeginUsage): UsageRecord {
const record: UsageRecord = {
...input,
id: randomUUID(),
ts: Math.floor(Date.now() / 1000),
gateway_task_id: "",
image_urls: [],
status: "pending",
};
this.store(record);
return record;
}
settle(id: string, status: Exclude<LedgerStatus, "pending">, patch: SettlePatch = {}): UsageRecord | undefined {
const current = this.records.get(id);
if (!current) {
this.logger.warn({ id }, "settle called for unknown usage record");
return undefined;
}
const next: UsageRecord = {
...current,
...patch,
status,
ts: Math.floor(Date.now() / 1000),
};
this.store(next);
if (status === "succeeded") {
this.succeededByToolCall.set(toolKey(next.uid, next.session_id, next.tool_call_id), next.id);
}
return next;
}
findSucceededToolCall(uid: string, sessionId: string, toolCallId: string): UsageRecord | undefined {
if (!toolCallId) return undefined;
const id = this.succeededByToolCall.get(toolKey(uid, sessionId, toolCallId));
return id ? this.records.get(id) : undefined;
}
list(filter: UsageFilter = {}): UsageRecord[] {
const result = [...this.records.values()].filter((record) => matches(record, filter));
result.sort((a, b) => b.ts - a.ts || a.id.localeCompare(b.id));
return filter.limit !== undefined ? result.slice(0, Math.max(0, filter.limit)) : result;
}
summary(filter: UsageFilter = {}): UsageSummary {
const records = this.list({ ...filter, limit: undefined });
const bySkill = new Map<string, UsageSummary["by_skill"][number]>();
const totals = {
records: records.length,
images: 0,
credits_succeeded: 0,
credits_pending: 0,
credits_failed: 0,
};
for (const record of records) {
const entry =
bySkill.get(record.skill_id) ??
{
skill_id: record.skill_id,
skill_name: record.skill_name,
records: 0,
images: 0,
credits: 0,
};
entry.records += 1;
if (record.status === "succeeded") {
totals.images += record.quantity;
totals.credits_succeeded += record.total_credits;
entry.images += record.quantity;
entry.credits += record.total_credits;
} else if (record.status === "pending") {
totals.credits_pending += record.total_credits;
} else {
totals.credits_failed += record.total_credits;
}
bySkill.set(record.skill_id, entry);
}
return {
billing_mode: records[0]?.billing_mode ?? "unknown",
currency: records[0]?.currency ?? "credits",
totals,
by_skill: [...bySkill.values()].sort((a, b) => b.credits - a.credits),
};
}
private store(record: UsageRecord): void {
this.records.set(record.id, record);
appendFileSync(this.file, `${JSON.stringify(record)}\n`, "utf8");
}
private load(): void {
if (!existsSync(this.file)) return;
for (const line of readFileSync(this.file, "utf8").split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const record = JSON.parse(trimmed) as UsageRecord;
if (!record.id) continue;
this.records.set(record.id, record);
if (record.status === "succeeded" && record.tool_call_id) {
this.succeededByToolCall.set(toolKey(record.uid, record.session_id, record.tool_call_id), record.id);
}
} catch {
this.logger.warn({ line: trimmed.slice(0, 120) }, "skipping malformed usage ledger line");
}
}
}
}
function toolKey(uid: string, sessionId: string, toolCallId: string): string {
return `${uid}:${sessionId}:${toolCallId}`;
}
function matches(record: UsageRecord, filter: UsageFilter): boolean {
if (filter.uid && record.uid !== filter.uid) return false;
if (filter.team_id && record.team_id !== filter.team_id) return false;
if (filter.session_id && record.session_id !== filter.session_id) return false;
if (filter.skill_id && record.skill_id !== filter.skill_id) return false;
if (filter.status && record.status !== filter.status) return false;
if (filter.tool_call_id && record.tool_call_id !== filter.tool_call_id) return false;
return true;
}
+72
View File
@@ -0,0 +1,72 @@
import type { BillingMode } from "../config.js";
import type { Quote, SkillDefinition } from "../types.js";
export class PricingError extends Error {
constructor(message: string) {
super(message);
this.name = "PricingError";
}
}
const SIZE = /^(\d{2,5})x(\d{2,5})$/;
export interface QuoteInput {
quantity?: number;
size?: string;
}
export interface QuoteDefaults {
size: string;
quantity: number;
}
export function quoteSkill(
skill: SkillDefinition,
defaults: QuoteDefaults,
input: QuoteInput,
billingMode: BillingMode,
): Quote {
const quantity = normalizeQuantity(skill, input.quantity ?? defaults.quantity);
const size = input.size ?? defaults.size;
if (!SIZE.test(size)) {
throw new PricingError(`size must look like 1024x1536, got "${size}"`);
}
const multiplier = skill.manifest.price.size_multipliers[size] ?? 1;
return {
skill_id: skill.key,
skill_name: skill.displayName,
model: skill.manifest.model,
size,
quantity,
unit_credits: skill.manifest.price.per_image,
size_multiplier: multiplier,
total_credits: Math.round(skill.manifest.price.per_image * multiplier * quantity),
currency: skill.manifest.price.currency,
billing_mode: billingMode,
};
}
export function repriceForQuantity(quote: Quote, quantity: number): Quote {
if (!Number.isInteger(quantity) || quantity < 0) {
throw new PricingError(`quantity must be a non-negative integer, got ${quantity}`);
}
return {
...quote,
quantity,
total_credits: Math.round(quote.unit_credits * quote.size_multiplier * quantity),
};
}
export function normalizeQuantity(skill: SkillDefinition, quantity: number): number {
if (!Number.isInteger(quantity)) {
throw new PricingError(`quantity must be an integer, got ${quantity}`);
}
if (quantity < 1) {
throw new PricingError("quantity must be at least 1");
}
if (quantity > skill.manifest.max_images) {
throw new PricingError(`quantity exceeds max_images (${skill.manifest.max_images}) for skill ${skill.key}`);
}
return quantity;
}
+307
View File
@@ -0,0 +1,307 @@
import { join, resolve } from "node:path";
import { z } from "zod";
import { parseHostAllowlist } from "./utils/net.js";
export type BillingMode = "live" | "dry_run" | "off";
export type NodeEnv = "development" | "test" | "production";
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
const boolish = z.preprocess((value) => {
if (typeof value === "boolean") return value;
if (typeof value === "string") return ["1", "true", "yes", "on"].includes(value.toLowerCase());
return value;
}, z.boolean());
const intish = (fallback: number, min: number, max: number) =>
z.preprocess(
(value) => (value === "" || value === undefined || value === null ? undefined : value),
z.coerce.number().int().min(min).max(max).default(fallback),
);
const envSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
HOST: z.string().default("0.0.0.0"),
PORT: intish(8100, 1, 65535),
CORS_ORIGIN: z.string().default("*"),
TEAM_API_URL: z.string().default("http://localhost:8000"),
SESSION_STORE: z.enum(["mysql", "file"]).default("mysql"),
SESSION_CONTEXT: z.enum(["mysql", "file"]).default("mysql"),
APP_ROOT: z.string().optional(),
DATA_DIR: z.string().default("data"),
FILE_FETCH_ALLOWED_HOSTS: z.string().default(""),
BILLING_MODE: z.enum(["live", "dry_run", "off"]).default("live"),
BILLING_CURRENCY: z.string().default("credits"),
DEFAULT_SKILL_PRICE: intish(10, 0, 1_000_000),
DEFAULT_SKILL_MODEL: z.string().default("gpt-image-2"),
DEFAULT_SKILL_SIZE: z.string().default("1024x1024"),
TOOL_CONFIRMATION_ENABLED: boolish.default(true),
TOOL_CONFIRMATION_TOOLS: z.string().default("generate_image"),
TOOL_CONFIRMATION_TIMEOUT_MS: intish(300_000, 1_000, 3_600_000),
MYSQL_HOST: z.string().default("127.0.0.1"),
MYSQL_PORT: intish(3306, 1, 65535),
MYSQL_USER: z.string().default("root"),
MYSQL_PASSWORD: z.string().default(""),
MYSQL_DATABASE: z.string().default("laimiaodb"),
MYSQL_CONNECTION_LIMIT: intish(10, 1, 100),
MYSQL_CONNECT_TIMEOUT_MS: intish(10_000, 1_000, 120_000),
GATEWAY_CN_URL: z.string().default("https://cn.gateway.laimiaoai.com"),
GATEWAY_GLOBAL_URL: z.string().default("https://gateway.laimiaoai.com"),
GATEWAY_FORCE_URL: z.string().optional(),
GATEWAY_MOCK: boolish.default(false),
GATEWAY_REQUEST_TIMEOUT_MS: intish(180_000, 1_000, 900_000),
GATEWAY_POLL_INTERVAL_MS: intish(2_000, 10, 60_000),
GATEWAY_POLL_TIMEOUT_MS: intish(900_000, 1_000, 3_600_000),
LAIMIAO_LLM_PROVIDER: z.string().default("laimiao"),
LAIMIAO_LLM_BASE_URL: z.string().default(""),
LAIMIAO_LLM_API_KEY: z.string().default(""),
LAIMIAO_LLM_MODEL: z.string().default("deepseek-chat"),
LAIMIAO_LLM_CONTEXT_WINDOW: intish(131_072, 1_024, 2_000_000),
LAIMIAO_LLM_MAX_TOKENS: intish(8_192, 128, 200_000),
LAIMIAO_LLM_REASONING: boolish.default(false),
LAIMIAO_LLM_THINKING: z
.enum(["off", "minimal", "low", "medium", "high", "xhigh", "max"])
.default("low"),
JWT_KEY: z.string().optional(),
JWT_ALGORITHM: z.string().default("HS256"),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
});
export interface LlmConfig {
providerId: string;
baseUrl: string;
apiKey: string;
modelId: string;
contextWindow: number;
maxTokens: number;
reasoning: boolean;
thinkingLevel: ThinkingLevel;
}
export interface GatewayConfig {
cnUrl: string;
globalUrl: string;
forceUrl?: string;
mock: boolean;
requestTimeoutMs: number;
pollIntervalMs: number;
pollTimeoutMs: number;
}
export interface ToolConfirmationConfig {
tools: string[];
timeoutMs: number;
}
export interface BillingConfig {
mode: BillingMode;
currency: string;
defaultSkillPrice: number;
}
export interface MysqlConfig {
host: string;
port: number;
user: string;
password: string;
database: string;
connectionLimit: number;
connectTimeoutMs: number;
}
export interface SkillDefaultsConfig {
model: string;
size: string;
}
export interface ToolConfirmationConfig {
enabled: boolean;
tools: string[];
timeoutMs: number;
}
export interface AuthConfig {
jwtKey?: string;
jwtAlgorithm: string;
}
export interface Paths {
rootDir: string;
dataDir: string;
agentDir: string;
workspaceDir: string;
piSessionsDir: string;
contextTmpDir: string;
sessionsFile: string;
messagesDir: string;
ledgerFile: string;
modelsFile: string;
}
export interface AppConfig extends Paths {
nodeEnv: NodeEnv;
isProduction: boolean;
host: string;
port: number;
teamApiUrl: string;
fileFetchAllowedHosts: string[];
sessionStore: "mysql" | "file";
sessionContext: "mysql" | "file";
corsOrigin: string;
logLevel: "debug" | "info" | "warn" | "error";
llm: LlmConfig;
gateway: GatewayConfig;
toolConfirmation: ToolConfirmationConfig;
billing: BillingConfig;
skillDefaults: SkillDefaultsConfig;
mysql: MysqlConfig;
auth: AuthConfig;
}
type RawEnv = Record<string, string | undefined>;
export function loadConfig(env: RawEnv = process.env): AppConfig {
const parsed = envSchema.safeParse(env);
if (!parsed.success) {
const issue = parsed.error.issues[0];
throw new Error(`invalid environment: ${issue?.path.join(".") ?? "?"} ${issue?.message ?? ""}`.trim());
}
const raw = parsed.data;
const rootDir = resolve(raw.APP_ROOT || process.cwd());
const dataDir = resolve(rootDir, raw.DATA_DIR);
const agentDir = join(dataDir, "pi");
const paths: Paths = {
rootDir,
dataDir,
agentDir,
workspaceDir: join(dataDir, "workspace"),
piSessionsDir: join(agentDir, "sessions"),
contextTmpDir: join(dataDir, "context-tmp"),
sessionsFile: join(dataDir, "sessions.json"),
messagesDir: join(dataDir, "messages"),
ledgerFile: join(dataDir, "usage-ledger.jsonl"),
modelsFile: join(agentDir, "models.json"),
};
for (const [name, value] of [
["GATEWAY_CN_URL", raw.GATEWAY_CN_URL],
["GATEWAY_GLOBAL_URL", raw.GATEWAY_GLOBAL_URL],
["GATEWAY_FORCE_URL", raw.GATEWAY_FORCE_URL],
["LAIMIAO_LLM_BASE_URL", raw.LAIMIAO_LLM_BASE_URL],
["TEAM_API_URL", raw.TEAM_API_URL],
] as const) {
if (value && !isHttpUrl(value)) throw new Error(`${name} must be an http(s) URL, got "${value}"`);
}
const corsOrigins = parseCorsOrigins(raw.CORS_ORIGIN);
const fileFetchAllowedHosts = parseHostAllowlist(raw.FILE_FETCH_ALLOWED_HOSTS);
const isProduction = raw.NODE_ENV === "production";
if (isProduction) {
if (!raw.JWT_KEY) {
throw new Error("invalid environment: JWT_KEY is required when NODE_ENV=production");
}
if (corsOrigins.length === 0 || corsOrigins.includes("*")) {
throw new Error(
"invalid environment: CORS_ORIGIN must list explicit origins (no wildcard) when NODE_ENV=production",
);
}
for (const origin of corsOrigins) {
if (!isHttpOrigin(origin)) throw new Error(`invalid environment: CORS_ORIGIN entry "${origin}" is not an origin`);
}
if (fileFetchAllowedHosts.length === 0) {
throw new Error(
"invalid environment: FILE_FETCH_ALLOWED_HOSTS is required when NODE_ENV=production (comma separated CDN hosts, e.g. cdn.example.com,*.oss.example.com)",
);
}
}
return {
...paths,
nodeEnv: raw.NODE_ENV,
isProduction,
host: raw.HOST,
port: raw.PORT,
teamApiUrl: raw.TEAM_API_URL.replace(/\/+$/, ""),
fileFetchAllowedHosts,
sessionStore: raw.SESSION_STORE,
sessionContext: raw.SESSION_CONTEXT,
corsOrigin: raw.CORS_ORIGIN,
logLevel: raw.LOG_LEVEL,
llm: {
providerId: raw.LAIMIAO_LLM_PROVIDER,
baseUrl: raw.LAIMIAO_LLM_BASE_URL,
apiKey: raw.LAIMIAO_LLM_API_KEY,
modelId: raw.LAIMIAO_LLM_MODEL,
contextWindow: raw.LAIMIAO_LLM_CONTEXT_WINDOW,
maxTokens: raw.LAIMIAO_LLM_MAX_TOKENS,
reasoning: raw.LAIMIAO_LLM_REASONING,
thinkingLevel: raw.LAIMIAO_LLM_THINKING,
},
gateway: {
cnUrl: raw.GATEWAY_CN_URL,
globalUrl: raw.GATEWAY_GLOBAL_URL,
forceUrl: raw.GATEWAY_FORCE_URL || undefined,
mock: raw.GATEWAY_MOCK,
requestTimeoutMs: raw.GATEWAY_REQUEST_TIMEOUT_MS,
pollIntervalMs: raw.GATEWAY_POLL_INTERVAL_MS,
pollTimeoutMs: raw.GATEWAY_POLL_TIMEOUT_MS,
},
billing: {
mode: raw.BILLING_MODE,
currency: raw.BILLING_CURRENCY,
defaultSkillPrice: raw.DEFAULT_SKILL_PRICE,
},
skillDefaults: {
model: raw.DEFAULT_SKILL_MODEL,
size: raw.DEFAULT_SKILL_SIZE,
},
toolConfirmation: {
enabled: raw.TOOL_CONFIRMATION_ENABLED,
tools: raw.TOOL_CONFIRMATION_TOOLS.split(",")
.map((tool) => tool.trim())
.filter(Boolean),
timeoutMs: raw.TOOL_CONFIRMATION_TIMEOUT_MS,
},
mysql: {
host: raw.MYSQL_HOST,
port: raw.MYSQL_PORT,
user: raw.MYSQL_USER,
password: raw.MYSQL_PASSWORD,
database: raw.MYSQL_DATABASE,
connectionLimit: raw.MYSQL_CONNECTION_LIMIT,
connectTimeoutMs: raw.MYSQL_CONNECT_TIMEOUT_MS,
},
auth: {
jwtKey: raw.JWT_KEY || undefined,
jwtAlgorithm: raw.JWT_ALGORITHM,
},
};
}
function isHttpUrl(value: string): boolean {
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
}
function parseCorsOrigins(value: string): string[] {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function isHttpOrigin(value: string): boolean {
if (value === "*") return false;
try {
const url = new URL(value);
return (url.protocol === "http:" || url.protocol === "https:") && url.pathname === "/" && !url.search && !url.hash;
} catch {
return false;
}
}
+330
View File
@@ -0,0 +1,330 @@
import { randomUUID } from "node:crypto";
import type { GatewayConfig } from "../config.js";
import type { Logger } from "../logger.js";
export class GatewayError extends Error {
constructor(
message: string,
readonly code: string,
readonly status?: number,
readonly detail?: unknown,
) {
super(message);
this.name = "GatewayError";
}
}
export interface GenerateImageInput {
uid: string;
team_id: string;
model: string;
prompt: string;
size: string;
n: number;
images?: string[];
watermark?: boolean;
metadata?: Record<string, unknown>;
signal?: AbortSignal;
}
export interface GenerateImageResult {
taskId: string;
urls: string[];
status: string;
raw: unknown;
elapsedMs: number;
}
const CN_PREFIXES = ["seedream", "seedance", "kling", "wan", "happyhorse"];
const FAILURE_STATES = new Set(["failed", "error", "failure", "cancelled", "canceled"]);
const SUCCESS_STATES = new Set(["success", "completed", "succeeded", "done", "finished", "ok"]);
const IMAGE_KEYS = ["url", "urls", "images", "data", "results", "output", "drawing", "media_list", "videos"] as const;
export class GatewayClient {
constructor(
private readonly config: GatewayConfig,
private readonly logger: Logger,
) {}
baseUrlFor(model: string): string {
if (this.config.forceUrl) return stripSlash(this.config.forceUrl);
const normalized = model.toLowerCase();
const isCn = CN_PREFIXES.some((prefix) => normalized.startsWith(prefix));
return stripSlash(isCn ? this.config.cnUrl : this.config.globalUrl);
}
async generateImage(input: GenerateImageInput): Promise<GenerateImageResult> {
const startedAt = Date.now();
if (this.config.mock) return this.mockGenerate(input, startedAt);
const base = this.baseUrlFor(input.model);
const submit = await this.requestJson(`${base}/generate/v1/images`, {
method: "POST",
body: stringifyWithRawIds({
uid: toRawInt(input.uid),
team_id: toRawInt(input.team_id),
model: input.model,
prompt: input.prompt,
size: normalizeSize(input.model, input.size),
n: input.n,
images: input.images?.length ? input.images : undefined,
watermark: input.watermark ?? false,
type: 0,
is_allowed: 1,
metadata: input.metadata,
}),
signal: input.signal,
});
const submitRecord = asRecord(submit);
const ret = submitRecord.ret;
if (ret !== undefined && ret !== null && Number(ret) !== 0) {
throw new GatewayError(
`gateway rejected the task: ${String(submitRecord.msg ?? ret)}`,
"GATEWAY_REJECTED",
502,
submit,
);
}
const taskId = String(submitRecord.task_id ?? submitRecord.id ?? "");
const inlineUrls = extractImageUrls(submitRecord);
if (inlineUrls.length > 0) {
return {
taskId,
urls: inlineUrls.slice(0, input.n),
status: "success",
raw: submit,
elapsedMs: Date.now() - startedAt,
};
}
if (!taskId) {
throw new GatewayError("gateway did not return a task_id", "GATEWAY_BAD_RESPONSE", 502, submit);
}
this.logger.info({ taskId, model: input.model, n: input.n }, "gateway task submitted");
const polled = await this.poll(taskId, base, input.signal);
return {
taskId,
urls: polled.urls.slice(0, input.n),
status: polled.status,
raw: polled.raw,
elapsedMs: Date.now() - startedAt,
};
}
private async poll(
taskId: string,
base: string,
signal?: AbortSignal,
): Promise<{ status: string; urls: string[]; raw: unknown }> {
const deadline = Date.now() + this.config.pollTimeoutMs;
const url = `${base}/generate/v1/images/${encodeURIComponent(taskId)}`;
let successWithoutUrls = 0;
while (Date.now() < deadline) {
await sleep(this.config.pollIntervalMs, signal);
let data: unknown;
try {
data = await this.requestJson(url, { method: "GET", signal });
} catch (error) {
if (signal?.aborted) {
throw new GatewayError("request aborted", "GATEWAY_ABORTED");
}
if (error instanceof GatewayError) {
if (error.code === "GATEWAY_TASK_NOT_FOUND" || error.code === "GATEWAY_GENERATION_FAILED") {
throw error;
}
if (
error.status &&
error.status >= 400 &&
error.status < 500 &&
error.status !== 408 &&
error.status !== 429
) {
throw error;
}
}
this.logger.warn(
{
taskId,
error: error instanceof Error ? error.message : String(error),
code: error instanceof GatewayError ? error.code : undefined,
},
"gateway poll failed; retrying",
);
continue;
}
const record = asRecord(data);
const status = String(record.status ?? "").toLowerCase();
const urls = extractImageUrls(record);
if (urls.length > 0) return { status: status || "success", urls, raw: data };
if (status === "not_found") {
throw new GatewayError(`gateway task not found: ${taskId}`, "GATEWAY_TASK_NOT_FOUND", 404, data);
}
if (FAILURE_STATES.has(status)) {
throw new GatewayError(
String(record.error ?? record.msg ?? "image generation failed"),
"GATEWAY_GENERATION_FAILED",
502,
data,
);
}
if (SUCCESS_STATES.has(status)) {
successWithoutUrls += 1;
if (successWithoutUrls >= 5) {
throw new GatewayError("gateway finished without returning image urls", "GATEWAY_NO_IMAGES", 502, data);
}
this.logger.warn(
{ taskId, status, attempts: successWithoutUrls },
"gateway reported success without image urls; waiting for finalization",
);
continue;
}
successWithoutUrls = 0;
}
throw new GatewayError(`gateway task timed out: ${taskId}`, "GATEWAY_TIMEOUT", 504);
}
private async requestJson(
url: string,
init: { method: string; body?: string; signal?: AbortSignal },
): Promise<unknown> {
const timeoutSignal = AbortSignal.timeout(this.config.requestTimeoutMs);
const signals: AbortSignal[] = [timeoutSignal];
if (init.signal) signals.push(init.signal);
let response: Response;
try {
response = await fetch(url, {
method: init.method,
headers: { "content-type": "application/json" },
body: init.body,
signal: AbortSignal.any(signals),
});
} catch (error) {
if (init.signal?.aborted) {
throw new GatewayError("request aborted", "GATEWAY_ABORTED");
}
if (timeoutSignal.aborted) {
throw new GatewayError("gateway request timed out", "GATEWAY_TIMEOUT", 504);
}
throw new GatewayError(
`gateway request failed: ${error instanceof Error ? error.message : String(error)}`,
"GATEWAY_NETWORK_ERROR",
);
}
const text = await response.text();
let data: unknown = text;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = text;
}
if (!response.ok) {
throw new GatewayError(`gateway responded ${response.status}`, "GATEWAY_HTTP_ERROR", response.status, data);
}
return data;
}
private async mockGenerate(input: GenerateImageInput, startedAt: number): Promise<GenerateImageResult> {
await sleep(Math.min(this.config.pollIntervalMs, 300), input.signal);
const taskId = `mock-${randomUUID()}`;
const urls = Array.from({ length: input.n }, (_, index) => {
const seed = encodeURIComponent(`${input.model}-${taskId}-${index + 1}`);
return `https://picsum.photos/seed/${seed}/${input.size.replace("x", "/")}`;
});
this.logger.info({ taskId, model: input.model, n: input.n }, "mock image generated");
return {
taskId,
urls,
status: "success",
raw: { mock: true },
elapsedMs: Date.now() - startedAt,
};
}
}
export function extractImageUrls(record: Record<string, unknown>): string[] {
const urls: string[] = [];
const seen = new Set<string>();
const add = (value: unknown): void => {
if (typeof value === "string" && /^https?:\/\//.test(value) && !seen.has(value)) {
seen.add(value);
urls.push(value);
}
};
const addNode = (value: unknown): void => {
if (Array.isArray(value)) {
for (const item of value) addNode(item);
return;
}
if (value && typeof value === "object") {
const entry = value as Record<string, unknown>;
add(entry.url ?? entry.image_url ?? entry.oss_url ?? entry.video_url);
for (const key of ["images", "output", "drawing", "media_list", "data", "results"]) {
if (key in entry) addNode(entry[key]);
}
return;
}
add(value);
};
for (const key of IMAGE_KEYS) {
if (key in record) addNode(record[key]);
}
return urls;
}
export function normalizeSize(model: string, size: string): string {
if (!model.toLowerCase().startsWith("gpt-image")) return size;
const match = size.toLowerCase().match(/^(\d+)x(\d+)$/);
if (!match) return size;
const round = (value: number) => Math.max(16, Math.round(value / 16) * 16);
return `${round(Number(match[1]))}x${round(Number(match[2]))}`;
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
}
function toRawInt(value: string): unknown {
const digits = String(value ?? "").replace(/[^0-9]/g, "") || "0";
const rawJson = (JSON as unknown as { rawJSON?: (text: string) => unknown }).rawJSON;
if (typeof rawJson === "function") return rawJson(digits);
return `__raw_${digits}__`;
}
function stringifyWithRawIds(body: Record<string, unknown>): string {
return JSON.stringify(body).replace(/"__raw_([0-9]+)__"/g, "$1");
}
function stripSlash(value: string): string {
return value.replace(/\/+$/, "");
}
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new GatewayError("request aborted", "GATEWAY_ABORTED"));
return;
}
const timer = setTimeout(() => {
cleanup();
resolve();
}, ms);
const onAbort = () => {
cleanup();
reject(new GatewayError("request aborted", "GATEWAY_ABORTED"));
};
const cleanup = () => {
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
};
signal?.addEventListener("abort", onAbort, { once: true });
});
}
+99
View File
@@ -0,0 +1,99 @@
import "dotenv/config";
import { AgentService } from "./agent/service.js";
import { UsageLedger } from "./billing/ledger.js";
import { loadConfig } from "./config.js";
import { GatewayClient } from "./gateway/client.js";
import { createLogger } from "./logger.js";
import { buildServer } from "./server.js";
import { SessionStore, type SessionStoreLike } from "./sessions/store.js";
import { MySqlSessionStore } from "./sessions/mysql_store.js";
import { MySqlSessionContextStore, NoopSessionContextStore, type SessionContextStore } from "./sessions/context_store.js";
import { SkillCatalog } from "./skills/catalog.js";
import { MySqlSkillRepository } from "./skills/repository.js";
const config = loadConfig();
const logger = createLogger(config.logLevel);
const skillRepository = new MySqlSkillRepository(
config.mysql,
{
size: config.skillDefaults.size,
price: config.billing.defaultSkillPrice,
currency: config.billing.currency,
},
logger,
);
const catalog = await new SkillCatalog({
repository: skillRepository,
defaultPrice: config.billing.defaultSkillPrice,
defaultModel: config.skillDefaults.model,
defaultSize: config.skillDefaults.size,
currency: config.billing.currency,
logger,
}).load();
const ledger = new UsageLedger(config.ledgerFile, logger);
async function createSessionStore(): Promise<SessionStoreLike> {
const fallback = new SessionStore(config.sessionsFile, config.messagesDir, logger);
if (config.sessionStore !== "mysql") return fallback;
try {
const store = new MySqlSessionStore(config.mysql, logger);
await store.init();
return store;
} catch (error) {
logger.error(
{ error: error instanceof Error ? error.message : String(error) },
"mysql session store init failed, fallback to file store",
);
return fallback;
}
}
const store = await createSessionStore();
async function createContextStore(): Promise<SessionContextStore> {
if (config.sessionContext !== "mysql") return new NoopSessionContextStore();
const storeImpl = new MySqlSessionContextStore(config.mysql, logger);
await storeImpl.init();
return storeImpl;
}
const contextStore = await createContextStore();
const gateway = new GatewayClient(config.gateway, logger);
const service = new AgentService(config, catalog, ledger, gateway, store, contextStore, logger);
const app = await buildServer({ config, logger, catalog, ledger, store, service });
for (const diagnostic of catalog.diagnostics()) {
logger.warn({ diagnostic }, "skill catalog diagnostic");
}
if (!config.auth.jwtKey) {
logger.warn("JWT_KEY 未配置:开发模式,接受 x-uid / x-team-id 头");
}
if (catalog.list().length === 0) {
logger.warn({ database: config.mysql.database }, "skills 表没有可用技能,请先向 skill_library 写入技能");
}
if (!service.isLlmConfigured()) {
logger.warn("LAIMIAO_LLM_BASE_URL / LAIMIAO_LLM_API_KEY 未配置:对话接口将返回 503");
}
await app.listen({ host: config.host, port: config.port });
logger.info(
{
port: config.port,
skills: catalog.list().map((skill) => skill.key),
skillSource: "mysql",
billing: config.billing.mode,
gatewayMock: config.gateway.mock,
},
"laimiao-skill-agent started",
);
const shutdown = async (signal: string): Promise<void> => {
logger.info({ signal }, "shutting down");
await app.close();
await skillRepository.close();
process.exit(0);
};
process.on("SIGINT", () => void shutdown("SIGINT"));
process.on("SIGTERM", () => void shutdown("SIGTERM"));
+39
View File
@@ -0,0 +1,39 @@
export type LogLevel = "debug" | "info" | "warn" | "error";
const ORDER: Record<LogLevel, number> = { debug: 10, info: 20, warn: 30, error: 40 };
export interface Logger {
debug(fields: unknown, message?: string): void;
info(fields: unknown, message?: string): void;
warn(fields: unknown, message?: string): void;
error(fields: unknown, message?: string): void;
}
export function createLogger(level: LogLevel = "info"): Logger {
return {
debug: (fields, message) => write("debug", level, fields, message),
info: (fields, message) => write("info", level, fields, message),
warn: (fields, message) => write("warn", level, fields, message),
error: (fields, message) => write("error", level, fields, message),
};
}
function write(level: LogLevel, threshold: LogLevel, fields: unknown, message?: string): void {
if (ORDER[level] < ORDER[threshold]) return;
const record: Record<string, unknown> = {
ts: new Date().toISOString(),
level,
};
if (message) record.msg = message;
if (fields instanceof Error) {
record.error = { name: fields.name, message: fields.message, stack: fields.stack };
} else if (fields && typeof fields === "object") {
Object.assign(record, fields);
} else if (fields !== undefined) {
record.detail = fields;
}
const line = JSON.stringify(record);
if (level === "error") console.error(line);
else if (level === "warn") console.warn(line);
else console.log(line);
}
+393
View File
@@ -0,0 +1,393 @@
import { sanitizeTitle } from "./utils/text.js";
import { MAX_IMAGES_PER_REQUEST } from "./skills/params.js";
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify";
import cors from "@fastify/cors";
import { z } from "zod";
import { AgentError, type AgentService, type AgentStreamEvent } from "./agent/service.js";
import { AuthError, resolveIdentity } from "./auth/jwt.js";
import { PricingError, quoteSkill } from "./billing/pricing.js";
import type { UsageLedger } from "./billing/ledger.js";
import type { AppConfig } from "./config.js";
import { GatewayError } from "./gateway/client.js";
import type { Logger } from "./logger.js";
import type { SessionStoreLike } from "./sessions/store.js";
import type { SkillCatalog } from "./skills/catalog.js";
export class HttpError extends Error {
constructor(
readonly statusCode: number,
message: string,
) {
super(message);
this.name = "HttpError";
}
}
export interface ServerDeps {
config: AppConfig;
logger: Logger;
catalog: SkillCatalog;
ledger: UsageLedger;
store: SessionStoreLike;
service: AgentService;
}
const createSessionSchema = z.object({
title: z.string().max(200).optional(),
type: z.number().int().min(0).max(99).optional(),
team_id: z.string().optional(),
uid: z.string().optional(),
});
const messageSchema = z.object({
message: z.string().min(1).max(20_000),
direct: z.boolean().optional(),
reference_images: z.array(z.url()).max(6).optional(),
skill_id: z.string().min(1).optional(),
industry: z.string().max(50).optional(),
stream: z.boolean().optional(),
team_id: z.string().optional(),
uid: z.string().optional(),
});
const runSchema = messageSchema.extend({
session_id: z.string().optional(),
title: z.string().max(200).optional(),
});
const quoteSchema = z.object({
skill_id: z.string().min(1),
size: z.string().optional(),
n: z.number().int().min(1).max(MAX_IMAGES_PER_REQUEST).optional(),
});
const confirmationSchema = z.object({
approved: z.boolean(),
content: z.string().max(20_000).optional(),
reason: z.string().max(2000).optional(),
team_id: z.string().optional(),
uid: z.string().optional(),
});
const listQuerySchema = z.object({
team_id: z.string().optional(),
uid: z.string().optional(),
limit: z.coerce.number().int().min(1).max(200).optional(),
offset: z.coerce.number().int().min(0).optional(),
});
const usageQuerySchema = listQuerySchema.extend({
skill_id: z.string().optional(),
status: z.enum(["pending", "succeeded", "failed"]).optional(),
});
export async function buildServer(deps: ServerDeps): Promise<FastifyInstance> {
const { config, logger, catalog, ledger, store, service } = deps;
const app = Fastify({ logger: false, bodyLimit: 2 * 1024 * 1024 });
await app.register(cors, {
origin: config.corsOrigin === "*" ? true : config.corsOrigin.split(",").map((item) => item.trim()),
credentials: true,
});
app.setErrorHandler((error, _request, reply) => {
const status = error instanceof HttpError ? error.statusCode : mapErrorStatus(error);
const message = error instanceof Error ? error.message : String(error);
if (status >= 500) logger.error({ error }, "request failed");
else logger.warn({ error: message }, "request rejected");
void reply.status(status).send({ detail: message || "internal error" });
});
app.get("/health", async () => ({
ok: true,
service: "laimiao-skill-agent",
version: "0.1.0",
node: process.version,
llm_configured: service.isLlmConfigured(),
billing_mode: config.billing.mode,
currency: config.billing.currency,
gateway_mock: config.gateway.mock,
image_models: [...new Set(catalog.list().map((skill) => skill.manifest.model))],
skills: catalog.list().length,
skill_source: "mysql",
skill_diagnostics: catalog.diagnostics(),
}));
app.get("/skills", async () => {
await catalog.refresh();
return {
skills: catalog.publicSkills(),
diagnostics: catalog.diagnostics(),
};
});
app.get("/skills/:key", async (request) => {
const key = (request.params as { key: string }).key;
await catalog.refresh();
const skill = catalog.get(key);
if (!skill) throw new HttpError(404, `skill 不存在:${key}`);
return { ...catalog.toPublic(skill), content: skill.content };
});
app.post("/images/quote", async (request) => {
const body = parse(quoteSchema, request.body ?? {});
await catalog.refresh();
const skill = catalog.get(body.skill_id);
if (!skill) throw new HttpError(404, `skill 不存在:${body.skill_id}`);
const defaults = catalog.defaultsFor(skill);
return quoteSkill(
skill,
{ size: defaults.size, quantity: defaults.n },
{ size: body.size, quantity: body.n },
config.billing.mode,
);
});
app.post("/sessions", async (request) => {
const body = parse(createSessionSchema, request.body ?? {});
const identity = resolveIdentity(config.auth, {
headers: request.headers,
teamId: body.team_id,
uid: body.uid,
});
return await store.create(
{ ...identity, team_id: identity.team_id || identity.uid },
{ title: sanitizeTitle(body.title), type: body.type },
);
});
app.get("/sessions", async (request) => {
const query = parse(listQuerySchema, request.query);
const identity = resolveIdentity(config.auth, { headers: request.headers, teamId: query.team_id, uid: query.uid });
return await store.list({
uid: identity.uid,
team_id: identity.team_id || query.team_id,
limit: query.limit,
offset: query.offset,
});
});
app.get("/sessions/:id", async (request) => {
const sessionId = (request.params as { id: string }).id;
const identity = resolveIdentity(config.auth, { headers: request.headers });
const session = await store.get(sessionId);
if (!session) throw new HttpError(404, "会话不存在");
if (session.uid !== identity.uid) throw new HttpError(403, "无权访问该会话");
return { session, messages: await store.messages(sessionId) };
});
app.get("/sessions/:id/messages", async (request) => {
const sessionId = (request.params as { id: string }).id;
const identity = resolveIdentity(config.auth, { headers: request.headers });
const session = await store.get(sessionId);
if (!session) throw new HttpError(404, "会话不存在");
if (session.uid !== identity.uid) throw new HttpError(403, "无权访问该会话");
return { messages: await store.messages(sessionId) };
});
const runInSession = async (
request: FastifyRequest,
sessionId: string,
body: z.infer<typeof messageSchema>,
) => {
const identity = resolveIdentity(config.auth, {
headers: request.headers,
teamId: body.team_id,
uid: body.uid,
});
const session = await store.get(sessionId);
if (!session) throw new HttpError(404, "会话不存在");
if (session.uid !== identity.uid) throw new HttpError(403, "无权访问该会话");
return { identity: { ...identity, team_id: identity.team_id || session.team_id }, session };
};
const handleRun = async (
request: FastifyRequest,
reply: FastifyReply,
sessionId: string,
body: z.infer<typeof messageSchema>,
) => {
const { identity } = await runInSession(request, sessionId, body);
if (!body.stream) {
return service.run({
identity,
sessionId,
message: body.message,
referenceImages: body.reference_images,
skillKey: body.skill_id,
industry: body.industry,
directPrompt: body.direct === true,
});
}
reply.hijack();
const raw = reply.raw;
// hijack 后不再经过 @fastify/cors 的 onSend 钩子,跨域调用必须手动补 CORS 头
const origin = request.headers.origin;
const allowOrigin =
config.corsOrigin === "*"
? origin || "*"
: config.corsOrigin.split(",").map((item) => item.trim()).includes(origin || "")
? origin
: "";
const corsHeaders: Record<string, string> = allowOrigin
? { "access-control-allow-origin": allowOrigin, "access-control-allow-credentials": "true", vary: "Origin" }
: {};
raw.writeHead(200, {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache, no-transform",
connection: "keep-alive",
"x-accel-buffering": "no",
...corsHeaders,
});
raw.write(": connected\n\n");
let clientClosed = false;
const heartbeat = setInterval(() => {
if (!clientClosed && !raw.destroyed && !raw.writableEnded) raw.write(": ping\n\n");
}, 15_000);
raw.on("close", () => {
clientClosed = true;
clearInterval(heartbeat);
});
let emittedError = false;
const writeSse = (event: AgentStreamEvent): void => {
if (event.type === "error") emittedError = true;
if (clientClosed || raw.destroyed || raw.writableEnded) return;
raw.write(`data: ${JSON.stringify(event)}\n\n`);
};
writeSse({ type: "session", session_id: sessionId });
try {
await service.run({
identity,
sessionId,
message: body.message,
referenceImages: body.reference_images,
skillKey: body.skill_id,
industry: body.industry,
directPrompt: body.direct === true,
onEvent: writeSse,
});
} catch (error) {
if (!emittedError) {
writeSse({ type: "error", message: error instanceof Error ? error.message : String(error) });
}
} finally {
clearInterval(heartbeat);
if (!raw.destroyed && !raw.writableEnded) raw.end();
}
};
app.post("/sessions/:id/messages", async (request, reply) => {
const sessionId = (request.params as { id: string }).id;
const body = parse(messageSchema, request.body ?? {});
return handleRun(request, reply, sessionId, body);
});
app.post("/agent/run", async (request, reply) => {
const body = parse(runSchema, request.body ?? {});
if (body.session_id) {
return handleRun(request, reply, body.session_id, body);
}
const identity = resolveIdentity(config.auth, {
headers: request.headers,
teamId: body.team_id,
uid: body.uid,
});
const session = await store.create({ ...identity, team_id: identity.team_id || identity.uid }, { title: sanitizeTitle(body.title) });
if (!body.stream) {
const result = await service.run({
identity,
sessionId: session.session_id,
message: body.message,
referenceImages: body.reference_images,
skillKey: body.skill_id,
industry: body.industry,
});
return { ...result, session: (await store.get(session.session_id)) ?? session };
}
return handleRun(request, reply, session.session_id, body);
});
app.post("/sessions/:id/tool-confirmations/:toolCallId", async (request) => {
const sessionId = (request.params as { id: string }).id;
const toolCallId = (request.params as { toolCallId: string }).toolCallId;
const body = parse(confirmationSchema, request.body ?? {});
const identity = resolveIdentity(config.auth, {
headers: request.headers,
teamId: body.team_id,
uid: body.uid,
});
return service.confirmTool({
identity,
sessionId,
toolCallId,
approved: body.approved,
content: body.content,
reason: body.reason,
});
});
app.get("/usage", async (request) => {
const query = parse(usageQuerySchema, request.query);
const identity = resolveIdentity(config.auth, { headers: request.headers, teamId: query.team_id, uid: query.uid });
const records = ledger.list({
uid: identity.uid,
team_id: identity.team_id || query.team_id,
skill_id: query.skill_id,
status: query.status,
});
const limit = query.limit ?? 50;
return { records: records.slice(0, limit), total: records.length };
});
app.get("/usage/summary", async (request) => {
const query = parse(usageQuerySchema, request.query);
const identity = resolveIdentity(config.auth, { headers: request.headers, teamId: query.team_id, uid: query.uid });
const summary = ledger.summary({
uid: identity.uid,
team_id: identity.team_id || query.team_id,
skill_id: query.skill_id,
status: query.status,
});
return { ...summary, billing_mode: config.billing.mode, currency: config.billing.currency };
});
app.get("/billing", async () => ({
mode: config.billing.mode,
currency: config.billing.currency,
default_skill_price: config.billing.defaultSkillPrice,
deduction_enabled: false,
note: config.billing.mode === "dry_run" ? "试点期按 skill 单价记账,不实际扣减团队积分" : "计费已关闭:不扣减费用,用量仅用于估算与审计",
ledger_file: config.ledgerFile,
}));
return app;
}
function parse<T>(schema: z.ZodType<T>, value: unknown): T {
const result = schema.safeParse(value);
if (!result.success) {
const issue = result.error.issues[0];
throw new HttpError(400, `${issue?.path.join(".") || "body"}: ${issue?.message ?? "参数不合法"}`);
}
return result.data;
}
function mapErrorStatus(error: unknown): number {
if (error instanceof HttpError) return error.statusCode;
if (error instanceof AgentError) return error.statusCode;
if (error instanceof AuthError) return 401;
if (error instanceof PricingError) return 400;
if (error instanceof GatewayError) return 502;
if (error && typeof error === "object" && "statusCode" in error) {
const statusCode = (error as { statusCode?: unknown }).statusCode;
if (typeof statusCode === "number" && statusCode >= 400 && statusCode < 600) return statusCode;
}
return 500;
}
+103
View File
@@ -0,0 +1,103 @@
import { gunzipSync, gzipSync } from "node:zlib";
import { createPool, type Pool, type RowDataPacket } from "mysql2/promise";
import type { MysqlConfig } from "../config.js";
import type { Logger } from "../logger.js";
export interface SessionContextStore {
load(sessionId: string): Promise<Buffer | null>;
save(sessionId: string, content: Buffer): Promise<void>;
remove(sessionId: string): Promise<void>;
}
const CONTEXT_DDL = `
CREATE TABLE IF NOT EXISTS agent_session_context (
session_id VARCHAR(64) NOT NULL,
content LONGBLOB NOT NULL,
raw_size INT NOT NULL DEFAULT 0,
update_time INT NOT NULL DEFAULT 0,
PRIMARY KEY (session_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`;
interface ContextRow extends RowDataPacket {
content: Buffer;
}
export class MySqlSessionContextStore implements SessionContextStore {
private readonly pool: Pool;
constructor(
config: MysqlConfig,
private readonly logger: Logger,
) {
this.pool = createPool({
host: config.host,
port: config.port,
user: config.user,
password: config.password,
database: config.database,
connectionLimit: config.connectionLimit,
connectTimeout: config.connectTimeoutMs,
charset: "utf8mb4",
supportBigNumbers: true,
bigNumberStrings: true,
});
}
async init(): Promise<void> {
await this.pool.query(CONTEXT_DDL);
this.logger.info("session context table ready");
}
async close(): Promise<void> {
await this.pool.end();
}
async load(sessionId: string): Promise<Buffer | null> {
const [rows] = await this.pool.execute<ContextRow[]>(
"SELECT content FROM agent_session_context WHERE session_id = ? LIMIT 1",
[sessionId],
);
const row = rows[0];
if (!row || !row.content) return null;
try {
return gunzipSync(row.content);
} catch (error) {
this.logger.warn(
{ sessionId, error: error instanceof Error ? error.message : String(error) },
"failed to decompress session context",
);
return null;
}
}
async save(sessionId: string, content: Buffer): Promise<void> {
const compressed = gzipSync(content);
await this.pool.execute(
`INSERT INTO agent_session_context (session_id, content, raw_size, update_time)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE content = VALUES(content), raw_size = VALUES(raw_size), update_time = VALUES(update_time)`,
[sessionId, compressed, content.length, Math.floor(Date.now() / 1000)],
);
}
async remove(sessionId: string): Promise<void> {
await this.pool.execute("DELETE FROM agent_session_context WHERE session_id = ?", [sessionId]);
}
}
/** 本地文件模式:pi 自己持久化,这里不需要额外读写 */
export class NoopSessionContextStore implements SessionContextStore {
async load(): Promise<Buffer | null> {
return null;
}
async save(): Promise<void> {
return;
}
async remove(): Promise<void> {
return;
}
}
+363
View File
@@ -0,0 +1,363 @@
import { randomUUID } from "node:crypto";
import { createPool, type Pool, type RowDataPacket } from "mysql2/promise";
import type { MysqlConfig } from "../config.js";
import type { Logger } from "../logger.js";
import type { Identity } from "../types.js";
import type { SessionMessage, SessionMeta, SessionStoreLike } from "./store.js";
const SESSION_DDL = `
CREATE TABLE IF NOT EXISTS agent_session (
session_id VARCHAR(64) NOT NULL,
uid VARCHAR(64) NOT NULL,
team_id VARCHAR(64) NOT NULL DEFAULT '',
title VARCHAR(200) NOT NULL DEFAULT '',
type INT NOT NULL DEFAULT 0,
last_mode VARCHAR(16) NOT NULL DEFAULT 'agent',
message_count INT NOT NULL DEFAULT 0,
add_time INT NOT NULL DEFAULT 0,
update_time INT NOT NULL DEFAULT 0,
PRIMARY KEY (session_id),
KEY idx_uid_time (uid, update_time),
KEY idx_team_time (team_id, update_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`;
const MESSAGE_DDL = `
CREATE TABLE IF NOT EXISTS agent_session_message (
id VARCHAR(64) NOT NULL,
session_id VARCHAR(64) NOT NULL,
role VARCHAR(16) NOT NULL,
mode VARCHAR(16) NOT NULL DEFAULT 'agent',
content MEDIUMTEXT,
thinking MEDIUMTEXT,
media MEDIUMTEXT,
status VARCHAR(16) NOT NULL DEFAULT 'ok',
error VARCHAR(2000) NOT NULL DEFAULT '',
add_time INT NOT NULL DEFAULT 0,
PRIMARY KEY (id),
KEY idx_session_time (session_id, add_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`;
interface SessionRow extends RowDataPacket {
session_id: string;
uid: string;
team_id: string;
title: string;
type: number;
last_mode: string;
message_count: number;
add_time: number;
update_time: number;
}
interface MessageRow extends RowDataPacket {
id: string;
session_id: string;
role: "user" | "assistant";
mode: "agent" | "image" | "video";
content: string | null;
thinking: string | null;
media: string | null;
status: "ok" | "error" | "loading";
error: string | null;
add_time: number;
}
function toMeta(row: SessionRow): SessionMeta {
return {
session_id: row.session_id,
uid: row.uid,
team_id: row.team_id,
title: row.title,
type: row.type,
last_mode: row.last_mode,
message_count: row.message_count,
add_time: row.add_time,
update_time: row.update_time,
};
}
function toMessage(row: MessageRow): SessionMessage {
let media: SessionMessage["media"] = [];
if (row.media) {
try {
const parsed = JSON.parse(row.media);
if (Array.isArray(parsed)) media = parsed;
} catch {
media = [];
}
}
const message: SessionMessage = {
id: row.id,
session_id: row.session_id,
role: row.role,
mode: row.mode,
content: row.content ?? "",
media,
status: row.status,
add_time: row.add_time,
};
if (row.thinking) message.thinking = row.thinking;
if (row.error) message.error = row.error;
return message;
}
export class MySqlSessionStore implements SessionStoreLike {
private readonly pool: Pool;
constructor(
config: MysqlConfig,
private readonly logger: Logger,
) {
this.pool = createPool({
host: config.host,
port: config.port,
user: config.user,
password: config.password,
database: config.database,
connectionLimit: config.connectionLimit,
connectTimeout: config.connectTimeoutMs,
charset: "utf8mb4",
supportBigNumbers: true,
bigNumberStrings: true,
});
}
async init(): Promise<void> {
await this.pool.query(SESSION_DDL);
await this.pool.query(MESSAGE_DDL);
this.logger.info("session tables ready");
}
async close(): Promise<void> {
await this.pool.end();
}
async create(identity: Identity, input: { title?: string; type?: number } = {}): Promise<SessionMeta> {
const now = Math.floor(Date.now() / 1000);
const meta: SessionMeta = {
session_id: randomUUID(),
uid: identity.uid,
team_id: identity.team_id || "",
title: input.title?.trim() ?? "",
type: input.type ?? 0,
last_mode: "agent",
message_count: 0,
add_time: now,
update_time: now,
};
await this.pool.execute(
`INSERT INTO agent_session
(session_id, uid, team_id, title, type, last_mode, message_count, add_time, update_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
meta.session_id,
meta.uid,
meta.team_id,
meta.title,
meta.type,
meta.last_mode,
meta.message_count,
meta.add_time,
meta.update_time,
],
);
return meta;
}
async get(sessionId: string): Promise<SessionMeta | undefined> {
const [rows] = await this.pool.execute<SessionRow[]>(
"SELECT * FROM agent_session WHERE session_id = ? LIMIT 1",
[sessionId],
);
return rows[0] ? toMeta(rows[0]) : undefined;
}
async getOwned(identity: Identity, sessionId: string): Promise<SessionMeta | undefined> {
const session = await this.get(sessionId);
return session && session.uid === identity.uid ? session : undefined;
}
async list(
filter: { uid?: string; team_id?: string; limit?: number; offset?: number } = {},
): Promise<{ sessions: SessionMeta[]; total: number }> {
const where: string[] = [];
const params: string[] = [];
if (filter.uid) {
where.push("uid = ?");
params.push(filter.uid);
}
if (filter.team_id) {
where.push("team_id = ?");
params.push(filter.team_id);
}
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
const limit = Math.max(1, Math.min(filter.limit ?? 50, 200));
const offset = Math.max(0, filter.offset ?? 0);
const [countRows] = await this.pool.execute<(RowDataPacket & { total: number })[]>(
`SELECT COUNT(*) AS total FROM agent_session ${whereSql}`,
params,
);
const [rows] = await this.pool.execute<SessionRow[]>(
`SELECT * FROM agent_session ${whereSql}
ORDER BY update_time DESC, session_id DESC
LIMIT ${limit} OFFSET ${offset}`,
params,
);
return { sessions: rows.map(toMeta), total: Number(countRows[0]?.total ?? 0) };
}
async append(
sessionId: string,
message: Omit<SessionMessage, "id" | "session_id" | "add_time">,
addTime?: number,
): Promise<SessionMessage> {
const full: SessionMessage = {
...message,
id: randomUUID(),
session_id: sessionId,
add_time: addTime ?? Math.floor(Date.now() / 1000),
};
const connection = await this.pool.getConnection();
try {
await connection.beginTransaction();
await connection.execute(
`INSERT INTO agent_session_message
(id, session_id, role, mode, content, thinking, media, status, error, add_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
full.id,
sessionId,
full.role,
full.mode,
full.content ?? "",
full.thinking ?? null,
JSON.stringify(full.media ?? []),
full.status,
full.error ?? "",
full.add_time,
],
);
await connection.execute(
`UPDATE agent_session
SET message_count = message_count + 1,
update_time = ?,
last_mode = ?,
title = CASE WHEN title = '' AND ? = 'user' THEN LEFT(?, 30) ELSE title END
WHERE session_id = ?`,
[full.add_time, full.mode, full.role, full.content ?? "", sessionId],
);
await connection.commit();
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
return full;
}
async update(
sessionId: string,
messageId: string,
patch: Partial<Pick<SessionMessage, "mode" | "content" | "thinking" | "media" | "status" | "error">>,
): Promise<void> {
const connection = await this.pool.getConnection();
try {
await connection.beginTransaction();
await connection.execute(
`UPDATE agent_session_message
SET mode = COALESCE(?, mode),
content = COALESCE(?, content),
thinking = COALESCE(?, thinking),
media = COALESCE(?, media),
status = COALESCE(?, status),
error = COALESCE(?, error)
WHERE id = ? AND session_id = ?`,
[
patch.mode ?? null,
patch.content ?? null,
patch.thinking ?? null,
patch.media ? JSON.stringify(patch.media) : null,
patch.status ?? null,
patch.error ?? null,
messageId,
sessionId,
],
);
if (patch.mode || patch.status) {
await connection.execute(
`UPDATE agent_session
SET update_time = ?, last_mode = COALESCE(?, last_mode)
WHERE session_id = ?`,
[Math.floor(Date.now() / 1000), patch.mode ?? null, sessionId],
);
}
await connection.commit();
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
}
async upsertSession(meta: SessionMeta): Promise<void> {
await this.pool.execute(
`INSERT INTO agent_session
(session_id, uid, team_id, title, type, last_mode, message_count, add_time, update_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
uid = VALUES(uid), team_id = VALUES(team_id), title = VALUES(title), type = VALUES(type),
last_mode = VALUES(last_mode), message_count = VALUES(message_count),
add_time = VALUES(add_time), update_time = VALUES(update_time)`,
[
meta.session_id,
meta.uid,
meta.team_id || "",
meta.title,
meta.type,
meta.last_mode,
meta.message_count,
meta.add_time,
meta.update_time,
],
);
}
async insertMessage(message: SessionMessage): Promise<void> {
await this.pool.execute(
`INSERT IGNORE INTO agent_session_message
(id, session_id, role, mode, content, thinking, media, status, error, add_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
message.id,
message.session_id,
message.role,
message.mode,
message.content ?? "",
message.thinking ?? null,
JSON.stringify(message.media ?? []),
message.status,
message.error ?? "",
message.add_time,
],
);
}
async messages(sessionId: string, limit = 200): Promise<SessionMessage[]> {
const size = Math.max(1, Math.min(limit, 1000));
const [rows] = await this.pool.execute<MessageRow[]>(
`SELECT * FROM agent_session_message
WHERE session_id = ?
ORDER BY add_time DESC, id DESC
LIMIT ${size}`,
[sessionId],
);
return rows.reverse().map(toMessage);
}
}
+198
View File
@@ -0,0 +1,198 @@
import { randomUUID } from "node:crypto";
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import type { Logger } from "../logger.js";
import type { Identity } from "../types.js";
export interface SessionMeta {
session_id: string;
uid: string;
team_id: string;
title: string;
type: number;
last_mode: string;
message_count: number;
add_time: number;
update_time: number;
}
export interface SessionMessage {
id: string;
session_id: string;
role: "user" | "assistant";
mode: "agent" | "image" | "video";
content: string;
thinking?: string;
media: Array<{ type: "image" | "video"; url: string }>;
status: "ok" | "error" | "loading";
error?: string;
add_time: number;
}
interface SessionIndex {
sessions: Record<string, SessionMeta>;
}
export interface SessionStoreLike {
create(identity: Identity, input?: { title?: string; type?: number }): Promise<SessionMeta>;
get(sessionId: string): Promise<SessionMeta | undefined>;
getOwned(identity: Identity, sessionId: string): Promise<SessionMeta | undefined>;
list(filter?: { uid?: string; team_id?: string; limit?: number; offset?: number }): Promise<{
sessions: SessionMeta[];
total: number;
}>;
append(sessionId: string, message: Omit<SessionMessage, "id" | "session_id" | "add_time">, addTime?: number): Promise<SessionMessage>;
update(
sessionId: string,
messageId: string,
patch: Partial<Pick<SessionMessage, "mode" | "content" | "thinking" | "media" | "status" | "error">>,
): Promise<void>;
messages(sessionId: string, limit?: number): Promise<SessionMessage[]>;
}
export class SessionStore implements SessionStoreLike {
constructor(
private readonly indexFile: string,
private readonly messagesDir: string,
private readonly logger: Logger,
) {
mkdirSync(messagesDir, { recursive: true });
}
async create(identity: Identity, input: { title?: string; type?: number } = {}): Promise<SessionMeta> {
const now = Math.floor(Date.now() / 1000);
const session: SessionMeta = {
session_id: randomUUID(),
uid: identity.uid,
team_id: identity.team_id,
title: input.title?.trim() ?? "",
type: input.type ?? 0,
last_mode: "agent",
message_count: 0,
add_time: now,
update_time: now,
};
const index = this.readIndex();
index.sessions[session.session_id] = session;
this.writeIndex(index);
return session;
}
async get(sessionId: string): Promise<SessionMeta | undefined> {
return this.readIndex().sessions[sessionId];
}
async getOwned(identity: Identity, sessionId: string): Promise<SessionMeta | undefined> {
const session = await this.get(sessionId);
return session && session.uid === identity.uid ? session : undefined;
}
async list(filter: { uid?: string; team_id?: string; limit?: number; offset?: number } = {}): Promise<{
sessions: SessionMeta[];
total: number;
}> {
const all = Object.values(this.readIndex().sessions)
.filter((session) => {
if (filter.uid && session.uid !== filter.uid) return false;
if (filter.team_id && session.team_id !== filter.team_id) return false;
return true;
})
.sort((a, b) => b.update_time - a.update_time || b.session_id.localeCompare(a.session_id));
const offset = Math.max(0, filter.offset ?? 0);
const limit = Math.max(1, Math.min(filter.limit ?? 50, 200));
return { sessions: all.slice(offset, offset + limit), total: all.length };
}
async append(sessionId: string, message: Omit<SessionMessage, "id" | "session_id" | "add_time">, addTime?: number): Promise<SessionMessage> {
const full: SessionMessage = {
...message,
id: randomUUID(),
session_id: sessionId,
add_time: addTime ?? Math.floor(Date.now() / 1000),
};
appendFileSync(this.messagesFile(sessionId), `${JSON.stringify(full)}\n`, "utf8");
const index = this.readIndex();
const meta = index.sessions[sessionId];
if (meta) {
meta.message_count += 1;
meta.update_time = full.add_time;
meta.last_mode = full.mode;
if (!meta.title && full.role === "user") {
meta.title = full.content.trim().slice(0, 30);
}
this.writeIndex(index);
}
return full;
}
async update(
sessionId: string,
messageId: string,
patch: Partial<Pick<SessionMessage, "mode" | "content" | "thinking" | "media" | "status" | "error">>,
): Promise<void> {
const file = this.messagesFile(sessionId);
if (!existsSync(file)) return;
const lines = readFileSync(file, "utf8").split("\n");
let found = false;
const next = lines.map((line) => {
const trimmed = line.trim();
if (!trimmed) return line;
try {
const message = JSON.parse(trimmed) as SessionMessage;
if (message.id !== messageId) return line;
found = true;
return JSON.stringify({ ...message, ...patch });
} catch {
return line;
}
});
if (!found) return;
writeFileSync(file, next.join("\n"), "utf8");
const index = this.readIndex();
const meta = index.sessions[sessionId];
if (meta) {
meta.update_time = Math.floor(Date.now() / 1000);
if (patch.mode) meta.last_mode = patch.mode;
this.writeIndex(index);
}
}
async messages(sessionId: string, limit = 200): Promise<SessionMessage[]> {
const file = this.messagesFile(sessionId);
if (!existsSync(file)) return [];
const result: SessionMessage[] = [];
for (const line of readFileSync(file, "utf8").split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
result.push(JSON.parse(trimmed) as SessionMessage);
} catch {
this.logger.warn({ sessionId, line: trimmed.slice(0, 120) }, "skipping malformed message line");
}
}
return result.slice(-Math.max(1, limit));
}
private messagesFile(sessionId: string): string {
return join(this.messagesDir, `${sessionId}.jsonl`);
}
private readIndex(): SessionIndex {
if (!existsSync(this.indexFile)) return { sessions: {} };
try {
const parsed = JSON.parse(readFileSync(this.indexFile, "utf8")) as SessionIndex;
return { sessions: parsed.sessions ?? {} };
} catch {
this.logger.warn({ file: this.indexFile }, "session index is corrupt, starting empty");
return { sessions: {} };
}
}
private writeIndex(index: SessionIndex): void {
const tmp = `${this.indexFile}.tmp`;
writeFileSync(tmp, JSON.stringify(index, null, 2), "utf8");
renameSync(tmp, this.indexFile);
}
}
+242
View File
@@ -0,0 +1,242 @@
import { z } from "zod";
import type { Logger } from "../logger.js";
import type { SkillDefinition, SkillManifest } from "../types.js";
import type { SkillRepository, StoredSkill, StoredSkillConfig } from "./repository.js";
import { parseSkillParams, MAX_IMAGES_PER_REQUEST } from "./params.js";
const KEY = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const storedConfigSchema = z.object({
pricePerImage: z.number().int().min(0),
enabled: z.boolean(),
});
export interface SkillCatalogOptions {
repository: SkillRepository;
defaultPrice: number;
defaultModel: string;
defaultSize: string;
currency: string;
logger: Logger;
}
export interface PublicSkill {
key: string;
name: string;
display_name: string;
description: string;
industry: string;
model: string;
defaults: { size: string; n: number; watermark: boolean };
price: { currency: string; per_image: number; size_multipliers: Record<string, number> };
capabilities: string[];
tags: string[];
max_images: number;
uses_defaults: boolean;
trigger_keywords: string;
}
export class SkillCatalog {
private skills = new Map<string, SkillDefinition>();
private diagnosticMessages: string[] = [];
constructor(private readonly options: SkillCatalogOptions) {}
async load(): Promise<this> {
await this.options.repository.ensureSchema();
await this.refresh();
return this;
}
async refresh(): Promise<this> {
const stored = await this.options.repository.listSkills();
const next = new Map<string, SkillDefinition>();
const diagnostics: string[] = [];
for (const row of stored) {
const skill = this.toSkill(row, diagnostics);
if (!skill) continue;
if (next.has(skill.key)) {
diagnostics.push(`skill ${skill.key} skipped: duplicate skill_key in database`);
continue;
}
next.set(skill.key, skill);
}
this.skills = next;
this.diagnosticMessages = diagnostics;
this.options.logger.debug(
{ skills: [...next.keys()], diagnostics: diagnostics.length },
"skill catalog loaded from database",
);
return this;
}
list(): SkillDefinition[] {
return [...this.skills.values()].filter((skill) => skill.manifest.enabled);
}
get(key: string): SkillDefinition | undefined {
const skill = this.skills.get(key);
return skill?.manifest.enabled ? skill : undefined;
}
diagnostics(): readonly string[] {
return this.diagnosticMessages;
}
requireReady(): void {
if (this.list().length === 0) {
throw new Error("no enabled skills found in the skill database");
}
}
publicSkills(): PublicSkill[] {
return this.list().map((skill) => this.toPublic(skill));
}
toPublic(skill: SkillDefinition): PublicSkill {
const defaults = this.defaultsFor(skill);
return {
key: skill.key,
name: skill.name,
display_name: skill.displayName,
description: skill.description,
industry: skill.manifest.industry,
model: skill.manifest.model,
defaults,
price: {
currency: skill.manifest.price.currency,
per_image: skill.manifest.price.per_image,
size_multipliers: skill.manifest.price.size_multipliers,
},
capabilities: skill.manifest.capabilities,
tags: skill.manifest.tags,
max_images: skill.manifest.max_images,
uses_defaults: skill.usesDefaults,
trigger_keywords: skill.triggerKeywords,
};
}
promptCatalog(skills?: SkillDefinition[]): string {
return (skills ?? this.list())
.map((skill) => {
const defaults = this.defaultsFor(skill);
const multipliers = Object.entries(skill.manifest.price.size_multipliers)
.map(([size, value]) => `${size} ×${value}`)
.join(",");
return [
`- skill_id: ${skill.key}`,
` 名称: ${skill.displayName}`,
` 用途: ${skill.description}`,
skill.triggerKeywords ? ` 触发词: ${skill.triggerKeywords}` : "",
` 默认模型: ${skill.manifest.model}`,
` 默认尺寸: ${defaults.size}`,
` 单价: ${skill.manifest.price.per_image} ${skill.manifest.price.currency}/张`,
multipliers ? ` 尺寸加价: ${multipliers}` : "",
` 单次最多: ${skill.manifest.max_images} 张`,
]
.filter(Boolean)
.join("\n");
})
.join("\n");
}
defaultsFor(skill: SkillDefinition): { size: string; n: number; watermark: boolean } {
return {
size: skill.manifest.defaults.size ?? this.options.defaultSize,
n: skill.manifest.defaults.n ?? 1,
watermark: skill.manifest.defaults.watermark ?? false,
};
}
private toSkill(row: StoredSkill, diagnostics: string[]): SkillDefinition | undefined {
if (!row.libraryEnabled) return undefined;
if (!row.key) {
diagnostics.push(`skill id=${row.key || "unknown"} skipped: skill_key is empty`);
return undefined;
}
if (!KEY.test(row.key)) {
diagnostics.push(`skill ${row.key} skipped: skill_key must be lowercase kebab-case`);
return undefined;
}
if (!row.name.trim()) {
diagnostics.push(`skill ${row.key} skipped: name is required`);
return undefined;
}
const description = row.description.trim() || row.name.trim();
if (!row.description.trim()) {
diagnostics.push(`skill ${row.key} description is empty, using name as description`);
}
if (!row.content.trim()) {
diagnostics.push(`skill ${row.key} skipped: content is empty`);
return undefined;
}
let usesDefaults = false;
let config: StoredSkillConfig;
if (row.config) {
const parsed = storedConfigSchema.safeParse(row.config);
if (!parsed.success) {
const issue = parsed.error.issues[0];
diagnostics.push(
`skill ${row.key} skipped: invalid agent_skill_config (${issue?.path.join(".") ?? "?"}: ${issue?.message ?? "unknown"})`,
);
return undefined;
}
config = parsed.data;
if (!config.enabled) return undefined;
} else {
usesDefaults = true;
config = {
pricePerImage: this.options.defaultPrice,
enabled: true,
};
diagnostics.push(
`skill ${row.key} has no agent_skill_config, using defaults (${this.options.defaultModel}, ${this.options.defaultPrice} ${this.options.currency})`,
);
}
const bodyParams = parseSkillParams(row.content);
const manifest: SkillManifest = {
key: row.key,
display_name: row.name,
version: "1.0.0",
industry: row.industry,
model: this.options.defaultModel,
defaults: {
size: bodyParams.size ?? this.options.defaultSize,
n: bodyParams.n ?? 1,
watermark: bodyParams.watermark ?? false,
},
price: {
currency: this.options.currency,
per_image: config.pricePerImage,
size_multipliers: {},
},
capabilities: [],
tags: bodyParams.tags,
max_images: bodyParams.maxImages ?? MAX_IMAGES_PER_REQUEST,
enabled: true,
};
return {
key: row.key,
name: row.name,
displayName: row.name,
description,
content: stripFrontmatter(row.content),
triggerKeywords: row.triggerKeywords,
manifest,
usesDefaults,
};
}
}
function stripFrontmatter(raw: string): string {
const text = raw.trim();
if (!text.startsWith("---")) return text;
const end = text.indexOf("\n---", 3);
if (end === -1) return text;
return text.slice(end + 4).trim();
}
+43
View File
@@ -0,0 +1,43 @@
import { MAX_IMAGES_PER_REQUEST } from "./params.js";
import type { AppConfig } from "../config.js";
import type { SkillDefinition } from "../types.js";
export const DIRECT_SKILL_KEY = "__direct__";
/**
* Pseudo-skill used when the caller did not select a skill.
* It has no SKILL.md instructions; it only carries the platform defaults needed
* for pricing and gateway calls.
*/
export function directSkill(config: AppConfig): SkillDefinition {
return {
key: DIRECT_SKILL_KEY,
name: "直接生成",
displayName: "直接生成",
description: "未选择技能时的直接生图模式,不加载任何技能规范。",
content: "",
triggerKeywords: "",
usesDefaults: true,
manifest: {
key: DIRECT_SKILL_KEY,
display_name: "直接生成",
version: "1.0.0",
industry: "general",
model: config.skillDefaults.model,
defaults: {
size: config.skillDefaults.size,
n: 1,
watermark: false,
},
price: {
currency: config.billing.currency,
per_image: config.billing.defaultSkillPrice,
size_multipliers: {},
},
capabilities: ["text2img"],
tags: ["direct"],
max_images: MAX_IMAGES_PER_REQUEST,
enabled: true,
},
};
}
+91
View File
@@ -0,0 +1,91 @@
export interface SkillBodyParams {
size?: string;
n?: number;
watermark?: boolean;
maxImages?: number;
tags: string[];
}
const SIZE_TOKEN = /(\d{3,5})\s*[x×]\s*(\d{3,5})/;
export const MAX_IMAGES_PER_REQUEST = 10;
const DEFAULT_MAX_IMAGES = MAX_IMAGES_PER_REQUEST;
function toBool(value: string): boolean | undefined {
const text = value.trim().toLowerCase();
if (["是", "true", "1", "yes", "on", "开启", "需要", "加水印"].includes(text)) return true;
if (["否", "false", "0", "no", "off", "关闭", "不需要", "不加"].includes(text)) return false;
return undefined;
}
function toTags(value: string): string[] {
return value
.replace(/[[\]"]/g, "")
.split(/[,,、;;\s]+/)
.map((item) => item.trim())
.filter(Boolean)
.slice(0, 12);
}
function readFrontMatter(content: string): Record<string, string> {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const body = match?.[1];
if (!body) return {};
const result: Record<string, string> = {};
for (const line of body.split(/\r?\n/)) {
const pair = line.match(/^\s*([A-Za-z0-9_]+)\s*:\s*(.*)$/);
if (!pair || !pair[1]) continue;
result[pair[1].toLowerCase()] = (pair[2] ?? "").trim();
}
return result;
}
function readInline(content: string, keys: string[]): string | undefined {
for (const key of keys) {
// 仅匹配行首的「参数:值」,避免正文里随口提到的“标签/尺寸”等词被误当成参数
const pattern = new RegExp(`^[ \\t]*${key}\\s*[::]\\s*([^\\n]+)$`, "m");
const match = content.match(pattern);
const value = match?.[1];
if (value) return value.trim();
}
return undefined;
}
export function parseSkillParams(content: string): SkillBodyParams {
const text = String(content || "");
const front = readFrontMatter(text);
const params: SkillBodyParams = { tags: [] };
const sizeRaw =
front["default_size"] ||
front["defaultsize"] ||
front["size"] ||
readInline(text, ["默认尺寸", "默认图片尺寸", "图片尺寸", "生成尺寸"]);
if (sizeRaw) {
const sizeMatch = sizeRaw.match(SIZE_TOKEN);
if (sizeMatch && sizeMatch[1] && sizeMatch[2]) params.size = `${sizeMatch[1]}x${sizeMatch[2]}`;
}
const nRaw = front["default_n"] || front["n"] || front["quantity"] || readInline(text, ["默认生成数量", "生成数量", "默认数量", "默认张数", "张数"]);
if (nRaw) {
const n = Number.parseInt(nRaw, 10);
if (Number.isFinite(n) && n >= 1 && n <= MAX_IMAGES_PER_REQUEST) params.n = n;
}
const watermarkRaw = front["watermark"] || readInline(text, ["默认水印", "水印"]);
if (watermarkRaw) {
const watermark = toBool(watermarkRaw);
if (watermark !== undefined) params.watermark = watermark;
}
const maxRaw = front["max_images"] || front["maximages"] || readInline(text, ["最大生成张数", "最大张数", "最大数量"]);
if (maxRaw) {
const max = Number.parseInt(maxRaw, 10);
if (Number.isFinite(max) && max >= 1 && max <= MAX_IMAGES_PER_REQUEST) params.maxImages = max;
}
const tagsRaw = front["tags"] || readInline(text, ["技能标签", "标签"]);
if (tagsRaw) params.tags = toTags(tagsRaw);
if (!params.maxImages) params.maxImages = DEFAULT_MAX_IMAGES;
return params;
}
+168
View File
@@ -0,0 +1,168 @@
import { createPool, type Pool, type RowDataPacket } from "mysql2/promise";
import type { MysqlConfig } from "../config.js";
import type { Logger } from "../logger.js";
const SKILL_LIBRARY_DDL = `
CREATE TABLE IF NOT EXISTS skill_library (
id BIGINT NOT NULL AUTO_INCREMENT,
skill_key VARCHAR(64) NOT NULL,
name VARCHAR(128) NOT NULL,
industry VARCHAR(64) NOT NULL DEFAULT '',
description VARCHAR(512) NOT NULL DEFAULT '',
trigger_keywords VARCHAR(255) NOT NULL DEFAULT '',
default_input VARCHAR(512) NOT NULL DEFAULT '',
content MEDIUMTEXT,
enabled TINYINT NOT NULL DEFAULT 1,
add_time INT(10) NOT NULL,
update_time BIGINT NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_skill_key (skill_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`;
const SKILL_CONFIG_DDL = `
CREATE TABLE IF NOT EXISTS agent_skill_config (
skill_key VARCHAR(64) NOT NULL,
price_per_image INT UNSIGNED NOT NULL DEFAULT 10,
enabled TINYINT UNSIGNED NOT NULL DEFAULT 1,
add_time INT(10) NOT NULL,
update_time BIGINT NOT NULL,
PRIMARY KEY (skill_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`;
export interface StoredSkillConfig {
pricePerImage: number;
enabled: boolean;
}
export interface StoredSkill {
key: string;
name: string;
industry: string;
description: string;
triggerKeywords: string;
content: string;
libraryEnabled: boolean;
config?: StoredSkillConfig;
}
export interface SkillRepository {
ensureSchema(): Promise<void>;
listSkills(): Promise<StoredSkill[]>;
close(): Promise<void>;
}
export interface MySqlSkillDefaults {
size: string;
price: number;
currency: string;
}
export class MySqlSkillRepository implements SkillRepository {
private readonly pool: Pool;
constructor(
config: MysqlConfig,
private readonly defaults: MySqlSkillDefaults,
private readonly logger: Logger,
) {
this.pool = createPool({
host: config.host,
port: config.port,
user: config.user,
password: config.password,
database: config.database,
connectionLimit: config.connectionLimit,
charset: "utf8mb4",
connectTimeout: config.connectTimeoutMs,
decimalNumbers: true,
});
}
async ensureSchema(): Promise<void> {
await this.pool.query(SKILL_LIBRARY_DDL);
await this.pool.query(SKILL_CONFIG_DDL);
// 旧版本的尺寸/数量/水印等参数列已改为从 skill 正文解析,这里清理残留列
const legacyColumns = [
"model",
"default_size",
"default_n",
"watermark",
"size_multipliers",
"max_images",
"currency",
"version",
"capabilities",
"tags",
];
const [existing] = await this.pool.query<RowDataPacket[]>(
`SELECT COLUMN_NAME FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'agent_skill_config'`,
);
const existingNames = new Set(existing.map((row) => String(row.COLUMN_NAME)));
for (const column of legacyColumns) {
if (!existingNames.has(column)) continue;
await this.pool.query(`ALTER TABLE agent_skill_config DROP COLUMN ${column}`);
this.logger.warn({ column }, "dropped legacy agent_skill_config column");
}
const nowSeconds = Math.floor(Date.now() / 1000);
await this.pool.execute(
`INSERT IGNORE INTO agent_skill_config
(skill_key, price_per_image, enabled, add_time, update_time)
SELECT skill_key, ?, 1, ?, ?
FROM skill_library`,
[this.defaults.price, nowSeconds, Date.now()],
);
this.logger.info({ database: "skill_library" }, "skill database schema ready");
}
async listSkills(): Promise<StoredSkill[]> {
const [rows] = await this.pool.query<RowDataPacket[]>(
`SELECT
s.skill_key, s.name, s.industry, s.description, s.trigger_keywords, s.content,
s.enabled AS library_enabled,
c.price_per_image, c.enabled AS config_enabled
FROM skill_library s
LEFT JOIN agent_skill_config c ON c.skill_key = s.skill_key
ORDER BY s.id ASC`,
);
return rows.map((row) => {
const hasConfig = row.price_per_image !== null && row.price_per_image !== undefined;
return {
key: stringValue(row.skill_key),
name: stringValue(row.name),
industry: stringValue(row.industry),
description: stringValue(row.description),
triggerKeywords: stringValue(row.trigger_keywords),
content: stringValue(row.content),
libraryEnabled: Number(row.library_enabled) === 1,
config: hasConfig
? {
pricePerImage: numberValue(row.price_per_image, this.defaults.price),
enabled: row.config_enabled === null || row.config_enabled === undefined
? true
: Number(row.config_enabled) === 1,
}
: undefined,
};
});
}
async close(): Promise<void> {
await this.pool.end();
}
}
function stringValue(value: unknown): string {
return value === null || value === undefined ? "" : String(value);
}
function numberValue(value: unknown, fallback: number): number {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
+84
View File
@@ -0,0 +1,84 @@
import type { BillingMode } from "./config.js";
export interface Identity {
uid: string;
team_id: string;
nickname?: string;
avatar?: string;
mobile?: string;
}
export interface SkillDefaults {
size?: string;
n?: number;
watermark?: boolean;
}
export interface SkillPrice {
currency: string;
per_image: number;
size_multipliers: Record<string, number>;
}
export interface SkillManifest {
key: string;
display_name?: string;
version: string;
industry: string;
model: string;
defaults: SkillDefaults;
price: SkillPrice;
capabilities: string[];
tags: string[];
max_images: number;
enabled: boolean;
}
export interface SkillDefinition {
key: string;
name: string;
displayName: string;
description: string;
content: string;
triggerKeywords: string;
manifest: SkillManifest;
usesDefaults: boolean;
}
export interface Quote {
skill_id: string;
skill_name: string;
model: string;
size: string;
quantity: number;
unit_credits: number;
size_multiplier: number;
total_credits: number;
currency: string;
billing_mode: BillingMode;
}
export type LedgerStatus = "pending" | "succeeded" | "failed";
export interface UsageRecord {
id: string;
ts: number;
uid: string;
team_id: string;
session_id: string;
tool_call_id: string;
skill_id: string;
skill_name: string;
model: string;
gateway_task_id: string;
size: string;
quantity: number;
unit_credits: number;
size_multiplier: number;
total_credits: number;
currency: string;
billing_mode: BillingMode;
status: LedgerStatus;
image_urls: string[];
error?: string;
}
+300
View File
@@ -0,0 +1,300 @@
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
/**
* 出站请求防护(SSRF)。
*
* 本服务会把「用户/模型可控的 URL」交给服务端 fetch(文档下载、参考图下载),
* 因此必须在每一跳校验主机:协议、凭证、域名白名单、以及解析后的真实 IP 是否落在
* 内网 / 回环 / 链路本地(含云元数据 169.254.169.254)/ 保留网段。
*/
export class UnsafeUrlError extends Error {
constructor(message: string) {
super(message);
this.name = "UnsafeUrlError";
}
}
export interface UrlPolicy {
/** 允许的主机名;空数组表示只限制「必须是公网」,不限制具体域名。 */
allowedHosts?: string[];
maxRedirects?: number;
}
const DEFAULT_MAX_REDIRECTS = 5;
/** `a.com,b.com,*.cdn.com` -> ["a.com","b.com","*.cdn.com"] */
export function parseHostAllowlist(value: string | undefined | null): string[] {
return (value ?? "")
.split(",")
.map((item) => item.trim().toLowerCase().replace(/\.$/, ""))
.filter(Boolean);
}
/** 支持精确匹配与 `*.example.com`(同时匹配 example.com 本身与任意子域)。 */
export function hostMatches(hostname: string, patterns: readonly string[]): boolean {
if (patterns.length === 0) return true;
const host = hostname.toLowerCase().replace(/\.$/, "");
return patterns.some((pattern) => {
if (pattern.startsWith("*.")) {
const base = pattern.slice(2);
return host === base || host.endsWith(`.${base}`);
}
return host === pattern;
});
}
const BLOCKED_IPV4_RANGES: ReadonlyArray<readonly [string, number]> = [
["0.0.0.0", 8],
["10.0.0.0", 8],
["100.64.0.0", 10], // CGNAT
["127.0.0.0", 8],
["169.254.0.0", 16], // 链路本地,含 169.254.169.254 云元数据
["172.16.0.0", 12],
["192.0.0.0", 24],
["192.0.2.0", 24],
["192.168.0.0", 16],
["198.18.0.0", 15], // benchmark
["198.51.100.0", 24],
["203.0.113.0", 24],
["224.0.0.0", 4], // 组播
["240.0.0.0", 4], // 保留 + 广播
];
function ipv4ToInt(ip: string): number | null {
const parts = ip.split(".");
if (parts.length !== 4) return null;
let value = 0;
for (const part of parts) {
if (!/^\d{1,3}$/.test(part)) return null;
const octet = Number(part);
if (octet < 0 || octet > 255) return null;
value = value * 256 + octet;
}
return value >>> 0;
}
function inIpv4Range(ip: number, base: number, prefix: number): boolean {
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
return (ip & mask) === (base & mask);
}
function isBlockedIpv4(ip: string): boolean {
const value = ipv4ToInt(ip);
if (value === null) return true;
return BLOCKED_IPV4_RANGES.some(([base, prefix]) => {
const baseValue = ipv4ToInt(base);
return baseValue !== null && inIpv4Range(value, baseValue, prefix);
});
}
/** 解析 IPv6 为 8 个 16 位分组;支持 `::` 缩写与内嵌 IPv4。 */
function parseIpv6(raw: string): number[] | null {
let value = raw.trim().toLowerCase().replace(/^\[/, "").replace(/\]$/, "");
const zoneIndex = value.indexOf("%");
if (zoneIndex >= 0) value = value.slice(0, zoneIndex);
if (!value.includes(":")) return null;
const lastColon = value.lastIndexOf(":");
const lastPart = value.slice(lastColon + 1);
if (lastPart.includes(".")) {
const octets = lastPart.split(".").map(Number);
if (octets.length !== 4 || octets.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return null;
const high = ((octets[0]! << 8) | octets[1]!) >>> 0;
const low = ((octets[2]! << 8) | octets[3]!) >>> 0;
value = `${value.slice(0, lastColon + 1)}${high.toString(16)}:${low.toString(16)}`;
}
const halves = value.split("::");
if (halves.length > 2) return null;
const head = halves[0] ? halves[0].split(":") : [];
const tail = halves.length === 2 && halves[1] ? halves[1].split(":") : [];
if (halves.length === 1 && head.length !== 8) return null;
const missing = 8 - head.length - tail.length;
if (halves.length === 2 && missing < 1) return null;
if (halves.length === 1 && missing !== 0) return null;
const groups = [...head, ...Array<string>(halves.length === 2 ? missing : 0).fill("0"), ...tail];
if (groups.length !== 8) return null;
const parsed: number[] = [];
for (const group of groups) {
if (!/^[0-9a-f]{1,4}$/.test(group)) return null;
parsed.push(Number.parseInt(group, 16));
}
return parsed;
}
function isBlockedIpv6(groups: number[]): boolean {
const [g0, g1, g2, g3, g4, g5, g6, g7] = groups as [
number,
number,
number,
number,
number,
number,
number,
number,
];
// ::
if (groups.every((group) => group === 0)) return true;
// ::1
if (g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0 && g6 === 0 && g7 === 1) {
return true;
}
// fc00::/7 唯一本地地址
if ((g0 & 0xfe00) === 0xfc00) return true;
// fe80::/10 链路本地
if ((g0 & 0xffc0) === 0xfe80) return true;
// ff00::/8 组播
if ((g0 & 0xff00) === 0xff00) return true;
// 2001:db8::/32 文档地址
if (g0 === 0x2001 && g1 === 0x0db8) return true;
// 100::/64 discard-only
if (g0 === 0x0100 && g1 === 0 && g2 === 0 && g3 === 0) return true;
// IPv4-mapped ::ffff:a.b.c.d、IPv4-compatible ::a.b.c.d、NAT64 64:ff9b::/96 的内嵌地址在末尾两个分组
if (
(g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0 && (g5 === 0xffff || g5 === 0)) ||
(g0 === 0x0064 && g1 === 0xff9b && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0)
) {
return isBlockedIpv4(`${g6 >> 8}.${g6 & 0xff}.${g7 >> 8}.${g7 & 0xff}`);
}
// 6to4 2002:AABB:CCDD::/48 的内嵌地址在第 2、3 个分组
if (g0 === 0x2002) {
return isBlockedIpv4(`${g1 >> 8}.${g1 & 0xff}.${g2 >> 8}.${g2 & 0xff}`);
}
return false;
}
export function isBlockedAddress(address: string): boolean {
const value = address.trim().replace(/^\[/, "").replace(/\]$/, "");
const family = isIP(value);
if (family === 4) return isBlockedIpv4(value);
if (family === 6) {
const groups = parseIpv6(value);
return groups === null ? true : isBlockedIpv6(groups);
}
return true;
}
const BLOCKED_HOST_SUFFIXES = [
".localhost",
".local",
".internal",
".home.arpa",
".lan",
".intranet",
".corp",
".svc",
".cluster.local",
];
const BLOCKED_HOSTNAMES = new Set([
"localhost",
"metadata",
"metadata.google.internal",
"instance-data",
"kubernetes.default",
]);
/** 主机名层面的粗筛:字面量 IP、单标签主机名(走 search domain)、内部域名。 */
export function isBlockedHostname(hostname: string): boolean {
const host = hostname.trim().toLowerCase().replace(/^\[/, "").replace(/\]$/, "").replace(/\.$/, "");
if (!host) return true;
if (isIP(host) !== 0) return isBlockedAddress(host);
if (BLOCKED_HOSTNAMES.has(host)) return true;
if (BLOCKED_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix))) return true;
// 单标签主机名会命中搜索域 / 内网 DNS,一律拒绝
if (!host.includes(".")) return true;
return false;
}
/**
* 校验一个用户可控 URL:协议、凭证、主机名、域名白名单,以及 DNS 解析后的真实 IP。
* 注意:DNS 校验与真正发起请求之间仍存在理论上的 rebinding 窗口,
* 因此生产环境还应配合容器级出网策略做兜底。
*/
export async function assertSafeUrl(rawUrl: string, policy: UrlPolicy = {}): Promise<URL> {
let url: URL;
try {
url = new URL(rawUrl);
} catch {
throw new UnsafeUrlError("URL 格式不合法");
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new UnsafeUrlError("仅支持 http/https 地址");
}
if (url.username || url.password) {
throw new UnsafeUrlError("URL 不允许包含账号或密码");
}
const hostname = url.hostname.replace(/^\[/, "").replace(/\]$/, "");
if (isBlockedHostname(hostname)) {
throw new UnsafeUrlError(`禁止访问该主机:${hostname}`);
}
if (!hostMatches(hostname, policy.allowedHosts ?? [])) {
throw new UnsafeUrlError(`主机不在白名单内:${hostname}`);
}
if (isIP(hostname) !== 0) return url;
let records: Array<{ address: string }>;
try {
records = await lookup(hostname, { all: true, verbatim: true });
} catch {
throw new UnsafeUrlError(`域名解析失败:${hostname}`);
}
if (records.length === 0) {
throw new UnsafeUrlError(`域名解析为空:${hostname}`);
}
for (const record of records) {
if (isBlockedAddress(record.address)) {
throw new UnsafeUrlError(`域名解析到内网地址:${hostname} -> ${record.address}`);
}
}
return url;
}
function isRedirect(status: number): boolean {
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
}
/**
* 逐跳校验重定向的安全 fetch:每一跳都重新做 assertSafeUrl。
* 重定向一律手动处理,避免被 30x 带到内网。
*/
export async function safeFetch(
rawUrl: string,
init: RequestInit = {},
policy: UrlPolicy = {},
): Promise<Response> {
const maxRedirects = policy.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
let current = rawUrl;
for (let hop = 0; hop <= maxRedirects; hop += 1) {
const url = await assertSafeUrl(current, policy);
const response = await fetch(url, { ...init, redirect: "manual" });
if (!isRedirect(response.status)) return response;
const location = response.headers.get("location");
if (!location) return response;
if (hop === maxRedirects) {
throw new UnsafeUrlError("重定向次数过多");
}
try {
await response.body?.cancel();
} catch {
// 忽略取消 body 的异常
}
current = new URL(location, url).toString();
}
throw new UnsafeUrlError("重定向次数过多");
}
+27
View File
@@ -0,0 +1,27 @@
/** 前端为 Agent 追加的附件指令块,例如:
* 【用户上传了以下文档(请先用 read_document_text …):\n文件名:url】
* 【用户上传了以下行程文档(请先用 read_document …):\nurl】
* 入库/展示时应去掉,只保留用户真正输入的内容。
*/
const UPLOADED_BLOCK = /【用户上传了[\s\S]*?】\s*/g;
const UPLOADED_MARK = "【用户上传了";
export function stripUploadedDocuments(message: string): string {
const text = String(message ?? "");
let cleaned = text.replace(UPLOADED_BLOCK, "");
// 标题被截断时可能只有起始标记、没有收尾的 】,从标记处直接截断
const mark = cleaned.indexOf(UPLOADED_MARK);
if (mark !== -1) cleaned = cleaned.slice(0, mark);
cleaned = cleaned.trim();
return cleaned || text.trim();
}
export function sanitizeTitle(title: string | undefined, fallback = "新对话", maxLength = 40): string {
const text = String(title ?? "");
const cleaned = stripUploadedDocuments(text);
// 只剩脏标题本身时视为空
const usable = cleaned === text.trim() && text.includes(UPLOADED_MARK) ? "" : cleaned;
const normalized = usable.replace(/\s+/g, " ").trim();
if (!normalized) return fallback;
return normalized.slice(0, maxLength);
}
+44
View File
@@ -0,0 +1,44 @@
import { createHmac } from "node:crypto";
import { describe, expect, it } from "vitest";
import { AuthError, resolveIdentity, verifyJwt } from "../src/auth/jwt.js";
function sign(payload: Record<string, unknown>, secret = "secret", alg = "HS256"): string {
const header = Buffer.from(JSON.stringify({ alg, typ: "JWT" })).toString("base64url");
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
const signature = createHmac("sha256", secret).update(`${header}.${body}`).digest("base64url");
return `${header}.${body}.${signature}`;
}
describe("auth", () => {
it("verifies a valid HS256 token and resolves identity", () => {
const token = sign({ uid: 42, nickname: "小明", exp: Math.floor(Date.now() / 1000) + 60 });
const identity = resolveIdentity(
{ jwtKey: "secret", jwtAlgorithm: "HS256" },
{ headers: { authorization: `Bearer ${token}`, "x-team-id": "9" } },
);
expect(identity).toMatchObject({ uid: "42", nickname: "小明", team_id: "9" });
});
it("rejects missing, expired, tampered, and wrong-algorithm tokens", () => {
const auth = { jwtKey: "secret", jwtAlgorithm: "HS256" };
expect(() => resolveIdentity(auth, { headers: {} })).toThrow(AuthError);
const expired = sign({ uid: 1, exp: Math.floor(Date.now() / 1000) - 1 });
expect(() => verifyJwt(expired, "secret", "HS256")).toThrow(/过期/);
const valid = sign({ uid: 1, exp: Math.floor(Date.now() / 1000) + 60 });
const tampered = `${valid.split(".").slice(0, 2).join(".")}.bad`;
expect(() => verifyJwt(tampered, "secret", "HS256")).toThrow(/签名/);
const wrongAlg = sign({ uid: 1, exp: Math.floor(Date.now() / 1000) + 60 }, "secret", "HS512");
expect(() => verifyJwt(wrongAlg, "secret", "HS256")).toThrow(/算法/);
});
it("uses x-uid headers in development mode", () => {
const identity = resolveIdentity(
{ jwtAlgorithm: "HS256" },
{ headers: { "x-uid": "7", "x-team-id": "8", "x-nickname": "dev" } },
);
expect(identity).toEqual({ uid: "7", team_id: "8", nickname: "dev" });
});
});
+203
View File
@@ -0,0 +1,203 @@
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { createAgentTools } from "../src/agent/tools.js";
import { UsageLedger } from "../src/billing/ledger.js";
import { GatewayClient } from "../src/gateway/client.js";
import type { Quote } from "../src/types.js";
import { makeCatalog, silentLogger, skillFixture, tempRoot, testConfig } from "./helpers.js";
interface BatchDetails {
ok: boolean;
quote?: Quote;
images?: string[];
error?: string;
idempotent?: boolean;
}
interface TestTool {
name: string;
execute: (
toolCallId: string,
params: Record<string, unknown>,
signal?: AbortSignal,
) => Promise<{ content: Array<{ type: string; text: string }>; details: BatchDetails }>;
}
async function setup(options: { content?: string } = {}) {
const root = tempRoot();
const config = testConfig(root);
const catalog = await makeCatalog([
skillFixture({ key: "suite", config: { pricePerImage: 20 }, content: options.content }),
]);
const ledger = new UsageLedger(join(root, "usage.jsonl"), silentLogger);
const gateway = new GatewayClient(config.gateway, silentLogger);
const captured: string[] = [];
const tools = createAgentTools({
config,
catalog,
ledger,
gateway,
logger: silentLogger,
identity: { uid: "1", team_id: "1" },
sessionId: "session-1",
allowedSkillKeys: ["suite"],
onImages: (urls) => { captured.push(...urls); },
});
const find = (name: string): TestTool => {
const tool = tools.find((item) => item.name === name);
if (!tool) throw new Error(`missing tool ${name}`);
return tool as unknown as TestTool;
};
return { find, ledger, gateway, captured };
}
const style = "统一风格:轻奢商业摄影,暖白背景,柔和侧光,低饱和暖棕色调,衬线标题字,大量留白";
const images = [
{ content: "主图,产品正面全身展示" },
{ content: "主图,佩戴场景特写" },
{ content: "详情图,材质细节" },
{ content: "详情图,规格参数" },
];
describe("generate_images_batch", () => {
it("submits every image as its own request but all concurrently", async () => {
const { find, gateway, ledger, captured } = await setup();
const prompts: string[] = [];
let call = 0;
const spy = vi.spyOn(gateway, "generateImage").mockImplementation(async (input) => {
const index = call++;
prompts.push(input.prompt);
expect(input.n).toBe(1);
await new Promise((resolve) => setTimeout(resolve, 120));
return {
taskId: `task-${index}`,
urls: [`https://img/${index}.png`],
status: "success",
raw: {},
elapsedMs: 120,
};
});
const t0 = Date.now();
const result = await find("generate_images_batch").execute("batch-1", {
skill_id: "suite",
style,
images,
});
const elapsed = Date.now() - t0;
expect(spy).toHaveBeenCalledTimes(4);
expect(prompts.every((prompt) => prompt.startsWith(style))).toBe(true);
expect(prompts[0]).toContain("图1:主图,产品正面全身展示");
expect(prompts[3]).toContain("图4:详情图,规格参数");
expect(result.details.ok).toBe(true);
expect(result.details.images).toHaveLength(4);
expect(captured).toHaveLength(4);
expect(result.details.quote?.total_credits).toBe(80);
expect(ledger.list()).toHaveLength(4);
expect(ledger.list().every((record) => record.status === "succeeded")).toBe(true);
// 4 张并发:总耗时应接近单张耗时,而不是 4 倍
expect(elapsed).toBeLessThan(320);
});
it("supports a different size per image", async () => {
const { find, gateway } = await setup();
const sizes: string[] = [];
vi.spyOn(gateway, "generateImage").mockImplementation(async (input) => {
sizes.push(input.size);
return {
taskId: `task-${sizes.length}`,
urls: [`https://img/${sizes.length}.png`],
status: "success",
raw: {},
elapsedMs: 5,
};
});
const result = await find("generate_images_batch").execute("batch-size", {
skill_id: "suite",
style,
images: [
{ content: "主图01", size: "800x800" },
{ content: "详情图01", size: "790x1200" },
],
});
expect(result.details.ok).toBe(true);
expect(sizes).toEqual(["800x800", "790x1200"]);
});
it("keeps successful images when one item fails", async () => {
const { find, gateway, ledger } = await setup();
let call = 0;
vi.spyOn(gateway, "generateImage").mockImplementation(async () => {
const index = call++;
if (index === 1) throw new Error("gateway timeout");
await new Promise((resolve) => setTimeout(resolve, 20));
return {
taskId: `task-${index}`,
urls: [`https://img/${index}.png`],
status: "success",
raw: {},
elapsedMs: 20,
};
});
const result = await find("generate_images_batch").execute("batch-2", {
skill_id: "suite",
style,
images,
});
expect(result.details.ok).toBe(true);
expect(result.details.images).toHaveLength(3);
expect(result.content[0]?.text).toContain("失败 1 张");
expect(ledger.list().filter((record) => record.status === "failed")).toHaveLength(1);
expect(ledger.list().filter((record) => record.status === "succeeded")).toHaveLength(3);
});
it("replays a batch without re-generating or re-billing", async () => {
const { find, gateway } = await setup();
let call = 0;
const spy = vi.spyOn(gateway, "generateImage").mockImplementation(async () => {
const index = call++;
return {
taskId: `task-${index}`,
urls: [`https://img/${index}.png`],
status: "success",
raw: {},
elapsedMs: 5,
};
});
await find("generate_images_batch").execute("batch-3", { skill_id: "suite", style, images });
expect(spy).toHaveBeenCalledTimes(4);
const replay = await find("generate_images_batch").execute("batch-3", { skill_id: "suite", style, images });
expect(spy).toHaveBeenCalledTimes(4);
expect(replay.details.images).toHaveLength(4);
});
it("requires one overall style for a multi-image set", async () => {
const { find, gateway } = await setup();
const spy = vi.spyOn(gateway, "generateImage");
const result = await find("generate_images_batch").execute("batch-no-style", {
skill_id: "suite",
images,
});
expect(result.details.ok).toBe(false);
expect(result.content[0]?.text).toContain("总体风格");
expect(spy).not.toHaveBeenCalled();
});
it("rejects batches larger than the skill max_images", async () => {
const { find, gateway } = await setup({ content: "---\nmax_images: 2\n---\n# suite\n" });
const spy = vi.spyOn(gateway, "generateImage");
const result = await find("generate_images_batch").execute("batch-4", {
skill_id: "suite",
style,
images,
});
expect(result.details.ok).toBe(false);
expect(result.content[0]?.text).toContain("超过技能");
expect(spy).not.toHaveBeenCalled();
});
});
+111
View File
@@ -0,0 +1,111 @@
import { describe, expect, it } from "vitest";
import { SkillCatalog } from "../src/skills/catalog.js";
import { FakeSkillRepository, makeCatalog, silentLogger, skillFixture } from "./helpers.js";
describe("SkillCatalog (database)", () => {
it("loads valid skills and exposes pricing metadata", async () => {
const catalog = await makeCatalog([
skillFixture({
key: "poster",
config: { pricePerImage: 30 },
content: "---\ndefault_size: 1024x1536\ndefault_n: 2\nwatermark: false\n---\n# Skill",
}),
skillFixture({ key: "space", config: { pricePerImage: 50 } }),
]);
expect(catalog.diagnostics()).toEqual([]);
expect(catalog.list().map((skill) => skill.key)).toEqual(["poster", "space"]);
expect(catalog.get("poster")?.displayName).toBe("Test poster");
expect(catalog.toPublic(catalog.get("poster")!)).toMatchObject({
key: "poster",
display_name: "Test poster",
price: { per_image: 30 },
defaults: { size: "1024x1536", n: 2, watermark: false },
});
expect(catalog.promptCatalog()).toContain("skill_id: poster");
expect(catalog.promptCatalog()).toContain("单价: 30 credits/张");
});
it("parses size, quantity, watermark and tags from skill body", async () => {
const catalog = await makeCatalog([
skillFixture({
key: "body-params",
content: "---\ndefault_size: 1536x864\ndefault_n: 1\nmax_images: 3\ntags: [旅游, 海报]\n---\n# Body",
}),
skillFixture({
key: "inline-params",
content: "# 技能\n默认尺寸:1024x1820\n生成数量:2\n水印:否\n标签:电商、海报",
}),
]);
const body = catalog.get("body-params");
expect(body?.manifest.defaults).toMatchObject({ size: "1536x864", n: 1 });
expect(body?.manifest.max_images).toBe(3);
expect(body?.manifest.tags).toEqual(["旅游", "海报"]);
const inline = catalog.get("inline-params");
expect(inline?.manifest.defaults).toMatchObject({ size: "1024x1820", n: 2, watermark: false });
expect(inline?.manifest.tags).toEqual(["电商", "海报"]);
});
it("falls back to defaults when agent_skill_config is absent", async () => {
const catalog = await makeCatalog([skillFixture({ key: "no-config", config: null })], { defaultPrice: 17 });
const skill = catalog.get("no-config");
expect(skill?.usesDefaults).toBe(true);
expect(skill?.manifest.price.per_image).toBe(17);
expect(skill?.manifest.model).toBe("gpt-image-2");
expect(catalog.diagnostics().join("\n")).toContain("no agent_skill_config");
});
it("rejects disabled, missing content, and invalid keys", async () => {
const catalog = await makeCatalog([
skillFixture({ key: "good" }),
skillFixture({ key: "no-description", description: "" }),
skillFixture({ key: "disabled", config: { enabled: false } }),
skillFixture({ key: "library-disabled", libraryEnabled: false }),
skillFixture({ key: "empty", content: "" }),
skillFixture({ key: "bad_name" }),
]);
expect(catalog.list().map((skill) => skill.key)).toEqual(["good", "no-description"]);
expect(catalog.get("disabled")).toBeUndefined();
expect(catalog.get("library-disabled")).toBeUndefined();
const diagnostics = catalog.diagnostics().join("\n");
expect(diagnostics).toContain("content is empty");
expect(diagnostics).toContain("description is empty");
expect(diagnostics).toContain("lowercase kebab-case");
});
it("rejects invalid database config without crashing", async () => {
const catalog = await makeCatalog([
skillFixture({ key: "bad-price", config: { pricePerImage: -1 } }),
]);
expect(catalog.list()).toHaveLength(0);
expect(catalog.diagnostics().join("\n")).toContain("bad-price");
});
it("refreshes the in-memory catalog from the repository", async () => {
const repository = new FakeSkillRepository([skillFixture({ key: "first" })]);
const catalog = new SkillCatalog({
repository,
defaultPrice: 10,
defaultModel: "seedream@5.0",
defaultSize: "1024x1024",
currency: "credits",
logger: silentLogger,
});
await catalog.load();
expect(catalog.list().map((skill) => skill.key)).toEqual(["first"]);
repository.setSkills([skillFixture({ key: "second", config: { pricePerImage: 20 } })]);
await catalog.refresh();
expect(catalog.list().map((skill) => skill.key)).toEqual(["second"]);
expect(catalog.get("first")).toBeUndefined();
});
it("requires at least one enabled skill", async () => {
const catalog = await makeCatalog([]);
expect(() => catalog.requireReady()).toThrow(/no enabled skills/);
});
});
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import { join } from "node:path";
import { loadConfig } from "../src/config.js";
import { tempRoot } from "./helpers.js";
describe("loadConfig", () => {
it("applies safe defaults", () => {
const root = tempRoot();
const config = loadConfig({ APP_ROOT: root });
expect(config.port).toBe(8100);
expect(config.billing.mode).toBe("live");
expect(config.billing.currency).toBe("credits");
expect(config.gateway.mock).toBe(false);
expect(config.gateway.pollTimeoutMs).toBe(900_000);
expect(config.llm.modelId).toBe("deepseek-chat");
expect(config.dataDir).toBe(join(root, "data"));
expect(config.mysql.host).toBe("127.0.0.1");
expect(config.mysql.database).toBe("laimiaodb");
expect(config.skillDefaults.model).toBe("gpt-image-2");
expect(config.toolConfirmation.enabled).toBe(true);
expect(config.toolConfirmation.tools).toEqual(["generate_image"]);
expect(config.toolConfirmation.timeoutMs).toBe(300_000);
expect(config.modelsFile).toBe(join(root, "data", "pi", "models.json"));
});
it("parses booleans and integers from env strings", () => {
const config = loadConfig({
APP_ROOT: tempRoot(),
PORT: "9001",
GATEWAY_MOCK: "TRUE",
BILLING_MODE: "off",
LAIMIAO_LLM_REASONING: "1",
GATEWAY_POLL_INTERVAL_MS: "250",
TOOL_CONFIRMATION_TOOLS: "generate_image, quote_image",
});
expect(config.port).toBe(9001);
expect(config.gateway.mock).toBe(true);
expect(config.billing.mode).toBe("off");
expect(config.llm.reasoning).toBe(true);
expect(config.gateway.pollIntervalMs).toBe(250);
expect(config.toolConfirmation.tools).toEqual(["generate_image", "quote_image"]);
});
it("treats an empty integer as the default", () => {
const config = loadConfig({ APP_ROOT: tempRoot(), PORT: "" });
expect(config.port).toBe(8100);
});
it("rejects invalid port and billing mode", () => {
expect(() => loadConfig({ APP_ROOT: tempRoot(), PORT: "0" })).toThrow(/PORT/);
expect(() => loadConfig({ APP_ROOT: tempRoot(), BILLING_MODE: "always" })).toThrow(/BILLING_MODE/);
});
it("rejects malformed gateway and LLM URLs", () => {
expect(() => loadConfig({ APP_ROOT: tempRoot(), GATEWAY_CN_URL: "not-a-url" })).toThrow(/GATEWAY_CN_URL/);
expect(() => loadConfig({ APP_ROOT: tempRoot(), LAIMIAO_LLM_BASE_URL: "ftp://x" })).toThrow(
/LAIMIAO_LLM_BASE_URL/,
);
});
it("enforces production security requirements", () => {
const base = {
APP_ROOT: tempRoot(),
NODE_ENV: "production",
JWT_KEY: "secret",
CORS_ORIGIN: "https://app.example.com",
FILE_FETCH_ALLOWED_HOSTS: "cdn.example.com,*.oss.example.com",
};
expect(() => loadConfig({ ...base, JWT_KEY: "" })).toThrow(/JWT_KEY/);
expect(() => loadConfig({ ...base, CORS_ORIGIN: "*" })).toThrow(/CORS_ORIGIN/);
expect(() => loadConfig({ ...base, CORS_ORIGIN: "https://app.example.com/path" })).toThrow(/CORS_ORIGIN/);
expect(() => loadConfig({ ...base, FILE_FETCH_ALLOWED_HOSTS: "" })).toThrow(/FILE_FETCH_ALLOWED_HOSTS/);
const config = loadConfig(base);
expect(config.isProduction).toBe(true);
expect(config.fileFetchAllowedHosts).toEqual(["cdn.example.com", "*.oss.example.com"]);
});
it("keeps permissive defaults outside production", () => {
const config = loadConfig({ APP_ROOT: tempRoot() });
expect(config.nodeEnv).toBe("development");
expect(config.isProduction).toBe(false);
expect(config.fileFetchAllowedHosts).toEqual([]);
});
it("resolves relative paths against APP_ROOT", () => {
const root = tempRoot();
const config = loadConfig({ APP_ROOT: root, DATA_DIR: "var/state" });
expect(config.dataDir).toBe(join(root, "var/state"));
expect(config.ledgerFile).toBe(join(root, "var/state/usage-ledger.jsonl"));
});
});
+115
View File
@@ -0,0 +1,115 @@
import { describe, expect, it } from "vitest";
import { createConfirmTools } from "../src/agent/confirm_tools.js";
import { ToolConfirmationManager } from "../src/agent/confirmations.js";
import type { AgentStreamEvent } from "../src/agent/service.js";
import { makeCatalog, silentLogger } from "./helpers.js";
interface TestTool {
name: string;
execute: (
toolCallId: string,
params: Record<string, unknown>,
signal?: AbortSignal,
) => Promise<{
content: Array<{ type: string; text: string }>;
details: Record<string, unknown>;
isError?: boolean;
}>;
}
async function setup() {
const catalog = await makeCatalog([]);
const manager = new ToolConfirmationManager(1_000);
const events: AgentStreamEvent[] = [];
const tools = createConfirmTools({
catalog,
confirmations: manager,
sessionId: "s1",
logger: silentLogger,
emit: (event) => events.push(event),
hasStream: true,
}) as unknown as TestTool[];
return { manager, events, tools };
}
function resultText(result: { content: Array<{ type: string; text: string }> }): unknown {
return JSON.parse(result.content[0]?.text ?? "{}");
}
describe("confirm tools", () => {
it("select mode emits options and returns the selected id", async () => {
const { manager, events, tools } = await setup();
const tool = tools.find((item) => item.name === "confirm_user");
expect(tool).toBeDefined();
const promise = tool!.execute("call-1", {
message: "请选择风格",
mode: "select",
options: [
{ id: "a", label: "A" },
{ id: "b", label: "B" },
],
});
expect(events[0]).toMatchObject({
type: "content_confirmation_required",
confirm_type: "user_confirm",
payload: { message: "请选择风格", mode: "select" },
});
expect(manager.resolve("s1", "call-1", { approved: true, content: "b" })).toBe(true);
expect(resultText(await promise)).toEqual({ approved: true, content: "b" });
expect(events[1]).toMatchObject({
type: "content_confirmation_resolved",
confirm_type: "user_confirm",
approved: true,
content: "b",
});
});
it("treats provided options as select mode", async () => {
const { manager, events, tools } = await setup();
const tool = tools.find((item) => item.name === "confirm_user")!;
const promise = tool.execute("call-options", {
message: "请选择风格",
mode: "confirm",
options: ["a", "b"],
});
expect(events[0]).toMatchObject({
type: "content_confirmation_required",
confirm_type: "user_confirm",
payload: { mode: "select" },
});
manager.resolve("s1", "call-options", { approved: true, content: "a" });
expect(resultText(await promise)).toEqual({ approved: true, content: "a" });
});
it("edit mode returns edited content", async () => {
const { manager, tools } = await setup();
const tool = tools.find((item) => item.name === "confirm_user")!;
const promise = tool.execute("call-edit", {
message: "请补充信息",
mode: "edit",
default_value: "默认内容",
});
manager.resolve("s1", "call-edit", { approved: true, content: "用户修改后的内容" });
expect(resultText(await promise)).toEqual({ approved: true, content: "用户修改后的内容" });
});
it("confirm mode clears content", async () => {
const { manager, tools } = await setup();
const tool = tools.find((item) => item.name === "confirm_user")!;
const promise = tool.execute("call-confirm", { message: "确认继续?", mode: "confirm" });
manager.resolve("s1", "call-confirm", { approved: true, content: "忽略内容" });
expect(resultText(await promise)).toEqual({ approved: true, content: "" });
});
it("rejects an option that was not offered", async () => {
const { manager, tools } = await setup();
const tool = tools.find((item) => item.name === "confirm_user")!;
const promise = tool.execute("call-invalid", {
message: "请选择",
mode: "select",
options: ["a", "b"],
});
manager.resolve("s1", "call-invalid", { approved: true, content: "c" });
expect(resultText(await promise)).toEqual({ approved: false, content: "" });
});
});
+94
View File
@@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import { ToolConfirmationManager, type ToolConfirmationRequest } from "../src/agent/confirmations.js";
function request(manager: ToolConfirmationManager, toolCallId = "call-1", signal?: AbortSignal) {
let emitted: ToolConfirmationRequest | undefined;
const promise = manager.request(
{
sessionId: "s1",
toolCallId,
toolName: "generate_image",
args: { prompt: "poster" },
},
(value) => {
emitted = value;
},
signal,
);
return { promise, get emitted() { return emitted; } };
}
describe("ToolConfirmationManager", () => {
it("waits for approval and resolves the tool call", async () => {
const manager = new ToolConfirmationManager(1_000);
const pending = request(manager);
expect(pending.emitted?.toolCallId).toBe("call-1");
expect(pending.emitted?.expiresAt).toBeGreaterThan(Date.now());
expect(manager.resolve("s1", "call-1", true)).toBe(true);
await expect(pending.promise).resolves.toEqual({ approved: true, reason: undefined });
expect(manager.count()).toBe(0);
});
it("carries confirm type/payload and resolves edited content", async () => {
const manager = new ToolConfirmationManager(1_000);
let emitted: ToolConfirmationRequest | undefined;
const promise = manager.request(
{
sessionId: "s1",
toolCallId: "call-content",
toolName: "confirm_travel_content",
confirmType: "travel_content_confirm",
payload: { summary: "线路:测试" },
args: {},
},
(value) => {
emitted = value;
},
);
expect(emitted?.confirmType).toBe("travel_content_confirm");
expect(emitted?.payload).toEqual({ summary: "线路:测试" });
expect(manager.resolve("s1", "call-content", { approved: true, content: "用户修改后的内容" })).toBe(true);
await expect(promise).resolves.toEqual({ approved: true, content: "用户修改后的内容" });
});
it("returns a rejection reason", async () => {
const manager = new ToolConfirmationManager(1_000);
const pending = request(manager);
manager.resolve("s1", "call-1", false, "不要生成");
await expect(pending.promise).resolves.toEqual({ approved: false, reason: "不要生成" });
});
it("times out and does not leave stale entries", async () => {
const manager = new ToolConfirmationManager(10);
const pending = request(manager);
await expect(pending.promise).resolves.toMatchObject({ approved: false, reason: "等待用户确认超时" });
expect(manager.count()).toBe(0);
});
it("cancels when the run is aborted", async () => {
const manager = new ToolConfirmationManager(1_000);
const controller = new AbortController();
const pending = request(manager, "call-abort", controller.signal);
controller.abort();
await expect(pending.promise).resolves.toEqual({ approved: false, reason: "请求已中止" });
});
it("clears all pending confirmations for a session", async () => {
const manager = new ToolConfirmationManager(1_000);
const first = request(manager, "call-1");
const second = request(manager, "call-2");
manager.clearSession("s1");
await expect(first.promise).resolves.toMatchObject({ approved: false, reason: "会话已结束" });
await expect(second.promise).resolves.toMatchObject({ approved: false, reason: "会话已结束" });
});
it("rejects duplicate pending confirmations for the same tool call", async () => {
const manager = new ToolConfirmationManager(1_000);
const first = request(manager);
const duplicate = request(manager);
await expect(duplicate.promise).resolves.toMatchObject({ approved: false });
manager.resolve("s1", "call-1", true);
await expect(first.promise).resolves.toEqual({ approved: true, reason: undefined });
});
});
+218
View File
@@ -0,0 +1,218 @@
import { describe, expect, it, vi, afterEach } from "vitest";
import { GatewayClient, GatewayError, extractImageUrls, normalizeSize } from "../src/gateway/client.js";
import { silentLogger, tempRoot, testConfig } from "./helpers.js";
afterEach(() => {
vi.unstubAllGlobals();
});
function client(overrides: Record<string, string> = {}) {
const config = testConfig(tempRoot(), overrides);
return new GatewayClient(config.gateway, silentLogger);
}
describe("GatewayClient", () => {
it("routes known CN model families to CN and unknown models to global", () => {
const gateway = client({ GATEWAY_MOCK: "false", GATEWAY_CN_URL: "https://cn.test/", GATEWAY_GLOBAL_URL: "https://global.test/" });
expect(gateway.baseUrlFor("seedream@5.0")).toBe("https://cn.test");
expect(gateway.baseUrlFor("kling-v2")).toBe("https://cn.test");
expect(gateway.baseUrlFor("gpt-image-2")).toBe("https://global.test");
expect(gateway.baseUrlFor("unknown-model")).toBe("https://global.test");
});
it("honours GATEWAY_FORCE_URL", () => {
const gateway = client({ GATEWAY_FORCE_URL: "https://forced.test/", GATEWAY_MOCK: "false" });
expect(gateway.baseUrlFor("seedream@5.0")).toBe("https://forced.test");
expect(gateway.baseUrlFor("gpt-image-2")).toBe("https://forced.test");
});
it("normalizes gpt-image sizes to multiples of 16", () => {
expect(normalizeSize("gpt-image-2", "1024x1536")).toBe("1024x1536");
expect(normalizeSize("gpt-image-2", "1000x1501")).toBe("1008x1504");
expect(normalizeSize("seedream@5.0", "1000x1501")).toBe("1000x1501");
});
it("extracts URLs from common gateway response shapes", () => {
expect(
extractImageUrls({
data: [{ url: "https://a/1.png" }, { image_url: "https://a/2.png" }],
output: { images: ["https://a/3.png"] },
drawing: "https://a/4.png",
media_list: [{ oss_url: "https://a/5.png" }],
}),
).toEqual(["https://a/1.png", "https://a/2.png", "https://a/3.png", "https://a/4.png", "https://a/5.png"]);
});
it("generates mock URLs for each requested image", async () => {
const gateway = client();
const result = await gateway.generateImage({
uid: "1",
team_id: "1",
model: "seedream@5.0",
prompt: "a cat",
size: "1024x1024",
n: 2,
});
expect(result.taskId).toMatch(/^mock-/);
expect(result.urls).toHaveLength(2);
expect(result.urls[0]).toContain("picsum.photos");
});
it("submits a task and polls until images are available", async () => {
const gateway = client({
GATEWAY_MOCK: "false",
GATEWAY_CN_URL: "https://cn.test",
GATEWAY_POLL_INTERVAL_MS: "10",
});
let call = 0;
const fetchMock = vi.fn(async (_url: string | URL | Request, _init?: RequestInit): Promise<Response> => {
call += 1;
if (call === 1) {
return new Response(JSON.stringify({ ret: 0, task_id: "task-1" }), { status: 200 });
}
return new Response(
JSON.stringify({ status: "running", data: call === 2 ? [] : [{ url: "https://img/1.png" }] }),
{ status: 200 },
);
});
vi.stubGlobal("fetch", fetchMock);
const result = await gateway.generateImage({
uid: "1",
team_id: "2",
model: "seedream@5.0",
prompt: "a cat",
size: "1024x1536",
n: 1,
watermark: true,
});
expect(result.taskId).toBe("task-1");
expect(result.urls).toEqual(["https://img/1.png"]);
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(String(fetchMock.mock.calls[0]?.[0])).toBe("https://cn.test/generate/v1/images");
const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)) as Record<string, unknown>;
expect(body).toMatchObject({ uid: 1, team_id: 2, model: "seedream@5.0", n: 1, watermark: true });
expect(String(fetchMock.mock.calls[1]?.[0])).toBe("https://cn.test/generate/v1/images/task-1");
});
it("retries transient poll network failures", async () => {
const gateway = client({
GATEWAY_MOCK: "false",
GATEWAY_CN_URL: "https://cn.test",
GATEWAY_POLL_INTERVAL_MS: "10",
});
let call = 0;
const fetchMock = vi.fn(async (): Promise<Response> => {
call += 1;
if (call === 1) {
return new Response(JSON.stringify({ ret: 0, task_id: "task-retry" }), { status: 200 });
}
if (call === 2) {
throw new TypeError("fetch failed");
}
return new Response(JSON.stringify({ status: "success", data: [{ url: "https://img/retry.png" }] }), {
status: 200,
});
});
vi.stubGlobal("fetch", fetchMock);
const result = await gateway.generateImage({
uid: "1",
team_id: "1",
model: "seedream@5.0",
prompt: "a cat",
size: "1024x1024",
n: 1,
});
expect(result.urls).toEqual(["https://img/retry.png"]);
expect(call).toBe(3);
});
it("waits briefly when the gateway reports success before urls are attached", async () => {
const gateway = client({
GATEWAY_MOCK: "false",
GATEWAY_CN_URL: "https://cn.test",
GATEWAY_POLL_INTERVAL_MS: "10",
});
let call = 0;
const fetchMock = vi.fn(async (): Promise<Response> => {
call += 1;
if (call === 1) {
return new Response(JSON.stringify({ ret: 0, task_id: "task-finalize" }), { status: 200 });
}
if (call === 2) {
return new Response(JSON.stringify({ status: "success", data: [] }), { status: 200 });
}
return new Response(JSON.stringify({ status: "success", data: [{ url: "https://img/final.png" }] }), {
status: 200,
});
});
vi.stubGlobal("fetch", fetchMock);
const result = await gateway.generateImage({
uid: "1",
team_id: "1",
model: "seedream@5.0",
prompt: "a cat",
size: "1024x1024",
n: 1,
});
expect(result.urls).toEqual(["https://img/final.png"]);
expect(call).toBe(3);
});
it("returns inline urls without polling", async () => {
const gateway = client({ GATEWAY_MOCK: "false" });
const fetchMock = vi.fn(
async (_url: string | URL | Request, _init?: RequestInit): Promise<Response> =>
new Response(JSON.stringify({ ret: 0, task_id: "t", images: ["https://img/1.png"] }), { status: 200 }),
);
vi.stubGlobal("fetch", fetchMock);
const result = await gateway.generateImage({
uid: "1",
team_id: "1",
model: "gpt-image-2",
prompt: "a cat",
size: "1000x1501",
n: 2,
});
expect(result.urls).toEqual(["https://img/1.png"]);
expect(fetchMock).toHaveBeenCalledTimes(1);
const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)) as Record<string, unknown>;
expect(body.size).toBe("1008x1504");
});
it("fails fast on a non-zero gateway ret", async () => {
const gateway = client({ GATEWAY_MOCK: "false" });
vi.stubGlobal(
"fetch",
vi.fn(async (): Promise<Response> => new Response(JSON.stringify({ ret: 1, msg: "no quota" }), { status: 200 })),
);
await expect(
gateway.generateImage({
uid: "1",
team_id: "1",
model: "seedream@5.0",
prompt: "a cat",
size: "1024x1024",
n: 1,
}),
).rejects.toThrow(/no quota/);
});
it("maps HTTP failures to GatewayError", async () => {
const gateway = client({ GATEWAY_MOCK: "false" });
vi.stubGlobal("fetch", vi.fn(async (): Promise<Response> => new Response("down", { status: 503 })));
await expect(
gateway.generateImage({
uid: "1",
team_id: "1",
model: "seedream@5.0",
prompt: "a cat",
size: "1024x1024",
n: 1,
}),
).rejects.toBeInstanceOf(GatewayError);
});
});
+92
View File
@@ -0,0 +1,92 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { loadConfig, type AppConfig } from "../src/config.js";
import type { Logger } from "../src/logger.js";
import { SkillCatalog } from "../src/skills/catalog.js";
import type { SkillRepository, StoredSkill, StoredSkillConfig } from "../src/skills/repository.js";
export const silentLogger: Logger = {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
};
export function tempRoot(prefix = "laimiao-v2-"): string {
return mkdtempSync(join(tmpdir(), prefix));
}
export class FakeSkillRepository implements SkillRepository {
constructor(private skills: StoredSkill[]) {}
async ensureSchema(): Promise<void> {}
async listSkills(): Promise<StoredSkill[]> {
return this.skills;
}
setSkills(skills: StoredSkill[]): void {
this.skills = skills;
}
async close(): Promise<void> {}
}
export interface SkillFixture {
key: string;
name?: string;
description?: string;
content?: string;
triggerKeywords?: string;
libraryEnabled?: boolean;
config?: Partial<StoredSkillConfig> | null;
}
export function skillFixture(fixture: SkillFixture): StoredSkill {
const config: StoredSkillConfig | undefined =
fixture.config === null
? undefined
: {
pricePerImage: 10,
enabled: true,
...fixture.config,
};
return {
key: fixture.key,
name: fixture.name ?? `Test ${fixture.key}`,
industry: "test",
description: fixture.description ?? `Test skill ${fixture.key}`,
triggerKeywords: fixture.triggerKeywords ?? "",
content: fixture.content ?? `# ${fixture.key}\n\nUse this skill for tests.\n`,
libraryEnabled: fixture.libraryEnabled ?? true,
config,
};
}
export async function makeCatalog(
skills: StoredSkill[],
options: { defaultPrice?: number; defaultModel?: string; defaultSize?: string } = {},
): Promise<SkillCatalog> {
return new SkillCatalog({
repository: new FakeSkillRepository(skills),
defaultPrice: options.defaultPrice ?? 10,
defaultModel: options.defaultModel ?? "gpt-image-2",
defaultSize: options.defaultSize ?? "1024x1024",
currency: "credits",
logger: silentLogger,
}).load();
}
export function testConfig(root: string, overrides: Record<string, string> = {}): AppConfig {
return loadConfig({
APP_ROOT: root,
DATA_DIR: "data",
LOG_LEVEL: "error",
GATEWAY_MOCK: "true",
GATEWAY_POLL_INTERVAL_MS: "10",
BILLING_MODE: "dry_run",
...overrides,
});
}
+89
View File
@@ -0,0 +1,89 @@
import { appendFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { UsageLedger, type BeginUsage } from "../src/billing/ledger.js";
import { silentLogger, tempRoot } from "./helpers.js";
function usage(overrides: Partial<BeginUsage> = {}): BeginUsage {
return {
uid: "1",
team_id: "1",
session_id: "s1",
tool_call_id: "call-1",
skill_id: "demo",
skill_name: "Demo",
model: "seedream@5.0",
size: "1024x1536",
quantity: 2,
unit_credits: 30,
size_multiplier: 1,
total_credits: 60,
currency: "credits",
billing_mode: "dry_run",
...overrides,
};
}
describe("UsageLedger", () => {
it("records pending and succeeded usage and finds it by tool call", () => {
const ledger = new UsageLedger(join(tempRoot(), "ledger.jsonl"), silentLogger);
const record = ledger.begin(usage());
expect(record.status).toBe("pending");
ledger.settle(record.id, "succeeded", {
gateway_task_id: "task-1",
image_urls: ["https://img/1.png", "https://img/2.png"],
});
const found = ledger.findSucceededToolCall("1", "s1", "call-1");
expect(found?.id).toBe(record.id);
expect(found?.status).toBe("succeeded");
expect(found?.image_urls).toHaveLength(2);
expect(ledger.findSucceededToolCall("2", "s1", "call-1")).toBeUndefined();
});
it("persists records and rebuilds the idempotency index", () => {
const file = join(tempRoot(), "ledger.jsonl");
const ledger = new UsageLedger(file, silentLogger);
const record = ledger.begin(usage());
ledger.settle(record.id, "succeeded", { image_urls: ["https://img/1.png"] });
const reloaded = new UsageLedger(file, silentLogger);
expect(reloaded.list()).toHaveLength(1);
expect(reloaded.findSucceededToolCall("1", "s1", "call-1")?.id).toBe(record.id);
});
it("does not index failed calls and summarises by status and skill", () => {
const ledger = new UsageLedger(join(tempRoot(), "ledger.jsonl"), silentLogger);
const failed = ledger.begin(usage({ tool_call_id: "call-failed" }));
ledger.settle(failed.id, "failed", { error: "nope" });
const ok = ledger.begin(usage({ tool_call_id: "call-ok", skill_id: "poster", total_credits: 30, quantity: 1 }));
ledger.settle(ok.id, "succeeded", { image_urls: ["https://img/p.png"] });
const pending = ledger.begin(usage({ tool_call_id: "call-pending", skill_id: "poster", total_credits: 30 }));
expect(ledger.findSucceededToolCall("1", "s1", "call-failed")).toBeUndefined();
const summary = ledger.summary({ uid: "1" });
expect(summary.totals).toEqual({
records: 3,
images: 1,
credits_succeeded: 30,
credits_pending: 30,
credits_failed: 60,
});
expect(summary.by_skill).toEqual([
{ skill_id: "poster", skill_name: "Demo", records: 2, images: 1, credits: 30 },
{ skill_id: "demo", skill_name: "Demo", records: 1, images: 0, credits: 0 },
]);
expect(ledger.list({ status: "pending" }).map((item) => item.id)).toEqual([pending.id]);
expect(ledger.list({ limit: 1 })).toHaveLength(1);
});
it("skips malformed lines instead of losing the ledger", () => {
const file = join(tempRoot(), "ledger.jsonl");
const ledger = new UsageLedger(file, silentLogger);
ledger.begin(usage());
appendFileSync(file, "{bad json\n", "utf8");
const reloaded = new UsageLedger(file, silentLogger);
expect(reloaded.list()).toHaveLength(1);
});
});
+135
View File
@@ -0,0 +1,135 @@
import { describe, expect, it } from "vitest";
import {
assertSafeUrl,
hostMatches,
isBlockedAddress,
isBlockedHostname,
parseHostAllowlist,
safeFetch,
} from "../src/utils/net.js";
describe("parseHostAllowlist", () => {
it("normalizes comma separated hosts", () => {
expect(parseHostAllowlist(" CDN.Example.com , *.OSS.example.com.,, ")).toEqual([
"cdn.example.com",
"*.oss.example.com",
]);
});
it("returns an empty list for empty input", () => {
expect(parseHostAllowlist("")).toEqual([]);
expect(parseHostAllowlist(undefined)).toEqual([]);
});
});
describe("hostMatches", () => {
it("allows every host when the allowlist is empty", () => {
expect(hostMatches("anything.example.com", [])).toBe(true);
});
it("matches exact hosts case-insensitively", () => {
expect(hostMatches("CDN.example.com", ["cdn.example.com"])).toBe(true);
expect(hostMatches("other.example.com", ["cdn.example.com"])).toBe(false);
});
it("matches wildcard subdomains and the apex", () => {
expect(hostMatches("a.oss.example.com", ["*.oss.example.com"])).toBe(true);
expect(hostMatches("oss.example.com", ["*.oss.example.com"])).toBe(true);
expect(hostMatches("evil-oss.example.com", ["*.oss.example.com"])).toBe(false);
});
});
describe("isBlockedAddress", () => {
it.each([
"0.0.0.0",
"10.1.2.3",
"100.64.0.1",
"127.0.0.1",
"169.254.169.254",
"172.16.0.1",
"172.31.255.255",
"192.168.1.1",
"198.18.0.1",
"224.0.0.1",
"255.255.255.255",
])("blocks private or reserved IPv4 %s", (address) => {
expect(isBlockedAddress(address)).toBe(true);
});
it.each(["8.8.8.8", "1.1.1.1", "172.32.0.1", "93.184.216.34"])("allows public IPv4 %s", (address) => {
expect(isBlockedAddress(address)).toBe(false);
});
it.each(["::", "::1", "fe80::1", "fd00::1", "fc00::1", "ff02::1", "2001:db8::1"])(
"blocks private or reserved IPv6 %s",
(address) => {
expect(isBlockedAddress(address)).toBe(true);
},
);
it("blocks IPv4 mapped into IPv6", () => {
expect(isBlockedAddress("::ffff:127.0.0.1")).toBe(true);
expect(isBlockedAddress("::ffff:169.254.169.254")).toBe(true);
expect(isBlockedAddress("::ffff:8.8.8.8")).toBe(false);
});
it("blocks 6to4 addresses that embed a private IPv4", () => {
expect(isBlockedAddress("2002:7f00:1::")).toBe(true);
expect(isBlockedAddress("2002:0808:0808::")).toBe(false);
});
});
describe("isBlockedHostname", () => {
it.each([
"localhost",
"api.localhost",
"metadata.google.internal",
"db.internal",
"redis",
"127.0.0.1",
"169.254.169.254",
"[::1]",
])("blocks %s", (host) => {
expect(isBlockedHostname(host)).toBe(true);
});
it.each(["example.com", "cdn.example.com", "8.8.8.8"])("allows %s", (host) => {
expect(isBlockedHostname(host)).toBe(false);
});
});
describe("assertSafeUrl", () => {
it("rejects non-http protocols", async () => {
await expect(assertSafeUrl("ftp://example.com/a")).rejects.toThrow(/http/);
await expect(assertSafeUrl("file:///etc/passwd")).rejects.toThrow(/http/);
});
it("rejects URLs with credentials", async () => {
await expect(assertSafeUrl("https://user:pass@example.com/a")).rejects.toThrow(/账号|密码/);
});
it("rejects loopback and cloud metadata endpoints", async () => {
await expect(assertSafeUrl("http://127.0.0.1:3306")).rejects.toThrow(/禁止访问/);
await expect(assertSafeUrl("http://169.254.169.254/latest/meta-data")).rejects.toThrow(/禁止访问/);
await expect(assertSafeUrl("http://[::1]/")).rejects.toThrow(/禁止访问/);
});
it("rejects hosts outside the allowlist", async () => {
await expect(assertSafeUrl("https://evil.example.com/a", { allowedHosts: ["cdn.example.com"] })).rejects.toThrow(
/白名单/,
);
await expect(
assertSafeUrl("https://93.184.216.34/a", { allowedHosts: ["93.184.216.34"] }),
).resolves.toBeInstanceOf(URL);
});
it("accepts public IP literals", async () => {
await expect(assertSafeUrl("https://93.184.216.34/a")).resolves.toBeInstanceOf(URL);
});
});
describe("safeFetch", () => {
it("refuses to connect to a blocked host before any request is made", async () => {
await expect(safeFetch("http://127.0.0.1:9/")).rejects.toThrow(/禁止访问/);
});
});
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { PricingError, quoteSkill, repriceForQuantity } from "../src/billing/pricing.js";
import type { SkillDefinition } from "../src/types.js";
const skill: SkillDefinition = {
key: "demo",
name: "demo",
displayName: "Demo Skill",
description: "demo",
content: "# demo",
triggerKeywords: "demo",
usesDefaults: false,
manifest: {
key: "demo",
version: "1.0.0",
industry: "test",
model: "seedream@5.0",
defaults: { size: "1024x1536", n: 1 },
price: { currency: "credits", per_image: 30, size_multipliers: { "2048x3072": 2 } },
capabilities: [],
tags: [],
max_images: 4,
enabled: true,
},
};
describe("pricing", () => {
it("quotes one image using defaults", () => {
expect(quoteSkill(skill, { size: "1024x1536", quantity: 1 }, {}, "dry_run")).toEqual({
skill_id: "demo",
skill_name: "Demo Skill",
model: "seedream@5.0",
size: "1024x1536",
quantity: 1,
unit_credits: 30,
size_multiplier: 1,
total_credits: 30,
currency: "credits",
billing_mode: "dry_run",
});
});
it("applies size multipliers and quantity", () => {
const quote = quoteSkill(
skill,
{ size: "1024x1536", quantity: 1 },
{ size: "2048x3072", quantity: 3 },
"off",
);
expect(quote.size_multiplier).toBe(2);
expect(quote.total_credits).toBe(180);
expect(quote.billing_mode).toBe("off");
});
it("rejects over-limit, non-integer, and malformed sizes", () => {
expect(() => quoteSkill(skill, { size: "1024x1536", quantity: 1 }, { quantity: 5 }, "dry_run")).toThrow(
PricingError,
);
expect(() => quoteSkill(skill, { size: "1024x1536", quantity: 1 }, { quantity: 1.5 }, "dry_run")).toThrow(
/integer/,
);
expect(() => quoteSkill(skill, { size: "1024x1536", quantity: 1 }, { size: "large" }, "dry_run")).toThrow(
/size/,
);
});
it("reprices to the actually returned image count", () => {
const quote = quoteSkill(skill, { size: "1024x1536", quantity: 1 }, { quantity: 3 }, "dry_run");
const repriced = repriceForQuantity(quote, 1);
expect(repriced.quantity).toBe(1);
expect(repriced.total_credits).toBe(30);
expect(() => repriceForQuantity(quote, -1)).toThrow(PricingError);
});
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { buildSystemPrompts } from "../src/agent/prompt.js";
import { makeCatalog, skillFixture, tempRoot, testConfig } from "./helpers.js";
describe("system prompts", () => {
it("uses direct mode when no skill is selected", async () => {
const catalog = await makeCatalog([
skillFixture({ key: "poster", content: "# Poster\n\nUse poster rules." }),
]);
const prompts = buildSystemPrompts(catalog, testConfig(tempRoot()));
const text = [prompts.systemPrompt, ...prompts.appendSystemPrompt].join("\n");
expect(text).toContain("直接生成模式");
expect(text).toContain("普通直接生成不要读取技能规范");
expect(text).toContain("省略 skill_id");
expect(text).toContain("confirm_travel_style");
expect(text).not.toContain("Use poster rules");
expect(text).not.toContain("skill_id: poster");
});
it("injects only the selected skill spec", async () => {
const catalog = await makeCatalog([
skillFixture({ key: "poster", content: "# Poster\n\nUse poster rules." }),
skillFixture({ key: "other", content: "# Other\n\nUse other rules." }),
]);
const skill = catalog.get("poster");
if (!skill) throw new Error("missing fixture");
const prompts = buildSystemPrompts(catalog, testConfig(tempRoot()), skill);
const text = [prompts.systemPrompt, ...prompts.appendSystemPrompt].join("\n");
expect(text).toContain("skill_id: poster");
expect(text).toContain("Test skill poster");
expect(text).toContain("Use poster rules");
expect(text).not.toContain("Use other rules");
expect(text).not.toContain("直接生成模式");
});
});
+216
View File
@@ -0,0 +1,216 @@
import { join } from "node:path";
import type { FastifyInstance } from "fastify";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { AgentService, ConfirmToolInput, RunInput, RunResult } from "../src/agent/service.js";
import { UsageLedger } from "../src/billing/ledger.js";
import { buildServer } from "../src/server.js";
import { SessionStore } from "../src/sessions/store.js";
import { makeCatalog, silentLogger, skillFixture, tempRoot, testConfig } from "./helpers.js";
const apps: FastifyInstance[] = [];
afterEach(async () => {
await Promise.all(apps.splice(0).map((app) => app.close()));
});
async function makeApp(overrides: Record<string, string> = {}) {
const root = tempRoot();
const config = testConfig(root, overrides);
const catalog = await makeCatalog([skillFixture({ key: "demo", config: { pricePerImage: 10 } })]);
const ledger = new UsageLedger(join(root, "usage.jsonl"), silentLogger);
const store = new SessionStore(join(root, "sessions.json"), join(root, "messages"), silentLogger);
const run = vi.fn(async (_input: RunInput): Promise<RunResult> => {
throw new Error("not used");
});
const confirmTool = vi.fn(async (_input: ConfirmToolInput) => ({
ok: true as const,
tool_call_id: "call-1",
approved: true,
}));
const service = {
isLlmConfigured: () => true,
run,
confirmTool,
} as unknown as AgentService;
const app = await buildServer({ config, logger: silentLogger, catalog, ledger, store, service });
apps.push(app);
return { app, ledger, store, root, run, confirmTool };
}
describe("HTTP API", () => {
it("exposes health, skills, and skill detail", async () => {
const { app } = await makeApp();
const health = await app.inject({ method: "GET", url: "/health" });
expect(health.statusCode).toBe(200);
expect(health.json()).toMatchObject({
ok: true,
billing_mode: "dry_run",
skills: 1,
gateway_mock: true,
image_models: ["gpt-image-2"],
});
const skills = await app.inject({ method: "GET", url: "/skills" });
expect(skills.json()).toMatchObject({ skills: [{ key: "demo", display_name: "Test demo" }] });
const detail = await app.inject({ method: "GET", url: "/skills/demo" });
expect(detail.statusCode).toBe(200);
expect(detail.json()).toMatchObject({ key: "demo", content: expect.stringContaining("Use this skill") });
const missing = await app.inject({ method: "GET", url: "/skills/nope" });
expect(missing.statusCode).toBe(404);
});
it("quotes images and validates input", async () => {
const { app } = await makeApp();
const quote = await app.inject({
method: "POST",
url: "/images/quote",
payload: { skill_id: "demo", n: 2 },
});
expect(quote.statusCode).toBe(200);
expect(quote.json()).toMatchObject({ total_credits: 20, quantity: 2, billing_mode: "dry_run" });
const invalid = await app.inject({ method: "POST", url: "/images/quote", payload: { skill_id: "demo", n: 0 } });
expect(invalid.statusCode).toBe(400);
const missing = await app.inject({ method: "POST", url: "/images/quote", payload: { skill_id: "nope" } });
expect(missing.statusCode).toBe(404);
});
it("passes an explicitly selected skill_id to the agent", async () => {
const { app, store, run } = await makeApp();
const session = await store.create({ uid: "1", team_id: "t1" });
run.mockResolvedValueOnce({
sessionId: session.session_id,
messageId: "msg-1",
answer: "ok",
media: [],
usage: { input: 0, output: 0, total: 0, cost: 0 },
skill_key: "demo",
});
const response = await app.inject({
method: "POST",
url: "/agent/run",
headers: { "x-uid": "1", "x-team-id": "t1" },
payload: { session_id: session.session_id, message: "做一张海报", skill_id: "demo" },
});
expect(response.statusCode).toBe(200);
expect(run.mock.calls[0]?.[0]).toMatchObject({ skillKey: "demo", message: "做一张海报" });
run.mockResolvedValueOnce({
sessionId: session.session_id,
messageId: "msg-2",
answer: "ok",
media: [],
usage: { input: 0, output: 0, total: 0, cost: 0 },
});
await app.inject({
method: "POST",
url: "/agent/run",
headers: { "x-uid": "1", "x-team-id": "t1" },
payload: { session_id: session.session_id, message: "直接画" },
});
expect(run.mock.calls[1]?.[0]).toMatchObject({ skillKey: undefined, message: "直接画" });
});
it("resolves a pending tool confirmation over HTTP", async () => {
const { app, store, confirmTool } = await makeApp();
const session = await store.create({ uid: "1", team_id: "t1" });
const response = await app.inject({
method: "POST",
url: `/sessions/${session.session_id}/tool-confirmations/call-1`,
headers: { "x-uid": "1", "x-team-id": "t1" },
payload: { approved: true },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({ ok: true, approved: true, tool_call_id: "call-1" });
expect(confirmTool.mock.calls[0]?.[0]).toMatchObject({
sessionId: session.session_id,
toolCallId: "call-1",
approved: true,
});
});
it("creates and lists sessions scoped to the caller", async () => {
const { app } = await makeApp();
const created = await app.inject({
method: "POST",
url: "/sessions",
headers: { "x-uid": "1", "x-team-id": "t1" },
payload: { title: "first" },
});
expect(created.statusCode).toBe(200);
const session = created.json() as { session_id: string; uid: string };
expect(session.uid).toBe("1");
const list = await app.inject({ method: "GET", url: "/sessions", headers: { "x-uid": "1", "x-team-id": "t1" } });
expect(list.json()).toMatchObject({ total: 1, sessions: [{ session_id: session.session_id }] });
const detail = await app.inject({
method: "GET",
url: `/sessions/${session.session_id}`,
headers: { "x-uid": "1" },
});
expect(detail.statusCode).toBe(200);
const forbidden = await app.inject({
method: "GET",
url: `/sessions/${session.session_id}`,
headers: { "x-uid": "2" },
});
expect(forbidden.statusCode).toBe(403);
});
it("returns 400 for malformed JSON bodies", async () => {
const { app } = await makeApp();
const response = await app.inject({
method: "POST",
url: "/sessions",
headers: { "content-type": "application/json", "x-uid": "1" },
payload: "{bad json",
});
expect(response.statusCode).toBe(400);
});
it("requires a JWT when JWT_KEY is configured", async () => {
const { app } = await makeApp({ JWT_KEY: "secret" });
const response = await app.inject({ method: "POST", url: "/sessions", payload: {} });
expect(response.statusCode).toBe(401);
});
it("summarises usage for the caller", async () => {
const { app, ledger } = await makeApp();
const record = ledger.begin({
uid: "1",
team_id: "t1",
session_id: "s1",
tool_call_id: "call-1",
skill_id: "demo",
skill_name: "Demo",
model: "seedream@5.0",
size: "1024x1536",
quantity: 1,
unit_credits: 10,
size_multiplier: 1,
total_credits: 10,
currency: "credits",
billing_mode: "dry_run",
});
ledger.settle(record.id, "succeeded", { image_urls: ["https://img/1.png"] });
const summary = await app.inject({ method: "GET", url: "/usage/summary", headers: { "x-uid": "1" } });
expect(summary.statusCode).toBe(200);
expect(summary.json()).toMatchObject({
billing_mode: "dry_run",
totals: { records: 1, images: 1, credits_succeeded: 10, credits_pending: 0, credits_failed: 0 },
});
const usage = await app.inject({ method: "GET", url: "/usage", headers: { "x-uid": "1" } });
expect(usage.json()).toMatchObject({ total: 1, records: [{ skill_id: "demo", status: "succeeded" }] });
const billing = await app.inject({ method: "GET", url: "/billing" });
expect(billing.json()).toMatchObject({ mode: "dry_run", deduction_enabled: false });
});
});
+61
View File
@@ -0,0 +1,61 @@
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { SessionStore } from "../src/sessions/store.js";
import { silentLogger, tempRoot } from "./helpers.js";
function makeStore(root: string) {
return new SessionStore(join(root, "sessions.json"), join(root, "messages"), silentLogger);
}
describe("SessionStore", () => {
it("creates, lists, and owns sessions", async () => {
const store = makeStore(tempRoot());
const session = await store.create({ uid: "1", team_id: "t1" }, { title: "hello" });
expect((await store.get(session.session_id))?.title).toBe("hello");
expect((await store.getOwned({ uid: "1", team_id: "t1" }, session.session_id))?.session_id).toBe(
session.session_id,
);
expect(await store.getOwned({ uid: "2", team_id: "t1" }, session.session_id)).toBeUndefined();
expect((await store.list({ uid: "1" })).total).toBe(1);
expect((await store.list({ uid: "2" })).total).toBe(0);
});
it("appends messages, updates metadata, and auto-titles from the first user message", async () => {
const store = makeStore(tempRoot());
const session = await store.create({ uid: "1", team_id: "t1" });
await store.append(session.session_id, {
role: "user",
mode: "agent",
content: "帮我做一张云南海报",
media: [],
status: "ok",
});
await store.append(session.session_id, {
role: "assistant",
mode: "image",
content: "已完成",
media: [{ type: "image", url: "https://img/1.png" }],
status: "ok",
});
const meta = await store.get(session.session_id);
expect(meta?.message_count).toBe(2);
expect(meta?.last_mode).toBe("image");
expect(meta?.title).toBe("帮我做一张云南海报");
const messages = await store.messages(session.session_id);
expect(messages).toHaveLength(2);
expect(messages[1]?.media[0]?.url).toBe("https://img/1.png");
});
it("persists the index and paginates", async () => {
const root = tempRoot();
const store = makeStore(root);
for (let index = 0; index < 3; index += 1) {
await store.create({ uid: "1", team_id: "t1" }, { title: `s${index}` });
}
const reloaded = makeStore(root);
expect((await reloaded.list({ uid: "1" })).total).toBe(3);
expect((await reloaded.list({ uid: "1", limit: 2 })).sessions).toHaveLength(2);
expect((await reloaded.list({ uid: "1", limit: 2, offset: 2 })).sessions).toHaveLength(1);
});
});
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { sanitizeTitle, stripUploadedDocuments } from "../src/utils/text.js";
describe("stripUploadedDocuments", () => {
it("removes the document instruction block appended by the frontend", () => {
const message = "根据上传文档生成旅游详情长图\n\n【用户上传了以下文档(请先用 read_document_text 逐个读取原文;如果是旅游行程文档,可再用 read_document 提取线路摘要):\n行程.pdf:https://oss/x.pdf】";
expect(stripUploadedDocuments(message)).toBe("根据上传文档生成旅游详情长图");
});
it("removes older block variants and the text after them", () => {
const message = "【用户上传了以下行程文档(请先用 read_document 逐个读取,再依据文档内容处理):\nhttps://oss/x.docx】\n请阅读文档并整理线路摘要。";
expect(stripUploadedDocuments(message)).toBe("请阅读文档并整理线路摘要。");
});
it("keeps plain messages untouched", () => {
expect(stripUploadedDocuments("生成三张产品图")).toBe("生成三张产品图");
});
});
describe("sanitizeTitle", () => {
it("cleans and truncates dirty titles", () => {
expect(sanitizeTitle("生成一张旅游海报\n\n【用户上传了以下文档(请先用 read_document_text 逐个读取原文")).toBe("生成一张旅游海报");
expect(sanitizeTitle("【用户上传了以下文档(请先用 read_document_text 逐个")).toBe("新对话");
expect(sanitizeTitle(undefined)).toBe("新对话");
expect(sanitizeTitle("内容", "新对话", 2)).toBe("内容");
});
});
+200
View File
@@ -0,0 +1,200 @@
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { createAgentTools } from "../src/agent/tools.js";
import { UsageLedger } from "../src/billing/ledger.js";
import { GatewayClient } from "../src/gateway/client.js";
import { DIRECT_SKILL_KEY, directSkill } from "../src/skills/direct.js";
import type { Quote } from "../src/types.js";
import { makeCatalog, silentLogger, skillFixture, tempRoot, testConfig } from "./helpers.js";
interface GenerateDetails {
ok: boolean;
quote?: Quote;
images?: string[];
task_id?: string;
idempotent?: boolean;
error?: string;
}
interface TestTool {
name: string;
execute: (
toolCallId: string,
params: Record<string, unknown>,
signal?: AbortSignal,
) => Promise<{ content: Array<{ type: string; text: string }>; details: GenerateDetails }>;
}
async function setup(perImage = 20, allowedSkillKeys?: string[], direct = false) {
const root = tempRoot();
const config = testConfig(root);
const catalog = await makeCatalog([
skillFixture({ key: "poster", config: { pricePerImage: perImage } }),
skillFixture({ key: "other", config: { pricePerImage: perImage } }),
]);
const ledger = new UsageLedger(join(root, "usage.jsonl"), silentLogger);
const gateway = new GatewayClient(config.gateway, silentLogger);
const directModeSkill = direct ? directSkill(config) : undefined;
const tools = createAgentTools({
config,
catalog,
ledger,
gateway,
logger: silentLogger,
identity: { uid: "1", team_id: "1" },
sessionId: "session-1",
allowedSkillKeys: directModeSkill ? [DIRECT_SKILL_KEY] : allowedSkillKeys,
directSkill: directModeSkill,
onImages: () => {},
});
const find = (name: string): TestTool => {
const tool = tools.find((item) => item.name === name);
if (!tool) throw new Error(`missing tool ${name}`);
return tool as unknown as TestTool;
};
return { root, config, catalog, ledger, gateway, tools, find };
}
describe("agent tools", () => {
it("lists and reads skills", async () => {
const { find } = await setup();
const list = await find("list_skills").execute("call-list", {});
expect(list.content[0]?.text).toContain("poster");
const read = await find("read_skill").execute("call-read", { skill_id: "poster" });
expect(read.content[0]?.text).toContain("Use this skill for tests");
expect(read.details.ok).toBe(true);
});
it("no longer exposes the quote_image tool", async () => {
const { tools } = await setup();
expect(tools.some((tool) => tool.name === "quote_image")).toBe(false);
});
it("generates images and records the actual returned count", async () => {
const { find, ledger, gateway } = await setup();
const spy = vi
.spyOn(gateway, "generateImage")
.mockResolvedValueOnce({
taskId: "task-1",
urls: ["https://img/1.png", "https://img/2.png"],
status: "success",
raw: {},
elapsedMs: 12,
})
.mockResolvedValueOnce({
taskId: "task-2",
urls: ["https://img/3.png"],
status: "success",
raw: {},
elapsedMs: 8,
});
const first = await find("generate_image").execute("call-1", {
skill_id: "poster",
prompt: "a travel poster",
n: 2,
});
expect(first.details.ok).toBe(true);
expect(first.details.images).toHaveLength(2);
expect(first.details.quote?.total_credits).toBe(40);
expect(first.details.quote?.billing_mode).toBe("dry_run");
expect(ledger.list()).toHaveLength(1);
expect(ledger.list()[0]?.status).toBe("succeeded");
expect(ledger.list()[0]?.tool_call_id).toBe("call-1");
const replay = await find("generate_image").execute("call-1", {
skill_id: "poster",
prompt: "a travel poster",
n: 2,
});
expect(replay.details.idempotent).toBe(true);
expect(spy).toHaveBeenCalledTimes(1);
expect(ledger.list()).toHaveLength(1);
const fewer = await find("generate_image").execute("call-2", {
skill_id: "poster",
prompt: "a travel poster",
n: 2,
});
expect(fewer.details.quote?.quantity).toBe(1);
expect(fewer.details.quote?.total_credits).toBe(20);
expect(ledger.list()).toHaveLength(2);
});
it("records a failed generation without charging it", async () => {
const { find, ledger, gateway } = await setup();
vi.spyOn(gateway, "generateImage").mockRejectedValue(new Error("gateway exploded"));
const result = await find("generate_image").execute("call-fail", {
skill_id: "poster",
prompt: "a travel poster",
n: 1,
});
expect(result.details.ok).toBe(false);
expect(result.content[0]?.text).toContain("gateway exploded");
expect(ledger.list()).toHaveLength(1);
expect(ledger.list()[0]?.status).toBe("failed");
});
it("limits tools to the routed skill when allowedSkillKeys is set", async () => {
const { find } = await setup(20, ["poster"]);
const list = await find("list_skills").execute("call-list", {});
expect(list.content[0]?.text).toContain("poster");
expect(list.content[0]?.text).not.toContain("other");
const blocked = await find("generate_image").execute("call-blocked", {
skill_id: "other",
prompt: "a travel poster",
});
expect(blocked.details.ok).toBe(false);
expect(blocked.details.error).toBe("unknown_skill");
});
it("generates directly without a skill_id in direct mode", async () => {
const { find, ledger, gateway } = await setup(20, undefined, true);
const spy = vi.spyOn(gateway, "generateImage").mockResolvedValue({
taskId: "task-direct",
urls: ["https://img/direct.png"],
status: "success",
raw: {},
elapsedMs: 3,
});
const result = await find("generate_image").execute("call-direct", { prompt: "a clean product photo" });
expect(result.details.ok).toBe(true);
expect(result.details.images).toEqual(["https://img/direct.png"]);
expect(spy).toHaveBeenCalledOnce();
expect(ledger.list()[0]?.skill_id).toBe(DIRECT_SKILL_KEY);
// 直接生成按默认单价(10)的 1.3 倍计费:ceil(10 * 1.3) = 13
expect(ledger.list()[0]?.total_credits).toBe(13);
const blocked = await find("generate_image").execute("call-direct-blocked", {
skill_id: "poster",
prompt: "a poster",
});
expect(blocked.details.ok).toBe(false);
expect(blocked.details.error).toBe("unknown_skill");
});
it("rejects unknown skills and invalid quantities before calling the gateway", async () => {
const { find, ledger, gateway } = await setup();
const spy = vi.spyOn(gateway, "generateImage");
const unknown = await find("generate_image").execute("call-unknown", {
skill_id: "nope",
prompt: "a travel poster",
});
expect(unknown.details.ok).toBe(false);
expect(unknown.details.error).toBe("unknown_skill");
const invalid = await find("generate_image").execute("call-invalid", {
skill_id: "poster",
prompt: "a travel poster",
n: 99,
});
expect(invalid.details.ok).toBe(false);
expect(spy).not.toHaveBeenCalled();
expect(ledger.list()).toHaveLength(0);
});
});
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"noEmit": false
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "tests"]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2023"],
"types": ["node"],
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": false,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true,
"outDir": "dist",
"rootDir": "."
},
"include": ["src/**/*.ts", "tests/**/*.ts", "vitest.config.ts"],
"exclude": ["node_modules", "dist"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests/**/*.test.ts"],
environment: "node",
testTimeout: 20_000,
hookTimeout: 20_000,
},
});