Skip to content
Closed
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
131 changes: 131 additions & 0 deletions App/memmy-agent/src/novel/agui-bridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* AG-UI 人类决策层 · Agent 端桥接(agui-bridge)
*
* 设计依据:docs/novel-design.md §2.1
* - 决策点发出 AG-UI 消息 → 经现有 OutboundMessage 的 metadata.agentUi 送达前端
* - 用户结构化响应沿 InboundMessage 回传 → resolve 挂起的 Promise → Agent 恢复执行
* - 与 session-dag 打通:等待决策 = 节点 blocked,收到响应 = 恢复 active
*/
import {
OutboundMessage,
INBOUND_META_RUNTIME_CONTROL,
OUTBOUND_META_AGENT_UI,
} from "../core/runtime-messages/events.js";
import { AguiMessage, AguiMessageType, AguiResponse, AguiField } from "./types.js";

/** 传输适配:默认走 MessageBus,测试可注入假实现 */
export interface AguiTransport {
publish(message: OutboundMessage): Promise<void> | void;
}

export interface AguiDecisionRequest {
type: AguiMessageType;
title: string;
body: string;
fields?: AguiField[];
irreversible?: boolean;
context_id?: string;
}

const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; // 决策等待默认 10 分钟

interface PendingDecision {
resolve: (response: AguiResponse) => void;
reject: (error: Error) => void;
timer: ReturnType<typeof setTimeout>;
}

export class AguiBridge {
private pending = new Map<string, PendingDecision>();
private seq = 0;

constructor(
private transport: AguiTransport,
private opts: { timeoutMs?: number; channel?: string; chatId?: string } = {},
) {}

/** 发出决策卡片并挂起等待用户响应(半自动确认节点的核心原语) */
requestDecision(request: AguiDecisionRequest): Promise<AguiResponse> {
const message: AguiMessage = {
id: this.nextId(),
type: request.type,
from: "chief",
title: request.title,
body: request.body,
fields: request.fields,
irreversible: request.irreversible ?? false,
context_id: request.context_id,
};

const outbound = new OutboundMessage({
channel: this.opts.channel ?? "novel",
chatId: this.opts.chatId ?? "editorial",
content: `[AG-UI] ${request.type}: ${request.title}`,
metadata: { [OUTBOUND_META_AGENT_UI]: message },
});

return new Promise<AguiResponse>((resolve, reject) => {
const timeoutMs = this.opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const timer = setTimeout(() => {
this.pending.delete(message.id);
reject(new Error(`AG-UI 决策超时(${message.id}:${request.title})`));
}, timeoutMs);
this.pending.set(message.id, { resolve, reject, timer });

try {
void this.transport.publish(outbound);
} catch (error) {
this.cancel(message.id);
reject(error as Error);
}
});
}

/** confirm 快捷方法:请用户批准/驳回 */
confirm(title: string, body: string, contextId?: string, irreversible = false): Promise<AguiResponse> {
return this.requestDecision({ type: "confirm", title, body, context_id: contextId, irreversible });
}

/** 确认采纳(带采纳理由) */
async accept(title: string, body: string, contextId?: string): Promise<boolean> {
const response = await this.confirm(title, body, contextId);
return response.decision === true;
}

/**
* 收到用户响应时由 Agent loop 调用(挂载于 InboundMessage 处理链)
* 响应消息携带 metadata.aguiResponse = { message_id, decision, reason }
*/
resolveResponse(payload: AguiResponse): boolean {
const pending = this.pending.get(payload.message_id);
if (!pending) return false;
clearTimeout(pending.timer);
this.pending.delete(payload.message_id);
pending.resolve(payload);
return true;
}

/** 取消挂起的决策(超时/主编撤回) */
cancel(messageId: string): boolean {
const pending = this.pending.get(messageId);
if (!pending) return false;
clearTimeout(pending.timer);
this.pending.delete(messageId);
pending.reject(new Error(`AG-UI 决策已取消(${messageId})`));
return true;
}

/** 挂起中的决策数量(主编控制台进度监控用) */
pendingCount(): number {
return this.pending.size;
}

private nextId(): string {
this.seq += 1;
return `agui_${Date.now().toString(36)}_${this.seq.toString(36)}`;
}
}

// 供 agent loop 识别用户 AG-UI 响应的元数据键(与 events.ts 的 runtimeControl 平行)
export const INBOUND_META_AGUI_RESPONSE = "aguiResponse";
export { INBOUND_META_RUNTIME_CONTROL };
171 changes: 171 additions & 0 deletions App/memmy-agent/src/novel/chief-console.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/**
* 主编控制台(Chief Console)
*
* 设计依据:docs/novel-design.md §2.3
* - 任务下发(带创作指令单)→ 专家执行器 → 结果回传 → 用户 AG-UI 决策 → 放行/驳回
* - 进度监控:节点状态、pending 决策数、专家调用记录
* - 中断/恢复/锁定/解锁/回退 操作面
*/
import { WorkflowEngine, PipelineNode, PipelineNodeStatus } from "./workflow-engine.js";
import { AguiBridge } from "./agui-bridge.js";
import { AguiResponse, ExpertId, ExpertResult, TaskCard } from "./types.js";
import { ExpertRegistry } from "./expert-registry.js";

