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:
@@ -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");
|
||||
Reference in New Issue
Block a user