feat: gate claw runtime selection by availability
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
hasPersistedProject,
|
||||
updateProjectAgentControls,
|
||||
} from "@/lib/boss-data";
|
||||
import { getClawBackendAvailability } from "@/lib/execution/backends/claw-config";
|
||||
|
||||
const reasoningEffortValues = new Set(["low", "medium", "high"]);
|
||||
|
||||
@@ -27,8 +28,11 @@ export async function GET(
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const controls = await getProjectAgentControls(projectId, session.account);
|
||||
return NextResponse.json({ ok: true, controls });
|
||||
const [controls, clawAvailability] = await Promise.all([
|
||||
getProjectAgentControls(projectId, session.account),
|
||||
getClawBackendAvailability(),
|
||||
]);
|
||||
return NextResponse.json({ ok: true, controls, clawAvailability });
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
@@ -102,6 +106,16 @@ export async function POST(
|
||||
}
|
||||
|
||||
try {
|
||||
if (hasBackendOverride && payload.backendOverride === "claw-runtime") {
|
||||
const clawAvailability = await getClawBackendAvailability();
|
||||
if (!clawAvailability.selectable) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: clawAvailability.reasonLabel, clawAvailability },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const controls = await updateProjectAgentControls(
|
||||
projectId,
|
||||
{
|
||||
@@ -112,7 +126,11 @@ export async function POST(
|
||||
},
|
||||
session.account,
|
||||
);
|
||||
return NextResponse.json({ ok: true, controls: controls ?? null });
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
controls: controls ?? null,
|
||||
clawAvailability: await getClawBackendAvailability(),
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
updateProjectAgentControls,
|
||||
updateUserMasterPrompt,
|
||||
} from "@/lib/boss-data";
|
||||
import { getClawBackendAvailability } from "@/lib/execution/backends/claw-config";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
@@ -25,10 +26,11 @@ export async function GET(
|
||||
return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
|
||||
const [promptPolicy, userPrompt, projectControls] = await Promise.all([
|
||||
const [promptPolicy, userPrompt, projectControls, clawAvailability] = await Promise.all([
|
||||
getMasterAgentPromptPolicy(),
|
||||
getUserMasterPrompt(session.account),
|
||||
getProjectAgentControls(projectId, session.account),
|
||||
getClawBackendAvailability(),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -38,6 +40,7 @@ export async function GET(
|
||||
userPrompt,
|
||||
projectControls,
|
||||
projectPromptOverride: projectControls?.promptOverride ?? null,
|
||||
clawAvailability,
|
||||
account: session.account,
|
||||
});
|
||||
}
|
||||
@@ -105,6 +108,24 @@ export async function POST(
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
hasBackendOverride &&
|
||||
typeof payload.backendOverride === "string" &&
|
||||
payload.backendOverride.trim() === "claw-runtime"
|
||||
) {
|
||||
const clawAvailability = await getClawBackendAvailability();
|
||||
if (!clawAvailability.selectable) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
message: clawAvailability.reasonLabel,
|
||||
clawAvailability,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasUserPromptContent) {
|
||||
const userPromptContent = typeof payload.userPromptContent === "string" ? payload.userPromptContent.trim() : "";
|
||||
if (userPromptContent) {
|
||||
@@ -121,10 +142,11 @@ export async function POST(
|
||||
}, session.account);
|
||||
}
|
||||
|
||||
const [promptPolicy, userPrompt, projectControls] = await Promise.all([
|
||||
const [promptPolicy, userPrompt, projectControls, clawAvailability] = await Promise.all([
|
||||
getMasterAgentPromptPolicy(),
|
||||
getUserMasterPrompt(session.account),
|
||||
getProjectAgentControls(projectId, session.account),
|
||||
getClawBackendAvailability(),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -134,6 +156,7 @@ export async function POST(
|
||||
userPrompt,
|
||||
projectControls,
|
||||
projectPromptOverride: projectControls?.promptOverride ?? null,
|
||||
clawAvailability,
|
||||
account: session.account,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AppShell, PageNav, StatusBar } from "@/components/app-ui";
|
||||
import { MasterAgentPromptMemoryClient } from "@/components/master-agent-prompt-memory-client";
|
||||
import { requirePageSession } from "@/lib/boss-auth";
|
||||
import { MASTER_AGENT_CHAT_PAGE_ANCHORS } from "@/lib/master-agent-chat-menu";
|
||||
import { getClawBackendAvailability } from "@/lib/execution/backends/claw-config";
|
||||
import {
|
||||
getMasterAgentPromptPolicy,
|
||||
getProjectAgentControls,
|
||||
@@ -13,13 +14,14 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MasterAgentPromptMemoryPage() {
|
||||
const session = await requirePageSession();
|
||||
const [promptPolicy, userPrompt, projectControls, globalMemories, projectMemories] =
|
||||
const [promptPolicy, userPrompt, projectControls, globalMemories, projectMemories, clawAvailability] =
|
||||
await Promise.all([
|
||||
getMasterAgentPromptPolicy(),
|
||||
getUserMasterPrompt(session.account),
|
||||
getProjectAgentControls("master-agent", session.account),
|
||||
listUserMasterMemories(session.account, { includeArchived: false, scope: "global" }),
|
||||
listUserMasterMemories(session.account, { includeArchived: false, scope: "project" }),
|
||||
getClawBackendAvailability(),
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -42,6 +44,7 @@ export default async function MasterAgentPromptMemoryPage() {
|
||||
promptPolicy={promptPolicy}
|
||||
userPrompt={userPrompt}
|
||||
projectControls={projectControls}
|
||||
clawAvailability={clawAvailability}
|
||||
globalMemories={globalMemories}
|
||||
projectMemories={projectMemories}
|
||||
anchors={MASTER_AGENT_CHAT_PAGE_ANCHORS}
|
||||
|
||||
@@ -24,6 +24,13 @@ type MemoryDraft = {
|
||||
sourceMessageId: string;
|
||||
};
|
||||
|
||||
type ClawAvailability = {
|
||||
status: "disabled" | "misconfigured" | "ready";
|
||||
selectable: boolean;
|
||||
reason: string;
|
||||
reasonLabel: string;
|
||||
};
|
||||
|
||||
const memoryScopeOptions: Array<{ value: MasterMemoryScope; label: string }> = [
|
||||
{ value: "global", label: "通用记忆" },
|
||||
{ value: "project", label: "项目记忆" },
|
||||
@@ -145,6 +152,7 @@ export function MasterAgentPromptMemoryClient({
|
||||
promptPolicy,
|
||||
userPrompt,
|
||||
projectControls,
|
||||
clawAvailability,
|
||||
globalMemories,
|
||||
projectMemories,
|
||||
anchors,
|
||||
@@ -153,6 +161,7 @@ export function MasterAgentPromptMemoryClient({
|
||||
promptPolicy: MasterAgentPromptPolicy | null;
|
||||
userPrompt: UserMasterPrompt | null;
|
||||
projectControls: ProjectAgentControls | null;
|
||||
clawAvailability: ClawAvailability;
|
||||
globalMemories: MasterAgentMemory[];
|
||||
projectMemories: MasterAgentMemory[];
|
||||
anchors: MasterAgentChatPageAnchors;
|
||||
@@ -167,8 +176,10 @@ export function MasterAgentPromptMemoryClient({
|
||||
projectControls?.reasoningEffortOverride ?? "",
|
||||
);
|
||||
const [promptOverride, setPromptOverride] = useState(projectControls?.promptOverride ?? "");
|
||||
const storedClawOverrideUnavailable =
|
||||
projectControls?.backendOverride === "claw-runtime" && !clawAvailability.selectable;
|
||||
const [backendOverride, setBackendOverride] = useState(
|
||||
projectControls?.backendOverride === "claw-runtime" ? "claw-runtime" : "",
|
||||
projectControls?.backendOverride === "claw-runtime" && clawAvailability.selectable ? "claw-runtime" : "",
|
||||
);
|
||||
const [newMemory, setNewMemory] = useState<MemoryDraft>(makeNewMemoryDraft());
|
||||
const [memoryDrafts, setMemoryDrafts] = useState<Record<string, MemoryDraft>>(() => {
|
||||
@@ -185,9 +196,14 @@ export function MasterAgentPromptMemoryClient({
|
||||
globalPrompt.trim() ? `【管理员全局主提示词】\n${globalPrompt.trim()}` : null,
|
||||
userPromptContent.trim() ? `【用户私有主提示词】\n${userPromptContent.trim()}` : null,
|
||||
promptOverride.trim() ? `【当前对话附加提示词】\n${promptOverride.trim()}` : null,
|
||||
backendOverride.trim()
|
||||
? `【执行后端】\n${backendOverride.trim()}`
|
||||
: storedClawOverrideUnavailable
|
||||
? "【执行后端】\n默认(Claw Runtime 当前不可用,运行时会自动回退)"
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
return sections.length > 0 ? sections.join("\n\n") : "当前还没有组合后的提示词内容。";
|
||||
}, [globalPrompt, userPromptContent, promptOverride]);
|
||||
}, [backendOverride, globalPrompt, promptOverride, storedClawOverrideUnavailable, userPromptContent]);
|
||||
|
||||
function updateMemoryDraft(memoryId: string, updater: (draft: MemoryDraft) => MemoryDraft) {
|
||||
setMemoryDrafts((current) => ({
|
||||
@@ -441,10 +457,21 @@ export function MasterAgentPromptMemoryClient({
|
||||
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="claw-runtime">Claw Runtime</option>
|
||||
{clawAvailability.selectable ? <option value="claw-runtime">Claw Runtime</option> : null}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{!clawAvailability.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]">Claw Runtime 当前不可用</div>
|
||||
<div>{clawAvailability.reasonLabel}</div>
|
||||
{storedClawOverrideUnavailable ? (
|
||||
<div className="mt-1 text-[#912018]">
|
||||
当前对话之前保存过 Claw Runtime,运行时会自动回退到默认后端。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<TextArea
|
||||
label="当前对话附加提示词"
|
||||
value={promptOverride}
|
||||
|
||||
@@ -1738,6 +1738,7 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
runtime.account.provider === "master_codex_node" && (!primaryDevice || primaryDevice.status !== "online")
|
||||
? "degraded"
|
||||
: runtime.account.status;
|
||||
const clawSelectionState = await getClawBackendSelectionState();
|
||||
const backendSelectionInput = {
|
||||
primary: {
|
||||
provider: runtime.account.provider,
|
||||
@@ -1751,7 +1752,7 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
})),
|
||||
requestKind: "master_agent_reply" as const,
|
||||
requestedBackendId: executionConfig.agentControls?.backendOverride,
|
||||
claw: getClawBackendSelectionState(),
|
||||
claw: clawSelectionState,
|
||||
};
|
||||
const selectedBackend = await selectExecutionBackend(backendSelectionInput);
|
||||
const backendChoices = listExecutionBackendChoices(backendSelectionInput);
|
||||
|
||||
@@ -51,7 +51,7 @@ function resolveBackendByProvider(provider: AiProvider): ExecutionBackendChoice
|
||||
function isReadyBackend(choice: ExecutionBackendChoice, input: ExecutionBackendSelectionInput) {
|
||||
if (choice.backendId === CLAW_BACKEND.backendId) {
|
||||
const requestKind = input.requestKind;
|
||||
if (!input.claw?.enabled || !requestKind) {
|
||||
if (!input.claw?.selectable || !requestKind) {
|
||||
return false;
|
||||
}
|
||||
return isClawRequestKindSupported(requestKind);
|
||||
|
||||
@@ -6,8 +6,10 @@ import type {
|
||||
ExecutionRequestKind,
|
||||
} from "@/lib/execution/types";
|
||||
import {
|
||||
getClawBackendAvailability,
|
||||
getClawBackendConfig,
|
||||
isClawBackendConfigured,
|
||||
type ClawBackendAvailability,
|
||||
type ClawBackendConfig,
|
||||
} from "@/lib/execution/backends/claw-config";
|
||||
import { runClawCommand } from "@/lib/execution/backends/claw-runner";
|
||||
@@ -30,6 +32,8 @@ type ClawRunner = (input: ClawRunnerInput) => Promise<ExecutionImmediateResult>;
|
||||
|
||||
export interface ClawBackendSelectionState {
|
||||
enabled: boolean;
|
||||
selectable: boolean;
|
||||
availability: ClawBackendAvailability;
|
||||
supportsKinds: ExecutionRequestKind[];
|
||||
}
|
||||
|
||||
@@ -45,11 +49,14 @@ export function isClawRequestKindSupported(kind: ExecutionRequestKind) {
|
||||
return SUPPORTED_CLAW_KINDS.has(kind);
|
||||
}
|
||||
|
||||
export function getClawBackendSelectionState(
|
||||
export async function getClawBackendSelectionState(
|
||||
config: ClawBackendConfig = getClawBackendConfig(),
|
||||
): ClawBackendSelectionState {
|
||||
): Promise<ClawBackendSelectionState> {
|
||||
const availability = await getClawBackendAvailability(config);
|
||||
return {
|
||||
enabled: isClawBackendConfigured(config),
|
||||
selectable: availability.selectable,
|
||||
availability,
|
||||
supportsKinds: [...SUPPORTED_CLAW_KINDS],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { access } from "node:fs/promises";
|
||||
import { constants } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export interface ClawBackendConfig {
|
||||
enabled: boolean;
|
||||
command?: string;
|
||||
@@ -7,6 +11,24 @@ export interface ClawBackendConfig {
|
||||
defaultModel?: string;
|
||||
}
|
||||
|
||||
export type ClawBackendAvailabilityStatus = "disabled" | "misconfigured" | "ready";
|
||||
|
||||
export interface ClawBackendAvailability {
|
||||
status: ClawBackendAvailabilityStatus;
|
||||
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";
|
||||
}
|
||||
@@ -38,5 +60,127 @@ export function isClawBackendConfigured(config: ClawBackendConfig) {
|
||||
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: ClawBackendConfig) {
|
||||
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"]);
|
||||
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 getClawBackendAvailability(
|
||||
config: ClawBackendConfig = getClawBackendConfig(),
|
||||
): Promise<ClawBackendAvailability> {
|
||||
const base = {
|
||||
command: config.command,
|
||||
cwd: config.cwd,
|
||||
configured: isClawBackendConfigured(config),
|
||||
};
|
||||
|
||||
if (!config.enabled) {
|
||||
return {
|
||||
...base,
|
||||
status: "disabled",
|
||||
selectable: false,
|
||||
reason: "disabled",
|
||||
reasonLabel: "Claw Runtime 当前未启用。",
|
||||
};
|
||||
}
|
||||
|
||||
if (!config.command) {
|
||||
return {
|
||||
...base,
|
||||
status: "misconfigured",
|
||||
selectable: false,
|
||||
reason: "command_not_set",
|
||||
reasonLabel: "Claw Runtime 缺少启动命令。",
|
||||
};
|
||||
}
|
||||
|
||||
if (!(await isCommandReachable(config.command))) {
|
||||
return {
|
||||
...base,
|
||||
status: "misconfigured",
|
||||
selectable: false,
|
||||
reason: "command_not_found",
|
||||
reasonLabel: "未检测到可执行的 Claw 启动命令。",
|
||||
};
|
||||
}
|
||||
|
||||
if (config.cwd && !(await fileExists(config.cwd, constants.F_OK))) {
|
||||
return {
|
||||
...base,
|
||||
status: "misconfigured",
|
||||
selectable: false,
|
||||
reason: "workdir_not_found",
|
||||
reasonLabel: "Claw Runtime 工作目录不存在。",
|
||||
};
|
||||
}
|
||||
|
||||
const scriptCandidate = resolveScriptCandidate(config);
|
||||
if (scriptCandidate && !(await fileExists(scriptCandidate, constants.F_OK))) {
|
||||
return {
|
||||
...base,
|
||||
status: "misconfigured",
|
||||
selectable: false,
|
||||
reason: "script_not_found",
|
||||
reasonLabel: "未检测到有效的 Claw 启动脚本,将自动回退到默认后端。",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
status: "ready",
|
||||
selectable: true,
|
||||
reason: "ready",
|
||||
reasonLabel: "Claw Runtime 可用。",
|
||||
};
|
||||
}
|
||||
|
||||
export const getClawBackendConfigForTesting = getClawBackendConfig;
|
||||
export const isClawBackendConfiguredForTesting = isClawBackendConfigured;
|
||||
export const getClawBackendAvailabilityForTesting = getClawBackendAvailability;
|
||||
|
||||
Reference in New Issue
Block a user