Files
devos/apps/api/src/modules/ai/ai-chat.service.ts
T
phptits 0d6cd37579 DevOS v1 + Phase 2: unified resource model, infra ops, website deployment, AI chat ops
- Monorepo: NestJS API (Fastify) + React web (antd/ProComponents) + shared packages
- PostgreSQL/Prisma (unified resources, relations graph, audit, events, jobs)
- Auth/RBAC/workspace isolation, credential vault (AES-256-GCM)
- Servers/SSH, Docker discovery & lifecycle, Job Center
- Websites & deployments (git provider credential clone, env, health verification)
- Domains/DNS (Cloudflare adapter), certificates (TLS probe), monitoring
- Automation (cron/event/webhook HMAC + notifications), AI Gateway, MCP (29 tools)
- AI chat window (LLM function-calling + rules fallback, SSE streaming, approvals)
- Notes: Vditor WYSIWYG markdown editor, non-blocking autosave
- Tests: 35 unit/security tests; UI audit clean (desktop+mobile, deep links)
2026-09-10 06:35:55 +00:00

658 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* # AI 聊天服务(对话式操作入口)
* 让用户用自然语言驱动整个 DevOS,而不是手动填表单。
*
* 双引擎:
* 1. LLM 引擎(配置 AI_PROVIDER 后自动启用):OpenAI function-calling 多轮循环,
* 模型自主选择 ToolRegistry 工具并填参(工作区快照注入上下文用于名称→ID 解析);
* 2. 规则引擎(无 LLM 时兜底,保证开箱可用):意图匹配核心操作。
*
* 安全:所有执行走 ToolRegistry(与 UI/MCP 同一授权与审计);
* HIGH/CRITICAL 工具不直接执行,而是创建 AWAITING_APPROVAL 的 AiRun 返回审批卡片。
*/
import { Injectable } from "@nestjs/common";
import { zodToJsonSchema } from "zod-to-json-schema";
import type { ResourceStatus } from "@prisma/client";
import { PrismaService } from "../../prisma/prisma.service";
import { ConfigService } from "../../common/config/config.service";
import { AiProviderService } from "../../common/ai/ai-provider.service";
import { ToolRegistryService, type ToolContext } from "./tool-registry.service";
import { LogService, StructuredLoggerInstance } from "../../common/logger/logger.module";
import { maskSecrets } from "../../common/utils/mask";
/** # 聊天允许使用的工具白名单(删除类操作刻意不开放,防误删) **/
const CHAT_TOOLS = [
"devos.server.list",
"devos.server.find",
"devos.server.status",
"devos.server.exec",
"devos.server.getDockerContainers",
"devos.website.list",
"devos.website.create",
"devos.website.deploy",
"devos.website.logs",
"devos.website.health",
"devos.deployment.list",
"devos.deployment.get",
"devos.domain.list",
"devos.domain.find",
"devos.domain.records",
"devos.domain.upsertRecord",
"devos.monitor.list",
"devos.automation.list",
"devos.automation.run",
"devos.task.list",
"devos.task.create",
"devos.task.update",
"devos.task.delete",
"devos.note.list",
"devos.note.create",
"devos.note.update",
"devos.note.delete",
"devos.web.fetch",
"devos.repository.createFromUrl",
] as const;
/** # 聊天消息(前端回传的简史) **/
export interface ChatTurn {
role: "user" | "assistant";
content: string;
}
/** # 已执行的工具步骤(回显) **/
export interface ChatToolCall {
tool: string;
args: Record<string, unknown>;
ok: boolean;
summary: string;
}
/** # 聊天响应 **/
export interface ChatResponse {
reply: string;
engine: "llm" | "rules";
toolCalls: ChatToolCall[];
/** # HIGH/CRITICAL 操作待审批卡片 **/
approval?: { runId: string; steps: { tool: string; args: Record<string, unknown>; risk: string }[] };
/** # 自动执行产生的引用(如新任务 ID) **/
refs?: { type: string; id: string; name: string }[];
}
/** # 单条快照行 **/
interface SnapshotItem {
id: string;
name: string;
status?: string;
extra?: string;
}
@Injectable()
export class AiChatService {
constructor(
private readonly prisma: PrismaService,
private readonly config: ConfigService,
private readonly ai: AiProviderService,
private readonly tools: ToolRegistryService,
loggers: LogService,
) {
this.logger = loggers.child("AiChat");
}
/** # 日志器 **/
private readonly logger: StructuredLoggerInstance;
/** # 主入口:处理一条用户消息
* @param onEvent 可选进度回调(工具开始/完成实时上报,供流式 UI)
*/
async chat(
workspaceId: string,
userId: string,
message: string,
history: ChatTurn[],
onEvent?: (event: { type: "status" | "tool_start" | "tool"; text?: string; call?: ChatToolCall }) => void,
): Promise<ChatResponse> {
const snapshot = await this.buildSnapshot(workspaceId);
const ctx: ToolContext = { workspaceId, actorType: "USER", actorId: userId };
if (this.ai.enabled) {
try {
return await this.llmChat(workspaceId, ctx, message, history, snapshot, onEvent);
} catch (error) {
this.logger.warn(
`LLM chat failed, falling back to rules: ${error instanceof Error ? error.message : ""}`,
);
}
}
return this.rulesChat(workspaceId, ctx, message, snapshot, onEvent);
}
// ─────────────────────────── LLM 引擎 ───────────────────────────
/** # LLM function-calling 多轮循环 */
private async llmChat(
workspaceId: string,
ctx: ToolContext,
message: string,
history: ChatTurn[],
snapshot: string,
onEvent?: (event: { type: "status" | "tool_start" | "tool"; text?: string; call?: ChatToolCall }) => void,
): Promise<ChatResponse> {
const chatTools = CHAT_TOOLS.map((name) => this.tools.get(name)).filter(Boolean);
const toolsParam = chatTools.map((t) => ({
type: "function" as const,
function: {
name: t!.name,
description: `${t!.description}(风险等级 ${t!.risk})`,
parameters: zodToJsonSchema(t!.schema as never, { $refStrategy: "none" }),
},
}));
const messages: Record<string, unknown>[] = [
{
role: "system",
content: [
"你是 DevOS 的运维助手,能通过工具完成平台上的几乎所有操作。今天是 " + new Date().toISOString().slice(0, 10) + "。",
"",
"## 能力地图",
"- 查询:server/website/domain/container/task/note/deployment/monitor/automation 列表与详情",
"- 创建:任务、笔记、网站(website.create)、仓库登记(repository.createFromUrl)、DNS 记录(domain.upsertRecord)",
"- 操作:部署(website.deploy)、重启容器(container.restart)、服务器命令(server.exec)、运行自动化",
"- 编辑/删除:task/note 的 update 与 delete;website/server/domain 的 delete",
"- 网页:web.fetch 抓取 URL 正文(供总结/摘录)",
"",
"## 复合任务编排(重要)",
"用户常一次提出多步需求,请按依赖顺序组合工具完成:",
"例1「总结 <url> 存到笔记」:web.fetch → 自己提炼总结 → note.create(content 用简洁 HTML:h3+p/ul,开头附原文链接)",
"例2「把 <github url> 安装到 <服务器名> 并用 <域名> 解析(CF 代理)」:",
" 1. repository.createFromUrl {url}",
" 2. server.find {name:<服务器名>} → 取 id",
" 3. domain.find {name:<域名根>} → 取 id(若无域名记录,用 domain.upsertRecord 直接建)",
" 4. website.create {name, serverId, repositoryId, primaryDomain:<域名>}",
" 5. domain.upsertRecord {domainId, type:'A', name:<域名或子域>, value:<服务器公网 IP=host>, proxied:true}",
" 6. website.deploy {websiteId}(HIGH,系统会转用户审批)",
" 7. website.health 验证",
"",
"## 规则",
"- 只能使用提供的工具;ID 必须来自工具返回或快照,禁止编造。",
"- 名称解析:优先 server.find/domain.find,其次快照。",
"- HIGH/CRITICAL 工具(部署/重启容器/删除/删除服务器)也直接调用——系统会自动转为用户审批卡片,你只需说明已提交审批。",
"- 删除前必须与用户确认过上下文;不确定时先查询再行动。",
"- 回答简洁、用用户的语言(默认中文);汇报做了什么、结果如何;不要复读工具原始 JSON。",
"",
"## 当前工作区快照",
snapshot,
].join("\n"),
},
...history.slice(-8).map((h) => ({ role: h.role, content: h.content })),
{ role: "user", content: message },
];
const toolCalls: ChatToolCall[] = [];
const pendingApproval: { tool: string; args: Record<string, unknown>; risk: string }[] = [];
let iterations = 0;
let finalText = "";
while (iterations < 6) {
iterations += 1;
const res = await this.rawChatCompletion(messages, toolsParam);
const choice = res.choices?.[0];
if (!choice) throw new Error("empty completion");
const msg = choice.message as {
content?: string | null;
tool_calls?: { id: string; function: { name: string; arguments: string } }[];
};
if (!msg.tool_calls || msg.tool_calls.length === 0) {
finalText = msg.content ?? "(无回复)";
break;
}
messages.push({ role: "assistant", content: msg.content ?? null, tool_calls: msg.tool_calls });
for (const call of msg.tool_calls) {
const tool = this.tools.get(call.function.name);
if (!tool) {
messages.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify({ error: "unknown tool" }) });
continue;
}
let args: Record<string, unknown> = {};
try {
args = JSON.parse(call.function.arguments || "{}") as Record<string, unknown>;
} catch {
args = {};
}
// HIGH/CRITICAL → 转审批,不直接执行
if (tool.risk === "HIGH" || tool.risk === "CRITICAL") {
pendingApproval.push({ tool: tool.name, args, risk: tool.risk });
messages.push({
role: "tool",
tool_call_id: call.id,
content: JSON.stringify({ ok: true, note: "submitted for user approval" }),
});
continue;
}
onEvent?.({ type: "tool_start", text: tool.name });
const rec: ChatToolCall = { tool: tool.name, args: maskSecrets(args) as Record<string, unknown>, ok: false, summary: "" };
try {
const result = await tool.execute(ctx, args as never);
rec.ok = true;
rec.summary = summarize(result);
messages.push({
role: "tool",
tool_call_id: call.id,
content: JSON.stringify(maskSecrets(result)).slice(0, 6000),
});
} catch (error) {
const err = error instanceof Error ? error.message : String(error);
rec.summary = err.slice(0, 200);
messages.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify({ error: err.slice(0, 500) }) });
}
toolCalls.push(rec);
onEvent?.({ type: "tool", call: rec });
}
if (pendingApproval.length > 0) break; // 有审批待确认,先返回
}
if (pendingApproval.length > 0) {
const run = await this.createApprovalRun(workspaceId, ctx.actorId ?? "", message, pendingApproval);
return {
reply: `以下操作风险较高,需要你确认后执行:\n${pendingApproval
.map((s) => `• ${s.tool}(${s.risk})`)
.join("\n")}\n确认后我会立即执行。`,
engine: "llm",
toolCalls,
approval: { runId: run.id, steps: pendingApproval },
};
}
if (!finalText) finalText = summarizeDone(toolCalls);
return { reply: finalText, engine: "llm", toolCalls };
}
/** # 调用 OpenAI 兼容 chat/completions(带工具) */
private async rawChatCompletion(
messages: Record<string, unknown>[],
tools: unknown[],
): Promise<{ choices?: { message?: unknown }[] }> {
const res = await fetch(`${this.config.aiBaseUrl.replace(/\/$/, "")}/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.config.aiApiKey}` },
body: JSON.stringify({
model: this.config.aiModel,
messages,
tools,
temperature: 0.2,
}),
signal: AbortSignal.timeout(90_000),
});
if (!res.ok) throw new Error(`AI provider HTTP ${res.status}`);
return (await res.json()) as { choices?: { message?: unknown }[] };
}
/** # 创建待审批运行(复用 AI Gateway 的审批执行管线) */
private async createApprovalRun(
workspaceId: string,
_userId: string,
objective: string,
steps: { tool: string; args: Record<string, unknown>; risk: string }[],
): Promise<{ id: string }> {
const plan = steps.map((s) => ({ tool: s.tool, args: s.args, reason: "chat request", risk: s.risk }));
const risk = steps.some((s) => s.risk === "CRITICAL") ? "CRITICAL" : "HIGH";
return this.prisma.$transaction(async (tx) => {
const resource = await tx.resource.create({
data: { workspaceId, type: "AI_RUN", name: objective.slice(0, 80), provider: "chat", status: "PENDING" },
});
return tx.aiRun.create({
data: {
workspaceId,
resourceId: resource.id,
objective: objective.slice(0, 2000),
status: "AWAITING_APPROVAL",
source: "CHAT",
riskLevel: risk,
plan: plan as unknown as object,
},
select: { id: true },
});
});
}
// ─────────────────────────── 规则引擎(离线兜底) ───────────────────────────
/** # 规则引擎:意图匹配核心操作 */
private async rulesChat(
workspaceId: string,
ctx: ToolContext,
message: string,
snapshot: string,
onEvent?: (event: { type: "status" | "tool_start" | "tool"; text?: string; call?: ChatToolCall }) => void,
): Promise<ChatResponse> {
const text = message.trim();
const toolCalls: ChatToolCall[] = [];
const run = async (toolName: string, args: Record<string, unknown>): Promise<unknown | null> => {
const tool = this.tools.get(toolName);
if (!tool) return null;
onEvent?.({ type: "tool_start", text: toolName });
try {
const result = await tool.execute(ctx, args as never);
const call: ChatToolCall = { tool: toolName, args, ok: true, summary: summarize(result) };
toolCalls.push(call);
onEvent?.({ type: "tool", call });
return result;
} catch (error) {
const err = error instanceof Error ? error.message : String(error);
const call: ChatToolCall = { tool: toolName, args, ok: false, summary: err.slice(0, 200) };
toolCalls.push(call);
onEvent?.({ type: "tool", call });
return { __error: err };
}
};
// 帮助
if (/帮助|help|你能做什么|能干什么/i.test(text)) {
return { reply: HELP_TEXT, engine: "rules", toolCalls: [] };
}
// URL 总结 → 存笔记(规则引擎用抽取式摘要兜底;LLM 模式下由模型深度总结)
const urlInText = text.match(/https?:\/\/[^\s,。;))]+/);
if (urlInText && /总结|摘要|summary|保存到?笔记|记到笔记/i.test(text)) {
const url = urlInText[0];
const fetchTool = this.tools.get("devos.web.fetch")!;
try {
const page = (await fetchTool.execute(ctx, { url })) as { title?: string; description?: string; text?: string };
const title = (page.title ?? url).slice(0, 120);
// 抽取式摘要:meta 描述 + 前几段
const paras = (page.text ?? "").split("\n\n").filter((p) => p && !p.startsWith("# ")).slice(0, 5);
const summaryHtml = [
`<p>📄 来源:<a href="${url}">${url}</a></p>`,
page.description ? `<p><strong>摘要:</strong>${page.description}</p>` : "",
"<ul>" + paras.map((p) => `<li>${p.slice(0, 260)}</li>`).join("") + "</ul>",
"<p><em>配置 LLM 后可获得更高质量的深度总结。</em></p>",
].filter(Boolean).join("");
const noteTool = this.tools.get("devos.note.create")!;
const note = (await noteTool.execute(ctx, {
title: `总结:${title}`,
content: summaryHtml,
tags: ["summary"],
})) as { id: string; title: string };
return {
reply: `✅ 已抓取并总结「${title}」,保存为笔记「${note.title}」。可在「笔记」页面查看与编辑(配置 LLM 后总结质量更高)。`,
engine: "rules",
toolCalls: [
{ tool: "devos.web.fetch", args: { url }, ok: true, summary: title },
{ tool: "devos.note.create", args: { title: note.title }, ok: true, summary: note.id },
],
refs: [{ type: "NOTE", id: note.id, name: note.title }],
};
} catch (error) {
return {
reply: `抓取页面失败:${error instanceof Error ? error.message : "unknown"}。请确认 URL 可公网访问。`,
engine: "rules",
toolCalls,
};
}
}
// 创建任务
const taskM = text.match(/(?:创建|新建|添加|记录)个?任务[::,,\s]*(.+)/);
if (taskM) {
const title = taskM[1].slice(0, 120).trim();
const result = await run("devos.task.create", { title });
if (result && !(result as { __error?: string }).__error) {
const t = result as { id: string; title: string };
return {
reply: `✅ 已创建任务「${t.title}」`,
engine: "rules",
toolCalls,
refs: [{ type: "TASK", id: t.id, name: t.title }],
};
}
}
// 创建笔记
const noteM = text.match(/(?:创建|新建|添加|记)个?笔记[::,,\s]*(.+)/);
if (noteM) {
const title = noteM[1].slice(0, 120).trim();
const result = await run("devos.note.create", { title, content: "" });
if (result && !(result as { __error?: string }).__error) {
const n = result as { id: string; title: string };
return {
reply: `✅ 已创建笔记「${n.title}」,可到「笔记」页面编辑内容。`,
engine: "rules",
toolCalls,
refs: [{ type: "NOTE", id: n.id, name: n.title }],
};
}
}
// 部署网站(HIGH → 审批)
const deployM = text.match(/部署\s*网站?\s*[::]?\s*(\S+)|deploy\s+(\S+)/i);
if (deployM) {
const name = (deployM[1] ?? deployM[2] ?? "").replace(/网站|到\S+$/g, "").trim();
const site = await this.findWebsite(workspaceId, name);
if (!site) {
const sites = await this.prisma.website.findMany({ where: { workspaceId }, take: 10, select: { name: true } });
return {
reply: `没有找到名称包含「${name}」的网站。${sites.length ? `当前的网站有:${sites.map((s) => s.name).join("、")}` : "当前还没有网站,可到「网站」页面创建。"}`,
engine: "rules",
toolCalls,
};
}
const run0 = await this.createApprovalRun(workspaceId, ctx.actorId ?? "", text, [
{ tool: "devos.website.deploy", args: { websiteId: site.id }, risk: "HIGH" },
]);
return {
reply: `⚠️ 部署是 HIGH 风险操作:将部署网站「${site.name}」。确认后执行(含健康验证)。`,
engine: "rules",
toolCalls,
approval: {
runId: run0.id,
steps: [{ tool: "devos.website.deploy", args: { websiteId: site.id }, risk: "HIGH" }],
},
};
}
// 重启容器(HIGH → 审批)
const restartM = text.match(/(?:重启|restart)\s*容器?\s*[::]?\s*(\S+)/i);
if (restartM) {
const name = restartM[1];
const container = await this.findContainer(workspaceId, name);
if (!container) {
const rows = await this.prisma.container.findMany({ where: { workspaceId }, take: 12, orderBy: { updatedAt: "desc" }, select: { name: true, state: true } });
return {
reply: `没有找到名称包含「${name}」的容器。${rows.length ? `运行中的容器如:${rows.map((c) => c.name).slice(0, 8).join("、")}` : "还没有发现过容器,先到服务器详情页执行「发现容器」。"}`,
engine: "rules",
toolCalls,
};
}
const run0 = await this.createApprovalRun(workspaceId, ctx.actorId ?? "", text, [
{ tool: "devos.container.restart", args: { id: container.id }, risk: "HIGH" },
]);
return {
reply: `⚠️ 重启容器是 HIGH 风险操作:将重启「${container.name}」(${container.image.slice(0, 30)})。确认后执行。`,
engine: "rules",
toolCalls,
approval: {
runId: run0.id,
steps: [{ tool: "devos.container.restart", args: { id: container.id }, risk: "HIGH" }],
},
};
}
// 列出/查看类
const listTarget = text.match(/(?:列出|查看|显示|有哪些|list)\s*(全部|所有|一下)?\s*(服务器|网站|域名|容器|任务|笔记|部署|监控|自动化|仓库|凭据)?/i);
if (listTarget) {
const target = listTarget[2] ?? inferTargetFromText(text);
if (target) {
const map: Record<string, string> = {
服务器: "devos.server.list",
网站: "devos.website.list",
域名: "devos.domain.list",
容器: "devos.server.getDockerContainers",
任务: "devos.task.list",
笔记: "devos.note.list",
部署: "devos.deployment.list",
监控: "devos.monitor.list",
自动化: "devos.automation.list",
};
const toolName = map[target];
if (toolName) {
// 容器列表需要 serverId:取第一台在线服务器
if (target === "容器") {
const firstServer = (await this.tools.get("devos.server.list")!.execute(ctx, {})) as { id: string }[] | undefined;
const sid = Array.isArray(firstServer) ? firstServer[0]?.id : undefined;
if (!sid) {
return { reply: "还没有登记服务器,无法发现容器。", engine: "rules", toolCalls };
}
const containers = await run("devos.server.getDockerContainers", { serverId: sid });
return { reply: renderList(target, containers), engine: "rules", toolCalls };
}
const result = await run(toolName, target === "任务" || target === "笔记" ? {} : {});
return { reply: renderList(target, result), engine: "rules", toolCalls };
}
}
}
// 网站健康/状态
const healthM = text.match(/(健康|health|状态|status|在线吗|正常吗)/i);
if (healthM) {
const site = await this.findAnyWebsite(workspaceId);
if (site) {
const result = await run("devos.website.health", { id: site.id });
const r = result as { ok?: boolean; status?: number; latencyMs?: number; error?: string };
return {
reply: r?.ok
? `✅ 「${site.name}」健康:HTTP ${r.status}(${r.latencyMs ?? "?"}ms)`
: `❌ 「${site.name}」异常:${r?.error ?? `HTTP ${r?.status}`}`,
engine: "rules",
toolCalls,
};
}
}
return {
reply: `我暂时没理解这句话。我可以(离线模式,配置 LLM 后理解任意表达):\n\n• 列出服务器 / 网站 / 域名 / 容器 / 任务 / 部署\n• 创建任务:<标题>\n• 创建笔记:<标题>\n• 部署网站:<名称>(需确认)\n• 重启容器:<名称>(需确认)\n• 网站健康检查\n• 帮助`,
engine: "rules",
toolCalls,
};
}
// ─────────────────────────── 上下文与解析 ───────────────────────────
/** # 构建工作区快照(名称→ID 解析用) */
private async buildSnapshot(workspaceId: string): Promise<string> {
const [servers, websites, domains, containers, tasks] = await this.prisma.$transaction([
this.prisma.server.findMany({ where: { workspaceId }, take: 15, include: { resource: { select: { status: true } } } }),
this.prisma.website.findMany({ where: { workspaceId }, take: 15, include: { resource: { select: { status: true } } } }),
this.prisma.domain.findMany({ where: { workspaceId }, take: 15 }),
this.prisma.container.findMany({ where: { workspaceId }, take: 20, orderBy: { updatedAt: "desc" } }),
this.prisma.task.findMany({ where: { workspaceId }, take: 10, orderBy: { updatedAt: "desc" } }),
]);
const lines: string[] = [];
lines.push(`服务器(${servers.length}): ${servers.map((s) => `${s.name}(id=${s.id}, ${s.resource.status})`).join(", ") || "无"}`);
lines.push(`网站(${websites.length}): ${websites.map((w) => `${w.name}(id=${w.id}, ${w.resource.status})`).join(", ") || "无"}`);
lines.push(`域名(${domains.length}): ${domains.map((d) => `${d.name}(id=${d.id})`).join(", ") || "无"}`);
lines.push(`容器(最近): ${containers.map((c) => `${c.name}(id=${c.id}, ${c.state})`).join(", ") || "无"}`);
lines.push(`任务(最近): ${tasks.map((t) => `${t.title}(status=${t.status})`).join(", ") || "无"}`);
return lines.join("\n");
}
/** # 模糊查找网站 */
private async findWebsite(workspaceId: string, name: string) {
const rows = await this.prisma.website.findMany({ where: { workspaceId } });
const lower = name.toLowerCase();
return (
rows.find((r) => r.name.toLowerCase() === lower) ??
rows.find((r) => r.name.toLowerCase().includes(lower)) ??
rows.find((r) => r.primaryDomain?.toLowerCase().includes(lower)) ??
null
);
}
/** # 任意网站(健康检查默认目标) */
private async findAnyWebsite(workspaceId: string) {
return this.prisma.website.findFirst({ where: { workspaceId }, orderBy: { updatedAt: "desc" } });
}
/** # 模糊查找容器 */
private async findContainer(workspaceId: string, name: string) {
const rows = await this.prisma.container.findMany({ where: { workspaceId } });
const lower = name.toLowerCase();
return rows.find((r) => r.name.toLowerCase() === lower) ?? rows.find((r) => r.name.toLowerCase().includes(lower)) ?? null;
}
}
/** # 从文本推断列表目标(规则引擎兜底) */
function inferTargetFromText(text: string): string | null {
const map: [RegExp, string][] = [
[/服务器|server/i, "服务器"],
[/网站|website/i, "网站"],
[/域名|domain/i, "域名"],
[/容器|container/i, "容器"],
[/任务|task/i, "任务"],
[/笔记|note/i, "笔记"],
[/部署|deployment/i, "部署"],
[/监控|monitor/i, "监控"],
[/自动化|automation/i, "自动化"],
];
for (const [re, label] of map) if (re.test(text)) return label;
return null;
}
/** # 渲染列表结果(人性化的简短文本,不吐 JSON) */
function renderList(target: string, result: unknown): string {
const rows = Array.isArray(result) ? (result as Record<string, unknown>[]) : [];
if (rows.length === 0) return `当前没有${target}。`;
const lines = rows.slice(0, 15).map((r) => {
const name = (r.name ?? r.title ?? r.fullName ?? "?") as string;
const status = (r.status ?? r.state ?? "") as string;
const extra = r.primaryDomain ?? r.host ?? "";
const image = r.image ? `(${String(r.image).slice(0, 32)})` : "";
return `- ${name}${status ? `(${status})` : ""}${extra ? ` — ${String(extra)}` : ""}${target === "容器" ? image : ""}`;
});
const more = rows.length > 15 ? `\n…及其它 ${rows.length - 15} 项` : "";
return `**${target}** 共 ${rows.length} 项:\n\n${lines.join("\n")}${more}`;
}
/** # 工具结果摘要(回显用,避免超长 JSON) */
function summarize(result: unknown): string {
if (Array.isArray(result)) {
const first = result[0] as Record<string, unknown> | undefined;
return `${result.length} 项${first ? `,如 ${String(first.name ?? first.title ?? first.id ?? "")}` : ""}`;
}
const r = result as Record<string, unknown> | null;
if (!r) return "完成";
if (r.title) return String(r.title);
if (r.name) return String(r.name);
if (r.ok !== undefined) return `ok=${r.ok}`;
return Object.keys(r).slice(0, 4).join(",");
}
/** # 无文本时的兜底总结 */
function summarizeDone(calls: ChatToolCall[]): string {
const okCount = calls.filter((c) => c.ok).length;
return `已完成 ${okCount}/${calls.length} 项操作。`;
}
/** # 能力说明(离线模式) */
const HELP_TEXT = [
"我是 DevOS 运维助手,可以直接帮我完成这些事:",
"",
"📋 任务与笔记",
" • 创建任务:修复登录页超时",
" • 创建笔记:部署排障手册",
" • 列出任务 / 列出笔记",
"",
"🖥️ 基础设施",
" • 列出服务器 / 列出容器 / 列出监控",
" • 服务器连通状态(到详情页看完整指标)",
"",
"🌐 网站与部署",
" • 列出网站 / 网站健康检查",
" • 部署网站:<名称>(需要你确认后执行)",
" • 重启容器:<名称>(需要你确认后执行)",
"",
"⚙️ 其它",
" • 列出域名 / 部署 / 自动化;运行自动化:<名称>",
"",
"配置 LLM(AI_PROVIDER_*)后,可以用任意自然语言表达,我会自动选择并组合工具完成操作。",
].join("\n");