Integrate master agent runtime orchestration updates

This commit is contained in:
kris
2026-04-16 04:41:46 +08:00
parent e0c0ea1814
commit 39be49630f
81 changed files with 9283 additions and 448 deletions

View File

@@ -13,6 +13,10 @@ export async function POST(
replyBody?: string;
errorMessage?: string;
requestId?: string;
warnings?: Array<{
title?: string;
summary?: string;
}>;
dispatchExecutionId?: string;
targetProjectId?: string;
targetThreadId?: string;
@@ -42,6 +46,7 @@ export async function POST(
replyBody: normalized.replyBody,
errorMessage: normalized.errorMessage,
requestId: normalized.requestId,
warnings: normalized.warnings,
dispatchExecutionId: normalized.dispatchExecutionId,
targetProjectId: normalized.targetProjectId,
targetThreadId: normalized.targetThreadId,

View File

@@ -3,12 +3,39 @@ import { requireRequestSession } from "@/lib/boss-auth";
import {
getProjectAgentControls,
hasPersistedProject,
listAiAccounts,
updateProjectAgentControls,
} from "@/lib/boss-data";
import { getClawBackendAvailability } from "@/lib/execution/backends/claw-config";
import { getHermesBackendAvailability } from "@/lib/execution/backends/hermes-config";
import { jsonNoStore } from "@/lib/api-response";
const reasoningEffortValues = new Set(["low", "medium", "high"]);
const MASTER_AGENT_MODEL_PRESETS = ["gpt-5.4", "gpt-5.4-mini", "gpt-4.1", "gpt-4.1-mini", "qwen3.5-plus"] as const;
async function buildMasterAgentModelCatalog() {
const aiAccounts = await listAiAccounts();
const availableModels = Array.from(
new Set(
aiAccounts.accounts
.filter((account) => account.canGenerate && account.model?.trim())
.map((account) => account.model!.trim()),
),
);
const configuredModels = Array.from(
new Set(
aiAccounts.accounts
.map((account) => account.model?.trim())
.filter((model): model is string => Boolean(model)),
),
);
return {
availableModels,
selectableModels: Array.from(new Set([...MASTER_AGENT_MODEL_PRESETS, ...configuredModels])),
presetModels: [...MASTER_AGENT_MODEL_PRESETS],
};
}
export async function GET(
request: NextRequest,
@@ -25,11 +52,14 @@ export async function GET(
return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
}
const [controls, clawAvailability] = await Promise.all([
const [controls, clawAvailability, hermesAvailability] = await Promise.all([
getProjectAgentControls(projectId, session.account),
getClawBackendAvailability(),
getHermesBackendAvailability(),
]);
return jsonNoStore({ ok: true, controls, clawAvailability });
const modelCatalog =
projectId === "master-agent" ? await buildMasterAgentModelCatalog() : undefined;
return jsonNoStore({ ok: true, controls, clawAvailability, hermesAvailability, modelCatalog });
}
export async function POST(
@@ -56,6 +86,10 @@ export async function POST(
const payload = body as {
modelOverride?: unknown;
reasoningEffortOverride?: unknown;
fastModelOverride?: unknown;
fastReasoningEffortOverride?: unknown;
smartModelOverride?: unknown;
smartReasoningEffortOverride?: unknown;
promptOverride?: unknown;
backendOverride?: unknown;
takeoverEnabled?: unknown;
@@ -66,6 +100,16 @@ export async function POST(
payload,
"reasoningEffortOverride",
);
const hasFastModelOverride = Object.prototype.hasOwnProperty.call(payload, "fastModelOverride");
const hasFastReasoningEffortOverride = Object.prototype.hasOwnProperty.call(
payload,
"fastReasoningEffortOverride",
);
const hasSmartModelOverride = Object.prototype.hasOwnProperty.call(payload, "smartModelOverride");
const hasSmartReasoningEffortOverride = Object.prototype.hasOwnProperty.call(
payload,
"smartReasoningEffortOverride",
);
const hasPromptOverride = Object.prototype.hasOwnProperty.call(payload, "promptOverride");
const hasBackendOverride = Object.prototype.hasOwnProperty.call(payload, "backendOverride");
const hasTakeoverEnabled = Object.prototype.hasOwnProperty.call(payload, "takeoverEnabled");
@@ -75,16 +119,24 @@ export async function POST(
? new Set([
"modelOverride",
"reasoningEffortOverride",
"fastModelOverride",
"fastReasoningEffortOverride",
"smartModelOverride",
"smartReasoningEffortOverride",
"promptOverride",
"backendOverride",
"globalTakeoverEnabled",
])
: new Set(["takeoverEnabled"]);
: new Set(["takeoverEnabled", "backendOverride"]);
const hasUnsupportedKeys = Object.keys(payload).some((key) => !allowedKeys.has(key));
if (
(
!hasModelOverride &&
!hasReasoningEffortOverride &&
!hasFastModelOverride &&
!hasFastReasoningEffortOverride &&
!hasSmartModelOverride &&
!hasSmartReasoningEffortOverride &&
!hasPromptOverride &&
!hasBackendOverride &&
!hasTakeoverEnabled &&
@@ -110,6 +162,64 @@ export async function POST(
{ status: 400 },
);
}
if (
hasFastModelOverride &&
payload.fastModelOverride !== undefined &&
payload.fastModelOverride !== null &&
typeof payload.fastModelOverride !== "string"
) {
return NextResponse.json({ ok: false, message: "INVALID_FAST_MODEL_OVERRIDE" }, { status: 400 });
}
if (
hasFastReasoningEffortOverride &&
payload.fastReasoningEffortOverride !== undefined &&
payload.fastReasoningEffortOverride !== null &&
typeof payload.fastReasoningEffortOverride !== "string"
) {
return NextResponse.json(
{ ok: false, message: "INVALID_FAST_REASONING_EFFORT_OVERRIDE" },
{ status: 400 },
);
}
if (
hasFastReasoningEffortOverride &&
typeof payload.fastReasoningEffortOverride === "string" &&
!reasoningEffortValues.has(payload.fastReasoningEffortOverride)
) {
return NextResponse.json(
{ ok: false, message: "INVALID_FAST_REASONING_EFFORT_OVERRIDE" },
{ status: 400 },
);
}
if (
hasSmartModelOverride &&
payload.smartModelOverride !== undefined &&
payload.smartModelOverride !== null &&
typeof payload.smartModelOverride !== "string"
) {
return NextResponse.json({ ok: false, message: "INVALID_SMART_MODEL_OVERRIDE" }, { status: 400 });
}
if (
hasSmartReasoningEffortOverride &&
payload.smartReasoningEffortOverride !== undefined &&
payload.smartReasoningEffortOverride !== null &&
typeof payload.smartReasoningEffortOverride !== "string"
) {
return NextResponse.json(
{ ok: false, message: "INVALID_SMART_REASONING_EFFORT_OVERRIDE" },
{ status: 400 },
);
}
if (
hasSmartReasoningEffortOverride &&
typeof payload.smartReasoningEffortOverride === "string" &&
!reasoningEffortValues.has(payload.smartReasoningEffortOverride)
) {
return NextResponse.json(
{ ok: false, message: "INVALID_SMART_REASONING_EFFORT_OVERRIDE" },
{ status: 400 },
);
}
if (hasPromptOverride && payload.promptOverride !== undefined && payload.promptOverride !== null && typeof payload.promptOverride !== "string") {
return NextResponse.json({ ok: false, message: "INVALID_PROMPT_OVERRIDE" }, { status: 400 });
}
@@ -117,7 +227,8 @@ export async function POST(
hasBackendOverride &&
payload.backendOverride !== undefined &&
payload.backendOverride !== null &&
payload.backendOverride !== "claw-runtime"
payload.backendOverride !== "claw-runtime" &&
payload.backendOverride !== "hermes-runtime"
) {
return NextResponse.json({ ok: false, message: "INVALID_BACKEND_OVERRIDE" }, { status: 400 });
}
@@ -149,11 +260,29 @@ export async function POST(
}
}
if (hasBackendOverride && payload.backendOverride === "hermes-runtime") {
const hermesAvailability = await getHermesBackendAvailability();
if (!hermesAvailability.selectable) {
return NextResponse.json(
{ ok: false, message: hermesAvailability.reasonLabel, hermesAvailability },
{ status: 400 },
);
}
}
const controls = await updateProjectAgentControls(
projectId,
{
...(hasModelOverride ? { modelOverride: payload.modelOverride } : {}),
...(hasReasoningEffortOverride ? { reasoningEffortOverride: payload.reasoningEffortOverride } : {}),
...(hasFastModelOverride ? { fastModelOverride: payload.fastModelOverride } : {}),
...(hasFastReasoningEffortOverride
? { fastReasoningEffortOverride: payload.fastReasoningEffortOverride }
: {}),
...(hasSmartModelOverride ? { smartModelOverride: payload.smartModelOverride } : {}),
...(hasSmartReasoningEffortOverride
? { smartReasoningEffortOverride: payload.smartReasoningEffortOverride }
: {}),
...(hasPromptOverride ? { promptOverride: payload.promptOverride } : {}),
...(hasBackendOverride ? { backendOverride: payload.backendOverride } : {}),
...(hasTakeoverEnabled ? { takeoverEnabled: payload.takeoverEnabled } : {}),
@@ -165,6 +294,7 @@ export async function POST(
ok: true,
controls: controls ?? null,
clawAvailability: await getClawBackendAvailability(),
hermesAvailability: await getHermesBackendAvailability(),
});
} catch (error) {
return NextResponse.json(

View File

@@ -152,6 +152,7 @@ export async function POST(
masterReplyState?: "queued" | "running" | "completed";
task?: {
taskId: string;
requestMessageId: string;
taskType: "conversation_reply";
status: "queued" | "running" | "completed";
};
@@ -160,6 +161,7 @@ export async function POST(
let task:
| {
taskId: string;
requestMessageId: string;
taskType: "conversation_reply";
status: "queued" | "running" | "completed";
}
@@ -213,6 +215,7 @@ export async function POST(
});
task = {
taskId: queuedTask.taskId,
requestMessageId: queuedTask.requestMessageId,
taskType: "conversation_reply",
status: "queued",
};
@@ -232,11 +235,11 @@ export async function POST(
currentSessionExpiresAt: session.expiresAt,
mode: "enqueue",
});
if (masterReply?.ok && masterReply.taskId) {
task = masterReply.task ?? null;
if (masterReply) {
if (masterReply.ok && masterReply.taskId) {
task = masterReply.task ?? null;
}
masterReplyState = masterReply.masterReplyState ?? null;
} else {
masterReplyState = null;
}
}

View File

@@ -1,122 +1,28 @@
import { NextRequest, NextResponse } from "next/server";
import { requireRequestSession } from "@/lib/boss-auth";
import { isDispatchableThreadProject, readState, replaceGroupChatMembers } from "@/lib/boss-data";
import { readState, replaceGroupChatMembers } from "@/lib/boss-data";
import { buildProjectParticipantsPayload } from "@/lib/boss-projections-shared";
import { jsonNoStore } from "@/lib/api-response";
type ConversationParticipant = {
projectId: string;
deviceId: string;
threadId: string;
threadDisplayName: string;
folderName: string;
avatar: string;
isSourceProject: boolean;
status: "active" | "missing_project" | "invalid_target";
statusLabel?: string;
canOpenProject: boolean;
};
function getFallbackAvatar(label: string) {
const trimmed = label.trim();
if (!trimmed) return "A";
return trimmed.slice(0, 1).toUpperCase();
}
function buildParticipant(
projectId: string,
deviceId: string,
threadId: string,
threadDisplayName: string,
folderName: string,
avatar?: string,
isSourceProject = false,
status: ConversationParticipant["status"] = "active",
canOpenProject = true,
): ConversationParticipant {
return {
projectId,
deviceId,
threadId,
threadDisplayName,
folderName,
avatar: avatar?.trim() || getFallbackAvatar(threadDisplayName),
isSourceProject,
status,
statusLabel:
status === "missing_project"
? "引用已失效"
: status === "invalid_target"
? "不是可下发线程"
: undefined,
canOpenProject,
};
}
function buildParticipantsPayload(
state: Awaited<ReturnType<typeof readState>>,
projectId: string,
) {
const project = state.projects.find((item) => item.id === projectId);
if (!project) {
return null;
function mapParticipantsRepairErrorMessage(error: unknown) {
if (!(error instanceof Error)) {
return "UNKNOWN_ERROR";
}
const participants = project.isGroup
? project.groupMembers.map((member) => {
const candidateProject = state.projects.find((item) => item.id === member.projectId);
const device = state.devices.find((item) => item.id === member.deviceId);
const status: ConversationParticipant["status"] = !candidateProject
? "missing_project"
: isDispatchableThreadProject(candidateProject)
? "active"
: "invalid_target";
return buildParticipant(
member.projectId,
member.deviceId,
member.threadId,
member.threadDisplayName,
member.folderName,
device?.avatar,
member.projectId === project.id,
status,
Boolean(candidateProject),
);
})
: [
buildParticipant(
project.id,
project.deviceIds[0] ?? project.id,
project.threadMeta.threadId,
project.threadMeta.threadDisplayName,
project.threadMeta.folderName,
state.devices.find((item) => item.id === project.deviceIds[0])?.avatar,
true,
),
];
const validParticipantCount = participants.filter((item) => item.status === "active").length;
const invalidParticipantCount = participants.length - validParticipantCount;
const repairRequired =
project.isGroup && (invalidParticipantCount > 0 || validParticipantCount < 2);
const repairReason = !repairRequired
? undefined
: validParticipantCount === 0
? "当前群聊里还没有可下发的真实线程,请重新添加线程。"
: invalidParticipantCount > 0
? "当前群聊里有失效或不可下发的线程引用,请重新整理群成员。"
: "当前群聊至少需要 2 个真实线程成员。";
return {
ok: true,
projectId: project.id,
isGroup: project.isGroup,
threadMeta: project.threadMeta,
participants,
repairRequired,
repairReason,
validParticipantCount,
invalidParticipantCount,
};
switch (error.message) {
case "GROUP_CHAT_MEMBER_NOT_FOUND":
return "有线程已经不存在,请刷新后重新选择。";
case "GROUP_CHAT_MEMBER_NOT_THREAD":
return "所选项目里包含不可下发的对象,请重新选择真实线程。";
case "GROUP_CHAT_REQUIRES_AT_LEAST_TWO_THREADS":
return "至少选择 2 个真实线程后才能修复群成员。";
case "PROJECT_NOT_FOUND":
return "当前群聊不存在或已被删除。";
case "PROJECT_NOT_GROUP_CHAT":
return "当前项目不是群聊,无法修复群成员。";
default:
return error.message;
}
}
export async function GET(
@@ -130,7 +36,7 @@ export async function GET(
const { projectId } = await context.params;
const state = await readState();
const payload = buildParticipantsPayload(state, projectId);
const payload = buildProjectParticipantsPayload(state, projectId);
if (!payload) {
return jsonNoStore({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 });
}
@@ -162,14 +68,14 @@ export async function POST(
requestedBy: session.account,
});
const nextState = await readState();
const payload = buildParticipantsPayload(nextState, projectId);
const payload = buildProjectParticipantsPayload(nextState, projectId);
if (!payload) {
return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 });
}
return NextResponse.json(payload);
} catch (error) {
return NextResponse.json(
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
{ ok: false, message: mapParticipantsRepairErrorMessage(error) },
{ status: 400 },
);
}

View File

@@ -10,6 +10,7 @@ import {
updateUserMasterPrompt,
} from "@/lib/boss-data";
import { getClawBackendAvailability } from "@/lib/execution/backends/claw-config";
import { getHermesBackendAvailability } from "@/lib/execution/backends/hermes-config";
import { jsonNoStore } from "@/lib/api-response";
export async function GET(
@@ -27,11 +28,12 @@ export async function GET(
return jsonNoStore({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 });
}
const [promptPolicy, userPrompt, projectControls, clawAvailability] = await Promise.all([
const [promptPolicy, userPrompt, projectControls, clawAvailability, hermesAvailability] = await Promise.all([
getMasterAgentPromptPolicy(),
getUserMasterPrompt(session.account),
getProjectAgentControls(projectId, session.account),
getClawBackendAvailability(),
getHermesBackendAvailability(),
]);
return jsonNoStore({
@@ -42,6 +44,7 @@ export async function GET(
projectControls,
projectPromptOverride: projectControls?.promptOverride ?? null,
clawAvailability,
hermesAvailability,
account: session.account,
});
}
@@ -104,6 +107,7 @@ export async function POST(
&& typeof payload.backendOverride === "string"
&& payload.backendOverride.trim() !== ""
&& payload.backendOverride.trim() !== "claw-runtime"
&& payload.backendOverride.trim() !== "hermes-runtime"
) {
return NextResponse.json({ ok: false, message: "INVALID_BACKEND_OVERRIDE" }, { status: 400 });
}
@@ -127,6 +131,24 @@ export async function POST(
}
}
if (
hasBackendOverride &&
typeof payload.backendOverride === "string" &&
payload.backendOverride.trim() === "hermes-runtime"
) {
const hermesAvailability = await getHermesBackendAvailability();
if (!hermesAvailability.selectable) {
return NextResponse.json(
{
ok: false,
message: hermesAvailability.reasonLabel,
hermesAvailability,
},
{ status: 400 },
);
}
}
if (hasUserPromptContent) {
const userPromptContent = typeof payload.userPromptContent === "string" ? payload.userPromptContent.trim() : "";
if (userPromptContent) {
@@ -143,11 +165,12 @@ export async function POST(
}, session.account);
}
const [promptPolicy, userPrompt, projectControls, clawAvailability] = await Promise.all([
const [promptPolicy, userPrompt, projectControls, clawAvailability, hermesAvailability] = await Promise.all([
getMasterAgentPromptPolicy(),
getUserMasterPrompt(session.account),
getProjectAgentControls(projectId, session.account),
getClawBackendAvailability(),
getHermesBackendAvailability(),
]);
return NextResponse.json({
@@ -158,6 +181,7 @@ export async function POST(
projectControls,
projectPromptOverride: projectControls?.promptOverride ?? null,
clawAvailability,
hermesAvailability,
account: session.account,
});
} catch (error) {

View File

@@ -15,14 +15,58 @@ import {
import { requirePageSession } from "@/lib/boss-auth";
import {
getProjectOrchestrationBackendState,
listDispatchPlansByProject,
readState,
} from "@/lib/boss-data";
import { resolveDispatchPlanComposerState } from "@/lib/dispatch-plan-ui";
import { formatTimestampLabel, getProjectDetailView } from "@/lib/boss-projections";
import { getProjectDetailView } from "@/lib/boss-projections";
import { formatTimestampLabel } from "@/lib/boss-projections-shared";
export const dynamic = "force-dynamic";
function conversationTaskStatusLabel(status: string) {
switch (status) {
case "queued":
return "排队中";
case "running":
return "执行中";
case "completed":
return "已完成";
case "failed":
return "已失败";
default:
return status;
}
}
function dedupeExecutionWarnings<
T extends {
title: string;
summary: string;
taskId: string;
requestMessageId: string;
sessionId?: string;
requestId?: string;
createdAt: string;
},
>(warnings: T[]) {
const warningMap = new Map<string, T>();
for (const warning of warnings) {
const dedupeKey = [
warning.requestMessageId,
warning.taskId,
warning.sessionId ?? "",
warning.requestId ?? "",
warning.title,
warning.summary,
].join("::");
const existing = warningMap.get(dedupeKey);
if (!existing || existing.createdAt < warning.createdAt) {
warningMap.set(dedupeKey, warning);
}
}
return Array.from(warningMap.values()).sort((left, right) => right.createdAt.localeCompare(left.createdAt));
}
export default async function ProjectChatPage({
params,
}: {
@@ -32,15 +76,16 @@ export default async function ProjectChatPage({
const { projectId } = await params;
const state = await readState();
const detail = getProjectDetailView(state, projectId, session.account);
const dispatchPlanState = detail?.project.isGroup
? resolveDispatchPlanComposerState(await listDispatchPlansByProject(projectId))
: resolveDispatchPlanComposerState([]);
const orchestrationBackendState = detail?.project.isGroup
? await getProjectOrchestrationBackendState(projectId)
: null;
if (!detail) notFound();
const dispatchPlanState = detail.project.isGroup
? resolveDispatchPlanComposerState(detail.dispatchPlans)
: resolveDispatchPlanComposerState([]);
const orchestrationBackendState = detail.project.isGroup
? await getProjectOrchestrationBackendState(projectId)
: null;
return (
<AppShell bottomNav={false}>
<RealtimeRefresh
@@ -89,7 +134,7 @@ export default async function ProjectChatPage({
</div>
) : null}
<div className="pt-3">
<ProjectHeaderActions projectId={detail.project.id} />
<ProjectHeaderActions projectId={detail.project.id} isGroup={detail.project.isGroup} />
</div>
{detail.project.isGroup && orchestrationBackendState ? (
<div className="mt-3">
@@ -99,6 +144,37 @@ export default async function ProjectChatPage({
/>
</div>
) : null}
{detail.project.isGroup && detail.participantsPayload && detail.participantsPayload.repairRequired ? (
<div className="mt-3 rounded-2xl border border-[#FAD7A0] bg-[#FFF7E6] px-4 py-4 text-[13px] leading-6 text-[#8A4B00]">
<div className="text-[14px] font-semibold text-[#6B3A00]"></div>
<div className="mt-2">
{detail.participantsPayload.repairReason || "当前群聊里有失效线程,请先修复群成员。"}
</div>
<div className="mt-1 text-[12px] text-[#A56A1D]">
线 {detail.participantsPayload.validParticipantCount} · {detail.participantsPayload.invalidParticipantCount}
</div>
<div className="mt-3 space-y-2">
{detail.participantsPayload.participants.filter((participant) => participant.status !== "active").map((participant) => (
<div
key={`${participant.projectId}-${participant.threadId}`}
className="rounded-xl bg-white/70 px-3 py-2 text-[12px] leading-5 text-[#6B3A00]"
>
<div className="font-semibold">{participant.threadDisplayName}</div>
<div className="mt-1">
{participant.statusLabel ?? participant.status}
{participant.canOpenProject ? " · 可打开项目" : " · 项目引用已丢失"}
</div>
</div>
))}
</div>
<Link
href={`/conversations/${detail.project.id}/participants`}
className="mt-3 inline-flex h-9 items-center justify-center rounded-full bg-[#F3B24B] px-4 text-[13px] font-semibold text-[#3D2400]"
>
</Link>
</div>
) : null}
<div className="mt-4 space-y-3">
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
<div className="text-[14px] font-semibold text-[#111111]"> Agent </div>
@@ -172,9 +248,74 @@ export default async function ProjectChatPage({
</div>
</div>
<div className="mt-4 flex-1 space-y-4 overflow-y-auto pb-6">
{detail.project.messages.map((message) => (
<ChatBubble key={message.id} message={message} />
))}
{detail.project.messages.map((message) => {
const messageTask = detail.conversationTasks.find((task) => task.requestMessageId === message.id);
const warningMap = new Map<string, typeof detail.executionWarnings[number]>();
for (const warning of detail.executionWarnings) {
if (warning.requestMessageId !== message.id) continue;
const dedupeKey = [
warning.requestMessageId,
warning.taskId,
warning.sessionId ?? "",
warning.requestId ?? "",
warning.title,
warning.summary,
].join("::");
const existing = warningMap.get(dedupeKey);
if (!existing || existing.createdAt < warning.createdAt) {
warningMap.set(dedupeKey, warning);
}
}
const dedupedWarnings = dedupeExecutionWarnings(Array.from(warningMap.values()));
return (
<div key={message.id} className="space-y-2">
<ChatBubble message={message} />
{messageTask ? (
<div
className="ml-1 max-w-[82%] rounded-2xl border border-[#D8E7DE] bg-[#F7FBF8] px-3 py-2 text-[12px] leading-5 text-[#45604D]"
>
<div className="font-semibold text-[#23412E]">
线 · {conversationTaskStatusLabel(messageTask.status)}
</div>
<div className="mt-1">
{messageTask.sessionId ? `Session ${messageTask.sessionId}` : `Task ${messageTask.taskId}`}
{messageTask.targetThreadId ? ` · 线程 ${messageTask.targetThreadId}` : ""}
</div>
</div>
) : null}
{dedupedWarnings.map((warning) => (
warning.requestMessageId === message.id ? (
<div
key={warning.warningId}
className="ml-1 max-w-[82%] rounded-2xl border border-[#FAD7A0] bg-[#FFF7E6] px-3 py-2 text-[12px] leading-5 text-[#8A4B00]"
>
<div className="font-semibold text-[#6B3A00]">{warning.title}</div>
<div className="mt-1">{warning.summary}</div>
<div className="mt-1 text-[#A56A1D]">
{warning.sessionId ? `Session ${warning.sessionId}` : `Task ${warning.taskId}`} ·{" "}
{formatTimestampLabel(warning.createdAt)}
</div>
</div>
) : null
))}
{dedupedWarnings.length > 1 ? (
<div className="ml-1 max-w-[82%] text-[11px] text-[#A56A1D]">
{dedupedWarnings.length}
</div>
) : null}
</div>
);
})}
{detail.conversationTasks.length ? (
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-3 text-[12px] leading-5 text-[#57606A]">
<div className="font-semibold text-[#111111]">线</div>
<div className="mt-1">
{detail.conversationTasks.length}
{conversationTaskStatusLabel(detail.conversationTasks[0].status)}
{detail.conversationTasks[0].sessionId ? ` · Session ${detail.conversationTasks[0].sessionId}` : ""}
</div>
</div>
) : null}
<div className="rounded-2xl bg-white px-4 py-3 text-[13px] leading-6 text-[#57606A]">
MVP
</div>

View File

@@ -0,0 +1,150 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { RealtimeRefresh } from "@/components/app-runtime";
import { AppShell, PageNav, StatusBar } from "@/components/app-ui";
import { GroupParticipantsRepairClient } from "@/components/group-participants-repair-client";
import { requirePageSession } from "@/lib/boss-auth";
import { isDispatchableThreadProject, readState } from "@/lib/boss-data";
import { getProjectDetailView } from "@/lib/boss-projections";
export const dynamic = "force-dynamic";
export default async function ParticipantsPage({
params,
searchParams,
}: {
params: Promise<{ projectId: string }>;
searchParams: Promise<{ repaired?: string | string[] | undefined }>;
}) {
const session = await requirePageSession();
const { projectId } = await params;
const repaired = (await searchParams).repaired;
const state = await readState();
const detail = getProjectDetailView(state, projectId, session.account);
if (!detail) notFound();
const participantsPayload = detail.participantsPayload;
const participants = participantsPayload?.participants ?? [];
const invalidParticipants = participants.filter((participant) => participant.status !== "active");
const availableThreads = state.projects
.filter((project) => project.id !== projectId && isDispatchableThreadProject(project))
.map((project) => ({
projectId: project.id,
threadDisplayName: project.threadMeta.threadDisplayName,
folderName: project.threadMeta.folderName,
}));
const canRepairGroupMembers = availableThreads.length >= 2;
const initialSelectedProjectIds = participants
.filter((participant) => participant.status === "active")
.map((participant) => participant.projectId);
return (
<AppShell bottomNav={false}>
<RealtimeRefresh projectId={projectId} events={["conversation.updated", "project.messages.updated"]} />
<StatusBar />
<PageNav title="成员状态" backHref={`/conversations/${projectId}`} />
<div className="space-y-3 px-[18px] pb-6">
{repaired === "1" ? (
<div className="rounded-2xl border border-[#D8E7DE] bg-[#F7FBF8] px-4 py-4 text-[13px] leading-6 text-[#23412E]">
<div className="text-[14px] font-semibold"></div>
<div className="mt-2">线</div>
</div>
) : null}
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
<div className="text-[18px] font-semibold text-[#111111]">{detail.project.name}</div>
<div className="mt-1 text-[13px] text-[#8C8C8C]">
{participantsPayload?.threadMeta?.folderName?.trim() || "群聊"} ·
</div>
{participantsPayload ? (
<div className="mt-3 text-[12px] leading-6 text-[#57606A]">
线 {participantsPayload.validParticipantCount} · {participantsPayload.invalidParticipantCount}
</div>
) : (
<div className="mt-3 text-[12px] leading-6 text-[#57606A]"></div>
)}
</div>
{participantsPayload?.repairRequired ? (
<div className="rounded-2xl border border-[#FAD7A0] bg-[#FFF7E6] px-4 py-4 text-[13px] leading-6 text-[#8A4B00]">
<div className="text-[14px] font-semibold text-[#6B3A00]"></div>
<div className="mt-2">
{participantsPayload.repairReason || "当前群聊里有失效线程,请先修复群成员。"}
</div>
</div>
) : null}
{participantsPayload?.repairRequired && canRepairGroupMembers ? (
<GroupParticipantsRepairClient
projectId={projectId}
availableThreads={availableThreads}
initialSelectedProjectIds={initialSelectedProjectIds}
/>
) : null}
{participantsPayload?.repairRequired && !canRepairGroupMembers ? (
<div className="rounded-2xl border border-dashed border-[#D8E7DE] bg-[#F7FBF8] px-4 py-4 text-[13px] leading-6 text-[#45604D]">
<div className="text-[14px] font-semibold text-[#23412E]"></div>
<div className="mt-2">线</div>
</div>
) : null}
{invalidParticipants.length ? (
<div className="rounded-2xl border border-[#FAD7A0] bg-[#FFFDF7] px-4 py-4">
<div className="text-[14px] font-semibold text-[#6B3A00]"></div>
<div className="mt-3 space-y-3">
{invalidParticipants.map((participant) => (
<div
key={`${participant.projectId}-${participant.threadId}`}
className="rounded-2xl border border-[#FBE3B0] bg-white px-3 py-3 text-[13px] leading-6 text-[#6B3A00]"
>
<div className="font-semibold text-[#3D2400]">{participant.threadDisplayName}</div>
<div className="mt-1">{participant.statusLabel ?? participant.status}</div>
<div className="mt-1 text-[12px] text-[#A56A1D]">
{participant.folderName}
{participant.canOpenProject ? " · 可打开项目" : " · 项目引用已丢失"}
</div>
{participant.canOpenProject ? (
<div className="mt-2">
<Link
href={`/conversations/${participant.projectId}`}
className="text-[12px] font-semibold text-[#07C160]"
>
线
</Link>
</div>
) : null}
</div>
))}
</div>
</div>
) : null}
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
<div className="text-[14px] font-semibold text-[#111111]"></div>
<div className="mt-3 space-y-3">
{participants.length ? (
participants.map((participant) => (
<div
key={`${participant.projectId}-${participant.threadId}`}
className="rounded-2xl bg-[#F7F8FA] px-3 py-3 text-[13px] leading-6 text-[#111111]"
>
<div className="font-semibold">{participant.threadDisplayName}</div>
<div className="mt-1 text-[#57606A]">{participant.folderName}</div>
<div className="mt-1 text-[12px] text-[#8C8C8C]">
{participant.statusLabel ?? participant.status}
{participant.isSourceProject ? " · 当前群聊来源线程" : ""}
</div>
</div>
))
) : (
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-3 text-[12px] leading-6 text-[#57606A]">
</div>
)}
</div>
</div>
</div>
</AppShell>
);
}

View File

@@ -4,7 +4,7 @@ import { AppShell, PageNav, StatusBar } from "@/components/app-ui";
import { requirePageSession } from "@/lib/boss-auth";
import { readState } from "@/lib/boss-data";
import { getProjectDetailView } from "@/lib/boss-projections";
import { formatTimestampLabel } from "@/lib/boss-projections";
import { formatTimestampLabel } from "@/lib/boss-projections-shared";
export const dynamic = "force-dynamic";

View File

@@ -4,6 +4,7 @@ import { MasterAgentPromptMemoryClient } from "@/components/master-agent-prompt-
import { requirePageSession } from "@/lib/boss-auth";
import { MASTER_AGENT_CHAT_PAGE_ANCHORS } from "@/lib/master-agent-chat-menu";
import { getClawBackendAvailability } from "@/lib/execution/backends/claw-config";
import { getHermesBackendAvailability } from "@/lib/execution/backends/hermes-config";
import {
getMasterAgentPromptPolicy,
getProjectAgentControls,
@@ -15,7 +16,7 @@ export const dynamic = "force-dynamic";
export default async function MasterAgentPromptMemoryPage() {
const session = await requirePageSession();
const [promptPolicy, userPrompt, projectControls, globalMemories, projectMemories, clawAvailability] =
const [promptPolicy, userPrompt, projectControls, globalMemories, projectMemories, clawAvailability, hermesAvailability] =
await Promise.all([
getMasterAgentPromptPolicy(),
getUserMasterPrompt(session.account),
@@ -23,6 +24,7 @@ export default async function MasterAgentPromptMemoryPage() {
listUserMasterMemories(session.account, { includeArchived: false, scope: "global" }),
listUserMasterMemories(session.account, { includeArchived: false, scope: "project" }),
getClawBackendAvailability(),
getHermesBackendAvailability(),
]);
return (
@@ -47,6 +49,7 @@ export default async function MasterAgentPromptMemoryPage() {
userPrompt={userPrompt}
projectControls={projectControls}
clawAvailability={clawAvailability}
hermesAvailability={hermesAvailability}
globalMemories={globalMemories}
projectMemories={projectMemories}
anchors={MASTER_AGENT_CHAT_PAGE_ANCHORS}

View File

@@ -3,7 +3,7 @@ import { AppShell, PageNav, StatusBar } from "@/components/app-ui";
import { MasterAgentTakeoverClient } from "@/components/master-agent-takeover-client";
import { requirePageSession } from "@/lib/boss-auth";
import { getProjectAgentControls } from "@/lib/boss-data";
import { formatTimestampLabel } from "@/lib/boss-projections";
import { formatTimestampLabel } from "@/lib/boss-projections-shared";
export const dynamic = "force-dynamic";

View File

@@ -16,7 +16,7 @@ import {
resolveAliyunQwenModelSelection,
resolveAliyunQwenModelValue,
} from "@/lib/ai-account-models";
import { formatTimestampLabel } from "@/lib/boss-projections";
import { formatTimestampLabel } from "@/lib/boss-projections-shared";
type AccountDraft = {
label: string;

View File

@@ -11,7 +11,7 @@ import {
planThrottledRefresh,
shouldRefreshRealtimeEvent,
} from "@/lib/realtime-refresh";
import type { SkillInventoryDeviceGroup } from "@/lib/boss-projections";
import type { SkillInventoryDeviceGroup } from "@/lib/boss-projections-shared";
import {
clearNativeSessionSnapshot,
currentAppLocation,

View File

@@ -48,8 +48,8 @@ import type {
UserProfile,
UserSettings,
} from "@/lib/boss-data";
import type { ConversationItem, DeviceWorkspaceView } from "@/lib/boss-projections";
import { formatTimestampLabel } from "@/lib/boss-projections";
import type { ConversationItem, DeviceWorkspaceView } from "@/lib/boss-projections-shared";
import { formatTimestampLabel } from "@/lib/boss-projections-shared";
function formatClock(value: string) {
return formatTimestampLabel(value);
@@ -914,7 +914,7 @@ export function ChatBubble({ message }: { message: Message }) {
);
}
export function ProjectHeaderActions({ projectId }: { projectId: string }) {
export function ProjectHeaderActions({ projectId, isGroup = false }: { projectId: string; isGroup?: boolean }) {
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Link
@@ -936,10 +936,10 @@ export function ProjectHeaderActions({ projectId }: { projectId: string }) {
</Link>
<Link
href={`/conversations/${projectId}/thread-status`}
href={isGroup ? `/conversations/${projectId}/participants` : `/conversations/${projectId}/thread-status`}
className="flex h-11 items-center justify-center rounded-2xl bg-white text-[14px] font-semibold text-[#111111]"
>
线
{isGroup ? "成员状态" : "线程状态"}
</Link>
</div>
);

View File

@@ -0,0 +1,115 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
export interface GroupParticipantsRepairTarget {
projectId: string;
threadDisplayName: string;
folderName: string;
}
export function GroupParticipantsRepairClient({
projectId,
availableThreads,
initialSelectedProjectIds,
}: {
projectId: string;
availableThreads: GroupParticipantsRepairTarget[];
initialSelectedProjectIds: string[];
}) {
const router = useRouter();
const [selectedProjectIds, setSelectedProjectIds] = useState(() => new Set(initialSelectedProjectIds));
const [message, setMessage] = useState("");
const [loading, setLoading] = useState(false);
const canSubmit = !loading && availableThreads.length >= 2 && selectedProjectIds.size >= 2;
function toggleProject(projectId: string) {
setSelectedProjectIds((current) => {
const next = new Set(current);
if (next.has(projectId)) {
next.delete(projectId);
} else {
next.add(projectId);
}
return next;
});
}
async function submitRepair() {
if (availableThreads.length < 2) {
setMessage("当前没有足够的真实线程可用于修复群成员。");
return;
}
const memberProjectIds = Array.from(selectedProjectIds);
if (memberProjectIds.length < 2) {
setMessage("至少选择 2 个真实线程后才能修复群成员。");
return;
}
setLoading(true);
setMessage("");
try {
const response = await fetch(`/api/v1/projects/${projectId}/participants`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ memberProjectIds }),
});
const result = (await response.json().catch(() => ({}))) as { ok?: boolean; message?: string };
if (!response.ok || !result.ok) {
setMessage(result.message ?? "修复群成员失败,请稍后重试。");
return;
}
setMessage("已更新群成员。");
router.replace(`/conversations/${projectId}/participants?repaired=1`);
router.refresh();
} catch (error) {
setMessage(error instanceof Error ? error.message : "网络异常,修复群成员失败。");
} finally {
setLoading(false);
}
}
return (
<div className="rounded-2xl border border-[#D8E7DE] bg-[#F7FBF8] px-4 py-4">
<div className="text-[14px] font-semibold text-[#23412E]">线</div>
<div className="mt-1 text-[12px] leading-5 text-[#45604D]">
2 线
</div>
<div className="mt-3 space-y-2">
{availableThreads.length ? (
availableThreads.map((thread) => (
<label
key={thread.projectId}
className="flex items-start gap-3 rounded-2xl bg-white px-3 py-3 text-[13px] leading-5 text-[#111111]"
>
<input
type="checkbox"
className="mt-1"
checked={selectedProjectIds.has(thread.projectId)}
onChange={() => toggleProject(thread.projectId)}
/>
<span>
<span className="block font-semibold">{thread.threadDisplayName}</span>
<span className="mt-1 block text-[12px] text-[#57606A]">{thread.folderName}</span>
</span>
</label>
))
) : (
<div className="rounded-2xl bg-white px-3 py-3 text-[12px] leading-6 text-[#57606A]">
线
</div>
)}
</div>
<button
type="button"
disabled={!canSubmit}
onClick={() => void submitRepair()}
className="mt-3 h-10 w-full rounded-full bg-[#07C160] text-[14px] font-semibold text-white disabled:opacity-60"
>
{loading ? "正在修复…" : "提交修复"}
</button>
{message ? <div className="mt-2 text-[12px] leading-5 text-[#45604D]">{message}</div> : null}
</div>
);
}

View File

@@ -12,7 +12,7 @@ import type {
UserMasterPrompt,
} from "@/lib/boss-data";
import type { MasterAgentChatPageAnchors } from "@/lib/master-agent-chat-menu";
import { formatTimestampLabel } from "@/lib/boss-projections";
import { formatTimestampLabel } from "@/lib/boss-projections-shared";
type MemoryDraft = {
scope: MasterMemoryScope;
@@ -31,6 +31,13 @@ type ClawAvailability = {
reasonLabel: string;
};
type HermesAvailability = {
status: "disabled" | "misconfigured" | "ready";
selectable: boolean;
reason: string;
reasonLabel: string;
};
const memoryScopeOptions: Array<{ value: MasterMemoryScope; label: string }> = [
{ value: "global", label: "通用记忆" },
{ value: "project", label: "项目记忆" },
@@ -153,6 +160,7 @@ export function MasterAgentPromptMemoryClient({
userPrompt,
projectControls,
clawAvailability,
hermesAvailability,
globalMemories,
projectMemories,
anchors,
@@ -162,6 +170,7 @@ export function MasterAgentPromptMemoryClient({
userPrompt: UserMasterPrompt | null;
projectControls: ProjectAgentControls | null;
clawAvailability: ClawAvailability;
hermesAvailability: HermesAvailability;
globalMemories: MasterAgentMemory[];
projectMemories: MasterAgentMemory[];
anchors: MasterAgentChatPageAnchors;
@@ -175,11 +184,25 @@ export function MasterAgentPromptMemoryClient({
const [reasoningEffortOverride, setReasoningEffortOverride] = useState(
projectControls?.reasoningEffortOverride ?? "",
);
const [fastModelOverride, setFastModelOverride] = useState(projectControls?.fastModelOverride ?? "");
const [fastReasoningEffortOverride, setFastReasoningEffortOverride] = useState(
projectControls?.fastReasoningEffortOverride ?? "",
);
const [smartModelOverride, setSmartModelOverride] = useState(projectControls?.smartModelOverride ?? "");
const [smartReasoningEffortOverride, setSmartReasoningEffortOverride] = useState(
projectControls?.smartReasoningEffortOverride ?? "",
);
const [promptOverride, setPromptOverride] = useState(projectControls?.promptOverride ?? "");
const storedClawOverrideUnavailable =
projectControls?.backendOverride === "claw-runtime" && !clawAvailability.selectable;
const storedHermesOverrideUnavailable =
projectControls?.backendOverride === "hermes-runtime" && !hermesAvailability.selectable;
const [backendOverride, setBackendOverride] = useState(
projectControls?.backendOverride === "claw-runtime" && clawAvailability.selectable ? "claw-runtime" : "",
projectControls?.backendOverride === "claw-runtime" && clawAvailability.selectable
? "claw-runtime"
: projectControls?.backendOverride === "hermes-runtime" && hermesAvailability.selectable
? "hermes-runtime"
: "",
);
const [newMemory, setNewMemory] = useState<MemoryDraft>(makeNewMemoryDraft());
const [memoryDrafts, setMemoryDrafts] = useState<Record<string, MemoryDraft>>(() => {
@@ -200,10 +223,19 @@ export function MasterAgentPromptMemoryClient({
? `【执行后端】\n${backendOverride.trim()}`
: storedClawOverrideUnavailable
? "【执行后端】\n默认Claw Runtime 当前不可用,运行时会自动回退)"
: storedHermesOverrideUnavailable
? "【执行后端】\n默认Hermes Runtime 当前不可用,运行时会自动回退)"
: null,
].filter(Boolean);
return sections.length > 0 ? sections.join("\n\n") : "当前还没有组合后的提示词内容。";
}, [backendOverride, globalPrompt, promptOverride, storedClawOverrideUnavailable, userPromptContent]);
}, [
backendOverride,
globalPrompt,
promptOverride,
storedClawOverrideUnavailable,
storedHermesOverrideUnavailable,
userPromptContent,
]);
function updateMemoryDraft(memoryId: string, updater: (draft: MemoryDraft) => MemoryDraft) {
setMemoryDrafts((current) => ({
@@ -264,6 +296,10 @@ export function MasterAgentPromptMemoryClient({
body: JSON.stringify({
modelOverride: modelOverride.trim() || null,
reasoningEffortOverride: reasoningEffortOverride.trim() || null,
fastModelOverride: fastModelOverride.trim() || null,
fastReasoningEffortOverride: fastReasoningEffortOverride.trim() || null,
smartModelOverride: smartModelOverride.trim() || null,
smartReasoningEffortOverride: smartReasoningEffortOverride.trim() || null,
promptOverride: promptOverride.trim() || null,
backendOverride: backendOverride.trim() || null,
}),
@@ -432,6 +468,7 @@ export function MasterAgentPromptMemoryClient({
>
<option value=""></option>
<option value="gpt-5.4">gpt-5.4</option>
<option value="gpt-5.4-mini">gpt-5.4-mini</option>
<option value="gpt-4.1">gpt-4.1</option>
<option value="gpt-4.1-mini">gpt-4.1-mini</option>
</select>
@@ -458,6 +495,63 @@ export function MasterAgentPromptMemoryClient({
>
<option value=""></option>
{clawAvailability.selectable ? <option value="claw-runtime">Claw Runtime</option> : null}
{hermesAvailability.selectable ? <option value="hermes-runtime">Hermes Runtime</option> : null}
</select>
</label>
</div>
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<div className="text-[12px] text-[#8C8C8C]"></div>
<select
value={fastModelOverride}
onChange={(event) => setFastModelOverride(event.target.value)}
className="w-full rounded-xl border border-[#E5E5EA] bg-[#F7F8FA] px-3 py-2 text-[13px] text-[#111111] outline-none"
>
<option value=""></option>
<option value="gpt-5.4-mini">gpt-5.4-mini</option>
<option value="gpt-4.1-mini">gpt-4.1-mini</option>
<option value="gpt-4.1">gpt-4.1</option>
<option value="gpt-5.4">gpt-5.4</option>
</select>
</label>
<label className="space-y-1">
<div className="text-[12px] text-[#8C8C8C]"></div>
<select
value={fastReasoningEffortOverride}
onChange={(event) => setFastReasoningEffortOverride(event.target.value)}
className="w-full rounded-xl border border-[#E5E5EA] bg-[#F7F8FA] px-3 py-2 text-[13px] text-[#111111] outline-none"
>
<option value=""></option>
<option value="low">low</option>
<option value="medium">medium</option>
<option value="high">high</option>
</select>
</label>
<label className="space-y-1">
<div className="text-[12px] text-[#8C8C8C]"></div>
<select
value={smartModelOverride}
onChange={(event) => setSmartModelOverride(event.target.value)}
className="w-full rounded-xl border border-[#E5E5EA] bg-[#F7F8FA] px-3 py-2 text-[13px] text-[#111111] outline-none"
>
<option value=""></option>
<option value="gpt-5.4">gpt-5.4</option>
<option value="gpt-4.1">gpt-4.1</option>
<option value="gpt-5.4-mini">gpt-5.4-mini</option>
<option value="gpt-4.1-mini">gpt-4.1-mini</option>
</select>
</label>
<label className="space-y-1">
<div className="text-[12px] text-[#8C8C8C]"></div>
<select
value={smartReasoningEffortOverride}
onChange={(event) => setSmartReasoningEffortOverride(event.target.value)}
className="w-full rounded-xl border border-[#E5E5EA] bg-[#F7F8FA] px-3 py-2 text-[13px] text-[#111111] outline-none"
>
<option value=""></option>
<option value="low">low</option>
<option value="medium">medium</option>
<option value="high">high</option>
</select>
</label>
</div>
@@ -472,6 +566,17 @@ export function MasterAgentPromptMemoryClient({
) : null}
</div>
) : null}
{!hermesAvailability.selectable ? (
<div className="rounded-2xl border border-[#F4C7C3] bg-[#FFF7F6] px-4 py-3 text-[12px] leading-6 text-[#B54708]">
<div className="font-semibold text-[#912018]">Hermes Runtime </div>
<div>{hermesAvailability.reasonLabel}</div>
{storedHermesOverrideUnavailable ? (
<div className="mt-1 text-[#912018]">
Hermes Runtime退
</div>
) : null}
</div>
) : null}
<TextArea
label="当前对话附加提示词"
value={promptOverride}

View File

@@ -380,6 +380,35 @@ export interface DispatchExecution {
completedByDeviceId?: string;
}
export interface DispatchPlanWithExecutions extends DispatchPlan {
executions: DispatchExecution[];
}
export interface ConversationParticipant {
projectId: string;
deviceId: string;
threadId: string;
threadDisplayName: string;
folderName: string;
avatar: string;
isSourceProject: boolean;
status: "active" | "missing_project" | "invalid_target";
statusLabel?: string;
canOpenProject: boolean;
}
export interface ProjectParticipantsPayload {
ok: true;
projectId: string;
isGroup: boolean;
threadMeta: Project["threadMeta"];
participants: ConversationParticipant[];
repairRequired: boolean;
repairReason?: string;
validParticipantCount: number;
invalidParticipantCount: number;
}
export function buildCollaborationGate(
project?: Pick<Project, "isGroup" | "collaborationMode" | "approvalState" | "lightDispatchReminderEnabled">,
) {
@@ -405,8 +434,12 @@ export function buildCollaborationGate(
export interface ProjectAgentControls {
modelOverride?: string;
reasoningEffortOverride?: ReasoningEffort;
fastModelOverride?: string;
fastReasoningEffortOverride?: ReasoningEffort;
smartModelOverride?: string;
smartReasoningEffortOverride?: ReasoningEffort;
promptOverride?: string;
backendOverride?: "claw-runtime";
backendOverride?: "claw-runtime" | "hermes-runtime";
takeoverEnabled?: boolean;
globalTakeoverEnabled?: boolean;
effectiveTakeoverEnabled?: boolean;
@@ -596,6 +629,20 @@ export interface ThreadProgressEvent {
sourceMessageId?: string;
}
export interface ThreadExecutionWarning {
warningId: string;
taskId: string;
requestMessageId: string;
projectId: string;
targetProjectId?: string;
targetThreadId?: string;
sessionId?: string;
requestId?: string;
title: string;
summary: string;
createdAt: string;
}
export interface VerificationCode {
id: string;
account: string;
@@ -740,6 +787,8 @@ export interface MasterAgentTask {
requestMessageId: string;
requestText: string;
executionPrompt: string;
executionModel?: string;
executionReasoningEffort?: ReasoningEffort;
requestedBy: string;
requestedByAccount: string;
deviceId: string;
@@ -771,6 +820,7 @@ export interface MasterAgentTask {
replyBody?: string;
errorMessage?: string;
requestId?: string;
sessionId?: string;
}
export interface OtaUpdate {
@@ -1019,6 +1069,7 @@ export interface BossState {
deviceImportResolutions: DeviceImportResolution[];
threadStatusDocuments: ThreadStatusDocument[];
threadProgressEvents: ThreadProgressEvent[];
threadExecutionWarnings: ThreadExecutionWarning[];
otaUpdates: OtaUpdate[];
otaUpdateLogs: OtaUpdateLog[];
deviceSkills: DeviceSkill[];
@@ -1663,6 +1714,7 @@ const initialState: BossState = {
projectExecutionPolicies: [],
threadStatusDocuments: [],
threadProgressEvents: [],
threadExecutionWarnings: [],
};
const levelPriority: Record<ContextBudgetLevel, number> = {
@@ -1876,14 +1928,19 @@ function parseReasoningEffortOverride(value: unknown) {
return { kind: "set" as const, value };
}
function parseBackendOverride(value: unknown) {
function parseBackendOverride(
value: unknown,
):
| { kind: "clear" }
| { kind: "invalid" }
| { kind: "set"; value: NonNullable<ProjectAgentControls["backendOverride"]> } {
if (value === undefined || value === null) {
return { kind: "clear" as const };
}
if (value !== "claw-runtime") {
return { kind: "invalid" as const };
if (value === "claw-runtime" || value === "hermes-runtime") {
return { kind: "set" as const, value };
}
return { kind: "set" as const, value: "claw-runtime" as const };
return { kind: "invalid" as const };
}
function parseBooleanControlOverride(value: unknown) {
@@ -2352,8 +2409,19 @@ function normalizeProjectAgentControls(
const reasoningEffortOverride = isReasoningEffort(raw?.reasoningEffortOverride)
? raw.reasoningEffortOverride
: undefined;
const fastModelOverride = trimToDefined(raw?.fastModelOverride);
const fastReasoningEffortOverride = isReasoningEffort(raw?.fastReasoningEffortOverride)
? raw.fastReasoningEffortOverride
: undefined;
const smartModelOverride = trimToDefined(raw?.smartModelOverride);
const smartReasoningEffortOverride = isReasoningEffort(raw?.smartReasoningEffortOverride)
? raw.smartReasoningEffortOverride
: undefined;
const promptOverride = trimToDefined(raw?.promptOverride);
const backendOverride = raw?.backendOverride === "claw-runtime" ? raw.backendOverride : undefined;
const backendOverride =
raw?.backendOverride === "claw-runtime" || raw?.backendOverride === "hermes-runtime"
? raw.backendOverride
: undefined;
const takeoverEnabled = typeof raw?.takeoverEnabled === "boolean" ? raw.takeoverEnabled : undefined;
const globalTakeoverEnabled =
typeof raw?.globalTakeoverEnabled === "boolean" ? raw.globalTakeoverEnabled : undefined;
@@ -2361,6 +2429,10 @@ function normalizeProjectAgentControls(
if (
!modelOverride &&
!reasoningEffortOverride &&
!fastModelOverride &&
!fastReasoningEffortOverride &&
!smartModelOverride &&
!smartReasoningEffortOverride &&
!promptOverride &&
!backendOverride &&
takeoverEnabled === undefined &&
@@ -2372,6 +2444,10 @@ function normalizeProjectAgentControls(
return {
modelOverride,
reasoningEffortOverride,
fastModelOverride,
fastReasoningEffortOverride,
smartModelOverride,
smartReasoningEffortOverride,
promptOverride,
backendOverride,
takeoverEnabled,
@@ -3060,6 +3136,43 @@ function normalizeThreadProgressEvent(
};
}
function normalizeThreadExecutionWarning(
raw: Partial<ThreadExecutionWarning>,
fallback?: ThreadExecutionWarning,
): ThreadExecutionWarning {
return {
warningId: raw.warningId ?? fallback?.warningId ?? randomToken("thread-warning"),
taskId: trimToDefined(raw.taskId ?? fallback?.taskId) ?? "",
requestMessageId: trimToDefined(raw.requestMessageId ?? fallback?.requestMessageId) ?? "",
projectId: trimToDefined(raw.projectId ?? fallback?.projectId) ?? "",
targetProjectId: trimToDefined(raw.targetProjectId ?? fallback?.targetProjectId),
targetThreadId: trimToDefined(raw.targetThreadId ?? fallback?.targetThreadId),
sessionId: trimToDefined(raw.sessionId ?? fallback?.sessionId),
requestId: trimToDefined(raw.requestId ?? fallback?.requestId),
title: trimToDefined(raw.title ?? fallback?.title) ?? "线程执行告警",
summary: trimToDefined(raw.summary ?? fallback?.summary) ?? "",
createdAt: raw.createdAt ?? fallback?.createdAt ?? nowIso(),
};
}
function compareThreadExecutionWarnings(a: ThreadExecutionWarning, b: ThreadExecutionWarning) {
const createdDelta = messageTimeValue(b.createdAt) - messageTimeValue(a.createdAt);
if (createdDelta !== 0) return createdDelta;
return b.warningId.localeCompare(a.warningId);
}
function appendThreadExecutionWarningInState(
state: BossState,
input: Omit<ThreadExecutionWarning, "warningId">,
) {
const warning = normalizeThreadExecutionWarning({
warningId: randomToken("thread-warning"),
...input,
});
state.threadExecutionWarnings.unshift(warning);
return warning;
}
function buildHeartbeatProgressSummary(threadDisplayName: string) {
return `检测到线程有新活动:${threadDisplayName}`;
}
@@ -3237,6 +3350,16 @@ function normalizeState(raw: Partial<BossState> | undefined): BossState {
requestMessageId: task.requestMessageId ?? "",
requestText: task.requestText ?? "",
executionPrompt: task.executionPrompt ?? task.requestText ?? "",
executionModel:
typeof task.executionModel === "string" && task.executionModel.trim()
? task.executionModel.trim()
: undefined,
executionReasoningEffort:
task.executionReasoningEffort === "low" ||
task.executionReasoningEffort === "medium" ||
task.executionReasoningEffort === "high"
? task.executionReasoningEffort
: undefined,
requestedBy: task.requestedBy ?? "用户",
requestedByAccount: task.requestedByAccount ?? "",
deviceId: task.deviceId ?? PRIMARY_CODEX_NODE_ID,
@@ -3272,6 +3395,7 @@ function normalizeState(raw: Partial<BossState> | undefined): BossState {
replyBody: task.replyBody,
errorMessage: task.errorMessage,
requestId: task.requestId,
sessionId: task.sessionId,
})),
dispatchPlans: ensureArray(raw.dispatchPlans, base.dispatchPlans).map((plan, index) =>
normalizeDispatchPlan(plan, base.dispatchPlans[index % Math.max(1, base.dispatchPlans.length)]),
@@ -3315,6 +3439,15 @@ function normalizeState(raw: Partial<BossState> | undefined): BossState {
base.threadProgressEvents[index % Math.max(1, base.threadProgressEvents.length)],
),
),
threadExecutionWarnings: ensureArray(
raw.threadExecutionWarnings as Partial<ThreadExecutionWarning>[] | undefined,
base.threadExecutionWarnings,
).map((warning, index) =>
normalizeThreadExecutionWarning(
warning,
base.threadExecutionWarnings[index % Math.max(1, base.threadExecutionWarnings.length)],
),
),
otaUpdates: ensureArray(raw.otaUpdates, base.otaUpdates).map((update, index) => ({
...base.otaUpdates[index % base.otaUpdates.length],
...update,
@@ -3455,9 +3588,6 @@ function removeLegacyBossConsoleArtifacts(state: BossState) {
...device,
projects: device.projects.filter((project) => !isLegacyBossConsoleRef(project)),
}));
state.masterAgentMemories = state.masterAgentMemories.filter(
(memory) => !isLegacyBossConsoleRef(memory.projectId),
);
state.userProjectAgentControls = state.userProjectAgentControls.filter(
(item) => !isLegacyBossConsoleRef(item.projectId),
);
@@ -3968,6 +4098,11 @@ function syncDerivedState(input: BossState) {
return true;
})
.slice(0, 400);
state.threadExecutionWarnings = state.threadExecutionWarnings
.map((warning) => normalizeThreadExecutionWarning(warning))
.filter((warning) => visibleProjectIds.has(warning.projectId))
.sort(compareThreadExecutionWarnings)
.slice(0, 400);
state.deviceSkills = state.deviceSkills
.filter((skill) => visibleDeviceIds.has(skill.deviceId))
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
@@ -4260,6 +4395,10 @@ export async function updateProjectAgentControls(
payload: {
modelOverride?: unknown;
reasoningEffortOverride?: unknown;
fastModelOverride?: unknown;
fastReasoningEffortOverride?: unknown;
smartModelOverride?: unknown;
smartReasoningEffortOverride?: unknown;
promptOverride?: unknown;
backendOverride?: unknown;
takeoverEnabled?: unknown;
@@ -4281,6 +4420,18 @@ export async function updateProjectAgentControls(
const promptOverrideInput = Object.prototype.hasOwnProperty.call(payload, "promptOverride")
? parseControlTextOverride(payload.promptOverride)
: { kind: "preserve" as const };
const fastModelOverrideInput = Object.prototype.hasOwnProperty.call(payload, "fastModelOverride")
? parseControlTextOverride(payload.fastModelOverride)
: { kind: "preserve" as const };
const fastReasoningEffortInput = Object.prototype.hasOwnProperty.call(payload, "fastReasoningEffortOverride")
? parseReasoningEffortOverride(payload.fastReasoningEffortOverride)
: { kind: "preserve" as const };
const smartModelOverrideInput = Object.prototype.hasOwnProperty.call(payload, "smartModelOverride")
? parseControlTextOverride(payload.smartModelOverride)
: { kind: "preserve" as const };
const smartReasoningEffortInput = Object.prototype.hasOwnProperty.call(payload, "smartReasoningEffortOverride")
? parseReasoningEffortOverride(payload.smartReasoningEffortOverride)
: { kind: "preserve" as const };
const backendOverrideInput = Object.prototype.hasOwnProperty.call(payload, "backendOverride")
? parseBackendOverride(payload.backendOverride)
: { kind: "preserve" as const };
@@ -4299,6 +4450,18 @@ export async function updateProjectAgentControls(
if (promptOverrideInput.kind === "invalid") {
throw new Error("INVALID_PROMPT_OVERRIDE");
}
if (fastModelOverrideInput.kind === "invalid") {
throw new Error("INVALID_FAST_MODEL_OVERRIDE");
}
if (fastReasoningEffortInput.kind === "invalid") {
throw new Error("INVALID_FAST_REASONING_EFFORT_OVERRIDE");
}
if (smartModelOverrideInput.kind === "invalid") {
throw new Error("INVALID_SMART_MODEL_OVERRIDE");
}
if (smartReasoningEffortInput.kind === "invalid") {
throw new Error("INVALID_SMART_REASONING_EFFORT_OVERRIDE");
}
if (backendOverrideInput.kind === "invalid") {
throw new Error("INVALID_BACKEND_OVERRIDE");
}
@@ -4308,16 +4471,34 @@ export async function updateProjectAgentControls(
if (globalTakeoverEnabledInput.kind === "invalid") {
throw new Error("INVALID_GLOBAL_TAKEOVER_ENABLED");
}
const persistedState = await readState();
const persistedProject = persistedState.projects.find((item) => item.id === projectId);
const allowsThreadBackendOverride =
persistedProject?.id !== undefined && persistedProject.id !== "master-agent" && !persistedProject.isGroup;
if (projectId !== "master-agent") {
if (
modelOverrideInput.kind !== "preserve" ||
reasoningEffortInput.kind !== "preserve" ||
fastModelOverrideInput.kind !== "preserve" ||
fastReasoningEffortInput.kind !== "preserve" ||
smartModelOverrideInput.kind !== "preserve" ||
smartReasoningEffortInput.kind !== "preserve" ||
promptOverrideInput.kind !== "preserve" ||
backendOverrideInput.kind !== "preserve" ||
globalTakeoverEnabledInput.kind !== "preserve"
) {
throw new Error("PROJECT_AGENT_CONTROLS_SCOPE_RESTRICTED");
}
if (!allowsThreadBackendOverride && backendOverrideInput.kind !== "preserve") {
throw new Error("PROJECT_AGENT_CONTROLS_SCOPE_RESTRICTED");
}
if (
allowsThreadBackendOverride &&
backendOverrideInput.kind === "set" &&
backendOverrideInput.value !== "hermes-runtime"
) {
throw new Error("PROJECT_AGENT_CONTROLS_SCOPE_RESTRICTED");
}
} else if (takeoverEnabledInput.kind !== "preserve") {
throw new Error("MASTER_AGENT_TAKEOVER_SCOPE_RESTRICTED");
}
@@ -4347,6 +4528,30 @@ export async function updateProjectAgentControls(
: promptOverrideInput.kind === "clear"
? undefined
: currentControls?.promptOverride;
const fastModelOverride =
fastModelOverrideInput.kind === "set"
? fastModelOverrideInput.value
: fastModelOverrideInput.kind === "clear"
? undefined
: currentControls?.fastModelOverride;
const fastReasoningEffortOverride =
fastReasoningEffortInput.kind === "set"
? fastReasoningEffortInput.value
: fastReasoningEffortInput.kind === "clear"
? undefined
: currentControls?.fastReasoningEffortOverride;
const smartModelOverride =
smartModelOverrideInput.kind === "set"
? smartModelOverrideInput.value
: smartModelOverrideInput.kind === "clear"
? undefined
: currentControls?.smartModelOverride;
const smartReasoningEffortOverride =
smartReasoningEffortInput.kind === "set"
? smartReasoningEffortInput.value
: smartReasoningEffortInput.kind === "clear"
? undefined
: currentControls?.smartReasoningEffortOverride;
const backendOverride =
backendOverrideInput.kind === "set"
? backendOverrideInput.value
@@ -4369,12 +4574,20 @@ export async function updateProjectAgentControls(
const currentModelOverride = currentControls?.modelOverride;
const currentReasoningEffortOverride = currentControls?.reasoningEffortOverride;
const currentPromptOverride = currentControls?.promptOverride;
const currentFastModelOverride = currentControls?.fastModelOverride;
const currentFastReasoningEffortOverride = currentControls?.fastReasoningEffortOverride;
const currentSmartModelOverride = currentControls?.smartModelOverride;
const currentSmartReasoningEffortOverride = currentControls?.smartReasoningEffortOverride;
const currentBackendOverride = currentControls?.backendOverride;
const currentTakeoverEnabled = currentControls?.takeoverEnabled;
const currentGlobalTakeoverEnabled = currentControls?.globalTakeoverEnabled;
if (
currentModelOverride === modelOverride &&
currentReasoningEffortOverride === reasoningEffortOverride &&
currentFastModelOverride === fastModelOverride &&
currentFastReasoningEffortOverride === fastReasoningEffortOverride &&
currentSmartModelOverride === smartModelOverride &&
currentSmartReasoningEffortOverride === smartReasoningEffortOverride &&
currentPromptOverride === promptOverride &&
currentBackendOverride === backendOverride &&
currentTakeoverEnabled === takeoverEnabled &&
@@ -4394,6 +4607,10 @@ export async function updateProjectAgentControls(
const nextControls = {
modelOverride,
reasoningEffortOverride,
fastModelOverride,
fastReasoningEffortOverride,
smartModelOverride,
smartReasoningEffortOverride,
promptOverride,
backendOverride,
takeoverEnabled,
@@ -5710,6 +5927,8 @@ export async function queueMasterAgentTask(payload: {
requestMessageId: string;
requestText: string;
executionPrompt: string;
executionModel?: string;
executionReasoningEffort?: ReasoningEffort;
requestedBy: string;
requestedByAccount: string;
deviceId: string;
@@ -5734,6 +5953,7 @@ export async function queueMasterAgentTask(payload: {
deviceImportCandidateFolderName?: string;
projectUnderstandingTargetProjectId?: string;
projectUnderstandingReason?: "heartbeat_activity" | "thread_reply";
sessionId?: string;
}) {
const task = await mutateState((state) => {
const task: MasterAgentTask = {
@@ -5743,6 +5963,8 @@ export async function queueMasterAgentTask(payload: {
requestMessageId: payload.requestMessageId,
requestText: payload.requestText,
executionPrompt: payload.executionPrompt,
executionModel: payload.executionModel?.trim() || undefined,
executionReasoningEffort: payload.executionReasoningEffort,
requestedBy: payload.requestedBy,
requestedByAccount: payload.requestedByAccount,
deviceId: payload.deviceId,
@@ -5767,6 +5989,7 @@ export async function queueMasterAgentTask(payload: {
deviceImportCandidateFolderName: payload.deviceImportCandidateFolderName,
projectUnderstandingTargetProjectId: payload.projectUnderstandingTargetProjectId,
projectUnderstandingReason: payload.projectUnderstandingReason,
sessionId: payload.sessionId,
status: "queued",
requestedAt: nowIso(),
};
@@ -5886,12 +6109,111 @@ function upsertDispatchPlanInState(
return plan;
}
export async function listDispatchPlansByProject(groupProjectId: string) {
const state = await readState();
export function buildDispatchPlansByProject(state: BossState, groupProjectId: string): DispatchPlanWithExecutions[] {
const normalizedGroupProjectId = groupProjectId.trim();
return state.dispatchPlans
.filter((plan) => plan.groupProjectId === normalizedGroupProjectId)
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.map((plan) => ({
...plan,
executions: state.dispatchExecutions
.filter((execution) => execution.planId === plan.planId)
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.map((execution) => ({ ...execution })),
}));
}
function buildParticipantAvatar(label: string, avatar?: string) {
const normalizedAvatar = avatar?.trim();
if (normalizedAvatar) {
return normalizedAvatar;
}
const trimmed = label.trim();
if (!trimmed) return "A";
return trimmed.slice(0, 1).toUpperCase();
}
export function buildProjectParticipantsPayload(
state: BossState,
projectId: string,
): ProjectParticipantsPayload | null {
const project = state.projects.find((item) => item.id === projectId);
if (!project) {
return null;
}
const participants: ConversationParticipant[] = project.isGroup
? project.groupMembers.map((member) => {
const candidateProject = state.projects.find((item) => item.id === member.projectId);
const device = state.devices.find((item) => item.id === member.deviceId);
const status: ConversationParticipant["status"] = !candidateProject
? "missing_project"
: isDispatchableThreadProject(candidateProject)
? "active"
: "invalid_target";
return {
projectId: member.projectId,
deviceId: member.deviceId,
threadId: member.threadId,
threadDisplayName: member.threadDisplayName,
folderName: member.folderName,
avatar: buildParticipantAvatar(member.threadDisplayName, device?.avatar),
isSourceProject: member.projectId === project.id,
status,
statusLabel:
status === "missing_project"
? "引用已失效"
: status === "invalid_target"
? "不是可下发线程"
: undefined,
canOpenProject: Boolean(candidateProject),
};
})
: [
{
projectId: project.id,
deviceId: project.deviceIds[0] ?? project.id,
threadId: project.threadMeta.threadId,
threadDisplayName: project.threadMeta.threadDisplayName,
folderName: project.threadMeta.folderName,
avatar: buildParticipantAvatar(
project.threadMeta.threadDisplayName,
state.devices.find((item) => item.id === project.deviceIds[0])?.avatar,
),
isSourceProject: true,
status: "active",
canOpenProject: true,
},
];
const validParticipantCount = participants.filter((item) => item.status === "active").length;
const invalidParticipantCount = participants.length - validParticipantCount;
const repairRequired =
project.isGroup && (invalidParticipantCount > 0 || validParticipantCount < 2);
const repairReason = !repairRequired
? undefined
: validParticipantCount === 0
? "当前群聊里还没有可下发的真实线程,请重新添加线程。"
: invalidParticipantCount > 0
? "当前群聊里有失效或不可下发的线程引用,请重新整理群成员。"
: "当前群聊至少需要 2 个真实线程成员。";
return {
ok: true,
projectId: project.id,
isGroup: project.isGroup,
threadMeta: project.threadMeta,
participants,
repairRequired,
repairReason,
validParticipantCount,
invalidParticipantCount,
};
}
export async function listDispatchPlansByProject(groupProjectId: string) {
const state = await readState();
return buildDispatchPlansByProject(state, groupProjectId);
}
function canOwnDispatchPlans(project: Project) {
@@ -6677,6 +6999,11 @@ export async function completeMasterAgentTask(payload: {
replyBody?: string;
errorMessage?: string;
requestId?: string;
sessionId?: string;
warnings?: Array<{
title: string;
summary: string;
}>;
dispatchExecutionId?: string;
targetProjectId?: string;
targetThreadId?: string;
@@ -6703,6 +7030,13 @@ export async function completeMasterAgentTask(payload: {
task.replyBody = payload.replyBody?.trim() || undefined;
task.errorMessage = payload.errorMessage?.trim() || undefined;
task.requestId = payload.requestId;
task.sessionId = payload.sessionId?.trim() || task.sessionId;
const normalizedWarnings = (payload.warnings ?? [])
.map((warning) => ({
title: warning.title.trim(),
summary: warning.summary.trim(),
}))
.filter((warning) => warning.title && warning.summary);
const linkedAccount = task.accountId
? state.aiAccounts.find((item) => item.accountId === task.accountId)
: undefined;
@@ -6948,6 +7282,23 @@ export async function completeMasterAgentTask(payload: {
}
}
if (normalizedWarnings.length > 0) {
for (const warning of normalizedWarnings) {
appendThreadExecutionWarningInState(state, {
taskId: task.taskId,
requestMessageId: task.requestMessageId,
projectId: task.projectId,
targetProjectId: payload.targetProjectId?.trim() || task.targetProjectId,
targetThreadId: payload.targetThreadId?.trim() || task.targetThreadId,
sessionId: payload.sessionId?.trim() || task.sessionId,
requestId: payload.requestId?.trim() || task.requestId,
title: warning.title,
summary: warning.summary,
createdAt: task.completedAt,
});
}
}
return {
...task,
dispatchPlan: createdDispatchPlan ? { ...createdDispatchPlan } : undefined,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,219 @@
import type {
BossState,
ConversationParticipant,
ContextBudgetLevel,
Device,
DeviceEnrollment,
DeviceImportDraft,
DeviceImportResolution,
DeviceSkill,
DispatchPlanWithExecutions,
Project,
ProjectExecutionPolicy,
ProjectParticipantsPayload,
RiskLevel,
ThreadContextSnapshot,
} from "@/lib/boss-data";
export interface ContextIndicator {
visible: boolean;
style: "ring_percent";
percent?: number;
level?: ContextBudgetLevel;
}
export interface ConversationItem {
conversationId: string;
conversationType: "master_agent" | "single_device" | "group" | "folder_archive";
projectId: string;
projectTitle: string;
threadTitle: string;
folderLabel: string;
folderKey?: string;
threadCount?: number;
searchAliases?: string[];
searchTargetProjectIds?: string[];
preview: string;
lastMessagePreview: string;
activityIconCount: number;
topPinnedLabel?: "置顶";
manualPinned: boolean;
latestReplyAt: string;
latestReplyLabel: string;
unreadCount: number;
riskLevel: RiskLevel;
activeDeviceCount: number;
deviceNamesPreview: string[];
avatar: {
primary: string;
secondary?: string;
overflowCount?: number;
};
groupMembers?: Array<{
threadId: string;
avatar: string;
title: string;
}>;
contextBudgetIndicator: ContextIndicator;
contextBudgetSourceNodeId?: string;
contextBudgetUpdatedAt?: string;
mustFinishBeforeCompaction: boolean;
}
export interface DeviceWorkspaceView {
selectedDevice?: Device;
relatedThreads: ThreadContextSnapshot[];
activeEnrollment?: DeviceEnrollment;
importDraft?: DeviceImportDraft;
importResolution?: DeviceImportResolution;
projectExecutionPolicies?: ProjectExecutionPolicy[];
}
export interface SkillInventoryDeviceGroup {
device: Device;
skills: DeviceSkill[];
}
const shanghaiFormatter = new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
const shanghaiDayFormatter = new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
export function formatTimestampLabel(value?: string, fallback = "刚刚") {
if (!value) return fallback;
if (!value.includes("T")) return value;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const diff = Date.now() - date.getTime();
if (Math.abs(diff) < 60_000) return "刚刚";
if (diff >= 0 && diff < 24 * 60 * 60_000) {
return shanghaiFormatter.format(date);
}
return shanghaiDayFormatter.format(date);
}
function isDispatchableThreadProject(project: Project) {
return (
project.id !== "master-agent" &&
!project.isGroup &&
Boolean(project.threadMeta.codexThreadRef?.trim()) &&
project.deviceIds.length > 0
);
}
function buildParticipantAvatar(label: string, avatar?: string) {
const normalizedAvatar = avatar?.trim();
if (normalizedAvatar) {
return normalizedAvatar;
}
const trimmed = label.trim();
if (!trimmed) return "A";
return trimmed.slice(0, 1).toUpperCase();
}
export function buildDispatchPlansByProject(
state: BossState,
groupProjectId: string,
): DispatchPlanWithExecutions[] {
const normalizedGroupProjectId = groupProjectId.trim();
return state.dispatchPlans
.filter((plan) => plan.groupProjectId === normalizedGroupProjectId)
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.map((plan) => ({
...plan,
executions: state.dispatchExecutions
.filter((execution) => execution.planId === plan.planId)
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.map((execution) => ({ ...execution })),
}));
}
export function buildProjectParticipantsPayload(
state: BossState,
projectId: string,
): ProjectParticipantsPayload | null {
const project = state.projects.find((item) => item.id === projectId);
if (!project) {
return null;
}
const participants = project.isGroup
? project.groupMembers.map((member) => {
const candidateProject = state.projects.find((item) => item.id === member.projectId);
const device = state.devices.find((item) => item.id === member.deviceId);
const status: ConversationParticipant["status"] = !candidateProject
? "missing_project"
: isDispatchableThreadProject(candidateProject)
? "active"
: "invalid_target";
return {
projectId: member.projectId,
deviceId: member.deviceId,
threadId: member.threadId,
threadDisplayName: member.threadDisplayName,
folderName: member.folderName,
avatar: buildParticipantAvatar(member.threadDisplayName, device?.avatar),
isSourceProject: member.projectId === project.id,
status,
statusLabel:
status === "missing_project"
? "引用已失效"
: status === "invalid_target"
? "不是可下发线程"
: undefined,
canOpenProject: Boolean(candidateProject),
};
})
: [
{
projectId: project.id,
deviceId: project.deviceIds[0] ?? project.id,
threadId: project.threadMeta.threadId,
threadDisplayName: project.threadMeta.threadDisplayName,
folderName: project.threadMeta.folderName,
avatar: buildParticipantAvatar(
project.threadMeta.threadDisplayName,
state.devices.find((item) => item.id === project.deviceIds[0])?.avatar,
),
isSourceProject: true,
status: "active" as const,
canOpenProject: true,
},
];
const validParticipantCount = participants.filter((item) => item.status === "active").length;
const invalidParticipantCount = participants.filter((item) => item.status !== "active").length;
const repairRequired =
project.isGroup && (invalidParticipantCount > 0 || validParticipantCount < 2);
const repairReason = !repairRequired
? undefined
: validParticipantCount === 0
? "当前群聊里还没有可下发的真实线程,请重新添加线程。"
: invalidParticipantCount > 0
? "当前群聊里有失效或不可下发的线程引用,请重新整理群成员。"
: "当前群聊至少需要 2 个真实线程成员。";
return {
ok: true,
projectId: project.id,
isGroup: project.isGroup,
threadMeta: project.threadMeta,
participants,
repairRequired,
repairReason,
validParticipantCount,
invalidParticipantCount,
};
}

View File

@@ -9,11 +9,7 @@ import type {
Capability,
ContextBudgetLevel,
Device,
DeviceEnrollment,
DeviceImportDraft,
DeviceImportResolution,
ProjectExecutionPolicy,
DeviceSkill,
DispatchPlanWithExecutions,
MasterIdentitySummary,
MasterAgentMemory,
MasterAgentPromptPolicy,
@@ -22,57 +18,29 @@ import type {
OpsRepairVerification,
Project,
ProjectAgentControls,
RiskLevel,
ProjectParticipantsPayload,
ThreadContextAlert,
ThreadContextSnapshot,
ThreadHandoffPackage,
UserMasterPrompt,
MasterAgentTaskStatus,
} from "@/lib/boss-data";
export interface ContextIndicator {
visible: boolean;
style: "ring_percent";
percent?: number;
level?: ContextBudgetLevel;
}
export interface ConversationItem {
conversationId: string;
conversationType: "master_agent" | "single_device" | "group" | "folder_archive";
projectId: string;
projectTitle: string;
threadTitle: string;
folderLabel: string;
folderKey?: string;
threadCount?: number;
searchAliases?: string[];
searchTargetProjectIds?: string[];
preview: string;
lastMessagePreview: string;
activityIconCount: number;
topPinnedLabel?: "置顶";
manualPinned: boolean;
latestReplyAt: string;
latestReplyLabel: string;
unreadCount: number;
riskLevel: RiskLevel;
activeDeviceCount: number;
deviceNamesPreview: string[];
avatar: {
primary: string;
secondary?: string;
overflowCount?: number;
};
groupMembers?: Array<{
threadId: string;
avatar: string;
title: string;
}>;
contextBudgetIndicator: ContextIndicator;
contextBudgetSourceNodeId?: string;
contextBudgetUpdatedAt?: string;
mustFinishBeforeCompaction: boolean;
}
import {
buildDispatchPlansByProject,
buildProjectParticipantsPayload,
formatTimestampLabel,
} from "@/lib/boss-projections-shared";
import type {
ConversationItem,
DeviceWorkspaceView,
SkillInventoryDeviceGroup,
} from "@/lib/boss-projections-shared";
export type {
ContextIndicator,
ConversationItem,
DeviceWorkspaceView,
SkillInventoryDeviceGroup,
} from "@/lib/boss-projections-shared";
export interface ThreadContextView {
snapshot: ThreadContextSnapshot;
@@ -84,6 +52,10 @@ export interface ProjectDetailView {
project: Project;
agentControls?: ProjectAgentControls | null;
devices: Device[];
conversationTasks: ConversationTaskSummary[];
executionWarnings: ExecutionWarningSummary[];
dispatchPlans: DispatchPlanWithExecutions[];
participantsPayload?: ProjectParticipantsPayload | null;
masterIdentity?: MasterIdentitySummary;
activeThreadContexts: ThreadContextView[];
nextCompactionRiskThreadId?: string;
@@ -102,15 +74,6 @@ export interface ThreadContextDetailView {
masterActions: string[];
}
export interface DeviceWorkspaceView {
selectedDevice?: Device;
relatedThreads: ThreadContextSnapshot[];
activeEnrollment?: DeviceEnrollment;
importDraft?: DeviceImportDraft;
importResolution?: DeviceImportResolution;
projectExecutionPolicies?: ProjectExecutionPolicy[];
}
export interface OpsSummaryView {
mode: "active" | "idle";
faults: OpsFault[];
@@ -127,11 +90,6 @@ export interface AuditSummaryView {
capabilities: Capability[];
}
export interface SkillInventoryDeviceGroup {
device: Device;
skills: DeviceSkill[];
}
export interface SkillInventoryView {
boundDeviceId?: string;
groups: SkillInventoryDeviceGroup[];
@@ -150,36 +108,6 @@ const aiRolePriority: Record<AiAccountRole, number> = {
api_fallback: 2,
};
const shanghaiFormatter = new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
const shanghaiDayFormatter = new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
export function formatTimestampLabel(value?: string, fallback = "刚刚") {
if (!value) return fallback;
if (!value.includes("T")) return value;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const diff = Date.now() - date.getTime();
if (Math.abs(diff) < 60_000) return "刚刚";
if (diff >= 0 && diff < 24 * 60 * 60_000) {
return shanghaiFormatter.format(date);
}
return shanghaiDayFormatter.format(date);
}
const STALE_CONTEXT_SYNC_LABEL = "待同步";
const STALE_CONTEXT_REPLY_THRESHOLD_MS = 7 * 24 * 60 * 60_000;
@@ -548,10 +476,103 @@ export interface ConversationFolderView {
threads: ConversationItem[];
}
export interface ConversationTaskSummary {
taskId: string;
requestMessageId: string;
status: MasterAgentTaskStatus;
requestId?: string;
sessionId?: string;
targetProjectId?: string;
targetThreadId?: string;
}
export interface ExecutionWarningSummary {
warningId: string;
taskId: string;
requestMessageId: string;
sessionId?: string;
requestId?: string;
targetProjectId?: string;
targetThreadId?: string;
title: string;
summary: string;
createdAt: string;
}
export interface ProjectMessagesRealtimePayload {
ok: true;
project: Project;
devices: Device[];
conversationTasks: ConversationTaskSummary[];
executionWarnings: ExecutionWarningSummary[];
}
function buildProjectConversationTaskSummaries(
state: BossState,
project: Project,
): ConversationTaskSummary[] {
const visibleMessageIds = new Set(
project.messages
.map((message) => message.id?.trim())
.filter((messageId): messageId is string => Boolean(messageId)),
);
if (visibleMessageIds.size === 0) {
return [];
}
return state.masterAgentTasks
.filter(
(task) =>
task.taskType === "conversation_reply" &&
task.projectId === project.id &&
visibleMessageIds.has(task.requestMessageId),
)
.sort((left, right) => right.requestedAt.localeCompare(left.requestedAt))
.map((task) => ({
taskId: task.taskId,
requestMessageId: task.requestMessageId,
status: task.status,
requestId: task.requestId,
sessionId: task.sessionId,
targetProjectId: task.targetProjectId,
targetThreadId: task.targetThreadId,
}));
}
function buildProjectExecutionWarnings(
state: BossState,
project: Project,
): ExecutionWarningSummary[] {
const visibleTaskIds = new Set(
buildProjectConversationTaskSummaries(state, project)
.map((task) => task.taskId.trim())
.filter(Boolean),
);
if (visibleTaskIds.size === 0) {
return [];
}
return state.threadExecutionWarnings
.filter(
(warning) =>
warning.projectId === project.id &&
visibleTaskIds.has(warning.taskId),
)
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))
.map((warning) => ({
warningId: warning.warningId,
taskId: warning.taskId,
requestMessageId: warning.requestMessageId,
sessionId: warning.sessionId,
requestId: warning.requestId,
targetProjectId: warning.targetProjectId,
targetThreadId: warning.targetThreadId,
title: warning.title,
summary: warning.summary,
createdAt: warning.createdAt,
}));
}
export function getConversationHomeItems(state: BossState): ConversationItem[] {
@@ -732,6 +753,8 @@ export function buildProjectMessagesRealtimePayload(
ok: true,
project,
devices: state.devices.filter((device) => project.deviceIds.includes(device.id)),
conversationTasks: buildProjectConversationTaskSummaries(state, project),
executionWarnings: buildProjectExecutionWarnings(state, project),
};
}
@@ -815,6 +838,10 @@ export function getProjectDetailView(state: BossState, projectId: string, accoun
project,
agentControls: resolveProjectAgentControls(state, projectId, account),
devices: state.devices.filter((device) => project.deviceIds.includes(device.id)),
conversationTasks: buildProjectConversationTaskSummaries(state, project),
executionWarnings: buildProjectExecutionWarnings(state, project),
dispatchPlans: project.isGroup ? buildDispatchPlansByProject(state, projectId) : [],
participantsPayload: project.isGroup ? buildProjectParticipantsPayload(state, projectId) : null,
masterIdentity: projectId === "master-agent" ? getProjectMasterIdentity(state) : undefined,
activeThreadContexts,
nextCompactionRiskThreadId: topRisk?.threadId,

View File

@@ -8,6 +8,11 @@ import {
type ClawBackendSelectionState,
isClawRequestKindSupported,
} from "@/lib/execution/backends/claw-backend";
import {
HERMES_BACKEND,
type HermesBackendSelectionState,
isHermesRequestKindSupported,
} from "@/lib/execution/backends/hermes-backend";
import {
MASTER_CODEX_NODE_BACKEND,
isReadyMasterCodexNodeBackend,
@@ -27,10 +32,12 @@ export interface ExecutionBackendSelectionInput {
requestKind?: ExecutionRequestKind;
requestedBackendId?: string;
claw?: ClawBackendSelectionState;
hermes?: HermesBackendSelectionState;
}
export type ExecutionBackendChoice =
| typeof CLAW_BACKEND
| typeof HERMES_BACKEND
| typeof MASTER_CODEX_NODE_BACKEND
| typeof OPENAI_BACKEND
| typeof ALIYUN_QWEN_BACKEND;
@@ -57,6 +64,14 @@ function isReadyBackend(choice: ExecutionBackendChoice, input: ExecutionBackendS
return isClawRequestKindSupported(requestKind);
}
if (choice.backendId === HERMES_BACKEND.backendId) {
const requestKind = input.requestKind;
if (!input.hermes?.selectable || !requestKind) {
return false;
}
return isHermesRequestKindSupported(requestKind);
}
const candidates = [
...(input.primary.provider === choice.provider ? [input.primary] : []),
...input.backups.filter((item) => item.provider === choice.provider),
@@ -104,6 +119,13 @@ export function listExecutionBackendChoices(
pushBackend(CLAW_BACKEND);
}
if (
input.requestedBackendId === HERMES_BACKEND.backendId &&
isReadyBackend(HERMES_BACKEND, input)
) {
pushBackend(HERMES_BACKEND);
}
if (input.primary.status === "ready") {
pushBackend(primaryBackend);
}

View File

@@ -0,0 +1,112 @@
import type { ExecutionBackend } from "@/lib/execution/execution-backend";
import type {
ExecutionBackendDescription,
ExecutionImmediateResult,
ExecutionRequest,
ExecutionRequestKind,
} from "@/lib/execution/types";
import {
getHermesBackendAvailability,
getHermesBackendConfig,
isHermesBackendConfigured,
type HermesBackendAvailability,
type HermesBackendConfig,
} from "@/lib/execution/backends/hermes-config";
import { runHermesCommand } from "@/lib/execution/backends/hermes-runner";
export const HERMES_BACKEND_ID = "hermes-runtime";
export const HERMES_BACKEND = {
backendId: HERMES_BACKEND_ID,
label: "Hermes Runtime",
mode: "local",
} as const satisfies ExecutionBackendDescription;
const SUPPORTED_HERMES_KINDS = new Set<ExecutionRequestKind>([
"master_agent_reply",
"thread_reply",
]);
type HermesRunnerInput = Parameters<typeof runHermesCommand>[0];
type HermesRunner = (input: HermesRunnerInput) => Promise<ExecutionImmediateResult>;
export interface HermesBackendSelectionState {
enabled: boolean;
selectable: boolean;
availability: HermesBackendAvailability;
supportsKinds: ExecutionRequestKind[];
}
function createFailedResult(error: string): ExecutionImmediateResult {
return {
status: "failed",
backendId: HERMES_BACKEND_ID,
error,
};
}
export function isHermesRequestKindSupported(kind: ExecutionRequestKind) {
return SUPPORTED_HERMES_KINDS.has(kind);
}
export async function getHermesBackendSelectionState(
config: HermesBackendConfig = getHermesBackendConfig(),
): Promise<HermesBackendSelectionState> {
const availability = await getHermesBackendAvailability(config);
return {
enabled: isHermesBackendConfigured(config),
selectable: availability.selectable,
availability,
supportsKinds: [...SUPPORTED_HERMES_KINDS],
};
}
function buildHermesPayload(input: ExecutionRequest, config: HermesBackendConfig) {
return {
kind: input.kind,
projectId: input.projectId,
requestMessageId: input.requestMessageId,
body: input.body,
executionPrompt: input.executionPrompt ?? input.body,
model: input.modelOverride ?? config.defaultModel,
reasoningEffort: input.reasoningEffortOverride ?? "medium",
toolsets: config.toolsets,
skills: config.skills,
...(input.targetProjectId ? { targetProjectId: input.targetProjectId } : {}),
...(input.targetThreadId ? { targetThreadId: input.targetThreadId } : {}),
...(input.requestedByAccount ? { requestedByAccount: input.requestedByAccount } : {}),
...(input.requestedByLabel ? { requestedByLabel: input.requestedByLabel } : {}),
...(input.taskId ? { taskId: input.taskId } : {}),
};
}
export function createHermesBackend(options?: {
config?: HermesBackendConfig;
runner?: HermesRunner;
}): ExecutionBackend {
const config = options?.config ?? getHermesBackendConfig();
const runner = options?.runner ?? runHermesCommand;
return {
backendId: HERMES_BACKEND_ID,
async canHandle(input) {
return isHermesBackendConfigured(config) && isHermesRequestKindSupported(input.kind);
},
async execute(input) {
const canHandle = await this.canHandle(input);
if (!canHandle) {
return createFailedResult("HERMES_BACKEND_NOT_AVAILABLE");
}
return runner({
config,
payload: buildHermesPayload(input, config),
});
},
async describe() {
return HERMES_BACKEND;
},
};
}
export const HERMES_BACKEND_ADAPTER = createHermesBackend();
export const createHermesBackendForTesting = createHermesBackend;

View File

@@ -0,0 +1,212 @@
import { constants } from "node:fs";
import { access } from "node:fs/promises";
import path from "node:path";
export interface HermesBackendConfig {
enabled: boolean;
command: string;
args: string[];
cwd?: string;
timeoutMs: number;
defaultModel?: string;
toolsets: string[];
skills: string[];
sourceTag: string;
}
export type HermesBackendAvailabilityStatus = "disabled" | "misconfigured" | "ready";
export interface HermesBackendAvailability {
status: HermesBackendAvailabilityStatus;
selectable: boolean;
configured: boolean;
reason:
| "disabled"
| "command_not_set"
| "command_not_found"
| "workdir_not_found"
| "script_not_found"
| "ready";
reasonLabel: string;
command?: string;
cwd?: string;
}
function parseBoolean(value: string | undefined) {
return value?.trim().toLowerCase() === "true";
}
function parseArgs(value: string | undefined) {
return String(value || "")
.trim()
.split(/\s+/)
.filter(Boolean);
}
function parseCsv(value: string | undefined) {
return String(value || "")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function parseTimeoutMs(value: string | undefined) {
const parsed = Number.parseInt(value || "", 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 120000;
}
export function getHermesBackendConfig(): HermesBackendConfig {
return {
enabled: parseBoolean(process.env.BOSS_HERMES_ENABLED),
command: process.env.BOSS_HERMES_COMMAND?.trim() || "hermes",
args: parseArgs(process.env.BOSS_HERMES_ARGS),
cwd: process.env.BOSS_HERMES_WORKDIR?.trim() || undefined,
timeoutMs: parseTimeoutMs(process.env.BOSS_HERMES_TIMEOUT_MS),
defaultModel: process.env.BOSS_HERMES_DEFAULT_MODEL?.trim() || undefined,
toolsets: parseCsv(process.env.BOSS_HERMES_TOOLSETS),
skills: parseCsv(process.env.BOSS_HERMES_SKILLS),
sourceTag: process.env.BOSS_HERMES_SOURCE_TAG?.trim() || "tool",
};
}
export function isHermesBackendConfigured(config: HermesBackendConfig) {
return config.enabled && Boolean(config.command);
}
function commandLooksLikePath(command: string) {
return command.includes("/") || command.includes("\\");
}
async function fileExists(filePath: string, mode = constants.F_OK) {
try {
await access(filePath, mode);
return true;
} catch {
return false;
}
}
function resolveScriptCandidate(config: HermesBackendConfig) {
if (!config.command || config.args.length === 0) {
return null;
}
const commandName = path.basename(config.command).toLowerCase();
const scriptRuntimes = new Set([
"node",
"node.exe",
"tsx",
"tsx.cmd",
"bun",
"bun.exe",
"deno",
"deno.exe",
"python",
"python.exe",
"python3",
"python3.exe",
]);
if (!scriptRuntimes.has(commandName)) {
return null;
}
const candidate = config.args[0];
if (!candidate || candidate.startsWith("-")) {
return null;
}
return path.isAbsolute(candidate)
? candidate
: path.resolve(config.cwd?.trim() || process.cwd(), candidate);
}
async function isCommandReachable(command: string) {
if (commandLooksLikePath(command)) {
return fileExists(path.resolve(command), constants.X_OK);
}
const searchPaths = (process.env.PATH || "")
.split(path.delimiter)
.map((entry) => entry.trim())
.filter(Boolean);
for (const entry of searchPaths) {
const candidate = path.join(entry, command);
if (await fileExists(candidate, constants.X_OK)) {
return true;
}
}
return false;
}
export async function getHermesBackendAvailability(
config: HermesBackendConfig = getHermesBackendConfig(),
): Promise<HermesBackendAvailability> {
const base = {
command: config.command,
cwd: config.cwd,
configured: isHermesBackendConfigured(config),
};
if (!config.enabled) {
return {
...base,
status: "disabled",
selectable: false,
reason: "disabled",
reasonLabel: "Hermes Runtime 当前未启用。",
};
}
if (!config.command) {
return {
...base,
status: "misconfigured",
selectable: false,
reason: "command_not_set",
reasonLabel: "Hermes Runtime 缺少启动命令。",
};
}
if (!(await isCommandReachable(config.command))) {
return {
...base,
status: "misconfigured",
selectable: false,
reason: "command_not_found",
reasonLabel: "未检测到可执行的 Hermes 启动命令。",
};
}
if (config.cwd && !(await fileExists(config.cwd, constants.F_OK))) {
return {
...base,
status: "misconfigured",
selectable: false,
reason: "workdir_not_found",
reasonLabel: "Hermes Runtime 工作目录不存在。",
};
}
const scriptCandidate = resolveScriptCandidate(config);
if (scriptCandidate && !(await fileExists(scriptCandidate, constants.F_OK))) {
return {
...base,
status: "misconfigured",
selectable: false,
reason: "script_not_found",
reasonLabel: "未检测到有效的 Hermes 启动脚本,将自动回退到默认后端。",
};
}
return {
...base,
status: "ready",
selectable: true,
reason: "ready",
reasonLabel: "Hermes Runtime 可用。",
};
}
export const getHermesBackendConfigForTesting = getHermesBackendConfig;
export const isHermesBackendConfiguredForTesting = isHermesBackendConfigured;
export const getHermesBackendAvailabilityForTesting = getHermesBackendAvailability;

View File

@@ -0,0 +1,152 @@
import { spawn } from "node:child_process";
import type { HermesBackendConfig } from "@/lib/execution/backends/hermes-config";
import type { ExecutionImmediateResult } from "@/lib/execution/types";
const HERMES_BACKEND_ID = "hermes-runtime";
function createFailedResult(error: string): ExecutionImmediateResult {
return {
status: "failed",
backendId: HERMES_BACKEND_ID,
error,
};
}
function trimHermesQuietFooter(stdout: string) {
return stdout
.split(/\r?\n/)
.filter((line) => !/^session_id:\s*\S+/i.test(line.trim()))
.join("\n")
.trim();
}
function extractHermesSessionId(stdout: string) {
for (const line of stdout.split(/\r?\n/)) {
const match = line.trim().match(/^session_id:\s*(\S+)/i);
if (match?.[1]) {
return match[1];
}
}
return undefined;
}
function normalizeHermesProcessResult(input: {
exitCode: number;
stdout: string;
stderr: string;
}): ExecutionImmediateResult {
if (input.exitCode !== 0) {
return createFailedResult(input.stderr.trim() || `HERMES_EXIT_${input.exitCode}`);
}
const output = trimHermesQuietFooter(input.stdout);
const sessionId = extractHermesSessionId(input.stdout);
if (!output) {
return createFailedResult("EMPTY_HERMES_RESPONSE");
}
return {
status: "completed",
backendId: HERMES_BACKEND_ID,
output,
sessionId,
};
}
function buildHermesArgs(config: HermesBackendConfig, payload: Record<string, unknown>) {
const executionPrompt =
typeof payload.executionPrompt === "string" && payload.executionPrompt.trim()
? payload.executionPrompt.trim()
: typeof payload.body === "string"
? payload.body
: "";
const model =
typeof payload.model === "string" && payload.model.trim()
? payload.model.trim()
: config.defaultModel?.trim();
const args = [
...config.args,
"chat",
"-q",
executionPrompt,
"-Q",
"--source",
config.sourceTag,
];
if (model) {
args.push("-m", model);
}
const toolsets = config.toolsets ?? [];
const skills = config.skills ?? [];
if (toolsets.length > 0) {
args.push("-t", toolsets.join(","));
}
if (skills.length > 0) {
args.push("-s", skills.join(","));
}
return args;
}
export async function runHermesCommand(input: {
config: HermesBackendConfig;
payload: Record<string, unknown>;
}): Promise<ExecutionImmediateResult> {
const command = input.config.command;
if (!command) {
return createFailedResult("HERMES_COMMAND_NOT_CONFIGURED");
}
return new Promise((resolve) => {
const child = spawn(command, buildHermesArgs(input.config, input.payload), {
cwd: input.config.cwd,
env: process.env,
stdio: ["ignore", "pipe", "pipe"] as const,
});
let stdout = "";
let stderr = "";
let settled = false;
const finish = (result: ExecutionImmediateResult) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
resolve(result);
};
const timer = setTimeout(() => {
child.kill("SIGKILL");
finish(createFailedResult("HERMES_TIMEOUT"));
}, input.config.timeoutMs);
child.stdout.on("data", (chunk: Buffer | string) => {
stdout += String(chunk);
});
child.stderr.on("data", (chunk: Buffer | string) => {
stderr += String(chunk);
});
child.on("error", (error: Error) => {
finish(createFailedResult(error.message));
});
child.on("close", (code: number | null) => {
finish(
normalizeHermesProcessResult({
exitCode: code ?? 1,
stdout,
stderr,
}),
);
});
});
}
export const runHermesCommandForTesting = runHermesCommand;
export const createHermesProcessResultForTesting = normalizeHermesProcessResult;

View File

@@ -5,6 +5,60 @@ export type RelevantMemory = Pick<
"memoryId" | "scope" | "projectId" | "title" | "content" | "tags" | "memoryType" | "lastUsedAt" | "updatedAt" | "createdAt"
>;
function normalizeLexicalText(value: string) {
return value.trim().toLowerCase();
}
function tokenizeLexicalText(value: string) {
const normalized = normalizeLexicalText(value);
if (!normalized) {
return [];
}
return Array.from(
new Set(
normalized
.split(/[^\p{L}\p{N}]+/u)
.map((token) => token.trim())
.filter((token) => token.length >= 2),
),
);
}
function scoreProjectMemoryMatch(memory: RelevantMemory, requestText: string) {
const lowered = normalizeLexicalText(requestText);
if (!lowered) {
return 0;
}
const haystacks = [memory.projectId, memory.title, memory.content, ...(memory.tags ?? [])]
.filter((value): value is string => Boolean(value))
.map((value) => normalizeLexicalText(value));
let score = 0;
for (const value of haystacks) {
if (!value) {
continue;
}
if (lowered.includes(value) || value.includes(lowered)) {
score += 10;
}
}
const requestTokens = tokenizeLexicalText(requestText);
if (requestTokens.length === 0) {
return score;
}
const memoryTokens = new Set(haystacks.flatMap((value) => tokenizeLexicalText(value)));
for (const token of requestTokens) {
if (memoryTokens.has(token)) {
score += 3;
}
}
return score;
}
export function resolveRelevantMemories(input: {
projectId: string;
requestText?: string;
@@ -26,12 +80,13 @@ export function resolveRelevantMemories(input: {
: !lowered
? projectScoped.slice(0, 6)
: projectScoped
.filter((memory) => {
const haystacks = [memory.projectId, memory.title, memory.content, ...(memory.tags ?? [])]
.filter((value): value is string => Boolean(value))
.map((value) => value.toLowerCase());
return haystacks.some((value) => lowered.includes(value) || value.includes(lowered));
})
.map((memory) => ({
memory,
score: scoreProjectMemoryMatch(memory, lowered),
}))
.filter((entry) => entry.score > 0)
.sort((left, right) => right.score - left.score)
.map((entry) => entry.memory)
.slice(0, 6);
const userMemories = input.memories.filter((memory) => memory.scope === "global").slice(0, 8);

View File

@@ -7,6 +7,10 @@ export interface RemoteExecutionResultInput {
replyBody?: string;
errorMessage?: string;
requestId?: string;
warnings?: Array<{
title?: string;
summary?: string;
}>;
}
export interface NormalizedRemoteExecutionResult {
@@ -18,6 +22,10 @@ export interface NormalizedRemoteExecutionResult {
replyBody?: string;
errorMessage?: string;
requestId?: string;
warnings?: Array<{
title: string;
summary: string;
}>;
}
function trimToDefined(value: string | undefined) {
@@ -56,12 +64,28 @@ function buildThreadEnvironmentErrorMessage() {
return "THREAD_ENVIRONMENT_INVALID: 线程返回了内部环境提示,已拦截,请检查线程绑定或工作目录。";
}
function normalizeExecutionWarnings(
warnings: RemoteExecutionResultInput["warnings"],
): NormalizedRemoteExecutionResult["warnings"] {
const normalized = (warnings ?? [])
.map((warning) => ({
title: trimToDefined(warning?.title),
summary: trimToDefined(warning?.summary),
}))
.filter(
(warning): warning is { title: string; summary: string } =>
Boolean(warning.title && warning.summary),
);
return normalized.length > 0 ? normalized : undefined;
}
export function normalizeRemoteExecutionResult(
input: RemoteExecutionResultInput,
): NormalizedRemoteExecutionResult {
const rawThreadReply = trimToDefined(input.rawThreadReply);
const replyBody = trimToDefined(input.replyBody);
const errorMessage = trimToDefined(input.errorMessage);
const warnings = normalizeExecutionWarnings(input.warnings);
const hasEnvironmentDiagnostic =
looksLikeThreadEnvironmentDiagnostic(rawThreadReply) ||
looksLikeThreadEnvironmentDiagnostic(replyBody);
@@ -74,6 +98,7 @@ export function normalizeRemoteExecutionResult(
targetThreadId: trimToDefined(input.targetThreadId),
errorMessage: errorMessage || buildThreadEnvironmentErrorMessage(),
requestId: trimToDefined(input.requestId),
warnings,
};
}
@@ -86,6 +111,7 @@ export function normalizeRemoteExecutionResult(
replyBody,
errorMessage,
requestId: trimToDefined(input.requestId),
warnings,
};
}

View File

@@ -33,6 +33,7 @@ export interface ExecutionImmediateCompletedResult {
status: "completed";
backendId: string;
output: string;
sessionId?: string;
}
export interface ExecutionImmediateFailedResult {