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)
This commit is contained in:
phptits
2026-09-10 06:35:55 +00:00
commit 0d6cd37579
209 changed files with 22783 additions and 0 deletions
+657
View File
@@ -0,0 +1,657 @@
/**
* # 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");