feat: pending-finish proposals and QQ image input

Proposal semantics: worker results no longer distinguish success/fail;
only an explicit finish settles a pending proposal. Pending owner input
follows up by resuming the original worker session. Dirty pending blocks
start_next globally; clean pending only blocks its owner.

QQ adapter now downloads image attachments and passes them as ACP image
content blocks; video/file attachments degrade to link text.
This commit is contained in:
zenord
2026-08-19 13:36:23 +08:00
parent 33854721bf
commit 7aa610294c
24 changed files with 1285 additions and 391 deletions
+53 -8
View File
@@ -3,6 +3,7 @@ import type { GatewayPolicy } from "../config.js";
import type { PlatformAdapter } from "./adapter.js";
import { CommandRouter, type ParsedCommand } from "./command-router.js";
import { chatKeyFor } from "./durable-session-store.js";
import type { Proposal } from "./proposal-store.js";
import type { IncomingMessage, MessageTarget } from "./types.js";
export interface GatewayResult { ok: boolean; reply?: string; ignored?: boolean; error?: string }
@@ -114,7 +115,8 @@ export class Gateway {
chatId: message.chatId,
userId: message.userId,
text: message.text,
messageId: message.messageId
messageId: message.messageId,
attachments: message.attachments
})).text;
await this.replyStream(chatKey, message, adapter).enqueue(reply);
return { ok: true, reply };
@@ -237,7 +239,8 @@ export class Gateway {
chatId: message.chatId,
userId: message.userId,
text: message.text,
messageId: message.messageId
messageId: message.messageId,
attachments: message.attachments
})).text;
return { ok: true, reply };
} catch (error) {
@@ -256,17 +259,19 @@ export class Gateway {
}
case "list": {
if (!this.runtime.listProposals) return "当前运行时不支持列出提案。";
const proposals = this.runtime.listProposals(message.platform, message.chatId, message.userId);
if (proposals.length === 0) return "当前没有提案。";
return proposals.map((proposal) => `${proposal.id} [${proposal.status}] ${proposal.title}`).join("\n");
return renderProposalPanel(this.runtime.listProposals(message.platform, message.chatId, message.userId));
}
case "confirm": {
if (!this.runtime.confirm) return "当前运行时不支持确认操作。";
return await this.runtime.confirm(message.platform, message.chatId, message.userId) ? "已确认,继续执行。" : "当前没有待确认的提案。";
return await this.runtime.confirm(message.platform, message.chatId, message.userId) ? "已确认,加入队列。" : "当前没有待确认的提案。";
}
case "finish": {
if (!this.runtime.finish) return "当前运行时不支持结束操作。";
return await this.runtime.finish(message.platform, message.chatId, message.userId) ? "已结束该任务。" : "当前没有待结束的任务。";
}
case "stop": {
if (!this.runtime.stop) return "当前运行时不支持停止操作。";
return await this.runtime.stop(message.platform, message.chatId, message.userId) ? "已停止当前任务。" : "当前没有正在执行的任务。";
return await this.runtime.stop(message.platform, message.chatId, message.userId) ? "已停止当前任务,任务转为待确认。" : "当前没有正在执行的任务。";
}
case "cancel":
return await this.runtime.cancel(message.platform, message.chatId, message.userId) ? "已取消最近的提案。" : "没有可取消的提案。";
@@ -282,6 +287,45 @@ export class Gateway {
}
function renderProposalPanel(proposals: Proposal[]): string {
if (proposals.length === 0) return "当前没有提案。";
const pending = proposals.filter((proposal) => proposal.status === "pending");
const working = proposals.filter((proposal) => proposal.status === "working");
const queued = proposals.filter((proposal) => proposal.status === "queued");
const proposed = proposals.filter((proposal) => proposal.status === "proposed");
const finished = proposals.filter((proposal) => proposal.status === "finished")
.sort((left, right) => (right.finishedAt ?? right.updatedAt) - (left.finishedAt ?? left.updatedAt))
.slice(0, 3);
const sections: string[] = [];
if (pending.length > 0) {
sections.push("待确认(等你处理):");
for (const proposal of pending) {
const card = proposal.pending;
sections.push(`- ${proposal.id}「${proposal.title}」${card ? `:${card.summary}${card.question ? `(问你:${card.question})` : ""}` : ""}(说 /finish 结束,或直接说要求继续改)`);
}
}
if (working.length > 0) {
sections.push("进行中:");
for (const proposal of working) sections.push(`- ${proposal.id}「${proposal.title}」正在执行(/stop 可中止)`);
}
if (queued.length > 0) {
sections.push("排队中:");
for (const proposal of queued) sections.push(`- ${proposal.id}「${proposal.title}」`);
}
if (proposed.length > 0) {
sections.push("未确认(proposed):");
for (const proposal of proposed) sections.push(`- ${proposal.id}「${proposal.title}」(/confirm 确认后排队)`);
}
if (finished.length > 0) {
sections.push("最近结束:");
for (const proposal of finished) {
const state = proposal.finishKind === "cancelled" ? "已取消" : "已完成";
sections.push(`- ${proposal.id}「${proposal.title}」${state}${proposal.finishNote ? `(${proposal.finishNote})` : ""}`);
}
}
return sections.join("\n");
}
function safeEventError(error: unknown): string {
const message = error instanceof Error ? error.message : "";
const http = /HTTP (\d{3})/.exec(message);
@@ -296,7 +340,8 @@ const HELP_TEXT = [
"兜底命令:",
"/list 列出当前提案",
"/confirm 确认最近待确认的提案",
"/finish 结束最近待确认的任务",
"/stop 停止正在执行的任务",
"/cancel 取消最近未开始的提案",
"/cancel 取消最近未开始或待确认的提案",
"/status 查看运行状态"
].join("\n");