feat: group imported threads into project archives
This commit is contained in:
21
src/app/api/v1/conversation-folders/[folderKey]/route.ts
Normal file
21
src/app/api/v1/conversation-folders/[folderKey]/route.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { getConversationFolderView } from "@/lib/boss-projections";
|
||||
import { readState } from "@/lib/boss-data";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ folderKey: string }> },
|
||||
) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
const { folderKey } = await context.params;
|
||||
const state = await readState();
|
||||
const folder = getConversationFolderView(state, decodeURIComponent(folderKey));
|
||||
if (!folder) {
|
||||
return NextResponse.json({ ok: false, message: "FOLDER_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ ok: true, folder });
|
||||
}
|
||||
16
src/app/api/v1/conversations/home/route.ts
Normal file
16
src/app/api/v1/conversations/home/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { getConversationHomeItems } from "@/lib/boss-projections";
|
||||
import { readState } from "@/lib/boss-data";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
const state = await readState();
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
conversations: getConversationHomeItems(state),
|
||||
});
|
||||
}
|
||||
46
src/app/conversations/folders/[folderKey]/page.tsx
Normal file
46
src/app/conversations/folders/[folderKey]/page.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
AppShell,
|
||||
ConversationList,
|
||||
PageNav,
|
||||
StatusBar,
|
||||
} from "@/components/app-ui";
|
||||
import { requirePageSession } from "@/lib/boss-auth";
|
||||
import { getConversationFolderView } from "@/lib/boss-projections";
|
||||
import { readState } from "@/lib/boss-data";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ConversationFolderPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ folderKey: string }>;
|
||||
}) {
|
||||
await requirePageSession();
|
||||
const { folderKey } = await params;
|
||||
const state = await readState();
|
||||
const folder = getConversationFolderView(state, decodeURIComponent(folderKey));
|
||||
|
||||
return (
|
||||
<AppShell bottomNav={false}>
|
||||
<StatusBar />
|
||||
<PageNav title={folder?.folderLabel ?? "项目线程"} backHref="/conversations" />
|
||||
<div className="space-y-3 px-[18px] pb-6">
|
||||
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||||
<div className="text-[16px] font-semibold text-[#111111]">
|
||||
{folder?.folderLabel ?? "未命名项目"}
|
||||
</div>
|
||||
<div className="mt-2 text-[13px] leading-6 text-[#57606A]">
|
||||
{folder
|
||||
? `${folder.deviceName ?? "当前设备"} · ${folder.threadCount} 个线程`
|
||||
: "当前项目下没有可显示的线程。"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{folder ? (
|
||||
<ConversationList conversations={folder.threads} />
|
||||
) : (
|
||||
<div className="px-[18px] pb-6 text-[13px] text-[#8C8C8C]">未找到项目线程。</div>
|
||||
)}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
StatusBar,
|
||||
} from "@/components/app-ui";
|
||||
import { requirePageSession } from "@/lib/boss-auth";
|
||||
import { getConversationItems } from "@/lib/boss-projections";
|
||||
import { getConversationHomeItems } from "@/lib/boss-projections";
|
||||
import { readState } from "@/lib/boss-data";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -15,7 +15,7 @@ export const dynamic = "force-dynamic";
|
||||
export default async function ConversationsPage() {
|
||||
await requirePageSession();
|
||||
const state = await readState();
|
||||
const conversations = getConversationItems(state);
|
||||
const conversations = getConversationHomeItems(state);
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
HeaderTitle,
|
||||
StatusBar,
|
||||
} from "@/components/app-ui";
|
||||
import { DeviceImportDraftManager } from "@/components/device-import-draft-manager";
|
||||
import { requirePageSession } from "@/lib/boss-auth";
|
||||
import { getDeviceWorkspaceView } from "@/lib/boss-projections";
|
||||
import { readState } from "@/lib/boss-data";
|
||||
@@ -55,6 +56,12 @@ export default async function DevicesPage({
|
||||
relatedThreads={workspace.relatedThreads}
|
||||
activeEnrollment={workspace.activeEnrollment}
|
||||
/>
|
||||
<div className="mt-3">
|
||||
<DeviceImportDraftManager
|
||||
deviceId={workspace.selectedDevice.id}
|
||||
deviceName={workspace.selectedDevice.name}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</AppShell>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { usePathname, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { sendAppLog } from "@/components/app-runtime";
|
||||
import { DeviceImportDraftManager } from "@/components/device-import-draft-manager";
|
||||
import {
|
||||
clearNativeSessionSnapshot,
|
||||
currentAppLocation,
|
||||
@@ -353,6 +354,10 @@ function ConversationActionButtons({
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState<"toggle_pin" | "mark_read" | null>(null);
|
||||
|
||||
if (conversation.conversationType === "folder_archive") {
|
||||
return <div className="min-h-[24px]" />;
|
||||
}
|
||||
|
||||
async function runAction(action: "toggle_pin" | "mark_read") {
|
||||
setLoading(action);
|
||||
await fetch(conversationActionsPath(conversation.projectId), {
|
||||
@@ -405,7 +410,14 @@ export function ConversationList({
|
||||
<div className="mb-2 flex justify-end">
|
||||
<ConversationActionButtons conversation={conversation} />
|
||||
</div>
|
||||
<Link href={`/conversations/${conversation.projectId}`} className="flex items-start gap-3">
|
||||
<Link
|
||||
href={
|
||||
conversation.conversationType === "folder_archive" && conversation.folderKey
|
||||
? `/conversations/folders/${encodeURIComponent(conversation.folderKey)}`
|
||||
: `/conversations/${conversation.projectId}`
|
||||
}
|
||||
className="flex items-start gap-3"
|
||||
>
|
||||
<AvatarStack
|
||||
primary={conversation.avatar.primary}
|
||||
secondary={conversation.avatar.secondary}
|
||||
@@ -416,20 +428,28 @@ export function ConversationList({
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="truncate text-[17px] font-medium text-[#111111]">
|
||||
{conversation.projectTitle}
|
||||
{conversation.conversationType === "folder_archive"
|
||||
? conversation.threadTitle
|
||||
: conversation.projectTitle}
|
||||
</div>
|
||||
<span
|
||||
className={clsx(
|
||||
"rounded-full px-2 py-0.5 text-[10px] font-semibold",
|
||||
riskBadgeColor(conversation.riskLevel),
|
||||
)}
|
||||
>
|
||||
{conversation.riskLevel === "high"
|
||||
? "高风险"
|
||||
: conversation.riskLevel === "medium"
|
||||
? "关注"
|
||||
: "稳定"}
|
||||
</span>
|
||||
{conversation.conversationType === "folder_archive" ? (
|
||||
<span className="rounded-full bg-[#F4F5F7] px-2 py-0.5 text-[10px] font-semibold text-[#57606A]">
|
||||
{conversation.threadCount ?? 0} 个线程
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={clsx(
|
||||
"rounded-full px-2 py-0.5 text-[10px] font-semibold",
|
||||
riskBadgeColor(conversation.riskLevel),
|
||||
)}
|
||||
>
|
||||
{conversation.riskLevel === "high"
|
||||
? "高风险"
|
||||
: conversation.riskLevel === "medium"
|
||||
? "关注"
|
||||
: "稳定"}
|
||||
</span>
|
||||
)}
|
||||
{conversation.unreadCount > 0 ? (
|
||||
<span className="rounded-full bg-[#FF4D4F] px-2 py-0.5 text-[10px] font-semibold text-white">
|
||||
{conversation.unreadCount}
|
||||
@@ -437,7 +457,9 @@ export function ConversationList({
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1 text-[13px] text-[#8C8C8C]">
|
||||
{conversation.deviceNamesPreview.join(" / ")}
|
||||
{conversation.conversationType === "folder_archive"
|
||||
? conversation.folderLabel
|
||||
: conversation.deviceNamesPreview.join(" / ")}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-[14px] text-[#57606A]">
|
||||
{conversation.preview}
|
||||
@@ -1450,6 +1472,9 @@ export function DeviceEnrollmentBuilder() {
|
||||
{configSnippet}
|
||||
</pre>
|
||||
) : null}
|
||||
{result.device?.id ? (
|
||||
<DeviceImportDraftManager deviceId={result.device.id} deviceName={result.device.name} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
243
src/components/device-import-draft-manager.tsx
Normal file
243
src/components/device-import-draft-manager.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { DeviceImportDraft, DeviceImportResolution } from "@/lib/boss-data";
|
||||
|
||||
type ImportDraftResponse = {
|
||||
ok: boolean;
|
||||
draft?: DeviceImportDraft | null;
|
||||
resolution?: DeviceImportResolution | null;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
function groupCandidates(draft: DeviceImportDraft | null) {
|
||||
const groups = new Map<
|
||||
string,
|
||||
Array<DeviceImportDraft["candidates"][number]>
|
||||
>();
|
||||
for (const candidate of draft?.candidates ?? []) {
|
||||
const key = candidate.codexFolderRef?.trim() || candidate.folderRef?.trim() || candidate.folderName;
|
||||
const bucket = groups.get(key) ?? [];
|
||||
bucket.push(candidate);
|
||||
groups.set(key, bucket);
|
||||
}
|
||||
return [...groups.entries()].map(([key, items]) => ({
|
||||
key,
|
||||
folderName: items[0]?.folderName ?? "未命名项目",
|
||||
items: [...items].sort((a, b) => b.lastActiveAt.localeCompare(a.lastActiveAt)),
|
||||
}));
|
||||
}
|
||||
|
||||
export function DeviceImportDraftManager({
|
||||
deviceId,
|
||||
deviceName,
|
||||
}: {
|
||||
deviceId: string;
|
||||
deviceName?: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [draft, setDraft] = useState<DeviceImportDraft | null>(null);
|
||||
const [resolution, setResolution] = useState<DeviceImportResolution | null>(null);
|
||||
const [selectedCandidateIds, setSelectedCandidateIds] = useState<string[]>([]);
|
||||
|
||||
const loadDraft = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const response = await fetch(`/api/v1/devices/${deviceId}/import-draft`, { cache: "no-store" });
|
||||
const data = (await response.json()) as ImportDraftResponse;
|
||||
setLoading(false);
|
||||
setDraft(data.draft ?? null);
|
||||
setResolution(data.resolution ?? null);
|
||||
setSelectedCandidateIds(data.draft?.selectedCandidateIds ?? []);
|
||||
setMessage(data.ok ? "" : data.message ?? "导入草稿加载失败");
|
||||
}, [deviceId]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
void loadDraft();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [loadDraft]);
|
||||
|
||||
const groups = useMemo(() => groupCandidates(draft), [draft]);
|
||||
|
||||
function toggle(candidateId: string) {
|
||||
setSelectedCandidateIds((current) =>
|
||||
current.includes(candidateId)
|
||||
? current.filter((item) => item !== candidateId)
|
||||
: [...current, candidateId],
|
||||
);
|
||||
}
|
||||
|
||||
async function reviewSelection() {
|
||||
setLoading(true);
|
||||
const selectResponse = await fetch(`/api/v1/devices/${deviceId}/import-draft/select`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ selectedCandidateIds }),
|
||||
});
|
||||
const selectResult = (await selectResponse.json()) as { ok: boolean; message?: string; draft?: DeviceImportDraft };
|
||||
if (!selectResult.ok) {
|
||||
setLoading(false);
|
||||
setMessage(selectResult.message ?? "勾选保存失败");
|
||||
return;
|
||||
}
|
||||
|
||||
const reviewResponse = await fetch(`/api/v1/devices/${deviceId}/import-draft/review`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const reviewResult = (await reviewResponse.json()) as {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
draft?: DeviceImportDraft;
|
||||
resolution?: DeviceImportResolution;
|
||||
};
|
||||
setLoading(false);
|
||||
if (!reviewResult.ok) {
|
||||
setMessage(reviewResult.message ?? "导入建议生成失败");
|
||||
return;
|
||||
}
|
||||
setDraft(reviewResult.draft ?? selectResult.draft ?? null);
|
||||
setResolution(reviewResult.resolution ?? null);
|
||||
setMessage("已生成导入建议。");
|
||||
}
|
||||
|
||||
async function applyResolution() {
|
||||
setLoading(true);
|
||||
const response = await fetch(`/api/v1/devices/${deviceId}/import-draft/apply`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const result = (await response.json()) as {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
draft?: DeviceImportDraft;
|
||||
resolution?: DeviceImportResolution;
|
||||
};
|
||||
setLoading(false);
|
||||
if (!result.ok) {
|
||||
setMessage(result.message ?? "导入应用失败");
|
||||
return;
|
||||
}
|
||||
setDraft(result.draft ?? draft);
|
||||
setResolution(result.resolution ?? resolution);
|
||||
setMessage("已把选中的项目线程导入到会话首页。");
|
||||
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">
|
||||
<div>
|
||||
<div className="text-[16px] font-semibold text-[#111111]">导入 Codex 项目</div>
|
||||
<div className="mt-1 text-[12px] text-[#8C8C8C]">
|
||||
{deviceName ?? deviceId} 完成首次 heartbeat 后,这里会出现可导入项目和线程。
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadDraft()}
|
||||
disabled={loading}
|
||||
className="rounded-full border border-[#D9D9D9] px-3 py-1 text-[12px] text-[#57606A]"
|
||||
>
|
||||
{loading ? "刷新中" : "刷新"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{draft ? (
|
||||
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
|
||||
候选线程:{draft.candidates.length}
|
||||
<br />
|
||||
当前状态:{draft.status}
|
||||
<br />
|
||||
已勾选:{selectedCandidateIds.length}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
|
||||
当前还没有导入草稿。请先让设备端完成配对并保持在线,然后回到这里点击刷新。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groups.map((group) => (
|
||||
<div key={group.key} className="rounded-2xl border border-[#EAECEF] bg-[#FCFCFD] px-3 py-3">
|
||||
<div className="text-[14px] font-semibold text-[#111111]">{group.folderName}</div>
|
||||
<div className="mt-1 text-[12px] text-[#8C8C8C]">{group.items.length} 个线程</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{group.items.map((candidate) => {
|
||||
const selected = selectedCandidateIds.includes(candidate.candidateId);
|
||||
return (
|
||||
<label
|
||||
key={candidate.candidateId}
|
||||
className="flex cursor-pointer items-start gap-3 rounded-2xl border border-[#E5E5EA] bg-white px-3 py-3"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 h-4 w-4 accent-[#07C160]"
|
||||
checked={selected}
|
||||
onChange={() => toggle(candidate.candidateId)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[14px] font-medium text-[#111111]">
|
||||
{candidate.threadDisplayName}
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] text-[#8C8C8C]">
|
||||
最近活跃:{candidate.lastActiveAt}
|
||||
</div>
|
||||
</div>
|
||||
{candidate.suggestedImport ? (
|
||||
<span className="rounded-full bg-[#EAF7F0] px-2 py-0.5 text-[10px] font-semibold text-[#215B39]">
|
||||
推荐
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{resolution ? (
|
||||
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
|
||||
<div className="font-semibold text-[#111111]">{resolution.summary}</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
{resolution.items.map((item) => (
|
||||
<div key={item.candidateId}>
|
||||
{item.threadDisplayName} · {item.folderName} · {item.action}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void reviewSelection()}
|
||||
disabled={loading || selectedCandidateIds.length === 0}
|
||||
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white disabled:bg-[#B7E6C9]"
|
||||
>
|
||||
生成导入建议
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void applyResolution()}
|
||||
disabled={loading || !resolution || draft?.status === "applied"}
|
||||
className="rounded-full border border-[#D9D9D9] px-4 py-2 text-[13px] font-semibold text-[#57606A] disabled:text-[#B8B8B8]"
|
||||
>
|
||||
{draft?.status === "applied" ? "已导入" : "应用导入"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{message ? (
|
||||
<div className="rounded-2xl bg-[#EAF7F0] px-4 py-3 text-[12px] leading-6 text-[#215B39]">
|
||||
{message}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -33,11 +33,13 @@ export interface ContextIndicator {
|
||||
|
||||
export interface ConversationItem {
|
||||
conversationId: string;
|
||||
conversationType: "master_agent" | "single_device" | "group";
|
||||
conversationType: "master_agent" | "single_device" | "group" | "folder_archive";
|
||||
projectId: string;
|
||||
projectTitle: string;
|
||||
threadTitle: string;
|
||||
folderLabel: string;
|
||||
folderKey?: string;
|
||||
threadCount?: number;
|
||||
preview: string;
|
||||
lastMessagePreview: string;
|
||||
activityIconCount: number;
|
||||
@@ -184,6 +186,14 @@ function projectType(project: Project): ConversationItem["conversationType"] {
|
||||
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();
|
||||
if (!deviceId || !folderRef) return undefined;
|
||||
return `${deviceId}:${folderRef}`;
|
||||
}
|
||||
|
||||
function isTopPinnedConversation(project: Project) {
|
||||
return Boolean(project.pinned || project.systemPinned || project.id === "audit-collab");
|
||||
}
|
||||
@@ -306,63 +316,64 @@ function threadViewsForProject(state: BossState, projectId: string) {
|
||||
}));
|
||||
}
|
||||
|
||||
export function getConversationItems(state: BossState): ConversationItem[] {
|
||||
const conversations = state.projects.map((project) => {
|
||||
const devices = state.devices.filter((device) => project.deviceIds.includes(device.id));
|
||||
const threadViews = threadViewsForProject(state, project.id);
|
||||
const topThread = threadViews[0]?.snapshot;
|
||||
const threadTitle = project.threadMeta?.threadDisplayName ?? project.name;
|
||||
const folderLabel = project.threadMeta?.folderName ?? "";
|
||||
const activityIconCount = project.threadMeta?.activityIconCount ?? 1;
|
||||
const topPinnedLabel = isTopPinnedConversation(project) ? "置顶" : undefined;
|
||||
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;
|
||||
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 threadTitle = project.threadMeta?.threadDisplayName ?? project.name;
|
||||
const folderLabel = project.threadMeta?.folderName ?? "";
|
||||
const activityIconCount = project.threadMeta?.activityIconCount ?? 1;
|
||||
const topPinnedLabel = isTopPinnedConversation(project) ? "置顶" : undefined;
|
||||
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: project.name,
|
||||
threadTitle,
|
||||
folderLabel,
|
||||
preview: project.preview,
|
||||
lastMessagePreview: project.preview,
|
||||
activityIconCount,
|
||||
topPinnedLabel,
|
||||
manualPinned: Boolean(project.pinned && !project.systemPinned),
|
||||
latestReplyAt: project.lastMessageAt,
|
||||
latestReplyLabel: formatTimestampLabel(project.lastMessageAt),
|
||||
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: !project.isGroup && Boolean(topThread),
|
||||
style: "ring_percent",
|
||||
percent: !project.isGroup ? topThread?.contextBudgetRemainingPct : undefined,
|
||||
level: !project.isGroup ? topThread?.contextBudgetLevel : undefined,
|
||||
},
|
||||
contextBudgetSourceNodeId: !project.isGroup ? topThread?.nodeId : undefined,
|
||||
contextBudgetUpdatedAt: !project.isGroup ? topThread?.capturedAt : undefined,
|
||||
mustFinishBeforeCompaction: Boolean(topThread?.mustFinishBeforeCompaction),
|
||||
} satisfies ConversationItem;
|
||||
});
|
||||
return {
|
||||
conversationId: `conv-${project.id}`,
|
||||
conversationType: projectType(project),
|
||||
projectId: project.id,
|
||||
projectTitle: project.name,
|
||||
threadTitle,
|
||||
folderLabel,
|
||||
folderKey: buildFolderKey(project),
|
||||
preview: project.preview,
|
||||
lastMessagePreview: project.preview,
|
||||
activityIconCount,
|
||||
topPinnedLabel,
|
||||
manualPinned: Boolean(project.pinned && !project.systemPinned),
|
||||
latestReplyAt: project.lastMessageAt,
|
||||
latestReplyLabel: formatTimestampLabel(project.lastMessageAt),
|
||||
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: !project.isGroup && Boolean(topThread),
|
||||
style: "ring_percent",
|
||||
percent: !project.isGroup ? topThread?.contextBudgetRemainingPct : undefined,
|
||||
level: !project.isGroup ? topThread?.contextBudgetLevel : undefined,
|
||||
},
|
||||
contextBudgetSourceNodeId: !project.isGroup ? topThread?.nodeId : undefined,
|
||||
contextBudgetUpdatedAt: !project.isGroup ? topThread?.capturedAt : undefined,
|
||||
mustFinishBeforeCompaction: Boolean(topThread?.mustFinishBeforeCompaction),
|
||||
} satisfies ConversationItem;
|
||||
}
|
||||
|
||||
return conversations.sort((a, b) => {
|
||||
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);
|
||||
@@ -372,6 +383,128 @@ export function getConversationItems(state: BossState): ConversationItem[] {
|
||||
});
|
||||
}
|
||||
|
||||
export function getConversationItems(state: BossState): ConversationItem[] {
|
||||
const conversations = state.projects.map((project) => buildConversationItem(state, project));
|
||||
|
||||
return sortConversationItems(conversations);
|
||||
}
|
||||
|
||||
export interface ConversationFolderView {
|
||||
folderKey: string;
|
||||
folderLabel: string;
|
||||
deviceId?: string;
|
||||
deviceName?: string;
|
||||
threadCount: number;
|
||||
threads: ConversationItem[];
|
||||
}
|
||||
|
||||
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;
|
||||
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: `${device?.name ?? latestItem.deviceNamesPreview[0] ?? "设备"} · ${items.length} 个线程`,
|
||||
folderKey,
|
||||
threadCount: items.length,
|
||||
preview:
|
||||
latestItem.preview || `包含 ${items.length} 个线程,最近活跃:《${latestItem.threadTitle}》`,
|
||||
lastMessagePreview:
|
||||
latestItem.lastMessagePreview ||
|
||||
latestItem.preview ||
|
||||
`包含 ${items.length} 个线程,最近活跃:《${latestItem.threadTitle}》`,
|
||||
activityIconCount: Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
4,
|
||||
items.reduce((sum, entry) => sum + Math.max(1, entry.activityIconCount), 0),
|
||||
),
|
||||
),
|
||||
manualPinned: false,
|
||||
topPinnedLabel: undefined,
|
||||
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: false,
|
||||
style: "ring_percent",
|
||||
},
|
||||
mustFinishBeforeCompaction: false,
|
||||
});
|
||||
}
|
||||
|
||||
return sortConversationItems(passthrough);
|
||||
}
|
||||
|
||||
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 getProjectDetailView(state: BossState, projectId: string): ProjectDetailView | null {
|
||||
const project = state.projects.find((item) => item.id === projectId);
|
||||
if (!project) return null;
|
||||
|
||||
Reference in New Issue
Block a user