Wire device execution mode controls into UI

This commit is contained in:
kris
2026-04-06 11:10:51 +08:00
parent 27ab594921
commit 43c733069c
11 changed files with 1033 additions and 19 deletions

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { requireRequestSession } from "@/lib/boss-auth";
import { updateDevice } from "@/lib/boss-data";
import { applyProjectConflictDecision, updateDevice } from "@/lib/boss-data";
export async function PATCH(
request: NextRequest,
@@ -19,9 +19,33 @@ export async function PATCH(
endpoint?: string;
note?: string;
projects?: string[];
capabilities?: {
gui?: {
connected?: boolean;
lastSeenAt?: string;
lastActiveProjectId?: string;
};
cli?: {
connected?: boolean;
lastSeenAt?: string;
lastActiveProjectId?: string;
};
};
preferredExecutionMode?: "gui" | "cli";
projectId?: string;
folderKey?: string;
conflictDecision?: "forbid" | "allow_once" | "allow_always";
};
try {
if (body.conflictDecision && body.projectId) {
await applyProjectConflictDecision({
deviceId,
projectId: body.projectId,
folderKey: body.folderKey,
decision: body.conflictDecision,
});
}
const device = await updateDevice(deviceId, body);
return NextResponse.json({ ok: true, device });
} catch (error) {

View File

@@ -55,6 +55,7 @@ export default async function DevicesPage({
device={workspace.selectedDevice}
relatedThreads={workspace.relatedThreads}
activeEnrollment={workspace.activeEnrollment}
workspace={workspace}
/>
<div className="mt-3">
<DeviceImportDraftManager

View File

@@ -37,7 +37,7 @@ import type {
UserProfile,
UserSettings,
} from "@/lib/boss-data";
import type { ConversationItem } from "@/lib/boss-projections";
import type { ConversationItem, DeviceWorkspaceView } from "@/lib/boss-projections";
import { formatTimestampLabel } from "@/lib/boss-projections";
function formatClock(value: string) {
@@ -55,6 +55,40 @@ function boundDeviceIdFromDom() {
return document.body.dataset.boundDeviceId || "mac-studio";
}
export function buildDeviceWorkspaceDetailCards(workspace: DeviceWorkspaceView) {
const selectedDevice = workspace.selectedDevice;
const primaryPolicy = workspace.projectExecutionPolicies?.[0];
return {
capabilities: {
title: "执行能力",
items: {
gui: `GUI${selectedDevice?.capabilities?.gui?.connected ? "已连接" : "未连接"}`,
cli: `CLI${selectedDevice?.capabilities?.cli?.connected ? "已连接" : "未连接"}`,
preferredExecutionMode: `默认执行模式:${
selectedDevice?.preferredExecutionMode === "gui"
? "GUI"
: selectedDevice?.preferredExecutionMode === "cli"
? "CLI"
: "未知"
}`,
},
},
conflicts: {
title: "异常项目 / 文件夹冲突",
scopeLabel: "仅作用于当前异常项目 / 文件夹",
actions: ["禁止", "允许本次", "永久放行"],
items: {
device: `设备:${selectedDevice?.name ?? selectedDevice?.id ?? "未知设备"}`,
folderKey: `文件夹:${primaryPolicy?.folderKey ?? "暂无"}`,
projectId: `项目:${primaryPolicy?.projectId ?? "暂无"}`,
allowPolicy: `当前策略:${primaryPolicy?.allowPolicy ?? "暂无"}`,
conflictState: `冲突态:${primaryPolicy?.conflictState ?? "暂无"}`,
},
},
};
}
async function waitForLoginSessionReady(nativeClient: boolean) {
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch("/api/auth/session", {
@@ -560,16 +594,23 @@ export function DeviceEditorCard({
device,
relatedThreads,
activeEnrollment,
workspace,
}: {
device: Device;
relatedThreads: ThreadContextSnapshot[];
activeEnrollment?: DeviceEnrollment;
workspace: DeviceWorkspaceView;
}) {
const router = useRouter();
const detailCards = buildDeviceWorkspaceDetailCards(workspace);
const primaryPolicy = workspace.projectExecutionPolicies?.[0];
const [name, setName] = useState(device.name);
const [avatar, setAvatar] = useState(device.avatar);
const [account, setAccount] = useState(device.account);
const [status, setStatus] = useState<Device["status"]>(device.status);
const [preferredExecutionMode, setPreferredExecutionMode] = useState<
Device["preferredExecutionMode"]
>(device.preferredExecutionMode ?? "cli");
const [endpoint, setEndpoint] = useState(device.endpoint ?? "");
const [note, setNote] = useState(device.note ?? "");
const [projects, setProjects] = useState(device.projects.join(", "));
@@ -586,6 +627,7 @@ export function DeviceEditorCard({
status,
endpoint,
note,
preferredExecutionMode,
projects: projects
.split(",")
.map((item) => item.trim())
@@ -597,6 +639,25 @@ export function DeviceEditorCard({
if (result.ok) router.refresh();
}
async function saveConflictDecision(decision: "forbid" | "allow_once" | "allow_always") {
if (!primaryPolicy?.projectId) {
setMessage("当前没有可操作的异常项目 / 文件夹。");
return;
}
const response = await fetch(`/api/v1/devices/${device.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectId: primaryPolicy.projectId,
folderKey: primaryPolicy.folderKey,
conflictDecision: decision,
}),
});
const result = (await response.json()) as { ok: boolean; message?: string };
setMessage(result.ok ? "冲突策略已更新。" : result.message ?? "更新失败");
if (result.ok) router.refresh();
}
return (
<div className="space-y-3 rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
<div className="flex items-center justify-between">
@@ -607,10 +668,88 @@ export function DeviceEditorCard({
<Field label="设备名称" value={name} onChange={setName} />
<Field label="头像缩写" value={avatar} onChange={setAvatar} />
</div>
<div className="space-y-3 rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
<div className="text-[16px] font-semibold text-[#111111]">{detailCards.capabilities.title}</div>
<div className="grid gap-2 text-[13px] leading-6 text-[#57606A]">
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">{detailCards.capabilities.items.gui}</div>
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">{detailCards.capabilities.items.cli}</div>
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">
{detailCards.capabilities.items.preferredExecutionMode}
</div>
</div>
<div className="space-y-1">
<div className="text-[12px] text-[#8C8C8C]"></div>
<div className="flex gap-2">
{(["gui", "cli"] as const).map((mode) => (
<button
key={mode}
type="button"
onClick={() => setPreferredExecutionMode(mode)}
className={clsx(
"rounded-full px-3 py-2 text-[12px] font-semibold",
preferredExecutionMode === mode
? "bg-[#07C160] text-white"
: "bg-[#F5F5F7] text-[#57606A]",
)}
>
{mode === "gui" ? "GUI" : "CLI"}
</button>
))}
</div>
</div>
</div>
<Field label="账号" value={account} onChange={setAccount} />
<Field label="Endpoint" value={endpoint} onChange={setEndpoint} />
<Field label="备注" value={note} onChange={setNote} />
<Field label="项目列表(逗号分隔)" value={projects} onChange={setProjects} />
<div className="space-y-3 rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
<div className="flex items-center justify-between gap-3">
<div className="text-[16px] font-semibold text-[#111111]">{detailCards.conflicts.title}</div>
<div className="text-[12px] text-[#8C8C8C]"></div>
</div>
<div className="grid gap-2 text-[13px] leading-6 text-[#57606A]">
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">{detailCards.conflicts.items.device}</div>
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">{detailCards.conflicts.items.folderKey}</div>
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">{detailCards.conflicts.items.projectId}</div>
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">{detailCards.conflicts.items.allowPolicy}</div>
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">{detailCards.conflicts.items.conflictState}</div>
</div>
<div className="text-[12px] text-[#8C8C8C]">{detailCards.conflicts.scopeLabel}</div>
{primaryPolicy ? (
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => void saveConflictDecision("forbid")}
className={clsx(
"rounded-full px-3 py-2 text-[12px] font-semibold",
primaryPolicy.allowPolicy === "forbid" ? "bg-[#111111] text-white" : "bg-[#F5F5F7] text-[#57606A]",
)}
>
{detailCards.conflicts.actions[0]}
</button>
<button
type="button"
onClick={() => void saveConflictDecision("allow_once")}
className={clsx(
"rounded-full px-3 py-2 text-[12px] font-semibold",
primaryPolicy.allowPolicy === "allow_once" ? "bg-[#07C160] text-white" : "bg-[#F5F5F7] text-[#57606A]",
)}
>
{detailCards.conflicts.actions[1]}
</button>
<button
type="button"
onClick={() => void saveConflictDecision("allow_always")}
className={clsx(
"rounded-full px-3 py-2 text-[12px] font-semibold",
primaryPolicy.allowPolicy === "allow_always" ? "bg-[#2563EB] text-white" : "bg-[#F5F5F7] text-[#57606A]",
)}
>
{detailCards.conflicts.actions[2]}
</button>
</div>
) : null}
</div>
<div className="space-y-1">
<div className="text-[12px] text-[#8C8C8C]"></div>
<div className="flex gap-2">

View File

@@ -12,6 +12,7 @@ import type {
DeviceEnrollment,
DeviceImportDraft,
DeviceImportResolution,
ProjectExecutionPolicy,
DeviceSkill,
MasterIdentitySummary,
MasterAgentMemory,
@@ -107,6 +108,7 @@ export interface DeviceWorkspaceView {
activeEnrollment?: DeviceEnrollment;
importDraft?: DeviceImportDraft;
importResolution?: DeviceImportResolution;
projectExecutionPolicies?: ProjectExecutionPolicy[];
}
export interface OpsSummaryView {
@@ -750,12 +752,14 @@ export function getDeviceWorkspaceView(
relatedThreads: [],
};
}
const selectedDevice = state.devices.find((item) => item.id === deviceId);
return {
selectedDevice: state.devices.find((item) => item.id === deviceId),
selectedDevice: selectedDevice ? { ...selectedDevice } : undefined,
relatedThreads: state.threadContextSnapshots.filter((item) => item.nodeId === deviceId),
activeEnrollment: state.deviceEnrollments.find((item) => item.deviceId === deviceId),
importDraft: state.deviceImportDrafts.find((item) => item.deviceId === deviceId),
importResolution: state.deviceImportResolutions.find((item) => item.deviceId === deviceId),
projectExecutionPolicies: state.projectExecutionPolicies.filter((item) => item.deviceId === deviceId),
};
}