chore: initial commit
Laimiao agent v2 service: agent orchestration, sessions, billing, gateway client, skills catalog, auth, config, plus tests.
This commit is contained in:
commit
6ec9778d45
53 files changed
+12340
No files matched your search
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
data
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
tests
|
||||||
|
*.log
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
data/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
+29
@@ -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"]
|
||||||
@@ -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:
|
||||||
Generated
+4486
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 与提交顺序一致)。不得把图片集中堆在末尾,也不要用表格或下载链接代替逐张展示。",
|
||||||
|
"生成参数(尺寸、张数、水印、最大张数)以技能正文中的规定为准;调用 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");
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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("重定向次数过多");
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: "" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(/禁止访问/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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("直接生成模式");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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("内容");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"noEmit": false
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist", "tests"]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in new issue
Block a user