Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { resolve } from 'node:path';
// 外部第三方依赖(electron / pino / yaml / zod ...)继续 externalize 让 Node 在运行时解析。
const internalPackages = [
'@meebox/shared',
'@meebox/ipc',
'@meebox/agent',
'@meebox/config',
'@meebox/logger',
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"@iconify/react": "^5.2.1",
"@meebox/agent": "*",
"@meebox/config": "*",
"@meebox/ipc": "*",
"@meebox/logger": "*",
"@meebox/platform-bitbucket-server": "*",
"@meebox/platform-github": "*",
Expand Down
158 changes: 158 additions & 0 deletions apps/desktop/src/main/controllers/agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { loadAgentRules } from '@meebox/agent';
import {
clearAgentSession,
clearAutopilotLedger,
clearReviewRunsForPr,
getAgentConversation,
getAgentSession,
getAgentTranscript,
getAutopilotLedger,
getReviewRun,
listReviewRunsForPr,
} from '@meebox/poller';
import { pickMatchingRule } from '@meebox/rules';
import type { AgentRecommendationVerdict } from '@meebox/shared';
import { t } from '../i18n/index.js';
import { getContext } from '../services/context.js';
import type { IpcController } from './types.js';

/*
* Agent 交互域 controllers:规则匹配 / 评审编排 / 自由规划 / 会话与台账读取 / pr-agent run 队列
*/

/**
* 查 PR 当前命中的规则(ask 工具不接规则;无命中回 null)。
*/
export const matchRuleForPr: IpcController<'rules:matchForPr'> = async (_event, req) => {
if (req.tool === 'ask') return null;
const ctx = getContext();
const pr = await ctx.pr.findPrOrThrow(req.localId);
const rules = await loadAgentRules(ctx.effectiveAgentDir(), {
onWarn: (msg, file) => ctx.logger.warn({ file }, `rules: ${msg}`),
});
const matched = pickMatchingRule(rules, {
projectKey: pr.repo.projectKey,
repoSlug: pr.repo.repoSlug,
targetBranch: pr.targetRef.displayId,
tool: req.tool,
});
if (!matched) return null;
return {
id: matched.id,
filePath: matched.filePath,
priority: matched.priority,
tools: [...matched.tools],
instructions: matched.instructions,
};
};

/**
* 评审微流程(describe→review→条件追问→总结),收尾落「评审总结」。
*/
export const runReview: IpcController<'agent:run'> = async (_event, req) => {
const ctx = getContext();
return ctx.orchestrator.runReview(await ctx.pr.findPrOrThrow(req.localId));
};

/**
* 自由规划 Agent(自然语言「对话即委派」)。
*/
export const runPlanning: IpcController<'agent:ask'> = async (_event, req) => {
const ctx = getContext();
return ctx.orchestrator.runPlanning(await ctx.pr.findPrOrThrow(req.localId), req.question);
};

/**
* 暂停某 PR 的 Agent 运行(思考 / 执行任意阶段即时中止)。
*/
export const stopAgent: IpcController<'agent:stop'> = (_event, req) =>
getContext().orchestrator.stop(req.localId);

/**
* 读指定 PR 已落盘的 Agent 会话(跨 PR 切换、重启后恢复)。
*/
export const getSession: IpcController<'agent:getSession'> = (_event, req) =>
getAgentSession(getContext().stateStore, req.localId);

/**
* 读指定 PR 的多轮对话消息。
*/
export const getConversation: IpcController<'agent:getConversation'> = (_event, req) =>
getAgentConversation(getContext().stateStore, req.localId);

/**
* 读指定 PR 的 Agent 过程步骤(transcript)。
*/
export const getTranscript: IpcController<'agent:getTranscript'> = (_event, req) =>
getAgentTranscript(getContext().stateStore, req.localId);

/**
* 批量读 AutoPilot 台账:仅返回 decision=review 且有建议者的 recommendation(PR 列表徽标用)。
*/
export const getAutopilotLedgers: IpcController<'agent:autopilotLedgers'> = async (_event, req) => {
const { stateStore } = getContext();
const out: Record<string, AgentRecommendationVerdict> = {};
for (const id of req.localIds) {
const ledger = await getAutopilotLedger(stateStore, id);
if (ledger?.decision === 'review' && ledger.recommendation) {
out[id] = ledger.recommendation;
}
}
return out;
};

/*
* pr-agent run 队列(评审工具执行层;agent:run / AutoPilot 与用户手动 run 共用同一队列)
*/

/**
* 触发一次 run(队列调度)。/ask 必须带 question,提前校验避免排队后才报错。
*/
export const runPragent: IpcController<'pragent:run'> = async (_event, req) => {
const ctx = getContext();
if (!ctx.getPrAgentBridge()) {
throw new Error(t('prAgent.notReadyDetail'));
}
if (req.tool === 'ask' && !req.question?.trim()) {
throw new Error(t('prAgent.askNeedsQuestion'));
}
const pr = await ctx.pr.findPrOrThrow(req.localId);
return ctx.runQueue.enqueuePragentRun(pr, req.tool, req.question);
};

/**
* 取消一个 run(active SIGKILL / waiting 出队)。
*/
export const cancelPragent: IpcController<'pragent:cancel'> = (_event, req) =>
getContext().runQueue.cancel(req.runId);

/**
* 当前队列快照(启动 / 重连兜底)。
*/
export const getQueue: IpcController<'pragent:queue'> = () => getContext().runQueue.snapshot();

/**
* 列某 PR 历史 run(游标分页)。
*/
export const listRuns: IpcController<'pragent:listRuns'> = (_event, req) =>
listReviewRunsForPr(getContext().stateStore, req.localId, {
limit: req.limit,
beforeId: req.beforeId,
});

/**
* 单条 run 查询。
*/
export const getRun: IpcController<'pragent:getRun'> = (_event, req) =>
getReviewRun(getContext().stateStore, req.localId, req.runId);

/**
* 清某 PR 全部 run 历史,并一并清 Agent 会话 + AutoPilot 台账(广播 ★ 徽标即时消失)。
*/
export const clearRuns: IpcController<'pragent:clearRuns'> = async (_event, req) => {
const ctx = getContext();
await clearAgentSession(ctx.stateStore, req.localId);
await clearAutopilotLedger(ctx.stateStore, req.localId);
ctx.broadcast('agent:reviewStatusCleared', { prLocalId: req.localId });
return { cleared: await clearReviewRunsForPr(ctx.stateStore, req.localId) };
};
211 changes: 211 additions & 0 deletions apps/desktop/src/main/controllers/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { app, BrowserWindow, dialog, shell } from 'electron';
import type { Logger } from 'pino';
import { t } from '../i18n/index.js';
import { buildAppInfo, buildConnectionSummaries } from '../services/app.js';
import { getContext } from '../services/context.js';
import { sniffImageContentType } from '../utils/image.js';
import { checkForUpdate } from '../utils/update-check.js';
import { getLastUpdateResult, publishUpdateResult } from '../utils/update-state.js';
import type { IpcController } from './types.js';

/*
* GUI 框架交互域 controllers:应用信息 / 框架窗口 / 外部打开 / 对话框 / 日志回传 / 连接与头像
*/

/**
* 应用 / 运行时版本信息(关于页)。
*/
export const readAppInfo: IpcController<'app:info'> = () => buildAppInfo(getContext().bootstrap);

/**
* 关键目录路径(config / agent / 日志)。
*/
export const readAppPaths: IpcController<'app:paths'> = () => getContext().bootstrap.paths;

/**
* pr-agent 探测状态(是否就绪)。
*/
export const readPrAgentStatus: IpcController<'app:prAgentStatus'> = () =>
getContext().getPrAgentStatus();

let rendererLogger: Logger | undefined;
/**
* 渲染层错误 / 未捕获异常转发到 main,按级别写 renderer scope 日志(落同一份 meebox.log)。
*/
export const writeRendererLog: IpcController<'log:write'> = (_event, req) => {
rendererLogger ??= getContext().logger.child({ scope: 'renderer' });
const obj = req.meta ?? {};
switch (req.level) {
case 'error':
rendererLogger.error(obj, req.msg);
break;
case 'warn':
rendererLogger.warn(obj, req.msg);
break;
case 'info':
rendererLogger.info(obj, req.msg);
break;
case 'debug':
rendererLogger.debug(obj, req.msg);
break;
}
};

/**
* 各连接 ping 后缓存(当前用户 + display_name),Header / 状态栏用。
*/
export const listConnections: IpcController<'app:connections'> = () => {
const { bootstrap, connectionRuntime } = getContext();
return buildConnectionSummaries(bootstrap, connectionRuntime.adapters);
};

// 头像两级缓存:进程内 Map(含 null 负缓存)+ 磁盘文件(TTL 7 天,按 mtime 判过期)。
const AVATAR_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const avatarMem = new Map<string, { dataUrl: string } | null>();

/**
* 按 (connectionId, slug) 拉头像 dataUrl:内存 → 磁盘 → 远端,失败回 null。
*/
export const getUserAvatar: IpcController<'app:userAvatar'> = async (_event, req) => {
const { logger, connectionRuntime, bootstrap } = getContext();
const avatarDir = path.join(bootstrap.paths.cacheDir, 'avatars');
const memKey = `${req.connectionId}|${req.slug}`;
if (avatarMem.has(memKey)) return avatarMem.get(memKey)!;

const hash = crypto.createHash('sha256').update(memKey).digest('hex').slice(0, 24);
const filePath = path.join(avatarDir, `${hash}.bin`);

// 1) 磁盘 cache 命中且未过期?命中不打日志 (高频路径,避免日志噪音)
try {
const stat = await fs.stat(filePath);
const age = Date.now() - stat.mtimeMs;
if (age < AVATAR_TTL_MS) {
const bytes = await fs.readFile(filePath);
const contentType = sniffImageContentType(bytes);
const result = { dataUrl: `data:${contentType};base64,${bytes.toString('base64')}` };
avatarMem.set(memKey, result);
return result;
}
// 过期:删了重拉。删失败也没关系(writeFile 会覆盖)
await fs.unlink(filePath).catch(() => undefined);
} catch {
// 文件不存在 / 读失败 → 走 fetch
}

// 2) 没缓存 / 已过期:去远端拉
const adapter = connectionRuntime.adapters.find(
(a) => a.connectionId === req.connectionId,
)?.adapter;
if (!adapter) {
avatarMem.set(memKey, null);
return null;
}
try {
const img = await adapter.getUserAvatar(req.slug, req.avatarUrl);
if (!img) {
logger.debug({ connectionId: req.connectionId, slug: req.slug }, 'avatar fetch returned null');
avatarMem.set(memKey, null);
return null;
}
// 落盘:best-effort,写失败不影响响应
try {
await fs.mkdir(avatarDir, { recursive: true });
await fs.writeFile(filePath, img.bytes);
} catch (writeErr) {
logger.warn({ err: writeErr, hash }, 'avatar disk write failed');
}
const base64 = Buffer.from(img.bytes).toString('base64');
const result = { dataUrl: `data:${img.contentType};base64,${base64}` };
avatarMem.set(memKey, result);
logger.debug(
{ hash, slug: req.slug, bytes: img.bytes.length, contentType: img.contentType },
'avatar fetched + cached to disk',
);
return result;
} catch (err) {
logger.warn({ err, connectionId: req.connectionId, slug: req.slug }, 'avatar fetch threw');
avatarMem.set(memKey, null);
return null;
}
};

/**
* OS 默认编辑器打开 config.yaml。
*/
export const openConfigFile: IpcController<'app:openConfigFile'> = async () => {
const err = await shell.openPath(getContext().bootstrap.paths.configFile);
if (err) throw new Error(`failed to open config.yaml: ${err}`);
};

/**
* 文件管理器打开当前生效的 Agent 目录(不存在则先建)。
*/
export const openAgentDir: IpcController<'app:openAgentDir'> = async () => {
const dir = getContext().effectiveAgentDir();
await fs.mkdir(dir, { recursive: true }).catch(() => undefined);
const err = await shell.openPath(dir);
if (err) throw new Error(`failed to open agent dir: ${err}`);
};

/**
* 打开 DevTools(分离窗口)——需访问发起调用的 webContents。
*/
export const openDevTools: IpcController<'app:openDevTools'> = (event) => {
event.sender.openDevTools({ mode: 'detach' });
};

/**
* 手动检测更新:受 check_enabled 门控;结果交单一真相源缓存 + 有新版广播。
*/
export const checkUpdate: IpcController<'app:checkUpdate'> = async () => {
const { bootstrap } = getContext();
if (!bootstrap.config.update.check_enabled) {
return {
ok: false,
hasUpdate: false,
currentVersion: app.getVersion(),
error: 'update check disabled by config',
};
}
const result = await checkForUpdate(app.getVersion(), bootstrap.config.proxy);
publishUpdateResult(result);
return result;
};

/**
* 读 main 缓存的最近一次更新检测结果(不发请求)。
*/
export const getUpdateStatus: IpcController<'app:getUpdateStatus'> = () => getLastUpdateResult();

/**
* 系统浏览器打开外链(白名单仅放行 http(s),防 file:// / javascript: 注入)。
*/
export const openExternal: IpcController<'app:openExternal'> = async (_event, req) => {
if (!/^https?:\/\//.test(req.url)) return;
await shell.openExternal(req.url);
};

/**
* 系统原生目录选择对话框——需绑定到发起调用的窗口。
*/
export const pickDirectory: IpcController<'dialog:pickDirectory'> = async (event, req) => {
const win = BrowserWindow.fromWebContents(event.sender) ?? undefined;
const result = win
? await dialog.showOpenDialog(win, {
title: req.title ?? t('dialog.selectDirectory'),
defaultPath: req.defaultPath,
properties: ['openDirectory', 'createDirectory'],
})
: await dialog.showOpenDialog({
title: req.title ?? t('dialog.selectDirectory'),
defaultPath: req.defaultPath,
properties: ['openDirectory', 'createDirectory'],
});
if (result.canceled || result.filePaths.length === 0) {
return { path: null };
}
return { path: result.filePaths[0]! };
};
Loading
Loading