feat: restore wechat thread ui and group chat

This commit is contained in:
kris
2026-03-28 05:21:44 +08:00
parent afa7e79ad2
commit f0735b31e5
41 changed files with 4091 additions and 578 deletions

View File

@@ -130,6 +130,25 @@ export interface VersionEntry {
createdAt: string;
}
export interface ThreadConversationMeta {
projectId: string;
threadId: string;
threadDisplayName: string;
folderName: string;
activityIconCount: number;
updatedAt: string;
codexThreadRef?: string;
codexFolderRef?: string;
}
export interface GroupConversationMember {
projectId: string;
deviceId: string;
threadId: string;
threadDisplayName: string;
folderName: string;
}
export interface Project {
id: string;
name: string;
@@ -140,6 +159,11 @@ export interface Project {
updatedAt: string;
lastMessageAt: string;
isGroup: boolean;
threadMeta: ThreadConversationMeta;
groupMembers: GroupConversationMember[];
createdByAgent: boolean;
collaborationMode: "development" | "approval_required";
approvalState: "not_required" | "pending_agent" | "pending_user" | "approved" | "rejected";
unreadCount: number;
riskLevel: RiskLevel;
contextBudgetPct?: number;
@@ -698,6 +722,20 @@ const initialState: BossState = {
updatedAt: "2026-03-25T12:06:00+08:00",
lastMessageAt: "2026-03-25T12:06:00+08:00",
isGroup: false,
threadMeta: {
projectId: "master-agent",
threadId: "thread-master-main",
threadDisplayName: "主 Agent 汇总",
folderName: "主控线程",
activityIconCount: 1,
updatedAt: "2026-03-25T12:06:00+08:00",
codexThreadRef: "thread-master-main",
codexFolderRef: "master-agent",
},
groupMembers: [],
createdByAgent: true,
collaborationMode: "development",
approvalState: "not_required",
unreadCount: 0,
riskLevel: "medium",
contextBudgetPct: 71,
@@ -724,6 +762,20 @@ const initialState: BossState = {
updatedAt: "2026-03-25T11:52:00+08:00",
lastMessageAt: "2026-03-25T11:52:00+08:00",
isGroup: false,
threadMeta: {
projectId: "boss-console",
threadId: "thread-boss-ui",
threadDisplayName: "北区试产线回归",
folderName: "归档确认",
activityIconCount: 1,
updatedAt: "2026-03-25T11:52:00+08:00",
codexThreadRef: "thread-boss-ui",
codexFolderRef: "boss-console",
},
groupMembers: [],
createdByAgent: true,
collaborationMode: "development",
approvalState: "not_required",
unreadCount: 2,
riskLevel: "medium",
contextBudgetPct: 62,
@@ -795,6 +847,35 @@ const initialState: BossState = {
updatedAt: "2026-03-25T10:58:00+08:00",
lastMessageAt: "2026-03-25T10:58:00+08:00",
isGroup: true,
threadMeta: {
projectId: "audit-collab",
threadId: "thread-audit-chief",
threadDisplayName: "审计对话",
folderName: "审计群聊",
activityIconCount: 2,
updatedAt: "2026-03-25T10:58:00+08:00",
codexThreadRef: "thread-audit-chief",
codexFolderRef: "audit-collab",
},
groupMembers: [
{
projectId: "audit-collab",
deviceId: "mac-studio",
threadId: "thread-audit-chief",
threadDisplayName: "审计对话",
folderName: "审计群聊",
},
{
projectId: "audit-collab",
deviceId: "win-gpu-01",
threadId: "thread-audit-hardware",
threadDisplayName: "Windows 摄像头证据",
folderName: "审计群聊",
},
],
createdByAgent: true,
collaborationMode: "development",
approvalState: "not_required",
unreadCount: 1,
riskLevel: "high",
messages: [
@@ -1267,6 +1348,140 @@ function nowIso() {
return new Date().toISOString();
}
function normalizeThreadMeta(
raw: Partial<ThreadConversationMeta> | undefined,
project: { id: string; name: string; isGroup: boolean; updatedAt: string },
fallback?: ThreadConversationMeta,
): ThreadConversationMeta {
return {
projectId: raw?.projectId ?? project.id,
threadId: raw?.threadId ?? `thread-${project.id}`,
threadDisplayName: raw?.threadDisplayName ?? project.name,
folderName: raw?.folderName ?? fallback?.folderName ?? (project.isGroup ? "群聊" : project.name),
activityIconCount: Math.max(1, raw?.activityIconCount ?? fallback?.activityIconCount ?? (project.isGroup ? 2 : 1)),
updatedAt: raw?.updatedAt ?? project.updatedAt ?? nowIso(),
codexThreadRef: raw?.codexThreadRef,
codexFolderRef: raw?.codexFolderRef,
};
}
function normalizeGroupMember(
raw: Partial<GroupConversationMember>,
fallbackProjectId: string,
fallbackThreadMeta: ThreadConversationMeta,
): GroupConversationMember {
return {
projectId: raw.projectId ?? fallbackProjectId,
deviceId: raw.deviceId ?? "",
threadId: raw.threadId ?? fallbackThreadMeta.threadId,
threadDisplayName: raw.threadDisplayName ?? fallbackThreadMeta.threadDisplayName,
folderName: raw.folderName ?? fallbackThreadMeta.folderName,
};
}
function dedupeStrings(values: string[]) {
return [...new Set(values.filter((value) => Boolean(value)))];
}
function dedupeGroupMembers(members: GroupConversationMember[]) {
const seen = new Set<string>();
const deduped: GroupConversationMember[] = [];
for (const member of members) {
const key = `${member.projectId}:${member.deviceId}:${member.threadId}`;
if (seen.has(key)) continue;
seen.add(key);
deduped.push(member);
}
return deduped;
}
function buildLegacyGroupMembers(
projectId: string,
deviceIds: string[],
threadMeta: ThreadConversationMeta,
) {
return dedupeStrings(deviceIds).map((deviceId, index) => ({
projectId,
deviceId,
threadId:
index === 0 ? threadMeta.threadId : `${threadMeta.threadId}:${slugify(deviceId)}`,
threadDisplayName: threadMeta.threadDisplayName,
folderName: threadMeta.folderName,
}));
}
function normalizeProjectConversationShape(
project: Project,
options?: {
allowedDeviceIds?: Set<string>;
},
) {
const allowedDeviceIds = options?.allowedDeviceIds;
const normalizedThreadMeta = {
...project.threadMeta,
projectId: project.id,
};
const normalizedExplicitMembers = dedupeGroupMembers(
project.groupMembers.map((member) =>
normalizeGroupMember(member, project.id, normalizedThreadMeta),
),
);
const hasExplicitGroupMembers = normalizedExplicitMembers.length > 0;
const legacyGroupRequested = !hasExplicitGroupMembers && project.isGroup;
const resolvedGroupMembers = hasExplicitGroupMembers
? normalizedExplicitMembers
: legacyGroupRequested
? buildLegacyGroupMembers(project.id, project.deviceIds, normalizedThreadMeta)
: [];
const filteredGroupMembers = allowedDeviceIds
? resolvedGroupMembers.filter((member) => allowedDeviceIds.has(member.deviceId))
: resolvedGroupMembers;
if (filteredGroupMembers.length > 0) {
project.isGroup = true;
project.groupMembers = dedupeGroupMembers(filteredGroupMembers);
project.deviceIds = dedupeStrings(project.groupMembers.map((member) => member.deviceId));
project.threadMeta = {
...normalizedThreadMeta,
activityIconCount: Math.max(1, project.groupMembers.length),
};
return project;
}
project.isGroup = false;
project.groupMembers = [];
project.deviceIds = allowedDeviceIds
? project.deviceIds.filter((deviceId) => allowedDeviceIds.has(deviceId))
: project.deviceIds;
project.threadMeta = {
...normalizedThreadMeta,
activityIconCount: Math.max(1, normalizedThreadMeta.activityIconCount ?? 1),
};
return project;
}
function resolveProjectUpdatedAt(project: Pick<Project, "updatedAt" | "lastMessageAt" | "threadMeta">, latestActivityAt?: string) {
return latestIsoTimestamp(
project.updatedAt,
project.lastMessageAt,
project.threadMeta.updatedAt,
latestActivityAt,
);
}
function latestIsoTimestamp(...values: Array<string | undefined>) {
let latestValue: string | undefined;
let latestTime = 0;
for (const value of values) {
const valueTime = messageTimeValue(value);
if (valueTime > latestTime) {
latestTime = valueTime;
latestValue = value;
}
}
return latestValue ?? nowIso();
}
function ensureArray<T>(value: T[] | undefined, fallback: T[]) {
return Array.isArray(value) ? value : fallback;
}
@@ -1597,7 +1812,17 @@ function normalizeMessage(raw: Partial<Message>): Message {
function normalizeProject(raw: Partial<Project>, fallback?: Project): Project {
const base = fallback ?? cloneInitialState().projects[0];
return {
const projectId = raw.id ?? base.id;
const projectName = raw.name ?? base.name;
const projectUpdatedAt = latestIsoTimestamp(raw.updatedAt, raw.lastMessageAt, base.updatedAt, base.lastMessageAt);
const threadMetaFallback = fallback?.id === projectId ? fallback.threadMeta : undefined;
const threadMeta = normalizeThreadMeta(raw.threadMeta, {
id: projectId,
name: projectName,
isGroup: raw.isGroup ?? base.isGroup ?? false,
updatedAt: projectUpdatedAt,
}, threadMetaFallback);
const project: Project = {
...base,
...raw,
pinned: raw.pinned ?? base.pinned,
@@ -1620,7 +1845,17 @@ function normalizeProject(raw: Partial<Project>, fallback?: Project): Project {
summary: version.summary ?? "",
createdAt: version.createdAt ?? nowIso(),
})),
threadMeta,
createdByAgent: raw.createdByAgent ?? false,
collaborationMode: raw.collaborationMode ?? "development",
approvalState: raw.approvalState ?? "not_required",
};
project.groupMembers = ensureArray(raw.groupMembers, []).map((member) =>
normalizeGroupMember(member, projectId, project.threadMeta),
);
normalizeProjectConversationShape(project);
project.updatedAt = resolveProjectUpdatedAt(project, project.threadMeta.updatedAt);
return project;
}
function normalizeState(raw: Partial<BossState> | undefined): BossState {
@@ -2253,11 +2488,11 @@ function syncDerivedState(input: BossState) {
for (const project of state.projects) {
project.deviceIds = project.deviceIds.filter((deviceId) => visibleDeviceIds.has(deviceId));
project.isGroup = project.deviceIds.length > 1;
const projectSnapshots = state.threadContextSnapshots
.filter((snapshot) => snapshot.projectId === project.id)
.sort(compareSnapshotsForRisk);
normalizeProjectConversationShape(project, { allowedDeviceIds: visibleDeviceIds });
project.riskLevel = deriveRiskFromSnapshots(projectSnapshots);
if (project.isGroup) {
project.contextBudgetPct = undefined;
@@ -2271,7 +2506,7 @@ function syncDerivedState(input: BossState) {
}
project.lastMessageAt = latestProjectTimestamp(state, project.id);
project.updatedAt = project.lastMessageAt;
project.updatedAt = resolveProjectUpdatedAt(project, project.lastMessageAt);
project.preview = deriveProjectPreview(state, project);
project.unreadCount = Math.max(0, project.unreadCount ?? 0);
}
@@ -3826,6 +4061,150 @@ export async function updateConversationAction(
return project;
}
export async function renameProjectThread(input: {
projectId: string;
threadDisplayName: string;
requestedBy: string;
}) {
const threadDisplayName = input.threadDisplayName.trim();
if (!threadDisplayName) {
throw new Error("THREAD_DISPLAY_NAME_REQUIRED");
}
const project = await mutateState((state) => {
const nextProject = state.projects.find((item) => item.id === input.projectId);
if (!nextProject) throw new Error("PROJECT_NOT_FOUND");
if (nextProject.isGroup) throw new Error("PROJECT_IS_GROUP_CHAT");
const updatedAt = nowIso();
nextProject.name = threadDisplayName;
nextProject.threadMeta.threadDisplayName = threadDisplayName;
nextProject.threadMeta.updatedAt = updatedAt;
nextProject.updatedAt = updatedAt;
return nextProject;
});
publishBossEvent("conversation.updated", {
projectId: input.projectId,
note: `renamed by ${input.requestedBy}`,
});
return project;
}
export async function renameGroupChat(input: {
projectId: string;
name: string;
requestedBy: string;
}) {
const name = input.name.trim();
if (!name) {
throw new Error("GROUP_CHAT_NAME_REQUIRED");
}
const project = await mutateState((state) => {
const nextProject = state.projects.find((item) => item.id === input.projectId);
if (!nextProject) throw new Error("PROJECT_NOT_FOUND");
if (!nextProject.isGroup) throw new Error("PROJECT_NOT_GROUP_CHAT");
const updatedAt = nowIso();
nextProject.name = name;
nextProject.threadMeta.threadDisplayName = name;
nextProject.threadMeta.updatedAt = updatedAt;
nextProject.updatedAt = updatedAt;
return nextProject;
});
publishBossEvent("conversation.updated", {
projectId: input.projectId,
note: `renamed by ${input.requestedBy}`,
});
return project;
}
export async function createProjectGroupChat(input: {
sourceProjectId: string;
memberProjectIds: string[];
createdBy: string;
}) {
const project = await mutateState((state) => {
const source = state.projects.find((item) => item.id === input.sourceProjectId);
if (!source) throw new Error("GROUP_CHAT_SOURCE_NOT_FOUND");
const requestedProjectIds = [input.sourceProjectId, ...input.memberProjectIds];
const memberProjects: Project[] = [];
const seenProjectIds = new Set<string>();
for (const projectId of requestedProjectIds) {
if (seenProjectIds.has(projectId)) {
continue;
}
seenProjectIds.add(projectId);
const memberProject = state.projects.find((item) => item.id === projectId);
if (!memberProject) {
throw new Error("GROUP_CHAT_MEMBER_NOT_FOUND");
}
memberProjects.push(memberProject);
}
if (memberProjects.length < 2) {
throw new Error("GROUP_CHAT_REQUIRES_AT_LEAST_TWO_THREADS");
}
const now = nowIso();
const projectId = randomToken("project");
const threadId = randomToken("thread");
const threadDisplayName = source.threadMeta.threadDisplayName ?? source.name;
const folderName = source.threadMeta.folderName ?? (source.isGroup ? "群聊" : source.name);
const groupMembers = memberProjects.map((memberProject) => ({
projectId: memberProject.id,
deviceId: memberProject.deviceIds[0] ?? memberProject.id,
threadId: memberProject.threadMeta.threadId,
threadDisplayName: memberProject.threadMeta.threadDisplayName,
folderName: memberProject.threadMeta.folderName,
}));
const nextProject = normalizeProject({
id: projectId,
name: threadDisplayName,
pinned: false,
systemPinned: false,
deviceIds: dedupeStrings(groupMembers.map((member) => member.deviceId)),
preview: `已创建群聊《${threadDisplayName}`,
updatedAt: now,
lastMessageAt: now,
isGroup: true,
unreadCount: 0,
riskLevel: source.riskLevel,
threadMeta: {
projectId,
threadId,
threadDisplayName,
folderName,
activityIconCount: Math.max(1, memberProjects.length),
updatedAt: now,
},
groupMembers,
createdByAgent: true,
collaborationMode: "development",
approvalState: "not_required",
messages: [
{
id: randomToken("msg"),
sender: "master",
senderLabel: input.createdBy || "群聊创建",
body: `已由 ${input.createdBy || "系统"} 创建群聊《${threadDisplayName}》。`,
sentAt: now,
kind: "text",
},
],
goals: [],
versions: [],
});
state.projects.unshift(nextProject);
return nextProject;
});
publishBossEvent("project.messages.updated", { projectId: project.id });
publishBossEvent("conversation.updated", { projectId: project.id });
return project;
}
export async function appendProjectMessage(payload: {
projectId: string;
sender?: MessageSender;

View File

@@ -34,7 +34,12 @@ export interface ConversationItem {
conversationType: "master_agent" | "single_device" | "group";
projectId: string;
projectTitle: string;
threadTitle: string;
folderLabel: string;
preview: string;
lastMessagePreview: string;
activityIconCount: number;
topPinnedLabel?: "置顶";
manualPinned: boolean;
latestReplyAt: string;
latestReplyLabel: string;
@@ -47,6 +52,11 @@ export interface ConversationItem {
secondary?: string;
overflowCount?: number;
};
groupMembers?: Array<{
threadId: string;
avatar: string;
title: string;
}>;
contextBudgetIndicator: ContextIndicator;
contextBudgetSourceNodeId?: string;
contextBudgetUpdatedAt?: string;
@@ -170,6 +180,22 @@ function projectType(project: Project): ConversationItem["conversationType"] {
return project.isGroup ? "group" : "single_device";
}
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":
@@ -281,13 +307,32 @@ export function getConversationItems(state: BossState): 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),
@@ -300,6 +345,7 @@ export function getConversationItems(state: BossState): ConversationItem[] {
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",
@@ -315,7 +361,9 @@ export function getConversationItems(state: BossState): ConversationItem[] {
return conversations.sort((a, b) => {
if (a.projectId === "master-agent") return -1;
if (b.projectId === "master-agent") return 1;
if (a.manualPinned !== b.manualPinned) return a.manualPinned ? -1 : 1;
const aPinned = Boolean(a.topPinnedLabel);
const bPinned = Boolean(b.topPinnedLabel);
if (aPinned !== bPinned) return aPinned ? -1 : 1;
return b.latestReplyAt.localeCompare(a.latestReplyAt);
});
}