1470 lines
47 KiB
TypeScript
1470 lines
47 KiB
TypeScript
import type {
|
||
AiAccountRole,
|
||
AiProvider,
|
||
AiAccountStatus,
|
||
AppLogEntry,
|
||
AuthSession,
|
||
AuditTaskRequest,
|
||
AuditTaskResult,
|
||
BossState,
|
||
Capability,
|
||
ContextBudgetLevel,
|
||
Device,
|
||
DeviceEnrollment,
|
||
DeviceImportDraft,
|
||
DeviceImportResolution,
|
||
ProjectExecutionPolicy,
|
||
DeviceSkill,
|
||
MasterIdentitySummary,
|
||
MasterAgentMemory,
|
||
MasterAgentPromptPolicy,
|
||
OpsFault,
|
||
OpsRepairTicket,
|
||
OpsRepairVerification,
|
||
Project,
|
||
ProjectAgentControls,
|
||
RiskLevel,
|
||
ThreadContextAlert,
|
||
ThreadContextSnapshot,
|
||
ThreadHandoffPackage,
|
||
UserMasterPrompt,
|
||
} from "@/lib/boss-data";
|
||
import {
|
||
canAccessDevice,
|
||
canAccessProject,
|
||
canViewSkill,
|
||
filterDevicesForSession,
|
||
filterProjectDevicesForSession,
|
||
filterProjectsForSession,
|
||
type PermissionSession,
|
||
} from "@/lib/boss-permissions";
|
||
|
||
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;
|
||
}
|
||
|
||
function conversationHistoryWasCleared(state: BossState) {
|
||
return Boolean(state.conversationHistoryClearedAt?.trim());
|
||
}
|
||
|
||
export interface ThreadContextView {
|
||
snapshot: ThreadContextSnapshot;
|
||
handoffPackage?: ThreadHandoffPackage;
|
||
alerts: ThreadContextAlert[];
|
||
}
|
||
|
||
export interface ProjectDetailView {
|
||
project: Project;
|
||
agentControls?: ProjectAgentControls | null;
|
||
devices: Device[];
|
||
masterIdentity?: MasterIdentitySummary;
|
||
activeThreadContexts: ThreadContextView[];
|
||
nextCompactionRiskThreadId?: string;
|
||
threadsRequiringHandoff: ThreadContextView[];
|
||
masterContextStrategySummary: string;
|
||
recentAppLogs: AppLogEntry[];
|
||
openFaults: OpsFault[];
|
||
relatedAuditResults: AuditTaskResult[];
|
||
}
|
||
|
||
export interface ThreadContextDetailView {
|
||
snapshot: ThreadContextSnapshot;
|
||
handoffPackage?: ThreadHandoffPackage;
|
||
alerts: ThreadContextAlert[];
|
||
currentChecklist: string[];
|
||
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[];
|
||
tickets: Array<
|
||
OpsRepairTicket & {
|
||
verification?: OpsRepairVerification;
|
||
}
|
||
>;
|
||
}
|
||
|
||
export interface AuditSummaryView {
|
||
pendingRequests: AuditTaskRequest[];
|
||
latestResults: AuditTaskResult[];
|
||
capabilities: Capability[];
|
||
}
|
||
|
||
export interface SkillInventoryDeviceGroup {
|
||
device: Device;
|
||
skills: DeviceSkill[];
|
||
}
|
||
|
||
export interface SkillInventoryView {
|
||
boundDeviceId?: string;
|
||
groups: SkillInventoryDeviceGroup[];
|
||
}
|
||
|
||
const levelPriority: Record<ContextBudgetLevel, number> = {
|
||
critical: 0,
|
||
urgent: 1,
|
||
watch: 2,
|
||
safe: 3,
|
||
};
|
||
|
||
const aiRolePriority: Record<AiAccountRole, number> = {
|
||
primary: 0,
|
||
backup: 1,
|
||
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;
|
||
const PROCESS_PREVIEW_PREFIXES = [
|
||
"我先",
|
||
"我现在",
|
||
"我会先",
|
||
"我发现",
|
||
"我准备",
|
||
"接下来",
|
||
"正在",
|
||
"先看",
|
||
"先读",
|
||
"我把",
|
||
"我再",
|
||
"目前在",
|
||
"现在在",
|
||
"补一组",
|
||
"处理一下",
|
||
"先确认",
|
||
"准备",
|
||
"同步一下",
|
||
"我这边已经",
|
||
];
|
||
const PROCESS_PREVIEW_CONTAINS = [
|
||
"我继续",
|
||
"我已经在",
|
||
"正在跑",
|
||
"正在检查",
|
||
"正在处理",
|
||
"正在同步",
|
||
"我会直接",
|
||
"我先把",
|
||
"先补",
|
||
"再接",
|
||
];
|
||
const PROCESS_PREVIEW_NUMBERED_HINTS = [
|
||
"先",
|
||
"再",
|
||
"接下来",
|
||
"然后",
|
||
"检查",
|
||
"确认",
|
||
"处理",
|
||
"同步",
|
||
"补",
|
||
"排查",
|
||
"推进",
|
||
"回你",
|
||
"回传",
|
||
"会把",
|
||
"我会",
|
||
];
|
||
const PROCESS_PREVIEW_BLOCK_MARKERS = [
|
||
"失败",
|
||
"报错",
|
||
"错误",
|
||
"阻塞",
|
||
"不能",
|
||
"无法",
|
||
"崩溃",
|
||
"超时",
|
||
"exception",
|
||
"error",
|
||
"fatal",
|
||
"结论",
|
||
"最终",
|
||
"总结",
|
||
"已完成",
|
||
"已经完成",
|
||
"验证通过",
|
||
"测试通过",
|
||
"已修复",
|
||
"修好了",
|
||
"已部署",
|
||
"已安装",
|
||
"可以直接",
|
||
];
|
||
const LEAKED_TITLE_PREFIXES = [
|
||
"你当前接手的项目根目录是",
|
||
"你现在接手的项目根目录是",
|
||
"你现在以目标线程身份直接回复用户",
|
||
"你正在向主 Agent 同步当前项目状态",
|
||
"只回复对用户真正有用的内容",
|
||
"只输出 JSON",
|
||
];
|
||
const LEAKED_TITLE_CONTAINS = [
|
||
"不要发送内部字段",
|
||
"不要自称主 Agent",
|
||
"不要解释系统如何分发",
|
||
"不要输出 JSON",
|
||
"项目名称:",
|
||
"线程名称:",
|
||
"文件夹:",
|
||
"同步原因:",
|
||
"当前消息:",
|
||
"用户当前消息:",
|
||
];
|
||
|
||
function formatConversationLatestReplyLabel(value: string, hasVisibleContext: boolean) {
|
||
if (hasVisibleContext && value.includes("T")) {
|
||
const date = new Date(value);
|
||
const diff = Date.now() - date.getTime();
|
||
if (!Number.isNaN(date.getTime()) && diff >= STALE_CONTEXT_REPLY_THRESHOLD_MS) {
|
||
return STALE_CONTEXT_SYNC_LABEL;
|
||
}
|
||
}
|
||
return formatTimestampLabel(value);
|
||
}
|
||
|
||
function compareSnapshots(a: ThreadContextSnapshot, b: ThreadContextSnapshot) {
|
||
if (a.mustFinishBeforeCompaction !== b.mustFinishBeforeCompaction) {
|
||
return a.mustFinishBeforeCompaction ? -1 : 1;
|
||
}
|
||
if (levelPriority[a.contextBudgetLevel] !== levelPriority[b.contextBudgetLevel]) {
|
||
return levelPriority[a.contextBudgetLevel] - levelPriority[b.contextBudgetLevel];
|
||
}
|
||
return (a.compactionExpectedAt ?? "").localeCompare(b.compactionExpectedAt ?? "");
|
||
}
|
||
|
||
function projectType(project: Project): ConversationItem["conversationType"] {
|
||
if (project.id === "master-agent") return "master_agent";
|
||
return project.isGroup ? "group" : "single_device";
|
||
}
|
||
|
||
function buildFolderKey(project: Project) {
|
||
if (project.id === "master-agent" || project.isGroup) return undefined;
|
||
const deviceId = project.deviceIds[0];
|
||
const folderRef = (project.threadMeta.codexFolderRef?.trim() || project.threadMeta.folderName.trim()).toLowerCase();
|
||
if (!deviceId || !folderRef) return undefined;
|
||
return `${deviceId}:${folderRef}`;
|
||
}
|
||
|
||
function isTopPinnedConversation(project: Project) {
|
||
return Boolean(project.pinned || project.systemPinned || project.id === "audit-collab");
|
||
}
|
||
|
||
function getThreadAvatarFallback(title: string) {
|
||
const trimmed = title.trim();
|
||
if (!trimmed) return "A";
|
||
return trimmed.slice(0, 1).toUpperCase();
|
||
}
|
||
|
||
function getGroupMemberAvatar(member: Project["groupMembers"][number], device?: Device) {
|
||
const avatar = device?.avatar?.trim();
|
||
if (avatar) return avatar;
|
||
return getThreadAvatarFallback(member.threadDisplayName);
|
||
}
|
||
|
||
function aiRoleLabel(role: AiAccountRole) {
|
||
switch (role) {
|
||
case "primary":
|
||
return "主 GPT";
|
||
case "backup":
|
||
return "备用 GPT";
|
||
case "api_fallback":
|
||
return "API 容灾";
|
||
default:
|
||
return role;
|
||
}
|
||
}
|
||
|
||
function aiProviderLabel(provider: AiProvider) {
|
||
switch (provider) {
|
||
case "master_codex_node":
|
||
return "Master Codex Node";
|
||
case "openai_api":
|
||
return "OpenAI API";
|
||
case "aliyun_qwen_api":
|
||
return "阿里百炼 Qwen";
|
||
case "deepseek_api":
|
||
return "DeepSeek API";
|
||
default:
|
||
return provider;
|
||
}
|
||
}
|
||
|
||
function aiStatusLabel(status: AiAccountStatus) {
|
||
switch (status) {
|
||
case "ready":
|
||
return "可用";
|
||
case "needs_login":
|
||
return "待登录";
|
||
case "needs_api_key":
|
||
return "待配置 Key";
|
||
case "degraded":
|
||
return "异常";
|
||
case "disabled":
|
||
return "已停用";
|
||
default:
|
||
return status;
|
||
}
|
||
}
|
||
|
||
function canGenerateAiAccount(account: BossState["aiAccounts"][number]) {
|
||
if (!account.enabled) return false;
|
||
if (account.provider === "master_codex_node") {
|
||
return Boolean(account.nodeId?.trim());
|
||
}
|
||
return Boolean(account.apiKey?.trim());
|
||
}
|
||
|
||
function getProjectMasterIdentity(state: BossState): MasterIdentitySummary {
|
||
const accounts = [...state.aiAccounts].sort((a, b) => {
|
||
if (a.isActive !== b.isActive) return a.isActive ? -1 : 1;
|
||
if (aiRolePriority[a.role] !== aiRolePriority[b.role]) {
|
||
return aiRolePriority[a.role] - aiRolePriority[b.role];
|
||
}
|
||
return (b.updatedAt ?? "").localeCompare(a.updatedAt ?? "");
|
||
});
|
||
const account = accounts.find((item) => item.isActive) ?? accounts.find(canGenerateAiAccount) ?? accounts[0];
|
||
|
||
if (!account) {
|
||
return {
|
||
label: "API 容灾",
|
||
role: "api_fallback",
|
||
roleLabel: aiRoleLabel("api_fallback"),
|
||
provider: "openai_api",
|
||
providerLabel: aiProviderLabel("openai_api"),
|
||
displayName: "未配置 AI 账号",
|
||
status: "needs_api_key",
|
||
statusLabel: aiStatusLabel("needs_api_key"),
|
||
canGenerate: false,
|
||
note: "请到“我的 > AI 账号”补齐主控 AI 账号。",
|
||
};
|
||
}
|
||
|
||
return {
|
||
accountId: account.accountId,
|
||
label: account.label,
|
||
role: account.role,
|
||
roleLabel: aiRoleLabel(account.role),
|
||
provider: account.provider,
|
||
providerLabel: aiProviderLabel(account.provider),
|
||
displayName: account.displayName,
|
||
nodeLabel: account.nodeLabel,
|
||
model: account.model,
|
||
status: account.status,
|
||
statusLabel: aiStatusLabel(account.status),
|
||
canGenerate: canGenerateAiAccount(account),
|
||
switchReason: account.switchReason,
|
||
lastSwitchedAt: account.lastSwitchedAt,
|
||
note: account.loginStatusNote,
|
||
};
|
||
}
|
||
|
||
export function getMasterAgentPromptPolicyView(state: BossState): MasterAgentPromptPolicy | null {
|
||
return state.masterAgentPromptPolicy ?? null;
|
||
}
|
||
|
||
export function getUserMasterPromptView(state: BossState, account: string): UserMasterPrompt | null {
|
||
return state.userMasterPrompts.find((item) => item.account === account) ?? null;
|
||
}
|
||
|
||
export function listUserMasterMemoriesView(
|
||
state: BossState,
|
||
account: string,
|
||
options?: { includeArchived?: boolean },
|
||
): MasterAgentMemory[] {
|
||
const includeArchived = options?.includeArchived ?? false;
|
||
return [...state.masterAgentMemories]
|
||
.filter((memory) => memory.account === account && (includeArchived || !memory.archived))
|
||
.sort((a, b) => {
|
||
const aTime = Date.parse(a.lastUsedAt ?? a.updatedAt ?? a.createdAt) || 0;
|
||
const bTime = Date.parse(b.lastUsedAt ?? b.updatedAt ?? b.createdAt) || 0;
|
||
if (aTime !== bTime) return bTime - aTime;
|
||
return b.memoryId.localeCompare(a.memoryId);
|
||
});
|
||
}
|
||
|
||
function threadViewsForProject(state: BossState, projectId: string) {
|
||
return state.threadContextSnapshots
|
||
.filter((snapshot) => snapshot.projectId === projectId)
|
||
.sort(compareSnapshots)
|
||
.map((snapshot) => ({
|
||
snapshot,
|
||
handoffPackage: state.threadHandoffPackages.find(
|
||
(item) => item.fromThreadId === snapshot.threadId && item.packageStatus !== "expired",
|
||
),
|
||
alerts: state.threadContextAlerts.filter((alert) => alert.threadId === snapshot.threadId),
|
||
}));
|
||
}
|
||
|
||
function buildConversationItem(state: BossState, project: Project): ConversationItem {
|
||
const devices = state.devices.filter((device) => project.deviceIds.includes(device.id));
|
||
const threadViews = threadViewsForProject(state, project.id);
|
||
const topThread = threadViews[0]?.snapshot;
|
||
const { folderLabel, threadTitle, projectTitle } = buildProjectDisplayTitles(project);
|
||
const activityIconCount = deriveConversationActivityIconCount(state, project);
|
||
const topPinnedLabel = isTopPinnedConversation(project) ? "置顶" : undefined;
|
||
const latestConversationActivityAt = deriveLatestConversationActivityAt(project);
|
||
const compactPreview = compactImportedThreadPreview(project.preview);
|
||
const groupMembers = project.isGroup
|
||
? project.groupMembers.map((member) => ({
|
||
threadId: member.threadId,
|
||
avatar: getGroupMemberAvatar(
|
||
member,
|
||
state.devices.find((device) => device.id === member.deviceId),
|
||
),
|
||
title: member.threadDisplayName,
|
||
}))
|
||
: undefined;
|
||
|
||
return {
|
||
conversationId: `conv-${project.id}`,
|
||
conversationType: projectType(project),
|
||
projectId: project.id,
|
||
projectTitle,
|
||
threadTitle,
|
||
folderLabel,
|
||
folderKey: buildFolderKey(project),
|
||
preview: compactPreview,
|
||
lastMessagePreview: compactPreview,
|
||
activityIconCount,
|
||
topPinnedLabel,
|
||
manualPinned: Boolean(project.pinned && !project.systemPinned),
|
||
latestReplyAt: latestConversationActivityAt,
|
||
latestReplyLabel: formatConversationLatestReplyLabel(
|
||
latestConversationActivityAt,
|
||
Boolean(topThread),
|
||
),
|
||
unreadCount: project.unreadCount,
|
||
riskLevel: project.riskLevel,
|
||
activeDeviceCount: devices.length,
|
||
deviceNamesPreview: devices.map((device) => device.name),
|
||
avatar: {
|
||
primary: devices[0]?.avatar ?? "A",
|
||
secondary: project.isGroup ? devices[1]?.avatar : undefined,
|
||
overflowCount: Math.max(0, devices.length - 2) || undefined,
|
||
},
|
||
groupMembers,
|
||
contextBudgetIndicator: {
|
||
visible: Boolean(topThread),
|
||
style: "ring_percent",
|
||
percent: topThread?.contextBudgetRemainingPct,
|
||
level: topThread?.contextBudgetLevel,
|
||
},
|
||
contextBudgetSourceNodeId: topThread?.nodeId,
|
||
contextBudgetUpdatedAt: topThread?.capturedAt,
|
||
mustFinishBeforeCompaction: Boolean(topThread?.mustFinishBeforeCompaction),
|
||
} satisfies ConversationItem;
|
||
}
|
||
|
||
function buildProjectDisplayTitles(project: Project) {
|
||
const folderLabel = normalizeConversationTitle(project.threadMeta?.folderName ?? "");
|
||
const folderFallback = pickConversationTitleFallback([
|
||
folderLabel,
|
||
project.threadMeta?.codexFolderRef,
|
||
project.name,
|
||
]);
|
||
const threadTitle = sanitizeConversationTitle(project.threadMeta?.threadDisplayName ?? project.name, [
|
||
folderFallback,
|
||
project.name,
|
||
project.threadMeta?.codexFolderRef,
|
||
]);
|
||
const projectTitle = projectType(project) === "single_device"
|
||
? threadTitle ||
|
||
sanitizeConversationTitle(project.name, [folderFallback, project.threadMeta?.codexFolderRef])
|
||
: sanitizeConversationTitle(project.name, [
|
||
threadTitle,
|
||
folderFallback,
|
||
project.threadMeta?.codexFolderRef,
|
||
]);
|
||
return {
|
||
folderLabel,
|
||
threadTitle,
|
||
projectTitle,
|
||
};
|
||
}
|
||
|
||
function cloneProjectWithDisplayTitles(project: Project): Project {
|
||
const { folderLabel, threadTitle, projectTitle } = buildProjectDisplayTitles(project);
|
||
return {
|
||
...project,
|
||
name: projectTitle || project.name,
|
||
threadMeta: {
|
||
...project.threadMeta,
|
||
threadDisplayName: threadTitle || project.threadMeta.threadDisplayName,
|
||
folderName: folderLabel || project.threadMeta.folderName,
|
||
},
|
||
};
|
||
}
|
||
|
||
function deriveLatestConversationActivityAt(project: Project) {
|
||
const messageCandidates = [
|
||
project.lastMessageAt,
|
||
...project.messages.map((message) => message.sentAt),
|
||
].filter(Boolean) as string[];
|
||
|
||
let latest = messageCandidates[0];
|
||
let latestTs = latest ? Date.parse(latest) : Number.NEGATIVE_INFINITY;
|
||
|
||
for (const candidate of messageCandidates.slice(1)) {
|
||
const candidateTs = Date.parse(candidate);
|
||
if (!Number.isFinite(candidateTs)) {
|
||
continue;
|
||
}
|
||
if (!Number.isFinite(latestTs) || candidateTs > latestTs) {
|
||
latest = candidate;
|
||
latestTs = candidateTs;
|
||
}
|
||
}
|
||
|
||
return latest ?? project.lastMessageAt ?? project.updatedAt;
|
||
}
|
||
|
||
function deriveConversationActivityIconCount(state: BossState, project: Project): number {
|
||
let count = 0;
|
||
|
||
if (
|
||
state.dispatchPlans.some(
|
||
(plan) => plan.groupProjectId === project.id && plan.status === "pending_user_confirmation",
|
||
)
|
||
) {
|
||
count += 1;
|
||
}
|
||
|
||
count += state.dispatchExecutions.filter(
|
||
(execution) =>
|
||
(execution.groupProjectId === project.id || execution.targetProjectId === project.id) &&
|
||
(execution.status === "queued" || execution.status === "running"),
|
||
).length;
|
||
|
||
count += state.masterAgentTasks.filter(
|
||
(task) =>
|
||
task.projectId === project.id &&
|
||
task.taskType !== "device_import_resolution" &&
|
||
(task.status === "queued" || task.status === "running"),
|
||
).length;
|
||
|
||
return Math.max(0, Math.min(4, count));
|
||
}
|
||
|
||
function sortConversationItems(items: ConversationItem[]) {
|
||
return items.sort((a, b) => {
|
||
if (a.projectId === "master-agent") return -1;
|
||
if (b.projectId === "master-agent") return 1;
|
||
const aPinned = Boolean(a.topPinnedLabel);
|
||
const bPinned = Boolean(b.topPinnedLabel);
|
||
if (aPinned !== bPinned) return aPinned ? -1 : 1;
|
||
return b.latestReplyAt.localeCompare(a.latestReplyAt);
|
||
});
|
||
}
|
||
|
||
function buildFolderSearchAliases(items: ConversationItem[]) {
|
||
const aliases: string[] = [];
|
||
const targetProjectIds: string[] = [];
|
||
for (const item of items) {
|
||
const alias = (item.threadTitle?.trim() || item.projectTitle?.trim() || "").trim();
|
||
if (!alias) continue;
|
||
aliases.push(alias);
|
||
targetProjectIds.push(item.projectId);
|
||
}
|
||
return aliases.length > 0
|
||
? {
|
||
aliases,
|
||
targetProjectIds,
|
||
}
|
||
: undefined;
|
||
}
|
||
|
||
function compactImportedThreadPreview(preview?: string) {
|
||
const value = preview?.trim();
|
||
if (!value) return "";
|
||
if (/^已从设备.+导入线程《.+》[。.]?$/.test(value)) {
|
||
return "已导入线程";
|
||
}
|
||
if (isLikelyProcessPreview(value)) {
|
||
return "";
|
||
}
|
||
return compactConversationPreview(value);
|
||
}
|
||
|
||
function isLikelyProcessPreview(preview: string) {
|
||
const normalized = compactProcessPreview(preview);
|
||
if (!normalized) {
|
||
return false;
|
||
}
|
||
if (containsProcessPreviewMarker(normalized, PROCESS_PREVIEW_BLOCK_MARKERS)) {
|
||
return false;
|
||
}
|
||
if (isStructuredNumberedProcessPreview(preview)) {
|
||
return true;
|
||
}
|
||
return (
|
||
PROCESS_PREVIEW_PREFIXES.some((marker) => normalized.startsWith(marker)) ||
|
||
containsProcessPreviewMarker(normalized, PROCESS_PREVIEW_CONTAINS)
|
||
);
|
||
}
|
||
|
||
function compactProcessPreview(value: string) {
|
||
return value
|
||
.replace(/\r\n/g, "\n")
|
||
.replace(/\r/g, "\n")
|
||
.replace(/\n{2,}/g, "\n")
|
||
.trim()
|
||
.toLowerCase();
|
||
}
|
||
|
||
function containsProcessPreviewMarker(value: string, markers: string[]) {
|
||
return markers.some((marker) => value.includes(marker));
|
||
}
|
||
|
||
function isStructuredNumberedProcessPreview(preview: string) {
|
||
const numberedLines = preview
|
||
.replace(/\r\n/g, "\n")
|
||
.replace(/\r/g, "\n")
|
||
.split("\n")
|
||
.map((line) => compactProcessPreview(line))
|
||
.filter((line) => /^\d+[.)\u3001]\s*/.test(line));
|
||
if (numberedLines.length < 2) {
|
||
return false;
|
||
}
|
||
return containsProcessPreviewMarker(
|
||
numberedLines.join(" "),
|
||
PROCESS_PREVIEW_NUMBERED_HINTS,
|
||
);
|
||
}
|
||
|
||
function compactConversationPreview(preview: string) {
|
||
const structuredPreview = compactStructuredSummaryPreview(preview);
|
||
const flattened = (structuredPreview || preview)
|
||
.replace(/\[[^\]]+\]\(([^)]+)\)/g, "$1")
|
||
.replace(/`([^`]+)`/g, "$1")
|
||
.replace(/\s+/g, " ")
|
||
.trim();
|
||
if (!flattened) {
|
||
return "";
|
||
}
|
||
return flattened.length <= 72 ? flattened : `${flattened.slice(0, 72).trimEnd()}…`;
|
||
}
|
||
|
||
function compactStructuredSummaryPreview(preview: string) {
|
||
const raw = preview.trim();
|
||
if (!raw.startsWith("{") || !raw.endsWith("}")) {
|
||
return "";
|
||
}
|
||
try {
|
||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||
if (!parsed || Array.isArray(parsed)) {
|
||
return "";
|
||
}
|
||
const segments = [
|
||
formatStructuredSummarySegment("目标", parsed.projectGoal),
|
||
formatStructuredSummarySegment("进度", parsed.currentProgress),
|
||
formatStructuredSummarySegment("版本", parsed.versionRecord),
|
||
formatStructuredSummarySegment("下一步", parsed.recommendedNextStep),
|
||
].filter(Boolean);
|
||
return segments.join(" ");
|
||
} catch {
|
||
return "";
|
||
}
|
||
}
|
||
|
||
function formatStructuredSummarySegment(label: string, value: unknown) {
|
||
const normalized = typeof value === "string" ? value.trim() : "";
|
||
return normalized ? `${label}:${normalized}` : "";
|
||
}
|
||
|
||
function normalizeConversationTitle(value?: string) {
|
||
const source = value?.replace(/\u0000/g, "") ?? "";
|
||
const firstLine = source
|
||
.split(/\r?\n/)
|
||
.map((line) => line.trim())
|
||
.find(Boolean);
|
||
if (!firstLine) {
|
||
return "";
|
||
}
|
||
return firstLine.replace(/\s+/g, " ").trim();
|
||
}
|
||
|
||
function stripTrailingConversationTitleNoise(value: string) {
|
||
return value.replace(/['"}\]]{2,}$/g, "").trimEnd();
|
||
}
|
||
|
||
function looksLikeLeakedConversationTitle(value?: string) {
|
||
const normalized = normalizeConversationTitle(value);
|
||
if (!normalized) {
|
||
return false;
|
||
}
|
||
return (
|
||
LEAKED_TITLE_PREFIXES.some((marker) => normalized.startsWith(marker)) ||
|
||
LEAKED_TITLE_CONTAINS.some((marker) => normalized.includes(marker))
|
||
);
|
||
}
|
||
|
||
function extractWorkspaceProjectName(value?: string) {
|
||
const normalized = normalizeConversationTitle(value).replaceAll("\\", "/");
|
||
if (!normalized) {
|
||
return "";
|
||
}
|
||
const patterns = [
|
||
/\/Users\/[^/]+\/code\/([^/\s"'`,。;!?]+)/i,
|
||
/\/home\/[^/]+\/code\/([^/\s"'`,。;!?]+)/i,
|
||
/[A-Za-z]:\/Users\/[^/]+\/code\/([^/\s"'`,。;!?]+)/i,
|
||
];
|
||
for (const pattern of patterns) {
|
||
const match = normalized.match(pattern);
|
||
if (match?.[1]) {
|
||
return match[1].split("/")[0]?.trim() ?? "";
|
||
}
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function pickConversationTitleFallback(candidates: Array<string | undefined>) {
|
||
for (const candidate of candidates) {
|
||
const extractedProjectName = extractWorkspaceProjectName(candidate);
|
||
if (extractedProjectName && !looksLikeLeakedConversationTitle(extractedProjectName)) {
|
||
return extractedProjectName;
|
||
}
|
||
const normalized = stripTrailingConversationTitleNoise(
|
||
trimLocalWorkspacePrefix(normalizeConversationTitle(candidate)),
|
||
);
|
||
if (normalized && !looksLikeLeakedConversationTitle(normalized)) {
|
||
return normalized;
|
||
}
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function sanitizeConversationTitle(value: string | undefined, fallbackCandidates: Array<string | undefined> = []) {
|
||
const normalized = normalizeConversationTitle(value);
|
||
const trimmed = stripTrailingConversationTitleNoise(trimLocalWorkspacePrefix(normalized));
|
||
if (trimmed && !looksLikeLeakedConversationTitle(normalized) && !looksLikeLeakedConversationTitle(trimmed)) {
|
||
return trimmed;
|
||
}
|
||
|
||
const extractedProjectName = extractWorkspaceProjectName(normalized);
|
||
if (extractedProjectName && !looksLikeLeakedConversationTitle(extractedProjectName)) {
|
||
return extractedProjectName;
|
||
}
|
||
|
||
const fallback = pickConversationTitleFallback(fallbackCandidates);
|
||
return fallback || trimmed;
|
||
}
|
||
|
||
function trimLocalWorkspacePrefix(label?: string) {
|
||
const value = label?.trim();
|
||
if (!value) return "";
|
||
const normalized = value.replaceAll("\\", "/");
|
||
const patterns = [
|
||
/^\/Users\/[^/]+\/code\/(.+)$/i,
|
||
/^\/home\/[^/]+\/code\/(.+)$/i,
|
||
/^[A-Za-z]:\/Users\/[^/]+\/code\/(.+)$/i,
|
||
];
|
||
for (const pattern of patterns) {
|
||
const match = normalized.match(pattern);
|
||
if (match?.[1]) {
|
||
return match[1];
|
||
}
|
||
}
|
||
return value;
|
||
}
|
||
|
||
export function getConversationItems(state: BossState): ConversationItem[] {
|
||
const conversations = state.projects.map((project) => buildConversationItem(state, project));
|
||
|
||
return sortConversationItems(conversations);
|
||
}
|
||
|
||
function stateForSession(state: BossState, session: PermissionSession): BossState {
|
||
const visibleDevices = filterDevicesForSession(state, session);
|
||
const visibleDeviceIds = new Set(visibleDevices.map((device) => device.id));
|
||
const visibleProjects = filterProjectsForSession(state, session).map((project) => ({
|
||
...project,
|
||
deviceIds: project.deviceIds.filter((deviceId) => visibleDeviceIds.has(deviceId)),
|
||
groupMembers: project.groupMembers.filter((member) => visibleDeviceIds.has(member.deviceId)),
|
||
}));
|
||
const scopedVisibleProjects = visibleProjects.map((project) =>
|
||
project.id === "master-agent" && session.role !== "highest_admin"
|
||
? projectWithAccountScopedMasterMessages(project, session.account)
|
||
: project,
|
||
);
|
||
const visibleProjectIds = new Set(scopedVisibleProjects.map((project) => project.id));
|
||
const canSeeThreadOnDevice = (projectId: string, deviceId: string) =>
|
||
visibleProjectIds.has(projectId) && visibleDeviceIds.has(deviceId);
|
||
return {
|
||
...state,
|
||
devices: visibleDevices,
|
||
projects: scopedVisibleProjects,
|
||
deviceSkills: state.deviceSkills.filter((skill) =>
|
||
visibleDeviceIds.has(skill.deviceId) &&
|
||
(session.role === "highest_admin" ||
|
||
canViewSkill(state, session, skill.skillId, { deviceId: skill.deviceId })),
|
||
),
|
||
threadStatusDocuments: state.threadStatusDocuments.filter((document) =>
|
||
canSeeThreadOnDevice(document.projectId, document.deviceId),
|
||
),
|
||
threadProgressEvents: state.threadProgressEvents.filter((event) =>
|
||
canSeeThreadOnDevice(event.projectId, event.deviceId),
|
||
),
|
||
threadContextSnapshots: state.threadContextSnapshots.filter((snapshot) =>
|
||
canSeeThreadOnDevice(snapshot.projectId, snapshot.nodeId),
|
||
),
|
||
threadHandoffPackages: state.threadHandoffPackages.filter((item) =>
|
||
visibleProjectIds.has(item.projectId),
|
||
),
|
||
threadContextAlerts: state.threadContextAlerts.filter((alert) =>
|
||
visibleProjectIds.has(alert.projectId),
|
||
),
|
||
dispatchPlans: state.dispatchPlans
|
||
.filter((plan) => visibleProjectIds.has(plan.groupProjectId))
|
||
.map((plan) => ({
|
||
...plan,
|
||
targets: plan.targets.filter(
|
||
(target) =>
|
||
visibleProjectIds.has(target.projectId) &&
|
||
visibleDeviceIds.has(target.deviceId),
|
||
),
|
||
confirmedTargetProjectIds: plan.confirmedTargetProjectIds?.filter((projectId) =>
|
||
visibleProjectIds.has(projectId),
|
||
),
|
||
})),
|
||
dispatchExecutions: state.dispatchExecutions.filter(
|
||
(execution) =>
|
||
visibleProjectIds.has(execution.groupProjectId) &&
|
||
visibleProjectIds.has(execution.targetProjectId) &&
|
||
visibleDeviceIds.has(execution.deviceId),
|
||
),
|
||
masterAgentTasks: state.masterAgentTasks.filter(
|
||
(task) =>
|
||
task.requestedByAccount === session.account ||
|
||
visibleProjectIds.has(task.projectId) ||
|
||
Boolean(task.targetProjectId && visibleProjectIds.has(task.targetProjectId)),
|
||
),
|
||
appLogs: state.appLogs.filter((log) =>
|
||
visibleDeviceIds.has(log.deviceId) ||
|
||
Boolean(log.projectId && visibleProjectIds.has(log.projectId)),
|
||
),
|
||
};
|
||
}
|
||
|
||
function projectWithAccountScopedMasterMessages(project: Project, account: string): Project {
|
||
const messages = project.messages.filter((message) => message.account === account);
|
||
const latestMessage = [...messages].sort(
|
||
(left, right) => Date.parse(right.sentAt) - Date.parse(left.sentAt),
|
||
)[0];
|
||
return {
|
||
...project,
|
||
messages,
|
||
preview: latestMessage?.body ?? "",
|
||
lastMessageAt: latestMessage?.sentAt ?? project.updatedAt,
|
||
unreadCount: messages.filter((message) => message.sender !== "user").length,
|
||
};
|
||
}
|
||
|
||
export function getAuthorizedStateSnapshot(
|
||
state: BossState,
|
||
session: Pick<AuthSession, "account" | "role" | "displayName">,
|
||
): BossState {
|
||
return stateForSession(state, session);
|
||
}
|
||
|
||
export function getConversationItemsForSession(
|
||
state: BossState,
|
||
session: Pick<AuthSession, "account" | "role" | "displayName">,
|
||
): ConversationItem[] {
|
||
return getConversationItems(stateForSession(state, session));
|
||
}
|
||
|
||
export interface ConversationFolderView {
|
||
folderKey: string;
|
||
folderLabel: string;
|
||
deviceId?: string;
|
||
deviceName?: string;
|
||
threadCount: number;
|
||
threads: ConversationItem[];
|
||
}
|
||
|
||
export interface ProjectMessagesRealtimePayload {
|
||
ok: true;
|
||
project: Project;
|
||
devices: Device[];
|
||
}
|
||
|
||
export function getConversationHomeItems(state: BossState): ConversationItem[] {
|
||
const flatItems = getConversationItems(state);
|
||
const projectMap = new Map(state.projects.map((project) => [project.id, project]));
|
||
const grouped = new Map<string, ConversationItem[]>();
|
||
const passthrough: ConversationItem[] = [];
|
||
|
||
for (const item of flatItems) {
|
||
const project = projectMap.get(item.projectId);
|
||
if (!project || item.conversationType !== "single_device") {
|
||
passthrough.push(item);
|
||
continue;
|
||
}
|
||
const folderKey = buildFolderKey(project);
|
||
if (!folderKey) {
|
||
passthrough.push(item);
|
||
continue;
|
||
}
|
||
const bucket = grouped.get(folderKey) ?? [];
|
||
bucket.push(item);
|
||
grouped.set(folderKey, bucket);
|
||
}
|
||
|
||
for (const [folderKey, items] of grouped) {
|
||
if (items.length <= 1) {
|
||
passthrough.push(items[0]);
|
||
continue;
|
||
}
|
||
const latestItem = [...items].sort((a, b) => b.latestReplyAt.localeCompare(a.latestReplyAt))[0];
|
||
const project = projectMap.get(latestItem.projectId);
|
||
const device = project?.deviceIds[0]
|
||
? state.devices.find((entry) => entry.id === project.deviceIds[0])
|
||
: undefined;
|
||
const topContextItem = [...items]
|
||
.filter((item) => item.contextBudgetIndicator.visible)
|
||
.sort((a, b) => {
|
||
if (a.mustFinishBeforeCompaction !== b.mustFinishBeforeCompaction) {
|
||
return a.mustFinishBeforeCompaction ? -1 : 1;
|
||
}
|
||
const aLevel = a.contextBudgetIndicator.level ?? "safe";
|
||
const bLevel = b.contextBudgetIndicator.level ?? "safe";
|
||
if (levelPriority[aLevel] !== levelPriority[bLevel]) {
|
||
return levelPriority[aLevel] - levelPriority[bLevel];
|
||
}
|
||
return b.latestReplyAt.localeCompare(a.latestReplyAt);
|
||
})[0];
|
||
const recentThreadLabel = trimLocalWorkspacePrefix(latestItem.threadTitle);
|
||
const searchAliases = buildFolderSearchAliases(items);
|
||
const latestPreview = compactImportedThreadPreview(latestItem.preview);
|
||
const latestMessagePreview = compactImportedThreadPreview(
|
||
latestItem.lastMessagePreview || latestItem.preview,
|
||
);
|
||
const historyCleared = conversationHistoryWasCleared(state);
|
||
passthrough.push({
|
||
conversationId: `folder-${folderKey}`,
|
||
conversationType: "folder_archive",
|
||
projectId: folderKey,
|
||
projectTitle:
|
||
project?.threadMeta.folderName ??
|
||
(latestItem.folderLabel || latestItem.projectTitle),
|
||
threadTitle:
|
||
project?.threadMeta.folderName ??
|
||
(latestItem.folderLabel || latestItem.threadTitle),
|
||
folderLabel: recentThreadLabel ? `${items.length} 个线程 · 最近:${recentThreadLabel}` : `${items.length} 个线程`,
|
||
folderKey,
|
||
threadCount: items.length,
|
||
topPinnedLabel: items.some((entry) => entry.topPinnedLabel) ? "置顶" : undefined,
|
||
manualPinned: items.some((entry) => entry.manualPinned),
|
||
...(searchAliases
|
||
? {
|
||
searchAliases: searchAliases.aliases,
|
||
searchTargetProjectIds: searchAliases.targetProjectIds,
|
||
}
|
||
: {}),
|
||
preview:
|
||
latestPreview ||
|
||
(historyCleared ? "" : `包含 ${items.length} 个线程,最近活跃:《${recentThreadLabel || latestItem.threadTitle}》`),
|
||
lastMessagePreview:
|
||
latestMessagePreview ||
|
||
latestPreview ||
|
||
(historyCleared ? "" : `包含 ${items.length} 个线程,最近活跃:《${recentThreadLabel || latestItem.threadTitle}》`),
|
||
activityIconCount: Math.max(0, Math.min(4, items.reduce((sum, entry) => sum + entry.activityIconCount, 0))),
|
||
latestReplyAt: latestItem.latestReplyAt,
|
||
latestReplyLabel: latestItem.latestReplyLabel,
|
||
unreadCount: items.reduce((sum, entry) => sum + entry.unreadCount, 0),
|
||
riskLevel: items.some((entry) => entry.riskLevel === "high")
|
||
? "high"
|
||
: items.some((entry) => entry.riskLevel === "medium")
|
||
? "medium"
|
||
: "low",
|
||
activeDeviceCount: 1,
|
||
deviceNamesPreview: device ? [device.name] : latestItem.deviceNamesPreview,
|
||
avatar: {
|
||
primary: device?.avatar ?? latestItem.avatar.primary,
|
||
},
|
||
contextBudgetIndicator: {
|
||
visible: Boolean(topContextItem),
|
||
style: "ring_percent",
|
||
percent: topContextItem?.contextBudgetIndicator.percent,
|
||
level: topContextItem?.contextBudgetIndicator.level,
|
||
},
|
||
contextBudgetSourceNodeId: topContextItem?.contextBudgetSourceNodeId,
|
||
contextBudgetUpdatedAt: topContextItem?.contextBudgetUpdatedAt,
|
||
mustFinishBeforeCompaction: Boolean(topContextItem?.mustFinishBeforeCompaction),
|
||
});
|
||
}
|
||
|
||
return sortConversationItems(passthrough);
|
||
}
|
||
|
||
export function getConversationHomeItemsForSession(
|
||
state: BossState,
|
||
session: Pick<AuthSession, "account" | "role" | "displayName">,
|
||
): ConversationItem[] {
|
||
return getConversationHomeItems(stateForSession(state, session));
|
||
}
|
||
|
||
export function getConversationWebItems(state: BossState): ConversationItem[] {
|
||
return getConversationHomeItems(state).map((item) => ({
|
||
...item,
|
||
topPinnedLabel: undefined,
|
||
manualPinned: false,
|
||
}));
|
||
}
|
||
|
||
export function getConversationHomeItemForProject(state: BossState, projectId: string): ConversationItem | null {
|
||
const normalizedProjectId = projectId.trim();
|
||
if (!normalizedProjectId) {
|
||
return null;
|
||
}
|
||
return (
|
||
getConversationHomeItems(state).find((item) => {
|
||
if (item.projectId === normalizedProjectId) {
|
||
return true;
|
||
}
|
||
return Array.isArray(item.searchTargetProjectIds)
|
||
? item.searchTargetProjectIds.includes(normalizedProjectId)
|
||
: false;
|
||
}) ?? null
|
||
);
|
||
}
|
||
|
||
export function getConversationThreadItemForProject(state: BossState, projectId: string): ConversationItem | null {
|
||
const normalizedProjectId = projectId.trim();
|
||
if (!normalizedProjectId) {
|
||
return null;
|
||
}
|
||
return getConversationItems(state).find((item) => item.projectId === normalizedProjectId) ?? null;
|
||
}
|
||
|
||
export function getConversationFolderView(
|
||
state: BossState,
|
||
folderKey: string,
|
||
): ConversationFolderView | null {
|
||
const flatItems = getConversationItems(state).filter(
|
||
(item) => item.conversationType === "single_device" && item.folderKey === folderKey,
|
||
);
|
||
if (flatItems.length === 0) {
|
||
return null;
|
||
}
|
||
const project = state.projects.find((entry) => buildFolderKey(entry) === folderKey);
|
||
const deviceId = project?.deviceIds[0];
|
||
const device = deviceId ? state.devices.find((entry) => entry.id === deviceId) : undefined;
|
||
return {
|
||
folderKey,
|
||
folderLabel: project?.threadMeta.folderName ?? flatItems[0].folderLabel,
|
||
deviceId,
|
||
deviceName: device?.name,
|
||
threadCount: flatItems.length,
|
||
threads: sortConversationItems(flatItems),
|
||
};
|
||
}
|
||
|
||
export function getConversationFolderViewForSession(
|
||
state: BossState,
|
||
session: Pick<AuthSession, "account" | "role" | "displayName">,
|
||
folderKey: string,
|
||
): ConversationFolderView | null {
|
||
return getConversationFolderView(stateForSession(state, session), folderKey);
|
||
}
|
||
|
||
export function buildProjectMessagesRealtimePayload(
|
||
state: BossState,
|
||
projectId: string,
|
||
): ProjectMessagesRealtimePayload | null {
|
||
const normalizedProjectId = projectId.trim();
|
||
if (!normalizedProjectId) {
|
||
return null;
|
||
}
|
||
const project = state.projects.find((item) => item.id === normalizedProjectId);
|
||
if (!project) {
|
||
return null;
|
||
}
|
||
return {
|
||
ok: true,
|
||
project: cloneProjectWithDisplayTitles(project),
|
||
devices: state.devices.filter((device) => project.deviceIds.includes(device.id)),
|
||
};
|
||
}
|
||
|
||
export function buildProjectMessagesRealtimePayloadForSession(
|
||
state: BossState,
|
||
session: Pick<AuthSession, "account" | "role" | "displayName">,
|
||
projectId: string,
|
||
): ProjectMessagesRealtimePayload | null {
|
||
if (!canAccessProject(state, session, projectId, "project.view")) {
|
||
return null;
|
||
}
|
||
const project = state.projects.find((item) => item.id === projectId);
|
||
if (!project) {
|
||
return null;
|
||
}
|
||
const scopedProject =
|
||
project.id === "master-agent" && session.role !== "highest_admin"
|
||
? projectWithAccountScopedMasterMessages(cloneProjectWithDisplayTitles(project), session.account)
|
||
: cloneProjectWithDisplayTitles(project);
|
||
return {
|
||
ok: true,
|
||
project: scopedProject,
|
||
devices: filterProjectDevicesForSession(state, session, project),
|
||
};
|
||
}
|
||
|
||
function resolveProjectAgentControls(
|
||
state: BossState,
|
||
projectId: string,
|
||
account?: string,
|
||
) {
|
||
const normalizedAccount = account?.trim();
|
||
const scoped = normalizedAccount
|
||
? (
|
||
state.userProjectAgentControls.find(
|
||
(item) => item.projectId === projectId && item.account === normalizedAccount,
|
||
) ?? null
|
||
)
|
||
: null;
|
||
const projectControls = scoped?.controls ?? state.projects.find((item) => item.id === projectId)?.agentControls ?? null;
|
||
if (projectId === "master-agent") {
|
||
return projectControls;
|
||
}
|
||
const globalControls = normalizedAccount
|
||
? (
|
||
state.userProjectAgentControls.find(
|
||
(item) => item.projectId === "master-agent" && item.account === normalizedAccount,
|
||
)?.controls ?? state.projects.find((item) => item.id === "master-agent")?.agentControls ?? null
|
||
)
|
||
: state.projects.find((item) => item.id === "master-agent")?.agentControls ?? null;
|
||
const explicitTakeover = projectControls?.takeoverEnabled;
|
||
const inheritedGlobalTakeover = globalControls?.globalTakeoverEnabled;
|
||
const effectiveTakeoverEnabled =
|
||
explicitTakeover !== undefined ? explicitTakeover : Boolean(inheritedGlobalTakeover);
|
||
const takeoverInheritedFromGlobal =
|
||
explicitTakeover === undefined && inheritedGlobalTakeover !== undefined;
|
||
if (!projectControls && !takeoverInheritedFromGlobal && !effectiveTakeoverEnabled) {
|
||
return null;
|
||
}
|
||
return {
|
||
...(projectControls ?? { updatedAt: globalControls?.updatedAt ?? new Date().toISOString() }),
|
||
updatedAt: projectControls?.updatedAt ?? globalControls?.updatedAt ?? new Date().toISOString(),
|
||
effectiveTakeoverEnabled,
|
||
takeoverInheritedFromGlobal,
|
||
};
|
||
}
|
||
|
||
export function getProjectDetailView(state: BossState, projectId: string, account?: string): ProjectDetailView | null {
|
||
const project = state.projects.find((item) => item.id === projectId);
|
||
if (!project) return null;
|
||
const displayProject = cloneProjectWithDisplayTitles(project);
|
||
|
||
const activeThreadContexts = threadViewsForProject(state, projectId);
|
||
const threadsRequiringHandoff = activeThreadContexts.filter(
|
||
(item) =>
|
||
item.snapshot.mustFinishBeforeCompaction ||
|
||
item.snapshot.contextBudgetLevel === "urgent" ||
|
||
item.snapshot.contextBudgetLevel === "critical",
|
||
);
|
||
|
||
const openFaults = state.opsFaults.filter(
|
||
(fault) => fault.projectId === projectId && fault.status !== "resolved",
|
||
);
|
||
const relatedAuditResults = state.auditResults.filter((result) =>
|
||
state.auditRequests.some(
|
||
(request) => request.auditRequestId === result.auditRequestId && request.projectId === projectId,
|
||
),
|
||
);
|
||
|
||
const topRisk = threadsRequiringHandoff[0]?.snapshot ?? activeThreadContexts[0]?.snapshot;
|
||
const masterContextStrategySummary = topRisk
|
||
? `${topRisk.title} 需要优先处理,当前 ${topRisk.contextBudgetLevel} ${topRisk.contextBudgetRemainingPct}%${topRisk.mustFinishBeforeCompaction ? ",必须先固化 patch / 测试 / 证据" : ""}。`
|
||
: "当前没有高风险线程,主 Agent 可以继续按正常优先级调度。";
|
||
const projectDeviceIds = new Set(project.deviceIds);
|
||
const recentAppLogs = [...state.appLogs]
|
||
.filter((log) =>
|
||
projectId === "master-agent"
|
||
? true
|
||
: log.projectId === projectId || projectDeviceIds.has(log.deviceId),
|
||
)
|
||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
||
.slice(0, 6);
|
||
|
||
return {
|
||
project: displayProject,
|
||
agentControls: resolveProjectAgentControls(state, projectId, account),
|
||
devices: state.devices.filter((device) => project.deviceIds.includes(device.id)),
|
||
masterIdentity: projectId === "master-agent" ? getProjectMasterIdentity(state) : undefined,
|
||
activeThreadContexts,
|
||
nextCompactionRiskThreadId: topRisk?.threadId,
|
||
threadsRequiringHandoff,
|
||
masterContextStrategySummary,
|
||
recentAppLogs,
|
||
openFaults,
|
||
relatedAuditResults,
|
||
};
|
||
}
|
||
|
||
export function getProjectDetailViewForSession(
|
||
state: BossState,
|
||
projectId: string,
|
||
session: Pick<AuthSession, "account" | "role" | "displayName">,
|
||
): ProjectDetailView | null {
|
||
if (!canAccessProject(state, session, projectId, "project.view")) {
|
||
return null;
|
||
}
|
||
const detail = getProjectDetailView(state, projectId, session.account);
|
||
if (!detail) {
|
||
return null;
|
||
}
|
||
const visibleProjectIds = new Set(filterProjectsForSession(state, session).map((project) => project.id));
|
||
const visibleDeviceIds = new Set(filterDevicesForSession(state, session).map((device) => device.id));
|
||
return {
|
||
...detail,
|
||
devices: filterProjectDevicesForSession(state, session, detail.project),
|
||
activeThreadContexts: detail.activeThreadContexts.filter((item) =>
|
||
visibleProjectIds.has(item.snapshot.projectId) && visibleDeviceIds.has(item.snapshot.nodeId),
|
||
),
|
||
threadsRequiringHandoff: detail.threadsRequiringHandoff.filter((item) =>
|
||
visibleProjectIds.has(item.snapshot.projectId) && visibleDeviceIds.has(item.snapshot.nodeId),
|
||
),
|
||
recentAppLogs: detail.recentAppLogs.filter((log) =>
|
||
visibleDeviceIds.has(log.deviceId) ||
|
||
Boolean(log.projectId && visibleProjectIds.has(log.projectId)),
|
||
),
|
||
};
|
||
}
|
||
|
||
export function getThreadContextDetailView(
|
||
state: BossState,
|
||
threadId: string,
|
||
): ThreadContextDetailView | null {
|
||
const snapshot = state.threadContextSnapshots.find((item) => item.threadId === threadId);
|
||
if (!snapshot) return null;
|
||
const handoffPackage = state.threadHandoffPackages.find(
|
||
(item) => item.fromThreadId === threadId && item.packageStatus !== "expired",
|
||
);
|
||
const alerts = state.threadContextAlerts.filter((item) => item.threadId === threadId);
|
||
const masterActions = Array.from(
|
||
new Set(alerts.flatMap((alert) => alert.masterActions)),
|
||
);
|
||
|
||
return {
|
||
snapshot,
|
||
handoffPackage,
|
||
alerts,
|
||
currentChecklist: snapshot.checklist,
|
||
masterActions,
|
||
};
|
||
}
|
||
|
||
export function getDeviceWorkspaceView(
|
||
state: BossState,
|
||
deviceId?: string,
|
||
): DeviceWorkspaceView {
|
||
if (!deviceId) {
|
||
return {
|
||
relatedThreads: [],
|
||
};
|
||
}
|
||
const selectedDevice = state.devices.find((item) => item.id === deviceId);
|
||
return {
|
||
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),
|
||
};
|
||
}
|
||
|
||
export function getDeviceWorkspaceViewForSession(
|
||
state: BossState,
|
||
session: Pick<AuthSession, "account" | "role" | "displayName">,
|
||
deviceId?: string,
|
||
): DeviceWorkspaceView {
|
||
if (!deviceId || !canAccessDevice(state, session, deviceId, "device.view")) {
|
||
return { relatedThreads: [] };
|
||
}
|
||
return getDeviceWorkspaceView(stateForSession(state, session), deviceId);
|
||
}
|
||
|
||
export function getOpsSummaryView(state: BossState): OpsSummaryView {
|
||
const tickets = state.opsRepairTickets.map((ticket) => ({
|
||
...ticket,
|
||
verification: state.opsRepairVerifications.find((item) => item.ticketId === ticket.ticketId),
|
||
}));
|
||
const mode =
|
||
state.opsFaults.some((fault) => fault.status !== "resolved") ||
|
||
state.threadContextSnapshots.some(
|
||
(snapshot) =>
|
||
snapshot.contextBudgetLevel === "urgent" || snapshot.contextBudgetLevel === "critical",
|
||
)
|
||
? "active"
|
||
: "idle";
|
||
|
||
return {
|
||
mode,
|
||
faults: [...state.opsFaults].sort((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt)),
|
||
tickets: tickets.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)),
|
||
};
|
||
}
|
||
|
||
export function getAuditSummaryView(state: BossState): AuditSummaryView {
|
||
const completedIds = new Set(state.auditResults.map((result) => result.auditRequestId));
|
||
return {
|
||
pendingRequests: state.auditRequests.filter(
|
||
(request) => !completedIds.has(request.auditRequestId),
|
||
),
|
||
latestResults: [...state.auditResults].sort((a, b) =>
|
||
b.completedAt.localeCompare(a.completedAt),
|
||
),
|
||
capabilities: state.capabilities,
|
||
};
|
||
}
|
||
|
||
export function getSkillInventoryView(state: BossState): SkillInventoryView {
|
||
return getSkillInventoryViewForAccount(
|
||
state,
|
||
state.user.account,
|
||
state.user.boundDeviceId,
|
||
);
|
||
}
|
||
|
||
export function getSkillInventoryViewForAccount(
|
||
state: BossState,
|
||
account: string,
|
||
boundDeviceId?: string,
|
||
): SkillInventoryView {
|
||
const devices = state.devices
|
||
.filter(
|
||
(device) =>
|
||
device.account === account || device.id === boundDeviceId,
|
||
)
|
||
.sort((a, b) => {
|
||
if (a.id === boundDeviceId) return -1;
|
||
if (b.id === boundDeviceId) return 1;
|
||
return b.lastSeenAt.localeCompare(a.lastSeenAt);
|
||
});
|
||
|
||
return {
|
||
boundDeviceId,
|
||
groups: devices
|
||
.map((device) => ({
|
||
device,
|
||
skills: state.deviceSkills
|
||
.filter((skill) => skill.deviceId === device.id)
|
||
.sort((a, b) => a.name.localeCompare(b.name, "zh-CN")),
|
||
}))
|
||
.filter((group) => group.skills.length > 0),
|
||
};
|
||
}
|
||
|
||
export function getSkillInventoryViewForSession(
|
||
state: BossState,
|
||
session: Pick<AuthSession, "account" | "role" | "displayName">,
|
||
boundDeviceId?: string,
|
||
): SkillInventoryView {
|
||
const devices = filterDevicesForSession(state, session)
|
||
.filter((device) => !boundDeviceId || device.id === boundDeviceId || session.role === "highest_admin")
|
||
.sort((a, b) => {
|
||
if (a.id === boundDeviceId) return -1;
|
||
if (b.id === boundDeviceId) return 1;
|
||
return b.lastSeenAt.localeCompare(a.lastSeenAt);
|
||
});
|
||
|
||
return {
|
||
boundDeviceId,
|
||
groups: devices
|
||
.map((device) => ({
|
||
device,
|
||
skills: state.deviceSkills
|
||
.filter((skill) => skill.deviceId === device.id)
|
||
.filter((skill) =>
|
||
session.role === "highest_admin" ||
|
||
canViewSkill(state, session, skill.skillId, { deviceId: device.id }),
|
||
)
|
||
.sort((a, b) => a.name.localeCompare(b.name, "zh-CN")),
|
||
}))
|
||
.filter((group) => group.skills.length > 0),
|
||
};
|
||
}
|