Commit 9b479a5e authored by xuchentao's avatar xuchentao

feat: add coding agent and manual version workflow

parent 9b34b595
......@@ -40,9 +40,14 @@ WEBAGENT_NGINX_AUTO_RELOAD=false
WEBAGENT_NGINX_BIN=nginx
# 待连接域名的所有权、证书和 DNS 自动检查间隔,最小 15000ms。
WEBAGENT_DOMAIN_CHECK_INTERVAL_MS=60000
# 可选:任意兼容 OpenAI Chat Completions 的模型服务。
# 默认示例使用国内可访问的 DeepSeek;也可以替换为企业内部网关,
# 或阿里云百炼兼容地址:https://dashscope.aliyuncs.com/compatible-mode/v1
# Coding Agent 使用 Qwen Code SDK,并通过下面的 OpenAI-compatible 配置连接模型。
# 模式由租户点击聊天输入框左下角的 Agent 模式按钮明确选择;默认固定为 local,不会因存在密钥自动切换。
OPENAI_API_KEY=
OPENAI_BASE_URL=https://api.deepseek.com/v1
OPENAI_BASE_URL=https://api.deepseek.com
OPENAI_MODEL=deepseek-chat
# 新租户尚未保存选择时的默认值;建议保持 local。
WEBAGENT_AGENT_PROVIDER=local
WEBAGENT_AGENT_TIMEOUT_MS=600000
WEBAGENT_AGENT_MAX_TURNS=20
# 输出 SDK 调试日志;正常运行保持 0。
QWEN_SDK_DEBUG=0
# WebAgent
WebAgent 是一个本地 AI 官网工作台。它把每个用户网站保存为独立 Astro 项目,通过 Git 记录每次有效修改,并在隔离 Workspace 中完成构建验证后再更新正式项目
WebAgent 是一个本地 AI 官网工作台。它把每个用户网站保存为独立 Astro 项目。Agent 修改会先在隔离的工作草稿中累积并通过构建验证;只有用户在左侧“版本”中手动保存时,系统才创建 Git 版本
## 快速开始
......@@ -35,7 +35,7 @@ WEBAGENT_PRODUCTION_DIR=../WebAgent-production
# 或 WEBAGENT_SITES_DIR=/data/WebAgent-sites
```
未配置模型密钥时,内置演示指令支持修改主题颜色、增加服务卡片、增加企业优势等常见场景。配置 `OPENAI_API_KEY` 后,会使用兼容 OpenAI Chat Completions 的接口生成受约束的 `SitePatch`。示例默认使用国内可访问的 DeepSeek API,也可换成阿里云百炼或企业内部兼容网关。
点击聊天输入框左下角的 Agent 模式按钮,可以明确选择“模拟 Agent”或“模型 Agent”。选择按租户持久化,系统不会因为检测到模型密钥而自动切换。模拟 Agent 使用内置规则;模型 Agent 由独立 Worker 通过 Qwen Code SDK 运行,并在站点草稿 Worktree 中受限读写。系统随后独立复验 diff 与 Astro 构建,成功后更新未保存草稿的测试预览,不自动提交 Git。模型服务使用 OpenAI-compatible 配置,可连接 DeepSeek 或企业内部兼容网关。
项目的 `.npmrc` 只对当前仓库生效,依赖从 `registry.npmmirror.com` 安装,pnpm store 写入已忽略的 `.runtime/pnpm-store/`,不会污染全局 npm 配置。
......@@ -55,14 +55,18 @@ WEBAGENT_PRODUCTION_DIR=../WebAgent-production
- 从 Astro 模板创建独立企业官网
- 自动初始化网站 Git 仓库并提交初始版本
- 在隔离 Git Worktree 中应用 Agent 修改
- 构建通过后才写入正式项目并创建提交
- Agent 修改可在同一工作草稿中连续累积,构建通过后更新测试预览但不自动提交
- 在左侧“版本”中手动保存草稿、放弃未保存修改或恢复历史版本
- 恢复历史版本会创建新的恢复提交,不改写已有 Git 历史
- 本地 iframe 预览与自动刷新
- 持久化 Agent 队列、SSE 事件、取消、超时与 Worker 崩溃标记
- Qwen Code Provider 工具权限限制,以及系统侧 diff/build 二次验证
- 测试环境与生产环境使用独立目录和独立链接
- 用户确认后手动发布,并记录生产版本 commit 与发布时间
- 为每个官网绑定自定义域名,通过 TXT 验证所有权并检测 CNAME/A/AAAA
- 可接入 Cloudflare for SaaS 自动创建 Custom Hostname、展示证书验证记录并跟踪 HTTPS 状态
- 自定义域名使用独立根路径产物,域名故障不影响系统默认生产链接
- 浏览 Git 历史并安全回滚到任意版本
- 浏览 Git 历史并安全恢复任意版本;存在未保存草稿时禁止发布上线
- 生成站点、测试预览和生产产物保存在独立的可配置目录,构建缓存等临时数据隔离在 `.runtime/`
详细目录约束见 [docs/storage-convention.md](docs/storage-convention.md),Agent 写入规则[docs/site-patch-schema.md](docs/site-patch-schema.md)
详细目录约束见 [docs/storage-convention.md](docs/storage-convention.md),Agent 运行架构见 [docs/agent-runtime.md](docs/agent-runtime.md),旧版受约束补丁格式[docs/site-patch-schema.md](docs/site-patch-schema.md)
......@@ -4,15 +4,20 @@
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"dev": "concurrently -k -n api,worker \"pnpm dev:api\" \"pnpm dev:worker\"",
"dev:api": "tsx watch src/server.ts",
"dev:worker": "tsx watch src/worker.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/server.js",
"start": "concurrently -k -n api,worker \"pnpm start:api\" \"pnpm start:worker\"",
"start:api": "node dist/server.js",
"start:worker": "node dist/worker.js",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "NODE_ENV=test WEBAGENT_INIT_TEST_DATA=true tsx --test src/**/*.test.ts",
"auth:admin": "tsx src/auth/admin-cli.ts"
},
"dependencies": {
"@fastify/cors": "^11.0.1",
"@qwen-code/sdk": "0.1.8",
"@webagent/shared": "workspace:*",
"dotenv": "^16.5.0",
"execa": "^9.5.2",
......@@ -21,6 +26,7 @@
},
"devDependencies": {
"@types/node": "^22.15.3",
"concurrently": "^9.2.0",
"tsx": "^4.19.4",
"typescript": "^5.8.3"
}
......
import path from "node:path";
import { readFile } from "node:fs/promises";
import type { SitePatch } from "@webagent/shared";
import { config } from "../config.js";
import { sitePatchSchema } from "../schemas.js";
import { selectContext } from "./context-selector.js";
import { agentSystemPrompt } from "./prompts.js";
export class AgentLoop {
async generate(projectPath: string, message: string): Promise<{ patch: SitePatch; mode: "model" | "local" }> {
if (config.openai.apiKey) return { patch: await this.generateWithModel(projectPath, message), mode: "model" };
return { patch: await this.generateLocally(projectPath, message), mode: "local" };
}
async repair(projectPath: string, request: string, error: string): Promise<SitePatch> {
if (!config.openai.apiKey) throw new Error("本地演示模式无法自动修复构建错误");
return this.generateWithModel(projectPath, request + "\n\n上一次构建失败,请修复当前文件。错误摘要:\n" + error.slice(-6000));
}
private async generateWithModel(projectPath: string, message: string): Promise<SitePatch> {
const context = await selectContext(projectPath);
const response = await fetch(config.openai.baseUrl + "/chat/completions", {
method: "POST",
headers: { "content-type": "application/json", authorization: "Bearer " + config.openai.apiKey },
body: JSON.stringify({
model: config.openai.model,
temperature: 0.75,
response_format: { type: "json_object" },
messages: [
{ role: "system", content: agentSystemPrompt },
{ role: "user", content: "用户要求:\n" + message + "\n\n当前项目文件:\n" + context },
],
}),
signal: AbortSignal.timeout(90000),
});
if (!response.ok) throw new Error("模型请求失败: " + response.status + " " + (await response.text()).slice(0, 500));
const data = await response.json() as { choices?: Array<{ message?: { content?: string } }> };
const content = data.choices?.[0]?.message?.content;
if (!content) throw new Error("模型没有返回 SitePatch");
const cleaned = content.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
return sitePatchSchema.parse(JSON.parse(cleaned)) as SitePatch;
async generateLocal(projectPath: string, message: string): Promise<SitePatch> {
return this.generateLocally(projectPath, message);
}
private async generateLocally(projectPath: string, message: string): Promise<SitePatch> {
......
import path from "node:path";
import { readdir, readFile } from "node:fs/promises";
const extensions = new Set([".astro", ".ts", ".css", ".json", ".md"]);
async function walk(directory: string, root: string, files: string[]): Promise<void> {
for (const entry of await readdir(directory, { withFileTypes: true })) {
const absolute = path.join(directory, entry.name);
if (entry.isDirectory()) await walk(absolute, root, files);
else if (extensions.has(path.extname(entry.name))) files.push(path.relative(root, absolute).replaceAll("\\", "/"));
}
}
export async function selectContext(projectPath: string): Promise<string> {
const paths: string[] = [];
await walk(path.join(projectPath, "src"), projectPath, paths);
const prioritized = paths.sort((a, b) => {
const score = (value: string) => value.includes("data/") ? 0 : value.includes("theme.css") ? 1 : value.includes("pages/") ? 2 : 3;
return score(a) - score(b) || a.localeCompare(b);
}).slice(0, 18);
const sections: string[] = [];
let total = 0;
for (const relativePath of prioritized) {
const content = await readFile(path.join(projectPath, relativePath), "utf8");
if (total + content.length > 60000) break;
sections.push("FILE: " + relativePath + "\n" + content);
total += content.length;
}
return sections.join("\n\n---\n\n");
}
import type { AgentRunEvent, AgentUsage } from "@webagent/shared";
export interface AgentToolPolicy {
readonly allowedCommands: readonly string[];
}
export interface AgentProviderInput {
runId: string;
workingDirectory: string;
prompt: string;
model?: string;
sessionId?: string;
resumeSession?: boolean;
timeoutMs: number;
maxTurns: number;
toolPolicy: AgentToolPolicy;
}
export interface AgentProviderResult {
status: "completed" | "failed" | "cancelled" | "timed_out";
summary?: string;
sessionId?: string;
usage?: AgentUsage;
error?: string;
}
export interface RunningAgent {
events: AsyncIterable<AgentRunEvent>;
result: Promise<AgentProviderResult>;
cancel(reason?: string): Promise<void>;
}
export interface CodingAgentProvider {
readonly name: string;
readonly version: string;
start(input: AgentProviderInput): Promise<RunningAgent>;
}
export const DEFAULT_AGENT_POLICY: AgentToolPolicy = {
allowedCommands: ["git status --short", "git diff --check", "git diff --no-ext-diff"],
};
export const AGENT_LIMITS = {
timeoutMs: 600_000,
maxTurns: 20,
maxFiles: 30,
maxDiffBytes: 1_048_576,
maxFileBytes: 262_144,
} as const;
import assert from "node:assert/strict";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { execa } from "execa";
import { AgentDiffValidator } from "./diff-validator.js";
async function workspace() {
const root = await mkdtemp(path.join(os.tmpdir(), "webagent-diff-"));
await mkdir(path.join(root, "src"));
await writeFile(path.join(root, "src", "index.ts"), "export const title = 'before';\n");
await writeFile(path.join(root, "package.json"), "{}\n");
await execa("git", ["init", "--quiet"], { cwd: root });
await execa("git", ["config", "user.name", "WebAgent Test"], { cwd: root });
await execa("git", ["config", "user.email", "test@example.invalid"], { cwd: root });
await execa("git", ["add", "."], { cwd: root });
await execa("git", ["commit", "--quiet", "-m", "baseline"], { cwd: root });
return root;
}
test("AgentDiffValidator accepts source edits and rejects protected files", async () => {
const root = await workspace();
try {
await writeFile(path.join(root, "src", "index.ts"), "export const title = 'after';\n");
const allowed = await new AgentDiffValidator().validate(root);
assert.equal(allowed.passed, true);
assert.deepEqual(allowed.files, ["src/index.ts"]);
await writeFile(path.join(root, "package.json"), "{\"scripts\":{}}\n");
const rejected = await new AgentDiffValidator().validate(root);
assert.equal(rejected.passed, false);
assert.ok(rejected.violations.some((violation) => violation.includes("受保护路径")));
} finally {
await rm(root, { recursive: true, force: true });
}
});
import path from "node:path";
import { lstat, realpath } from "node:fs/promises";
import { execa } from "execa";
import { AGENT_LIMITS } from "./contracts.js";
export interface AgentDiffValidation {
passed: boolean;
diff: string;
files: string[];
totalBytes: number;
violations: string[];
}
const protectedPath = (file: string) => file === "package.json" || file === "pnpm-lock.yaml"
|| file === ".env" || file.startsWith(".env.") || file === ".git" || file.startsWith(".git/")
|| file === "dist" || file.startsWith("dist/") || file === "node_modules" || file.startsWith("node_modules/");
const allowedPath = (file: string) => file.startsWith("src/") || file.startsWith("public/images/");
export class AgentDiffValidator {
async validate(workspace: string): Promise<AgentDiffValidation> {
const status = await execa("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], { cwd: workspace });
const chunks = status.stdout.split("\0").filter(Boolean);
const files = [...new Set(chunks.map((entry) => {
const raw = entry.slice(3);
return raw.includes(" -> ") ? raw.split(" -> ").at(-1)! : raw;
}))].sort();
const violations: string[] = [];
let fileBytes = 0;
const root = await realpath(workspace);
if (!files.length) violations.push("Agent 没有产生文件修改");
if (files.length > AGENT_LIMITS.maxFiles) violations.push(`修改文件数 ${files.length} 超过限制 ${AGENT_LIMITS.maxFiles}`);
for (const file of files) {
if (file.includes("..") || file.startsWith("/") || file.includes("\\")) {
violations.push(`不安全路径: ${file}`);
continue;
}
if (protectedPath(file)) violations.push(`禁止修改受保护路径: ${file}`);
if (!allowedPath(file)) violations.push(`路径不在网站可编辑范围: ${file}`);
const candidate = path.resolve(workspace, file);
try {
const info = await lstat(candidate);
if (info.isSymbolicLink()) violations.push(`禁止符号链接: ${file}`);
if (info.isFile()) {
const resolved = await realpath(candidate);
if (!resolved.startsWith(root + path.sep)) violations.push(`文件解析到工作区外: ${file}`);
if (info.size > AGENT_LIMITS.maxFileBytes) violations.push(`文件超过 ${AGENT_LIMITS.maxFileBytes} 字节: ${file}`);
fileBytes += info.size;
}
} catch { /* deleted file */ }
}
await execa("git", ["add", "-N", "--", "."], { cwd: workspace });
const [diffResult, checkResult] = await Promise.all([
execa("git", ["diff", "--no-ext-diff", "--binary", "HEAD", "--"], { cwd: workspace }),
execa("git", ["diff", "--check", "HEAD", "--"], { cwd: workspace, reject: false }),
]);
if (checkResult.exitCode !== 0) violations.push("git diff --check 未通过: " + checkResult.stdout.slice(-1000));
const totalBytes = Buffer.byteLength(diffResult.stdout);
if (totalBytes > AGENT_LIMITS.maxDiffBytes) violations.push(`Diff 超过 ${AGENT_LIMITS.maxDiffBytes} 字节`);
return { passed: violations.length === 0, diff: diffResult.stdout, files, totalBytes: Math.max(totalBytes, fileBytes), violations };
}
}
import type { AgentFailureCategory } from "@webagent/shared";
export class AgentRunError extends Error {
constructor(public readonly category: AgentFailureCategory, message: string) {
super(message);
this.name = "AgentRunError";
}
}
export class AgentCancelledError extends AgentRunError {
constructor(message = "Agent 运行已取消") { super("cancelled", message); }
}
export class AgentTimeoutError extends AgentRunError {
constructor(message = "Agent 运行超时") { super("timeout", message); }
}
import type { AgentRunEvent } from "@webagent/shared";
import { applyPatch } from "../sites/apply-patch.js";
import { validatePatch } from "../security/path-policy.js";
import { AgentLoop } from "./agent-loop.js";
import type { AgentProviderInput, AgentProviderResult, CodingAgentProvider, RunningAgent } from "./contracts.js";
export class LocalAgentProvider implements CodingAgentProvider {
readonly name = "local";
readonly version = "1.0.0";
constructor(private readonly agent = new AgentLoop()) {}
async start(input: AgentProviderInput): Promise<RunningAgent> {
const controller = new AbortController();
const events: AgentRunEvent[] = [];
let done = false;
let wake: (() => void) | undefined;
const emit = (type: AgentRunEvent["type"], message: string, data?: Record<string, unknown>) => {
events.push({ runId: input.runId, type, timestamp: new Date().toISOString(), message, ...(data ? { data } : {}) });
wake?.(); wake = undefined;
};
const result = (async (): Promise<AgentProviderResult> => {
try {
emit("assistant", "本地 Agent 已开始分析修改要求");
const patch = await this.agent.generateLocal(input.workingDirectory, input.prompt);
if (controller.signal.aborted) return { status: "cancelled", error: "Agent 运行已取消" };
emit("tool_started", "正在校验并写入站点文件", { tool: "local_patch" });
await validatePatch(patch, input.workingDirectory);
await applyPatch(input.workingDirectory, patch);
emit("tool_completed", "站点文件修改完成", { files: patch.operations.map((operation) => operation.path) });
return { status: "completed", summary: patch.summary, usage: { turns: 1 } };
} catch (error) {
if (controller.signal.aborted) return { status: "cancelled", error: "Agent 运行已取消" };
return { status: "failed", error: error instanceof Error ? error.message : String(error) };
} finally {
done = true; wake?.();
}
})();
const stream: AsyncIterable<AgentRunEvent> = {
async *[Symbol.asyncIterator]() {
while (!done || events.length) {
if (!events.length) await new Promise<void>((resolve) => { wake = resolve; });
while (events.length) yield events.shift()!;
}
},
};
return { events: stream, result, cancel: async () => controller.abort() };
}
}
import crypto from "node:crypto";
import { access } from "node:fs/promises";
import type { AgentBuildValidation, AgentRunEvent, AgentRunStatus } from "@webagent/shared";
import { BuildError, BuildManager } from "../build/build-manager.js";
import { config, getPublicPreviewUrl } from "../config.js";
import { GitManager } from "../git/git-manager.js";
import { SiteRepository } from "../sites/site-repository.js";
import { AGENT_LIMITS, DEFAULT_AGENT_POLICY, type CodingAgentProvider, type RunningAgent } from "./contracts.js";
import { AgentDiffValidator } from "./diff-validator.js";
import { AgentCancelledError, AgentRunError, AgentTimeoutError } from "./errors.js";
import { AgentRunRepository } from "./run-repository.js";
import { assertAgentTransition } from "./state-machine.js";
export class AgentOrchestrator {
private active?: { runId: string; handle: RunningAgent };
constructor(
private readonly runs: AgentRunRepository,
private readonly providers: Map<string, CodingAgentProvider>,
private readonly sites: SiteRepository,
private readonly git: GitManager,
private readonly builds: BuildManager,
private readonly diffs = new AgentDiffValidator(),
) {}
async executeClaimed(runId: string): Promise<void> {
let timer: NodeJS.Timeout | undefined;
let cancelPoll: NodeJS.Timeout | undefined;
let project = "";
let workspace = "";
let baseCommit = "";
let creatingDraft = false;
let buildValidation: AgentBuildValidation | undefined;
try {
let run = this.requireRun(runId);
if (run.status !== "preparing_workspace") throw new AgentRunError("internal", "任务尚未被 Worker 正确领取");
const site = await this.sites.get(run.tenantId, run.siteId);
project = this.sites.getProjectPath(run.tenantId, run.siteId);
workspace = this.sites.getDraftPath(run.tenantId, run.siteId);
baseCommit = site.draftBaseCommit || site.currentCommit;
creatingDraft = !site.draftBaseCommit;
if (run.cancelRequested) throw new AgentCancelledError();
if (creatingDraft) await this.git.createPersistentWorktree(project, workspace, baseCommit);
else {
const exists = await access(workspace).then(() => true).catch(() => false);
if (!exists) throw new AgentRunError("workspace", "工作草稿目录不存在,请刷新站点后重试");
if (await this.git.currentCommit(workspace) !== baseCommit) throw new AgentRunError("workspace", "工作草稿基线与站点记录不一致");
}
this.runs.transition(runId, "preparing_workspace", { baselineCommit: baseCommit });
await this.sites.update(run.tenantId, run.siteId, { status: "building", lastError: undefined });
this.move(runId, "running_agent", "Agent 正在读取并修改网站");
run = this.requireRun(runId);
const provider = this.providers.get(run.provider);
if (!provider) throw new AgentRunError("provider", `未配置 Agent Provider: ${run.provider}`);
const handle = await provider.start({
runId, workingDirectory: workspace, prompt: run.prompt,
...(run.model ? { model: run.model } : {}),
...(run.sessionId ? { sessionId: run.sessionId, resumeSession: true } : {}),
timeoutMs: config.agent.timeoutMs || AGENT_LIMITS.timeoutMs,
maxTurns: config.agent.maxTurns || AGENT_LIMITS.maxTurns,
toolPolicy: DEFAULT_AGENT_POLICY,
});
this.active = { runId, handle };
let timedOut = false;
timer = setTimeout(() => { timedOut = true; void handle.cancel("Agent 运行超时"); }, config.agent.timeoutMs);
cancelPoll = setInterval(() => {
if (this.runs.get(runId)?.cancelRequested) void handle.cancel("用户取消了 Agent 运行");
}, 150);
const pump = (async () => { for await (const event of handle.events) this.runs.appendEvent(event); })();
const result = await handle.result;
await pump;
if (timer) { clearTimeout(timer); timer = undefined; }
this.runs.transition(runId, "running_agent", {
...(result.sessionId ? { sessionId: result.sessionId } : {}),
...(result.usage ? { usage: result.usage } : {}),
...(result.summary ? { summary: result.summary.slice(0, 1000) } : {}),
});
if (timedOut || result.status === "timed_out") throw new AgentTimeoutError();
if (result.status === "cancelled" || this.requireRun(runId).cancelRequested) throw new AgentCancelledError();
if (result.status === "failed") throw new AgentRunError("provider", result.error || "Agent Provider 运行失败");
this.move(runId, "checking_diff", "正在独立检查 Agent 文件改动");
const diff = await this.diffs.validate(workspace);
this.runs.transition(runId, "checking_diff", { changedFiles: diff.files });
if (!diff.passed) throw new AgentRunError("policy", diff.violations.join(";"));
if (this.requireRun(runId).cancelRequested) throw new AgentCancelledError();
this.move(runId, "validating_build", "正在独立构建网站");
const buildStarted = Date.now();
let dist: string;
try {
dist = await this.builds.build(workspace, "agent_" + runId, getPublicPreviewUrl(run.tenantId, run.siteId));
buildValidation = { passed: true, buildExitCode: 0, durationMs: Date.now() - buildStarted, output: "网站构建通过" };
this.runs.transition(runId, "validating_build", { validation: buildValidation });
} catch (error) {
const output = error instanceof BuildError ? error.output : error instanceof Error ? error.message : String(error);
buildValidation = { passed: false, buildExitCode: 1, durationMs: Date.now() - buildStarted, output: output.slice(-12_000) };
this.runs.transition(runId, "validating_build", { validation: buildValidation });
throw new AgentRunError("build", "Agent 修改未通过网站构建验证");
}
if (this.requireRun(runId).cancelRequested) throw new AgentCancelledError();
this.move(runId, "updating_preview", "构建通过,正在更新未保存草稿预览");
const latest = this.requireRun(runId);
const summary = (latest.summary || "更新网站").replace(/\s+/g, " ").slice(0, 120);
await this.builds.publishPreview(run.tenantId, run.siteId, dist);
await this.sites.update(run.tenantId, run.siteId, {
status: "ready", previewCommit: baseCommit,
draftBaseCommit: baseCommit, draftUpdatedAt: new Date().toISOString(), draftSummary: summary,
environmentVersion: 3, lastError: undefined,
});
this.move(runId, "completed", "Agent 修改已通过验证并保留为未保存草稿", {
endedAt: new Date().toISOString(), previewUrl: getPublicPreviewUrl(run.tenantId, run.siteId),
});
} catch (error) {
const run = this.runs.get(runId);
if (!run || ["completed", "failed", "cancelled", "timed_out"].includes(run.status)) return;
const known = error instanceof AgentRunError ? error : new AgentRunError("internal", error instanceof Error ? error.message : String(error));
const next: AgentRunStatus = known.category === "cancelled" ? "cancelled" : known.category === "timeout" ? "timed_out" : "failed";
let keepDraft = false;
if (workspace) keepDraft = await this.git.hasChanges(workspace).catch(() => false);
if (project && workspace && creatingDraft && !keepDraft) await this.git.removePersistentWorktree(project, workspace).catch(() => undefined);
if (run.tenantId && run.siteId) {
const site = await this.sites.get(run.tenantId, run.siteId).catch(() => undefined);
if (site) await this.sites.update(run.tenantId, run.siteId, {
status: site.previewCommit ? "ready" : "failed", lastError: known.message,
draftBaseCommit: keepDraft ? baseCommit : site.draftBaseCommit,
draftUpdatedAt: keepDraft ? new Date().toISOString() : site.draftUpdatedAt,
draftSummary: keepDraft ? "Agent 未完成的工作草稿" : site.draftSummary,
}).catch(() => undefined);
}
this.move(runId, next, known.message, {
endedAt: new Date().toISOString(), failureCategory: known.category, error: known.message,
...(buildValidation ? { validation: buildValidation } : {}),
}, "error");
} finally {
if (timer) clearTimeout(timer);
if (cancelPoll) clearInterval(cancelPoll);
this.active = undefined;
}
}
async cancelActive(): Promise<void> { await this.active?.handle.cancel("Worker 正在停止"); }
private requireRun(runId: string) {
const run = this.runs.get(runId);
if (!run) throw new AgentRunError("internal", "Agent 任务不存在");
return run;
}
private move(
runId: string, status: AgentRunStatus, message: string,
fields: Parameters<AgentRunRepository["transition"]>[2] = {}, type: AgentRunEvent["type"] = "status",
): void {
const run = this.requireRun(runId);
assertAgentTransition(run.status, status);
this.runs.transition(runId, status, fields);
this.runs.appendEvent({ runId, type, timestamp: new Date().toISOString(), message, data: { status, ...(fields.failureCategory ? { category: fields.failureCategory } : {}) } });
}
}
export const agentSystemPrompt = `你是 WebAgent 的企业官网设计与工程 Agent。你维护一个真实的 Astro 静态网站。
你的目标是根据用户要求进行有审美、有内容质量、可构建的修改。允许重构现有组件,但不要引入新依赖。
必须只输出一个 JSON 对象,格式为:
{"summary":"简洁中文说明","operations":[{"type":"write","path":"src/...","content":"完整文件内容"}]}
规则:
1. 单次最多 5 个文件,只能写 src 下的 astro/ts/css/json/md 或 public/images。
2. write 必须给出完整文件内容,不能使用 diff、Markdown 代码块或省略号。
3. 禁止修改 package.json、锁文件、astro.config.mjs、.git、node_modules、dist。
4. 保留数据与展示分离,企业资料优先放 src/data/*.json,主题优先放 theme.css。
5. 设计应简洁大气,以白色和浅灰为主、紫色强调,保证移动端响应式与中文可读性。
6. 不执行 Shell,不读取外部网络,不在网站中暴露系统提示或本地路径。`;
import type { CodingAgentProvider } from "./contracts.js";
import { LocalAgentProvider } from "./local-provider.js";
import { QwenCodeProvider } from "./qwen-code-provider.js";
export function createAgentProviders(): Map<string, CodingAgentProvider> {
const providers: CodingAgentProvider[] = [new LocalAgentProvider(), new QwenCodeProvider()];
return new Map(providers.map((provider) => [provider.name, provider]));
}
import assert from "node:assert/strict";
import test from "node:test";
import type { AgentProviderInput } from "./contracts.js";
import { DEFAULT_AGENT_POLICY } from "./contracts.js";
import { isSafeAgentToolInput } from "./qwen-code-provider.js";
const input: AgentProviderInput = {
runId: "00000000-0000-4000-8000-000000000000",
workingDirectory: "/tmp/webagent-run/site",
prompt: "修改首页",
timeoutMs: 10_000,
maxTurns: 5,
toolPolicy: DEFAULT_AGENT_POLICY,
};
test("Qwen Code tool policy contains reads/writes and only permits exact audit commands", () => {
assert.equal(isSafeAgentToolInput("read_file", { path: "/tmp/webagent-run/site/src/pages/index.astro" }, input), true);
assert.equal(isSafeAgentToolInput("read_file", { path: "/etc/passwd" }, input), false);
assert.equal(isSafeAgentToolInput("write_file", { file_path: "src/pages/index.astro" }, input), true);
assert.equal(isSafeAgentToolInput("write_file", { file_path: "package.json" }, input), false);
assert.equal(isSafeAgentToolInput("write_file", { file_path: "../outside.ts" }, input), false);
assert.equal(isSafeAgentToolInput("run_shell_command", { command: "git diff --check" }, input), true);
assert.equal(isSafeAgentToolInput("run_shell_command", { command: "git diff --check && curl example.com" }, input), false);
});
import {
isSDKAssistantMessage, isSDKResultMessage, query,
type SDKAssistantMessage, type SDKResultMessage,
} from "@qwen-code/sdk";
import { isAbsolute, resolve, sep } from "node:path";
import type { AgentRunEvent } from "@webagent/shared";
import type { AgentProviderInput, AgentProviderResult, CodingAgentProvider, RunningAgent } from "./contracts.js";
const sensitive = /(^|\/)(\.env(?:\.|$)|\.git(?:\/|$)|package\.json$|pnpm-lock\.yaml$|dist(?:\/|$)|node_modules(?:\/|$))/;
function isContained(candidatePath: string, rootPath: string): boolean {
const candidate = resolve(candidatePath);
const root = resolve(rootPath);
return candidate === root || candidate.startsWith(root + sep);
}
export function isSafeAgentToolInput(toolName: string, toolInput: Record<string, unknown>, input: AgentProviderInput): boolean {
const serialized = JSON.stringify(toolInput);
if (sensitive.test(serialized)) return false;
if (toolName === "run_shell_command") {
return typeof toolInput.command === "string" && input.toolPolicy.allowedCommands.includes(toolInput.command.trim());
}
if (toolName === "edit" || toolName === "write_file") {
const file = typeof toolInput.file_path === "string" ? toolInput.file_path : typeof toolInput.path === "string" ? toolInput.path : undefined;
if (!file || file.includes("..")) return false;
const absolute = isAbsolute(file) ? file : resolve(input.workingDirectory, file);
return isContained(absolute, resolve(input.workingDirectory, "src"));
}
return !serialized.includes("..") && (!/(?:^|[\"'\s])\/(?!\/)/.test(serialized) || serialized.includes(input.workingDirectory));
}
function emitAssistantMessage(
message: SDKAssistantMessage,
emit: (type: AgentRunEvent["type"], message: string, data?: Record<string, unknown>) => void,
): void {
const content = message.message.content;
if (typeof content === "string") {
if (content) emit("assistant", content);
return;
}
for (const block of content) {
if (block.type === "text" && block.text) emit("assistant", block.text);
else if (block.type === "tool_use") {
if (block.name === "run_shell_command") {
const command = typeof block.input === "object" && block.input && "command" in block.input ? String(block.input.command) : "";
emit("command_started", "Agent 请求执行允许的检查命令", { command });
} else emit("tool_started", `Agent 请求使用 ${block.name}`, { tool: block.name });
} else if (block.type === "tool_result") {
emit("tool_completed", block.is_error ? "工具执行失败" : "工具执行完成", { toolUseId: block.tool_use_id, isError: Boolean(block.is_error) });
}
}
}
function usageOf(message: SDKResultMessage) {
return {
inputTokens: message.usage.input_tokens,
outputTokens: message.usage.output_tokens,
totalTokens: message.usage.total_tokens ?? message.usage.input_tokens + message.usage.output_tokens,
turns: message.num_turns,
};
}
export class QwenCodeProvider implements CodingAgentProvider {
readonly name = "qwen-code";
readonly version = "0.1.8";
async start(input: AgentProviderInput): Promise<RunningAgent> {
const controller = new AbortController();
const events: AgentRunEvent[] = [];
let done = false;
let wake: (() => void) | undefined;
const emit = (type: AgentRunEvent["type"], message: string, data?: Record<string, unknown>) => {
events.push({ runId: input.runId, type, timestamp: new Date().toISOString(), message, ...(data ? { data } : {}) });
wake?.(); wake = undefined;
};
const session = query({
prompt: input.prompt,
options: {
cwd: input.workingDirectory,
...(input.model ? { model: input.model } : {}),
...(input.sessionId ? input.resumeSession ? { resume: input.sessionId } : { sessionId: input.sessionId } : {}),
permissionMode: "default",
abortController: controller,
maxSessionTurns: input.maxTurns,
includePartialMessages: false,
debug: process.env.QWEN_SDK_DEBUG === "1",
sandbox: true,
env: { ...process.env, SEATBELT_PROFILE: "restrictive-open" },
systemPrompt: {
type: "preset", preset: "qwen_code",
append: `你正在修改 WebAgent 的 Astro 网站。只能在 ${input.workingDirectory} 内工作。使用 read_file/glob/grep 检查代码;只能用 edit/write_file 修改 src;shell 只能执行明确允许的 Git 自检命令。完成编辑后重新读取每个改动文件,并依次执行 git diff --check、git diff --no-ext-diff、git status --short。发现问题必须修复。禁止安装依赖、构建、测试、部署、联网、computer-use 以及访问工作区外路径。`,
},
coreTools: ["read_file", "read_many_files", "list_directory", "glob", "grep_search", "edit", "write_file", "run_shell_command"],
excludeTools: [
"Read(/.env)", "Read(/.env.*)", "Read(/.qwen/**)", "Read(/.git/**)", "Read(/node_modules/**)", "Read(/dist/**)",
"Edit(/.env)", "Edit(/.env.*)", "Edit(/.qwen/**)", "Edit(/package.json)", "Edit(/pnpm-lock.yaml)", "Edit(/.git/**)", "Edit(/dist/**)", "Edit(/node_modules/**)",
"Bash(git push*)", "Bash(git remote*)", "Bash(pnpm add*)", "Bash(pnpm install*)", "Bash(npm install*)", "Bash(curl*)", "Bash(wget*)",
],
canUseTool: async (toolName, toolInput) => isSafeAgentToolInput(toolName, toolInput, input)
? { behavior: "allow", updatedInput: toolInput }
: { behavior: "deny", message: `WebAgent 安全策略拒绝了工具调用: ${toolName}` },
},
});
const result = (async (): Promise<AgentProviderResult> => {
let final: AgentProviderResult = { status: "failed", error: "Qwen Code 没有返回运行结果" };
try {
for await (const message of session) {
if (isSDKAssistantMessage(message)) emitAssistantMessage(message, emit);
else if (isSDKResultMessage(message)) {
const failed = message.subtype !== "success";
const error = failed && message.error ? message.error.message : failed ? "Qwen Code 运行失败" : undefined;
final = {
status: failed ? "failed" : "completed",
summary: message.subtype === "success" ? message.result : undefined,
sessionId: session.getSessionId(), usage: usageOf(message), ...(error ? { error } : {}),
};
}
}
return final;
} catch (error) {
if (controller.signal.aborted) return { status: "cancelled", error: "Qwen Code 已中止" };
return { status: "failed", error: error instanceof Error ? error.message : String(error) };
} finally {
done = true; wake?.();
}
})();
const stream: AsyncIterable<AgentRunEvent> = {
async *[Symbol.asyncIterator]() {
while (!done || events.length) {
if (!events.length) await new Promise<void>((resolve) => { wake = resolve; });
while (events.length) yield events.shift()!;
}
},
};
return {
events: stream,
result,
cancel: async () => {
controller.abort();
try { await session.interrupt(); } catch { /* session may already be closed */ }
try { await session.close(); } catch { /* session may already be closed */ }
},
};
}
}
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { AgentRunRepository } from "./run-repository.js";
test("AgentRunRepository persists, scopes and atomically claims runs", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "webagent-runs-"));
const repository = new AgentRunRepository(path.join(root, "runs.sqlite"));
try {
const created = repository.create({
tenantId: "tenant_a", siteId: "site_one", provider: "local", providerVersion: "1.0.0", prompt: "修改标题",
});
assert.equal(created.status, "queued");
assert.equal(repository.getScoped("tenant_a", "site_one", created.runId)?.runId, created.runId);
assert.equal(repository.getScoped("tenant_b", "site_one", created.runId), undefined);
assert.throws(() => repository.create({
tenantId: "tenant_a", siteId: "site_one", provider: "local", providerVersion: "1.0.0", prompt: "另一个任务",
}), /已有正在运行/);
const claimed = repository.claimNext();
assert.equal(claimed?.runId, created.runId);
assert.equal(claimed?.status, "preparing_workspace");
assert.equal(repository.claimNext(), undefined);
assert.ok(repository.events(created.runId).some((event) => event.data?.status === "preparing_workspace"));
assert.equal(repository.getAgentProvider("tenant_a"), "local");
assert.equal(repository.setAgentProvider("tenant_a", "qwen-code"), "qwen-code");
assert.equal(repository.getAgentProvider("tenant_a"), "qwen-code");
assert.equal(repository.getAgentProvider("tenant_b"), "local");
repository.requestCancel(created.runId);
assert.equal(repository.get(created.runId)?.cancelRequested, true);
} finally {
repository.close();
await rm(root, { recursive: true, force: true });
}
});
test("queued Agent runs can be cancelled before a worker claims them", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "webagent-cancel-"));
const repository = new AgentRunRepository(path.join(root, "runs.sqlite"));
try {
const run = repository.create({
tenantId: "tenant_a", siteId: "site_two", provider: "local", providerVersion: "1.0.0", prompt: "修改配色",
});
assert.equal(repository.requestCancel(run.runId), true);
assert.equal(repository.get(run.runId)?.status, "cancelled");
assert.equal(repository.claimNext(), undefined);
} finally {
repository.close();
await rm(root, { recursive: true, force: true });
}
});
This diff is collapsed.
import type { AgentRunStatus } from "@webagent/shared";
const transitions: Record<AgentRunStatus, readonly AgentRunStatus[]> = {
queued: ["preparing_workspace", "cancelled"],
preparing_workspace: ["running_agent", "failed", "cancelled", "timed_out"],
running_agent: ["checking_diff", "failed", "cancelled", "timed_out"],
checking_diff: ["validating_build", "failed", "cancelled", "timed_out"],
validating_build: ["updating_preview", "failed", "cancelled", "timed_out"],
updating_preview: ["completed", "failed", "cancelled", "timed_out"],
completed: [], failed: [], cancelled: [], timed_out: [],
};
export function assertAgentTransition(from: AgentRunStatus, to: AgentRunStatus): void {
if (!transitions[from].includes(to)) throw new Error(`无效的 Agent 状态转换: ${from} -> ${to}`);
}
import path from "node:path";
import { fileURLToPath } from "node:url";
import "dotenv/config";
import { config as loadEnv } from "dotenv";
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(currentDir, "../..");
loadEnv({ path: path.join(rootDir, ".env") });
const configuredSitesDir = process.env.WEBAGENT_SITES_DIR?.trim() || "../WebAgent-sites";
const sitesDir = path.isAbsolute(configuredSitesDir)
? configuredSitesDir
......@@ -28,6 +29,9 @@ const configuredDomainCheckInterval = Number(process.env.WEBAGENT_DOMAIN_CHECK_I
const domainCheckIntervalMs = Number.isFinite(configuredDomainCheckInterval)
? Math.max(15000, configuredDomainCheckInterval)
: 60000;
const configuredAgentTimeout = Number(process.env.WEBAGENT_AGENT_TIMEOUT_MS || 600000);
const configuredAgentTurns = Number(process.env.WEBAGENT_AGENT_MAX_TURNS || 20);
const configuredAgentProvider = process.env.WEBAGENT_AGENT_PROVIDER?.trim();
const environment = process.env.NODE_ENV?.trim() || "development";
const testDataFlag = process.env.WEBAGENT_INIT_TEST_DATA?.trim();
const testDataEnabled = testDataFlag == null || testDataFlag === ""
......@@ -72,6 +76,11 @@ export const config = {
baseUrl: (process.env.OPENAI_BASE_URL || "https://api.deepseek.com/v1").replace(/\/$/, ""),
model: process.env.OPENAI_MODEL || "deepseek-chat",
},
agent: {
provider: configuredAgentProvider === "qwen-code" ? "qwen-code" : "local",
timeoutMs: Number.isFinite(configuredAgentTimeout) ? Math.max(10_000, configuredAgentTimeout) : 600_000,
maxTurns: Number.isFinite(configuredAgentTurns) ? Math.max(1, Math.floor(configuredAgentTurns)) : 20,
},
domains: {
cnameTarget: (process.env.WEBAGENT_DOMAIN_CNAME_TARGET || "domains.webagent.local").trim().toLowerCase().replace(/\.$/, ""),
ipv4: process.env.WEBAGENT_DOMAIN_IPV4?.trim() || "",
......@@ -95,6 +104,7 @@ export const runtimePaths = {
uploads: path.join(config.runtimeDir, "uploads"),
pnpmStore: path.join(config.runtimeDir, "pnpm-store"),
logs: path.join(config.runtimeDir, "logs"),
agentRuns: path.join(config.runtimeDir, "agent-runs"),
domainMaps: path.join(config.runtimeDir, "nginx"),
};
......
......@@ -24,12 +24,6 @@ export class GitManager {
return result.stdout.trim();
}
async previousCommit(projectPath: string, commit = "HEAD"): Promise<string | undefined> {
await this.assertCommit(projectPath, commit);
const result = await execa("git", ["rev-list", "--parents", "-n", "1", commit], { cwd: projectPath });
return result.stdout.trim().split(/\s+/)[1];
}
async history(projectPath: string): Promise<GitHistoryItem[]> {
const current = await this.currentCommit(projectPath);
const result = await execa("git", ["log", "--date=iso-strict", "--format=%H%x1f%h%x1f%s%x1f%an%x1f%cI%x1e", "-30"], { cwd: projectPath });
......@@ -94,10 +88,10 @@ export class GitManager {
return this.currentCommit(projectPath);
}
async restoreAsCommit(projectPath: string, sourceCommit: string): Promise<string> {
async restoreAsCommit(projectPath: string, sourceCommit: string, message = "恢复版本 " + sourceCommit.slice(0, 7)): Promise<string> {
await this.assertCommit(projectPath, sourceCommit);
await execa("git", ["restore", "--source", sourceCommit, "--staged", "--worktree", "--", ".", ":(exclude)astro.config.mjs"], { cwd: projectPath });
return this.commit(projectPath, "Rollback to " + sourceCommit.slice(0, 7));
return this.commit(projectPath, message);
}
async assertCommit(projectPath: string, commit: string): Promise<void> {
......
......@@ -17,12 +17,22 @@ export const loginSchema = z.object({
export const chatSchema = z.object({
message: z.string().trim().min(2).max(2000),
model: z.string().trim().min(1).max(120).optional(),
sessionId: z.string().trim().min(1).max(200).optional(),
});
export const agentSettingsSchema = z.object({
provider: z.enum(["local", "qwen-code"]),
});
export const versionCommitSchema = z.object({
commit: z.string().regex(/^[0-9a-f]{7,40}$/),
});
export const saveDraftSchema = z.object({
message: z.string().trim().min(2, "版本说明至少 2 个字符").max(120),
});
export const addDomainSchema = z.object({
hostname: z.string().trim().min(4, "请输入完整域名").max(253),
});
......@@ -39,11 +49,3 @@ export const createTenantSchema = z.object({
export const tenantStatusSchema = z.object({ enabled: z.boolean() });
export const resetPasswordSchema = z.object({ password: z.string().min(8, "密码至少 8 个字符").max(200) });
export const sitePatchSchema = z.object({
summary: z.string().trim().min(2).max(200),
operations: z.array(z.discriminatedUnion("type", [
z.object({ type: z.literal("write"), path: z.string(), content: z.string() }),
z.object({ type: z.literal("delete"), path: z.string() }),
])).min(1).max(5),
});
This diff is collapsed.
import type { ChatResult, SitePatch } from "@webagent/shared";
import { AgentLoop } from "../agent/agent-loop.js";
import { GitManager } from "../git/git-manager.js";
import { validatePatch } from "../security/path-policy.js";
import { applyPatch } from "./apply-patch.js";
import { SiteRepository } from "./site-repository.js";
export class SiteAgentService {
constructor(
private readonly sites: SiteRepository, private readonly git: GitManager,
private readonly agent: AgentLoop,
) {}
async execute(tenantId: string, siteId: string, message: string): Promise<ChatResult> {
const site = await this.sites.get(tenantId, siteId);
const projectPath = this.sites.getProjectPath(tenantId, siteId);
const workspace = this.sites.getDraftPath(tenantId, siteId);
const baseCommit = site.draftBaseCommit || site.currentCommit;
const creatingDraft = !site.draftBaseCommit;
await this.sites.update(tenantId, siteId, { status: "building", lastError: undefined });
let generated: { patch: SitePatch; mode: "model" | "local" } | undefined;
try {
if (creatingDraft) await this.git.createPersistentWorktree(projectPath, workspace, baseCommit);
generated = await this.agent.generate(workspace, message);
await validatePatch(generated.patch, workspace);
await applyPatch(workspace, generated.patch);
const changedFiles = generated.patch.operations.map((item) => item.path);
const updatedAt = new Date().toISOString();
await this.sites.update(tenantId, siteId, {
status: "ready", draftBaseCommit: baseCommit, draftUpdatedAt: updatedAt,
draftSummary: generated.patch.summary, lastError: undefined,
});
return { summary: generated.patch.summary, draft: true, baseCommit, updatedAt, changedFiles, mode: generated.mode };
} catch (error) {
const details = error instanceof Error ? error.message : String(error);
const keepDraft = !creatingDraft || await this.git.hasChanges(workspace).catch(() => false);
if (!keepDraft) await this.git.removePersistentWorktree(projectPath, workspace);
await this.sites.update(tenantId, siteId, {
status: site.previewCommit ? "ready" : "failed", lastError: details,
draftBaseCommit: keepDraft ? baseCommit : undefined,
draftUpdatedAt: keepDraft ? new Date().toISOString() : undefined,
});
throw error;
}
}
}
import crypto from "node:crypto";
import path from "node:path";
import type { PreviewVersionResult, PublishResult, SiteInfo } from "@webagent/shared";
import type { DraftPreviewResult, PreviewVersionResult, PublishResult, SiteInfo } from "@webagent/shared";
import { getPublicPreviewUrl, getPublicProductionUrl, runtimePaths } from "../config.js";
import { BuildError, BuildManager } from "../build/build-manager.js";
import { GitManager } from "../git/git-manager.js";
......@@ -15,8 +15,12 @@ export class SiteVersionService {
) {}
async rebuild(tenantId: string, siteId: string): Promise<{ previewUrl: string }> {
const project = this.sites.getProjectPath(tenantId, siteId);
const site = await this.sites.get(tenantId, siteId);
if (site.draftBaseCommit) {
await this.previewDraft(tenantId, siteId);
return { previewUrl: site.previewUrl };
}
const project = this.sites.getProjectPath(tenantId, siteId);
let currentCommit = await this.git.currentCommit(project);
let previewCommit = site.previewCommit || site.currentCommit || currentCommit;
if (await ensureEnvironmentConfig(project)) {
......@@ -35,7 +39,7 @@ export class SiteVersionService {
return { commit: targetCommit, previewUrl: site.previewUrl };
}
async previewDraft(tenantId: string, siteId: string): Promise<PreviewVersionResult> {
async previewDraft(tenantId: string, siteId: string): Promise<DraftPreviewResult> {
const site = await this.sites.get(tenantId, siteId);
if (!site.draftBaseCommit) throw new Error("当前没有可预览的工作草稿");
const project = this.sites.getProjectPath(tenantId, siteId);
......@@ -47,14 +51,44 @@ export class SiteVersionService {
try {
await ensureEnvironmentConfig(workspace);
const dist = await this.builds.build(workspace, taskId, getPublicPreviewUrl(tenantId, siteId));
const draftCommit = await this.git.commit(workspace, "Agent: " + (site.draftSummary || "保存工作草稿"));
const published = await this.builds.publishPreview(tenantId, siteId, dist);
await this.previews.start(tenantId, siteId, published, site.previewPort);
await this.sites.update(tenantId, siteId, {
status: "ready", previewCommit: site.draftBaseCommit,
draftUpdatedAt: new Date().toISOString(),
environmentVersion: 3, lastError: undefined,
});
return { baseCommit: site.draftBaseCommit, previewUrl: site.previewUrl };
} catch (error) {
const details = error instanceof BuildError ? error.output.slice(-3000) : error instanceof Error ? error.message : String(error);
await this.sites.update(tenantId, siteId, { status: site.previewCommit ? "ready" : "failed", lastError: details });
throw error;
}
}
async saveDraft(tenantId: string, siteId: string, message: string): Promise<PreviewVersionResult> {
const site = await this.sites.get(tenantId, siteId);
if (!site.draftBaseCommit) throw new Error("当前没有可保存的未保存修改");
const project = this.sites.getProjectPath(tenantId, siteId);
const workspace = this.sites.getDraftPath(tenantId, siteId);
const draftHead = await this.git.currentCommit(workspace);
if (draftHead === site.draftBaseCommit && !await this.git.hasChanges(workspace)) {
throw new Error("工作草稿没有实际修改");
}
const taskId = "save_draft_" + crypto.randomBytes(4).toString("hex");
await this.sites.update(tenantId, siteId, { status: "building", lastError: undefined });
try {
await ensureEnvironmentConfig(workspace);
const dist = await this.builds.build(workspace, taskId, getPublicPreviewUrl(tenantId, siteId));
const draftCommit = await this.git.hasChanges(workspace)
? await this.git.commit(workspace, message)
: await this.git.currentCommit(workspace);
const commit = await this.git.fastForward(project, draftCommit, site.draftBaseCommit);
const published = await this.builds.publishPreview(tenantId, siteId, dist);
await this.previews.start(tenantId, siteId, published, site.previewPort);
await this.sites.update(tenantId, siteId, {
status: "ready", currentCommit: commit, previewCommit: commit,
previousCommit: site.currentCommit, draftBaseCommit: undefined,
draftUpdatedAt: undefined, draftSummary: undefined,
draftBaseCommit: undefined, draftUpdatedAt: undefined, draftSummary: undefined,
environmentVersion: 3, lastError: undefined,
});
await this.git.removePersistentWorktree(project, workspace);
......@@ -66,18 +100,25 @@ export class SiteVersionService {
}
}
async undo(tenantId: string, siteId: string): Promise<PreviewVersionResult> {
async discardDraft(tenantId: string, siteId: string): Promise<PreviewVersionResult> {
const site = await this.sites.get(tenantId, siteId);
if (site.draftBaseCommit) throw new Error("工作草稿尚未预览,请先预览草稿或继续编辑");
if (!site.draftBaseCommit) throw new Error("当前没有可放弃的未保存修改");
const project = this.sites.getProjectPath(tenantId, siteId);
await this.buildPreview(site, site.currentCommit, "discard_draft");
await this.git.removePersistentWorktree(project, this.sites.getDraftPath(tenantId, siteId));
await this.sites.update(tenantId, siteId, {
draftBaseCommit: undefined, draftUpdatedAt: undefined, draftSummary: undefined,
});
return { commit: site.currentCommit, previewUrl: site.previewUrl };
}
async restoreVersion(tenantId: string, siteId: string, targetCommit: string): Promise<PreviewVersionResult> {
const site = await this.sites.get(tenantId, siteId);
if (site.draftBaseCommit) throw new Error("存在未保存修改,请先保存或放弃草稿");
const project = this.sites.getProjectPath(tenantId, siteId);
if (site.previewCommit && site.previewCommit !== site.currentCommit) {
throw new Error("当前正在查看历史版本,请先切换回最近保存版本再撤销修改");
}
const currentCommit = await this.git.currentCommit(project);
const targetCommit = site.previousCommit || await this.git.previousCommit(project, currentCommit);
if (!targetCommit) throw new Error("当前没有可撤销的上一版本");
await this.git.assertCommit(project, targetCommit);
const taskId = "undo_" + crypto.randomBytes(4).toString("hex");
if (targetCommit === site.currentCommit) throw new Error("目标已经是当前保存版本");
const taskId = "restore_" + crypto.randomBytes(4).toString("hex");
const workspace = path.join(runtimePaths.builds, taskId, "workspace");
await this.sites.update(tenantId, siteId, { status: "building", lastError: undefined });
try {
......@@ -89,7 +130,7 @@ export class SiteVersionService {
await this.previews.start(tenantId, siteId, published, site.previewPort);
await this.sites.update(tenantId, siteId, {
status: "ready", currentCommit: commit, previewCommit: commit,
previousCommit: undefined, environmentVersion: 3, lastError: undefined,
environmentVersion: 3, lastError: undefined,
});
return { commit, previewUrl: site.previewUrl };
} catch (error) {
......@@ -102,6 +143,7 @@ export class SiteVersionService {
const project = this.sites.getProjectPath(tenantId, siteId);
const site = await this.sites.get(tenantId, siteId);
if (site.status !== "ready") throw new Error("测试环境尚未构建成功,不能发布到生产环境");
if (site.draftBaseCommit) throw new Error("存在未保存修改,请先在“版本”中保存或放弃草稿后再发布");
const taskId = "publish_" + crypto.randomBytes(4).toString("hex");
const workspace = path.join(runtimePaths.builds, taskId, "workspace");
await this.sites.update(tenantId, siteId, { publishStatus: "publishing", lastPublishError: undefined });
......
import { AgentOrchestrator } from "./agent/orchestrator.js";
import { createAgentProviders } from "./agent/providers.js";
import { AgentRunRepository } from "./agent/run-repository.js";
import { BuildManager } from "./build/build-manager.js";
import { GitManager } from "./git/git-manager.js";
import { SiteRepository } from "./sites/site-repository.js";
const runs = new AgentRunRepository();
const sites = new SiteRepository();
await sites.ensureRuntime();
runs.recoverInterruptedRuns();
const orchestrator = new AgentOrchestrator(runs, createAgentProviders(), sites, new GitManager(), new BuildManager());
let stopping = false;
const stop = () => { stopping = true; void orchestrator.cancelActive(); };
process.on("SIGTERM", stop);
process.on("SIGINT", stop);
while (!stopping) {
const run = runs.claimNext();
if (run) await orchestrator.executeClaimed(run.runId);
else await new Promise((resolve) => setTimeout(resolve, 250));
}
runs.close();
# Coding Agent 运行时
WebAgent 的 Agent API 与 Worker 是两个独立进程,共享 `WEBAGENT_DATABASE_PATH` 指向的 SQLite 队列。API 只负责鉴权、创建/查询/取消任务和输出 SSE;Worker 负责真实执行,避免长时间模型调用阻塞 HTTP 进程。
运行状态固定为:
```text
queued → preparing_workspace → running_agent → checking_diff
→ validating_build → updating_preview → completed
```
任一执行阶段都只能转入 `failed``cancelled``timed_out`。前端通过带 Bearer Token 的流式请求消费事件,断线后可通过 `Last-Event-ID` 从 SQLite 事件序号继续读取。
## Provider 边界
业务层只依赖 `CodingAgentProvider`,Qwen SDK 类型只存在于 `qwen-code-provider.ts`。Agent 模式由租户点击聊天输入框左下角的模式按钮明确选择并写入 SQLite:
- `模拟 Agent`:使用确定性的 `local` 演示 Provider;
- `真实 Agent`:使用 `qwen-code`,且必须已配置 `OPENAI_API_KEY`
- 未保存过选择时使用 `WEBAGENT_AGENT_PROVIDER`,默认固定为 `local`
密钥是否存在只决定“真实 Agent”是否可选,不会自动改变当前模式。模式切换只影响之后新创建的任务,不会中途替换正在执行的 Agent。
Qwen Code 使用 `OPENAI_API_KEY``OPENAI_BASE_URL``OPENAI_MODEL` 的 OpenAI-compatible 配置。SDK 权限模式固定为 `default`,不会启用 `yolo`
## 安全与版本
- Agent 只能读取工作草稿,写入限制在 `src/**`;shell 只允许三条只读 Git 自检命令。
- 禁止访问 `.env*``.git/**`、依赖、构建产物、package/lockfile、网络和发布工具。
- Provider 完成后,系统仍会独立检查路径、符号链接、文件数、文件大小、diff 大小和 `git diff --check`
- 系统独立执行网站构建;构建通过后只原子替换未保存草稿的测试预览,不创建 Git 提交。后续 Agent 任务继续使用同一草稿,因此多轮修改可以累积。
- 用户在左侧“版本”中填写说明并手动保存时,系统再次构建草稿,通过后才创建一个 Git 提交并将其设为当前保存版本。
- 用户可以放弃未保存草稿;系统会先确认当前保存版本可重新构建,再删除草稿,避免因构建异常丢失修改。
- 历史版本恢复会创建新的恢复提交,不使用强制重置,不改写已有版本历史。
- 存在未保存草稿时禁止发布生产环境,用户必须先保存或放弃草稿。
- 构建或策略检查失败时,原测试预览保持可用;若 Agent 已产生文件改动,会保留为可继续修复的草稿。
这些是应用层防线。生产部署还应让 Worker 使用独立低权限账号或容器,并配置网络隔离、CPU/内存/磁盘配额和日志审计。
## API
- `POST /api/sites/:siteId/agent-runs`:创建任务;
- `GET /api/sites/:siteId/agent-runs/:runId`:读取任务;
- `GET /api/sites/:siteId/agent-runs/:runId/events`:SSE 事件;
- `GET /api/sites/:siteId/agent-runs/:runId/report`:最终报告;
- `POST /api/sites/:siteId/agent-runs/:runId/cancel`:取消任务。
- `POST /api/sites/:siteId/save-draft`:验证并手动保存累积草稿;
- `POST /api/sites/:siteId/discard-draft`:恢复当前保存版本并放弃草稿;
- `POST /api/sites/:siteId/restore-version`:把指定历史版本恢复为一个新版本。
这些路由都受现有租户认证和站点归属校验保护。
This diff is collapsed.
import type { ChatResult, CreateSiteInput, CreateTenantInput, DomainBinding, DomainListResult, GitHistoryItem, PreviewVersionResult, PublishResult, SessionInfo, SiteInfo, TenantAdminInfo, TenantAdminSiteInfo } from "@webagent/shared";
import type { AgentRunEvent, AgentRunInfo, AgentSettings, CreateSiteInput, CreateTenantInput, DomainBinding, DomainListResult, DraftPreviewResult, GitHistoryItem, PreviewVersionResult, PublishResult, SessionInfo, SiteInfo, TenantAdminInfo, TenantAdminSiteInfo } from "@webagent/shared";
async function request<T>(url: string, options?: RequestInit): Promise<T> {
const headers = new Headers(options?.headers);
......@@ -23,6 +23,8 @@ export const api = {
logout: () => request<{ success: true }>("/api/logout", { method: "POST" }),
session: () => request<SessionInfo>("/api/session"),
health: () => request<{ status: string; agentMode: "model" | "local" }>("/api/health"),
agentSettings: () => request<AgentSettings>("/api/agent-settings"),
updateAgentSettings: (provider: AgentSettings["provider"]) => request<AgentSettings>("/api/agent-settings", { method: "PUT", body: JSON.stringify({ provider }) }),
sites: () => request<SiteInfo[]>("/api/sites"),
archivedSites: () => request<SiteInfo[]>("/api/archived-sites"),
site: (siteId: string) => request<SiteInfo>("/api/sites/" + siteId),
......@@ -30,13 +32,26 @@ export const api = {
archiveSite: (siteId: string) => request<SiteInfo>("/api/sites/" + siteId + "/archive", { method: "POST" }),
restoreSite: (siteId: string) => request<SiteInfo>("/api/archived-sites/" + siteId + "/restore", { method: "POST" }),
deleteSite: (siteId: string) => request<void>("/api/archived-sites/" + siteId, { method: "DELETE" }),
chat: (siteId: string, message: string) => request<ChatResult>("/api/sites/" + siteId + "/chat", { method: "POST", body: JSON.stringify({ message }) }),
runAgent: async (
siteId: string, message: string,
handlers: { onStarted?: (run: AgentRunInfo) => void; onEvent?: (event: AgentRunEvent) => void } = {},
) => {
const run = await request<AgentRunInfo>(`/api/sites/${siteId}/agent-runs`, { method: "POST", body: JSON.stringify({ message }) });
handlers.onStarted?.(run);
await consumeAgentEvents(siteId, run.runId, handlers.onEvent);
const report = await request<AgentRunInfo>(`/api/sites/${siteId}/agent-runs/${run.runId}/report`);
if (report.status !== "completed") throw new Error(report.error || `Agent 运行结束:${report.status}`);
return report;
},
cancelAgent: (siteId: string, runId: string) => request<{ cancelRequested: true }>(`/api/sites/${siteId}/agent-runs/${runId}/cancel`, { method: "POST" }),
history: (siteId: string) => request<GitHistoryItem[]>("/api/sites/" + siteId + "/history"),
previewVersion: (siteId: string, commit: string) => request<PreviewVersionResult>("/api/sites/" + siteId + "/preview-version", { method: "POST", body: JSON.stringify({ commit }) }),
previewDraft: (siteId: string) => request<PreviewVersionResult>("/api/sites/" + siteId + "/preview-draft", { method: "POST" }),
previewDraft: (siteId: string) => request<DraftPreviewResult>("/api/sites/" + siteId + "/preview-draft", { method: "POST" }),
saveDraft: (siteId: string, message: string) => request<PreviewVersionResult>("/api/sites/" + siteId + "/save-draft", { method: "POST", body: JSON.stringify({ message }) }),
discardDraft: (siteId: string) => request<PreviewVersionResult>("/api/sites/" + siteId + "/discard-draft", { method: "POST" }),
restoreVersion: (siteId: string, commit: string) => request<PreviewVersionResult>("/api/sites/" + siteId + "/restore-version", { method: "POST", body: JSON.stringify({ commit }) }),
rebuild: (siteId: string) => request<{ previewUrl: string }>("/api/sites/" + siteId + "/build", { method: "POST" }),
publish: (siteId: string) => request<PublishResult>("/api/sites/" + siteId + "/publish", { method: "POST" }),
undo: (siteId: string) => request<PreviewVersionResult>("/api/sites/" + siteId + "/undo", { method: "POST" }),
domains: (siteId: string) => request<DomainListResult>("/api/sites/" + siteId + "/domains"),
addDomain: (siteId: string, hostname: string) => request<DomainBinding>("/api/sites/" + siteId + "/domains", { method: "POST", body: JSON.stringify({ hostname }) }),
verifyDomain: (siteId: string, domainId: string) => request<DomainBinding>("/api/sites/" + siteId + "/domains/" + domainId + "/verify", { method: "POST" }),
......@@ -50,3 +65,31 @@ export const api = {
resetTenantPassword: (tenantId: string, userId: string, password: string) => request<{ success: true }>(`/api/admin/tenants/${tenantId}/accounts/${userId}/reset-password`, { method: "POST", body: JSON.stringify({ password }) }),
revokeTenantSessions: (tenantId: string, userId: string) => request<{ success: true }>(`/api/admin/tenants/${tenantId}/accounts/${userId}/revoke-sessions`, { method: "POST" }),
};
async function consumeAgentEvents(siteId: string, runId: string, onEvent?: (event: AgentRunEvent) => void): Promise<void> {
const token = localStorage.getItem("webagent-session");
const response = await fetch(`/api/sites/${siteId}/agent-runs/${runId}/events`, {
headers: token ? { authorization: "Bearer " + token } : undefined,
});
if (!response.ok || !response.body) {
const data = await response.json().catch(() => ({})) as { error?: string };
throw new Error(data.error || "无法连接 Agent 事件流");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
buffer += decoder.decode(value, { stream: !done }).replaceAll("\r\n", "\n");
let boundary = buffer.indexOf("\n\n");
while (boundary >= 0) {
const block = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
const eventName = block.split("\n").find((line) => line.startsWith("event:"))?.slice(6).trim();
const payload = block.split("\n").filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).join("\n");
if (payload && eventName !== "end") onEvent?.(JSON.parse(payload) as AgentRunEvent);
boundary = buffer.indexOf("\n\n");
}
if (done) break;
}
}
This diff is collapsed.
......@@ -2,6 +2,7 @@
"name": "webagent",
"version": "0.1.0",
"private": true,
"engines": { "node": ">=22.0.0" },
"packageManager": "pnpm@11.7.0",
"scripts": {
"dev": "pnpm --parallel --stream --filter @webagent/backend --filter @webagent/frontend dev",
......
This diff is collapsed.
......@@ -5,5 +5,6 @@ packages:
- astro-template
allowBuilds:
better-sqlite3: true
esbuild: true
sharp: true
......@@ -25,7 +25,6 @@ export interface SiteInfo {
productionUrl?: string;
currentCommit: string;
previewCommit?: string;
previousCommit?: string;
draftBaseCommit?: string;
draftUpdatedAt?: string;
draftSummary?: string;
......@@ -116,6 +115,71 @@ export interface ChatResult {
mode: "model" | "local";
}
export const AGENT_RUN_STATES = [
"queued", "preparing_workspace", "running_agent", "checking_diff",
"validating_build", "updating_preview", "completed", "failed",
"cancelled", "timed_out",
] as const;
export type AgentRunStatus = (typeof AGENT_RUN_STATES)[number];
export type AgentFailureCategory = "provider" | "workspace" | "policy" | "build" | "cancelled" | "timeout" | "internal";
export type AgentEventType = "status" | "assistant" | "tool_started" | "tool_completed" | "command_started" | "command_completed" | "warning" | "error";
export interface AgentUsage {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
turns?: number;
}
export interface AgentBuildValidation {
passed: boolean;
buildExitCode: number;
durationMs: number;
output: string;
}
export interface AgentRunEvent {
sequence?: number;
runId: string;
type: AgentEventType;
timestamp: string;
message: string;
data?: Record<string, unknown>;
}
export interface AgentRunInfo {
runId: string;
tenantId: string;
siteId: string;
provider: string;
providerVersion: string;
model?: string;
sessionId?: string;
status: AgentRunStatus;
prompt: string;
summary?: string;
createdAt: string;
startedAt?: string;
endedAt?: string;
baselineCommit?: string;
finalCommit?: string;
changedFiles: string[];
failureCategory?: AgentFailureCategory;
error?: string;
usage?: AgentUsage;
validation?: AgentBuildValidation;
previewUrl?: string;
cancelRequested: boolean;
}
export type AgentProviderMode = "local" | "qwen-code";
export interface AgentSettings {
provider: AgentProviderMode;
realAvailable: boolean;
}
export interface PublishResult {
productionUrl: string;
commit: string;
......@@ -127,6 +191,11 @@ export interface PreviewVersionResult {
commit: string;
}
export interface DraftPreviewResult {
previewUrl: string;
baseCommit: string;
}
export type DomainOwnershipStatus = "pending" | "verified" | "failed";
export type DomainDnsStatus = "pending" | "valid" | "invalid";
export type DomainDeploymentStatus = "pending" | "deploying" | "active" | "failed";
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment