Integrate master agent runtime orchestration updates
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
150
src/app/conversations/[projectId]/participants/page.tsx
Normal file
150
src/app/conversations/[projectId]/participants/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
115
src/components/group-participants-repair-client.tsx
Normal file
115
src/components/group-participants-repair-client.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
219
src/lib/boss-projections-shared.ts
Normal file
219
src/lib/boss-projections-shared.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
112
src/lib/execution/backends/hermes-backend.ts
Normal file
112
src/lib/execution/backends/hermes-backend.ts
Normal 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;
|
||||
212
src/lib/execution/backends/hermes-config.ts
Normal file
212
src/lib/execution/backends/hermes-config.ts
Normal 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;
|
||||
152
src/lib/execution/backends/hermes-runner.ts
Normal file
152
src/lib/execution/backends/hermes-runner.ts
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface ExecutionImmediateCompletedResult {
|
||||
status: "completed";
|
||||
backendId: string;
|
||||
output: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export interface ExecutionImmediateFailedResult {
|
||||
|
||||
Reference in New Issue
Block a user