/** 专家执行器:把任务卡交给专家(真实环境 = 经 SubagentManager 派发的 subagent) */
export interface ExpertRunner {
run(bookId: string, node: PipelineNode, taskCard: TaskCard): Promise<ExpertResult>;
}

export interface NodeView {
expertId: ExpertId;
name: string;
order: number;
status: PipelineNodeStatus;
hasResult: boolean;
rejectCount: number;
decision: AguiResponse | null;
}

export interface ConsoleSnapshot {
bookId: string;
nodes: NodeView[];
pendingDecisions: number;
complete: boolean;
failed: boolean;
currentExpertId: ExpertId | null;
}

export class ChiefConsole {
private registry = new ExpertRegistry();
private workflow: WorkflowEngine;
private bridge: AguiBridge;
private runner: ExpertRunner;
private bookId: string;
private callLog: { expertId: ExpertId; ts: number; taskId: string }[] = [];

constructor(init: {
bookId: string;
workflow: WorkflowEngine;
bridge: AguiBridge;
runner: ExpertRunner;
}) {
this.bookId = init.bookId;
this.workflow = init.workflow;
this.bridge = init.bridge;
this.runner = init.runner;
}

/** 启动流水线:主编自动把当前节点派单给专家执行 */
start(): void {
this.workflow.start();
void this.pump();
}

/** 主编派单 + 执行循环:running 节点 → 构造 TaskCard → runner 执行 → 结果入 waiting */
private async pump(): Promise<void> {
const node = this.workflow.current();
if (!node || this.workflow.isComplete()) return;
if (node.status !== "running") return;

const taskCard: TaskCard = this.buildTaskCard(node);
this.callLog.push({ expertId: node.expertId, ts: Date.now(), taskId: taskCard.task_id });
try {
const result = await this.runner.run(this.bookId, node, taskCard);
this.workflow.submitResult(node.expertId, result, taskCard);
// submitResult 触发 onNodeWaiting —— 外部在此时发 AG-UI 确认卡片
} catch (error) {
this.workflow.fail(node.expertId, (error as Error).message);
}
}

/** 按专家档案与上游产出构造任务卡(references 注入已确认的上游结果) */
private buildTaskCard(node: PipelineNode): TaskCard {
const profile = this.registry.get(node.expertId);
const upstream = this.workflow
.getNodes()
.filter((n) => n.order < node.order && n.result)
.map((n) => `上游 ${n.expertId}: ${n.result?.summary ?? ""}`);
return {
task_id: `tsk_${Date.now().toString(36)}_${node.order}`,
expert: node.expertId,
objective: `${profile.name}:${profile.outputs.join("、")}`,
constraints: [
"不得改变已锁定设定(基线快照)",
"按产出物 schema 回传 {title, summary, detail, refs, extra}",
...profile.asKnowledgeCenter ? [] : [],
],
references: [
...upstream,
`角色卡:${profile.roleCard}`,
`技能:${profile.category === "creation" ? "novel-orchestration" : "novel-flow"}`,
],
acceptance: [
"满足角色卡质量标准",
...(node.expertId === "zhi-bi" ? ["控字达标", "章末钩子生效"] : []),
...(node.expertId === "shen-xiao" ? ["双基准查重完成", "审校清单可定位"] : []),
],
deadline: null,
parent_decision: this.workflow.getNodes()[node.order - 2]?.decision?.reason ?? null,
};
}

/** 用户采纳 → 放行下一节点并继续派单 */
async accept(expertId: ExpertId, decision: AguiResponse): Promise<void> {
this.workflow.accept(expertId, decision);
await this.pump();
}

/** 用户驳回 → 打回(默认上一节点;可指定回退目标) */
async reject(expertId: ExpertId, decision: AguiResponse, rollbackTo?: ExpertId): Promise<void> {
this.workflow.reject(expertId, decision, rollbackTo);
await this.pump();
}

/** 主编控制台操作面:中断/恢复/锁定/解锁 */
pause(): void {
this.workflow.pause();
}

resume(): void {
this.workflow.resume();
void this.pump();
}

freeze(expertId: ExpertId): void {
this.workflow.freeze(expertId);
}

unfreeze(expertId: ExpertId): void {
this.workflow.unfreeze(expertId);
}

/** 进度监控快照(主编控制台/前端编委会视图的数据源) */
snapshot(): ConsoleSnapshot {
const nodes = this.workflow.getNodes();
return {
bookId: this.bookId,
nodes: nodes.map((n) => ({
expertId: n.expertId,
name: this.registry.get(n.expertId).name,
order: n.order,
status: n.status,
hasResult: n.result !== null,
rejectCount: n.rejectCount,
decision: n.decision,
})),
pendingDecisions: this.bridge.pendingCount(),
complete: this.workflow.isComplete(),
failed: nodes.some((n) => n.status === "failed"),
currentExpertId: this.workflow.current()?.expertId ?? null,
};
}

callHistory(): { expertId: ExpertId; ts: number; taskId: string }[] {
return [...this.callLog];
}

/** 挂起中的 AG-UI 决策(供前端渲染卡片) */
pendingDecisions(): number {
return this.bridge.pendingCount();
}
}
Loading