feat: polish web master-agent controls and dispatch recovery

This commit is contained in:
kris
2026-04-01 07:50:20 +08:00
parent 87093677b8
commit e52932e8ef
8 changed files with 153 additions and 29 deletions

View File

@@ -13,7 +13,7 @@ import {
} from "@/components/app-ui";
import { requirePageSession } from "@/lib/boss-auth";
import { listDispatchPlansByProject, readState } from "@/lib/boss-data";
import { latestPendingDispatchPlan, latestRejectedDispatchPlan } from "@/lib/dispatch-plan-ui";
import { resolveDispatchPlanComposerState } from "@/lib/dispatch-plan-ui";
import { formatTimestampLabel, getProjectDetailView } from "@/lib/boss-projections";
export const dynamic = "force-dynamic";
@@ -27,13 +27,9 @@ export default async function ProjectChatPage({
const { projectId } = await params;
const state = await readState();
const detail = getProjectDetailView(state, projectId, session.account);
const pendingDispatchPlan = detail?.project.isGroup
? latestPendingDispatchPlan(await listDispatchPlansByProject(projectId))
: null;
const rejectedDispatchPlan =
detail?.project.isGroup && !pendingDispatchPlan
? latestRejectedDispatchPlan(await listDispatchPlansByProject(projectId))
: null;
const dispatchPlanState = detail?.project.isGroup
? resolveDispatchPlanComposerState(await listDispatchPlansByProject(projectId))
: resolveDispatchPlanComposerState([]);
if (!detail) notFound();
@@ -169,12 +165,13 @@ export default async function ProjectChatPage({
</div>
<ChatComposer
projectId={detail.project.id}
dispatchPlanRecoveryHint={dispatchPlanState.pendingDispatchPlan ? dispatchPlanState.recoveryHint : null}
initialPendingDispatchPlan={
pendingDispatchPlan
dispatchPlanState.pendingDispatchPlan
? {
planId: pendingDispatchPlan.planId,
summary: pendingDispatchPlan.summary,
targets: (pendingDispatchPlan.targets ?? []).map((target) => ({
planId: dispatchPlanState.pendingDispatchPlan.planId,
summary: dispatchPlanState.pendingDispatchPlan.summary,
targets: (dispatchPlanState.pendingDispatchPlan.targets ?? []).map((target) => ({
projectId: target.projectId,
threadDisplayName: target.threadDisplayName,
})),
@@ -182,11 +179,11 @@ export default async function ProjectChatPage({
: null
}
initialRejectedDispatchPlan={
rejectedDispatchPlan
dispatchPlanState.rejectedDispatchPlan
? {
planId: rejectedDispatchPlan.planId,
summary: rejectedDispatchPlan.summary,
targets: (rejectedDispatchPlan.targets ?? []).map((target) => ({
planId: dispatchPlanState.rejectedDispatchPlan.planId,
summary: dispatchPlanState.rejectedDispatchPlan.summary,
targets: (dispatchPlanState.rejectedDispatchPlan.targets ?? []).map((target) => ({
projectId: target.projectId,
threadDisplayName: target.threadDisplayName,
})),

View File

@@ -1,6 +1,7 @@
import { AppShell, PageNav, StatusBar } from "@/components/app-ui";
import { MasterAgentPromptMemoryClient } from "@/components/master-agent-prompt-memory-client";
import { requirePageSession } from "@/lib/boss-auth";
import { MASTER_AGENT_CHAT_PAGE_ANCHORS } from "@/lib/master-agent-chat-menu";
import {
getMasterAgentPromptPolicy,
getProjectAgentControls,
@@ -43,6 +44,7 @@ export default async function MasterAgentPromptMemoryPage() {
projectControls={projectControls}
globalMemories={globalMemories}
projectMemories={projectMemories}
anchors={MASTER_AGENT_CHAT_PAGE_ANCHORS}
/>
</AppShell>
);

View File

@@ -867,10 +867,12 @@ export function ChatComposer({
projectId,
initialPendingDispatchPlan,
initialRejectedDispatchPlan,
dispatchPlanRecoveryHint,
}: {
projectId: string;
initialPendingDispatchPlan?: PendingDispatchPlanState | null;
initialRejectedDispatchPlan?: PendingDispatchPlanState | null;
dispatchPlanRecoveryHint?: string | null;
}) {
const router = useRouter();
const [value, setValue] = useState("");
@@ -1128,6 +1130,11 @@ export function ChatComposer({
{message}
</div>
) : null}
{dispatchPlanRecoveryHint ? (
<div className="mt-3 rounded-2xl border border-[#E5E5EA] bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
{dispatchPlanRecoveryHint}
</div>
) : null}
{pendingDispatchPlan ? (
<div className="mt-3 rounded-2xl border border-[#E5E5EA] bg-[#F7F8FA] px-4 py-4 text-[12px] leading-6 text-[#57606A]">
<div className="text-[14px] font-semibold text-[#111111]"> Agent </div>
@@ -1167,7 +1174,10 @@ export function ChatComposer({
<div className="mt-3 rounded-2xl border border-[#F3D19C] bg-[#FFF7E6] px-4 py-4 text-[12px] leading-6 text-[#8D5D00]">
<div className="text-[14px] font-semibold text-[#111111]"></div>
<div className="mt-2 whitespace-pre-line">{summarizeDispatchPlan(rejectedDispatchPlan)}</div>
<div className="mt-2"></div>
<div className="mt-2">
{dispatchPlanRecoveryHint ??
"如果还想继续当前协作,可以直接重新生成新的推荐,不用把整条需求重新打一遍。"}
</div>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
@@ -1175,7 +1185,7 @@ export function ChatComposer({
onClick={() => void retryDispatchPlan()}
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white disabled:bg-[#B7E6C9]"
>
</button>
</div>
</div>

View File

@@ -11,6 +11,7 @@ import type {
ProjectAgentControls,
UserMasterPrompt,
} from "@/lib/boss-data";
import type { MasterAgentChatPageAnchors } from "@/lib/master-agent-chat-menu";
import { formatTimestampLabel } from "@/lib/boss-projections";
type MemoryDraft = {
@@ -146,6 +147,7 @@ export function MasterAgentPromptMemoryClient({
projectControls,
globalMemories,
projectMemories,
anchors,
}: {
isAdmin: boolean;
promptPolicy: MasterAgentPromptPolicy | null;
@@ -153,6 +155,7 @@ export function MasterAgentPromptMemoryClient({
projectControls: ProjectAgentControls | null;
globalMemories: MasterAgentMemory[];
projectMemories: MasterAgentMemory[];
anchors: MasterAgentChatPageAnchors;
}) {
const router = useRouter();
const [busyKey, setBusyKey] = useState<string | null>(null);
@@ -326,7 +329,7 @@ export function MasterAgentPromptMemoryClient({
return (
<div className="flex flex-col gap-4 px-[18px] pb-6">
<div id="prompt-section" className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4 scroll-mt-4">
<div id={anchors.prompt.split("#")[1]} className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4 scroll-mt-4">
<div className="text-[16px] font-semibold text-[#111111]"> Agent </div>
<div className="mt-2 text-[12px] leading-6 text-[#8C8C8C]">
@@ -400,7 +403,7 @@ export function MasterAgentPromptMemoryClient({
</span>
</div>
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<label id={anchors.model.split("#")[1]} className="space-y-1 scroll-mt-4">
<div className="text-[12px] text-[#8C8C8C]"></div>
<select
value={modelOverride}
@@ -413,7 +416,7 @@ export function MasterAgentPromptMemoryClient({
<option value="gpt-4.1-mini">gpt-4.1-mini</option>
</select>
</label>
<label className="space-y-1">
<label id={anchors.reasoningEffort.split("#")[1]} className="space-y-1 scroll-mt-4">
<div className="text-[12px] text-[#8C8C8C]"></div>
<select
value={reasoningEffortOverride}
@@ -453,7 +456,7 @@ export function MasterAgentPromptMemoryClient({
</div>
</div>
<div id="memory-section" className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4 scroll-mt-4">
<div id={anchors.memory.split("#")[1]} className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4 scroll-mt-4">
<div className="text-[16px] font-semibold text-[#111111]"></div>
<div className="mt-2 text-[12px] leading-6 text-[#8C8C8C]">
master-agent

View File

@@ -18,6 +18,45 @@ export function latestRejectedDispatchPlan(plans: DispatchPlanUiPayload[] | null
return (plans ?? []).find((plan) => plan.status === "rejected") ?? null;
}
export type DispatchPlanComposerState = {
pendingDispatchPlan: DispatchPlanUiPayload | null;
rejectedDispatchPlan: DispatchPlanUiPayload | null;
recoveryHint: string | null;
recoveryActionLabel: string | null;
};
export function resolveDispatchPlanComposerState(
plans: DispatchPlanUiPayload[] | null | undefined,
): DispatchPlanComposerState {
const pendingDispatchPlan = latestPendingDispatchPlan(plans);
const rejectedDispatchPlan = pendingDispatchPlan ? null : latestRejectedDispatchPlan(plans);
if (rejectedDispatchPlan) {
return {
pendingDispatchPlan,
rejectedDispatchPlan,
recoveryHint: "上次推荐已拒绝。直接点击“重新生成新的推荐”即可继续协作,不用重新发送整条消息。",
recoveryActionLabel: "重新生成新的推荐",
};
}
if (pendingDispatchPlan) {
return {
pendingDispatchPlan,
rejectedDispatchPlan: null,
recoveryHint: "当前有待确认推荐,已折叠旧的拒绝状态。",
recoveryActionLabel: null,
};
}
return {
pendingDispatchPlan: null,
rejectedDispatchPlan: null,
recoveryHint: null,
recoveryActionLabel: null,
};
}
export function summarizeDispatchPlan(plan: DispatchPlanUiPayload | null | undefined) {
if (!plan) {
return "主 Agent 暂未生成推荐线程。";

View File

@@ -1,5 +1,14 @@
export const MASTER_AGENT_CHAT_PAGE_ANCHORS = {
prompt: "/me/master-agent#prompt-section",
model: "/me/master-agent#model-section",
reasoningEffort: "/me/master-agent#reasoning-effort-section",
memory: "/me/master-agent#memory-section",
} as const;
export type MasterAgentChatPageAnchors = typeof MASTER_AGENT_CHAT_PAGE_ANCHORS;
export type MasterAgentChatMenuItem = {
key: "prompt" | "memory" | "refresh";
key: "prompt" | "model" | "reasoning_effort" | "memory" | "refresh";
label: string;
href?: string;
action?: "refresh";
@@ -14,12 +23,22 @@ export function getMasterAgentChatMenuItems(projectId: string): MasterAgentChatM
{
key: "prompt",
label: "提示词",
href: "/me/master-agent#prompt-section",
href: MASTER_AGENT_CHAT_PAGE_ANCHORS.prompt,
},
{
key: "model",
label: "模型",
href: MASTER_AGENT_CHAT_PAGE_ANCHORS.model,
},
{
key: "reasoning_effort",
label: "推理强度",
href: MASTER_AGENT_CHAT_PAGE_ANCHORS.reasoningEffort,
},
{
key: "memory",
label: "记忆",
href: "/me/master-agent#memory-section",
href: MASTER_AGENT_CHAT_PAGE_ANCHORS.memory,
},
{
key: "refresh",

View File

@@ -4,6 +4,7 @@ import {
extractApprovedTargetProjectIds,
latestRejectedDispatchPlan,
latestPendingDispatchPlan,
resolveDispatchPlanComposerState,
summarizeDispatchPlan,
} from "@/lib/dispatch-plan-ui";
@@ -79,3 +80,54 @@ test("latestRejectedDispatchPlan returns the latest rejected item", () => {
targets: [{ projectId: "p3", threadDisplayName: "调度修复线程" }],
});
});
test("resolveDispatchPlanComposerState folds older rejected plans once a newer pending plan exists", () => {
const state = resolveDispatchPlanComposerState([
{
planId: "dispatch-plan-1",
status: "rejected",
summary: "已拒绝的推荐",
targets: [{ projectId: "p3", threadDisplayName: "调度修复线程" }],
},
{
planId: "dispatch-plan-2",
status: "pending_user_confirmation",
summary: "重新生成的推荐",
targets: [{ projectId: "p2", threadDisplayName: "设备接入线程" }],
},
]);
assert.deepEqual(state.pendingDispatchPlan, {
planId: "dispatch-plan-2",
status: "pending_user_confirmation",
summary: "重新生成的推荐",
targets: [{ projectId: "p2", threadDisplayName: "设备接入线程" }],
});
assert.equal(state.rejectedDispatchPlan, null);
assert.equal(state.recoveryHint, "当前有待确认推荐,已折叠旧的拒绝状态。");
assert.equal(state.recoveryActionLabel, null);
});
test("resolveDispatchPlanComposerState exposes a retry action for a rejected plan", () => {
const state = resolveDispatchPlanComposerState([
{
planId: "dispatch-plan-3",
status: "rejected",
summary: "已拒绝的推荐",
targets: [{ projectId: "p3", threadDisplayName: "调度修复线程" }],
},
]);
assert.equal(state.pendingDispatchPlan, null);
assert.deepEqual(state.rejectedDispatchPlan, {
planId: "dispatch-plan-3",
status: "rejected",
summary: "已拒绝的推荐",
targets: [{ projectId: "p3", threadDisplayName: "调度修复线程" }],
});
assert.equal(state.recoveryActionLabel, "重新生成新的推荐");
assert.equal(
state.recoveryHint,
"上次推荐已拒绝。直接点击“重新生成新的推荐”即可继续协作,不用重新发送整条消息。",
);
});

View File

@@ -2,15 +2,17 @@ import test from "node:test";
import assert from "node:assert/strict";
import { getMasterAgentChatMenuItems } from "../src/lib/master-agent-chat-menu";
test("master-agent 聊天页菜单包含提示词、记忆和刷新", () => {
test("master-agent 聊天页菜单包含提示词、模型、推理强度、记忆和刷新", () => {
const items = getMasterAgentChatMenuItems("master-agent");
assert.deepEqual(
items.map((item) => item.key),
["prompt", "memory", "refresh"],
["prompt", "model", "reasoning_effort", "memory", "refresh"],
);
assert.equal(items[0]?.href, "/me/master-agent#prompt-section");
assert.equal(items[1]?.href, "/me/master-agent#memory-section");
assert.equal(items[2]?.action, "refresh");
assert.equal(items[1]?.href, "/me/master-agent#model-section");
assert.equal(items[2]?.href, "/me/master-agent#reasoning-effort-section");
assert.equal(items[3]?.href, "/me/master-agent#memory-section");
assert.equal(items[4]?.action, "refresh");
});
test("普通会话不返回主 Agent 专属菜单", () => {