feat: update Makelore modules and conversations
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

This commit is contained in:
inman
2026-07-31 10:08:41 +08:00
parent b8ca3f8eea
commit 80e8386fa6
175 changed files with 8036 additions and 10581 deletions

View File

@@ -0,0 +1,348 @@
import { useCallback, useMemo, useState } from 'react';
import { Archive, ChevronRight, Pin, Plus, RotateCcw, Settings as SettingsIcon, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { AgentCreationDialog, type AgentCreationInput } from '@/components/opencode/AgentCreationDialog';
import { getAgentAvatarSrc } from '@/lib/agent-avatars';
import type { ConfiguredModelOption } from '@/lib/model-options';
import { extractText } from '@/pages/Chat/message-utils';
import type { ProjectAgentConfig } from '../../../shared/project-config';
import type { ProjectSessionMetadata } from '../../../shared/project-conversations';
import type { RawMessage } from '@/types/chat';
import type { OpencodeSession, OpencodeSessionStatusMap } from '@/types/opencode';
import { cn } from '@/lib/utils';
type ArchiveTarget =
| { kind: 'agent'; agent: ProjectAgentConfig }
| { kind: 'session'; sessionId: string; title: string }
| null;
type DeleteTarget =
| { kind: 'agent'; agent: ProjectAgentConfig; sessionCount: number }
| { kind: 'session'; sessionId: string; title: string }
| null;
type AgentConversationSidebarProps = {
agents: ProjectAgentConfig[];
sessions: OpencodeSession[];
sessionMessagesBySessionId: Record<string, RawMessage[]>;
conversationSessions: ProjectSessionMetadata[];
selectedAgentId: string | null;
selectedSessionId: string | null;
sessionStatuses: OpencodeSessionStatusMap;
pendingQuestionCountBySession: Record<string, number>;
pendingPermissionCountBySession: Record<string, number>;
startingAgentId: string | null;
modelOptions: ConfiguredModelOption[];
onSelectAgent: (agentId: string) => void;
onSelectSession: (sessionId: string) => void;
onNewSession: (agentId: string) => void;
onCreateAgent: (input: AgentCreationInput) => Promise<void>;
onArchiveAgent: (agent: ProjectAgentConfig) => Promise<void>;
onRestoreAgent: (agent: ProjectAgentConfig) => Promise<void>;
onDeleteAgent: (agent: ProjectAgentConfig) => Promise<void>;
onArchiveSession: (sessionId: string) => Promise<void>;
onRestoreSession: (sessionId: string) => Promise<void>;
onDeleteSession: (sessionId: string) => Promise<void>;
onTogglePinAgent: (agent: ProjectAgentConfig) => Promise<void>;
onOpenProjectSettings?: () => void;
};
function getSessionId(session: OpencodeSession): string {
return String(session.id ?? session.sessionID ?? '');
}
function sessionTimestamp(session: OpencodeSession | undefined, metadata: ProjectSessionMetadata | undefined): number {
const runtimeTime = session?.time?.updated;
if (typeof runtimeTime === 'number') return runtimeTime;
const runtimeUpdated = Date.parse(session?.updatedAt ?? '');
if (Number.isFinite(runtimeUpdated)) return runtimeUpdated;
const metadataUpdated = Date.parse(metadata?.updatedAt ?? '');
return Number.isFinite(metadataUpdated) ? metadataUpdated : 0;
}
function formatSessionTime(timestamp: number): string {
if (!timestamp) return '';
const date = new Date(timestamp);
const now = new Date();
if (date.toDateString() === now.toDateString()) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' });
}
function sessionTitle(session: OpencodeSession | undefined): string {
const title = session?.title?.trim() || session?.name?.trim();
return title || '新对话';
}
function latestMessagePreview(messages: RawMessage[] | undefined, fallback: string): string {
for (let index = (messages?.length ?? 0) - 1; index >= 0; index -= 1) {
const message = messages?.[index];
if (!message) continue;
const preview = extractText(message).replace(/\s+/gu, ' ').trim();
if (preview) return preview;
}
return fallback;
}
const SESSION_PREVIEW_LIMIT = 24;
function compactSessionPreview(preview: string): string {
const characters = Array.from(preview);
return characters.length > SESSION_PREVIEW_LIMIT
? `${characters.slice(0, SESSION_PREVIEW_LIMIT - 1).join('')}`
: preview;
}
export function AgentConversationSidebar({
agents,
sessions,
sessionMessagesBySessionId,
conversationSessions,
selectedAgentId,
selectedSessionId,
sessionStatuses,
pendingQuestionCountBySession,
pendingPermissionCountBySession,
startingAgentId,
modelOptions,
onSelectAgent,
onSelectSession,
onNewSession,
onCreateAgent,
onArchiveAgent,
onRestoreAgent,
onDeleteAgent,
onArchiveSession,
onRestoreSession,
onDeleteSession,
onTogglePinAgent,
onOpenProjectSettings,
}: AgentConversationSidebarProps) {
const [createOpen, setCreateOpen] = useState(false);
const [archiveTarget, setArchiveTarget] = useState<ArchiveTarget>(null);
const [deleteTarget, setDeleteTarget] = useState<DeleteTarget>(null);
const [showArchived, setShowArchived] = useState(false);
const [expandedSessionLists, setExpandedSessionLists] = useState<Record<string, boolean>>({});
const runtimeBySessionId = useMemo(
() => new Map(sessions.map((session) => [getSessionId(session), session])),
[sessions],
);
const sessionsByAgentId = useMemo(() => {
const grouped = new Map<string, ProjectSessionMetadata[]>();
for (const metadata of conversationSessions) {
const current = grouped.get(metadata.agentId) ?? [];
current.push(metadata);
grouped.set(metadata.agentId, current);
}
return grouped;
}, [conversationSessions]);
const getAgentSessions = useCallback((agentId: string) => {
const metadata = sessionsByAgentId.get(agentId) ?? [];
return [...metadata].sort((left, right) => {
const leftArchived = Boolean(left.archivedAt);
const rightArchived = Boolean(right.archivedAt);
if (leftArchived !== rightArchived) return leftArchived ? 1 : -1;
return sessionTimestamp(runtimeBySessionId.get(right.sessionId), right)
- sessionTimestamp(runtimeBySessionId.get(left.sessionId), left);
});
}, [runtimeBySessionId, sessionsByAgentId]);
const agentActivity = useCallback((agent: ProjectAgentConfig) => {
const agentSessions = getAgentSessions(agent.id);
const unread = agentSessions.reduce((total, item) => total + item.unreadCount, 0);
const running = agentSessions.some((item) => {
const type = sessionStatuses[item.sessionId]?.type;
return type === 'busy' || type === 'retry';
});
const pending = agentSessions.reduce(
(total, item) => total + (pendingQuestionCountBySession[item.sessionId] ?? 0) + (pendingPermissionCountBySession[item.sessionId] ?? 0),
0,
);
const latest = agentSessions[0];
return { unread, running, pending, latestAt: latest ? sessionTimestamp(runtimeBySessionId.get(latest.sessionId), latest) : 0 };
}, [getAgentSessions, pendingPermissionCountBySession, pendingQuestionCountBySession, runtimeBySessionId, sessionStatuses]);
const activeAgents = useMemo(
() => agents
.filter((agent) => !agent.archivedAt)
.sort((left, right) => {
if (Boolean(left.pinned) !== Boolean(right.pinned)) return left.pinned ? -1 : 1;
return agentActivity(right).latestAt - agentActivity(left).latestAt;
}),
[agentActivity, agents],
);
const archivedAgents = useMemo(() => agents.filter((agent) => Boolean(agent.archivedAt)), [agents]);
const getAgentStatus = (activity: ReturnType<typeof agentActivity>) => {
if (activity.pending > 0) return { label: '处理中', dot: 'bg-amber-500 animate-pulse' };
if (activity.running) return { label: '处理中', dot: 'bg-emerald-500 animate-pulse' };
if (activity.unread > 0) return { label: '有新消息', dot: 'bg-brand' };
return null;
};
const renderAgentSessions = (agent: ProjectAgentConfig) => {
const agentSessions = getAgentSessions(agent.id);
const activeSessions = agentSessions.filter((item) => !item.archivedAt);
const visibleSessions = activeSessions.slice(0, 5);
const hiddenSessions = activeSessions.slice(5);
const archivedSessions = agentSessions.filter((item) => Boolean(item.archivedAt));
const renderSession = (metadata: ProjectSessionMetadata) => {
const runtimeSession = runtimeBySessionId.get(metadata.sessionId);
const title = sessionTitle(runtimeSession);
const preview = compactSessionPreview(latestMessagePreview(sessionMessagesBySessionId[metadata.sessionId], title));
const selected = metadata.sessionId === selectedSessionId;
const pending = (pendingQuestionCountBySession[metadata.sessionId] ?? 0) + (pendingPermissionCountBySession[metadata.sessionId] ?? 0);
return (
<div
key={metadata.sessionId}
className={cn(
'group/session-card flex w-full items-center gap-1 rounded-lg px-1 py-0.5',
selected ? 'bg-background shadow-soft' : 'hover:bg-background/70',
)}
>
<button
type="button"
aria-current={selected ? 'page' : undefined}
className="flex min-w-0 flex-1 items-center gap-1.5 px-1.5 py-1 text-left"
onClick={() => onSelectSession(metadata.sessionId)}
>
<span className="min-w-0 flex-1 truncate text-[11px] font-semibold" title={latestMessagePreview(sessionMessagesBySessionId[metadata.sessionId], title)}>
{preview}
</span>
{metadata.unreadCount > 0 ? <Badge className="shrink-0 rounded-full border-0 bg-brand px-1.5 text-[10px] text-primary-foreground">{metadata.unreadCount > 99 ? '99+' : metadata.unreadCount}</Badge> : pending > 0 ? <Badge className="shrink-0 rounded-full border-0 bg-amber-500 px-1.5 text-[10px] text-white">{pending}</Badge> : null}
<span className="shrink-0 whitespace-nowrap text-[10px] text-muted-foreground">
{formatSessionTime(sessionTimestamp(runtimeSession, metadata)) || '刚刚'}
</span>
</button>
<button
type="button"
className="pointer-events-none shrink-0 rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-surface-subtle hover:text-destructive group-hover/session-card:pointer-events-auto group-hover/session-card:opacity-100"
aria-label={`归档会话 ${title}`}
title="归档会话"
onClick={() => setArchiveTarget({ kind: 'session', sessionId: metadata.sessionId, title })}
>
<Archive className="h-3 w-3" />
</button>
</div>
);
};
return (
<section className="border-t border-brand/15 px-2 pb-1.5 pt-1" aria-label={`${agent.name || '联系人'}的会话列表`}>
<div className="flex items-center justify-end">
<button type="button" className="rounded p-1 text-muted-foreground hover:bg-background hover:text-brand" aria-label="新建会话" title="新建会话" onClick={() => onNewSession(agent.id)}><Plus className="h-3.5 w-3.5" /></button>
</div>
<div className="space-y-0.5">
{visibleSessions.map(renderSession)}
{activeSessions.length === 0 ? <button type="button" className="flex w-full items-center gap-2 rounded-lg border border-dashed border-border/80 px-2.5 py-2.5 text-left text-xs text-muted-foreground hover:bg-background" onClick={() => onNewSession(agent.id)}><Plus className="h-3.5 w-3.5" /></button> : null}
{hiddenSessions.length > 0 ? <details className="pt-1" open={expandedSessionLists[agent.id] ?? hiddenSessions.some((item) => item.sessionId === selectedSessionId)} onToggle={(event) => setExpandedSessionLists((current) => ({ ...current, [agent.id]: event.currentTarget.open }))}><summary className="cursor-pointer px-2.5 py-1 text-[10px] font-semibold text-muted-foreground">{hiddenSessions.length}</summary><div className="mt-0.5 space-y-0.5">{hiddenSessions.map(renderSession)}</div></details> : null}
{archivedSessions.length > 0 ? <details className="pt-2"><summary className="cursor-pointer px-2.5 text-[10px] font-semibold text-muted-foreground">{archivedSessions.length}</summary><div className="mt-1 space-y-1">{archivedSessions.map((metadata) => { const title = sessionTitle(runtimeBySessionId.get(metadata.sessionId)); return <div key={metadata.sessionId} className="flex items-center gap-2 rounded-lg px-2.5 py-2 text-xs text-muted-foreground"><span className="min-w-0 flex-1 truncate" title={title}>{compactSessionPreview(title)}</span><button type="button" className="rounded p-1 text-muted-foreground hover:bg-background hover:text-brand" aria-label={`恢复会话 ${title}`} onClick={() => void onRestoreSession(metadata.sessionId)}><RotateCcw className="h-3 w-3" /></button><button type="button" className="rounded p-1 text-muted-foreground hover:bg-accent-soft hover:text-destructive" aria-label={`永久删除会话 ${title}`} title="永久删除会话" onClick={() => setDeleteTarget({ kind: 'session', sessionId: metadata.sessionId, title })}><Trash2 className="h-3 w-3" /></button></div>; })}</div></details> : null}
</div>
</section>
);
};
const handleArchiveConfirm = async () => {
if (!archiveTarget) return;
if (archiveTarget.kind === 'agent') await onArchiveAgent(archiveTarget.agent);
else await onArchiveSession(archiveTarget.sessionId);
setArchiveTarget(null);
};
const handleDeleteConfirm = async () => {
if (!deleteTarget) return;
if (deleteTarget.kind === 'agent') await onDeleteAgent(deleteTarget.agent);
else await onDeleteSession(deleteTarget.sessionId);
setDeleteTarget(null);
};
return (
<aside className="flex h-full min-h-0 w-full shrink-0 flex-col overflow-hidden rounded-none border-r border-border/80 bg-surface-tertiary text-foreground lg:w-[19rem]" data-testid="agent-conversation-sidebar">
<div className="relative flex shrink-0 items-center border-b border-border/70 bg-background/70 px-3 py-3 pr-24">
<h2 className="min-w-0 truncate text-left text-base font-semibold"></h2>
<div className="absolute right-3 flex items-center gap-1">
{onOpenProjectSettings ? (
<Button type="button" size="icon" variant="ghost" className="h-8 w-8 rounded-full text-muted-foreground hover:bg-surface-subtle hover:text-foreground" aria-label="项目设置" title="项目设置" onClick={onOpenProjectSettings}>
<SettingsIcon className="h-4 w-4" />
</Button>
) : null}
<Button type="button" size="icon" className="h-8 w-8 rounded-full border border-brand/20 bg-brand-soft text-brand" aria-label="创建联系人" onClick={() => { if (modelOptions.length === 0) { toast.error('请先配置至少一个可用模型。'); return; } setCreateOpen(true); }}>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-0" data-testid="agent-conversation-sidebar-content">
<div className="space-y-1">
{activeAgents.map((agent) => {
const activity = agentActivity(agent);
const selected = agent.id === selectedAgentId;
const status = getAgentStatus(activity);
return (
<div key={agent.id} className={cn('group/agent-card relative overflow-hidden rounded-none border transition-colors', selected ? 'border-brand/45 bg-brand-soft shadow-soft before:absolute before:inset-y-2 before:left-0 before:w-0.5 before:rounded-full before:bg-brand' : 'border-transparent hover:border-border/70 hover:bg-background')}>
<div className="relative">
<button type="button" className="flex w-full min-w-0 items-center gap-2.5 px-2.5 py-2.5 pr-12 text-left" aria-pressed={selected} aria-expanded={selected} aria-busy={startingAgentId === agent.id} title={selected ? '收起最近会话' : '展开最近会话'} data-testid={`project-agent-chat-${agent.id}`} disabled={startingAgentId === agent.id} onClick={() => onSelectAgent(agent.id)}>
<span className="relative shrink-0"><img src={getAgentAvatarSrc(agent.avatarId)} alt="" className="h-11 w-11 rounded-xl border border-border object-cover [image-rendering:pixelated]" /></span>
<span className="min-w-0 flex-1"><span className="flex items-center gap-1.5"><span className="truncate text-sm font-semibold">{agent.name || '未命名联系人'}</span>{agent.pinned ? <Pin className="h-3 w-3 shrink-0 text-brand" /> : null}</span><span className="mt-0.5 block truncate text-[11px] text-muted-foreground">{agent.responsibility.mission || '还没有填写职责'}</span></span>
<span className="flex shrink-0 flex-col items-end gap-1">
<span data-testid={`agent-status-row-${agent.id}`} className="inline-flex items-center gap-1">
{status ? <span className="inline-flex max-w-24 items-center gap-1 text-[10px] font-medium text-muted-foreground" title={status.label}><span aria-hidden="true" className={cn('h-2 w-2 shrink-0 rounded-full', status.dot)} /><span className="truncate">{status.label}</span></span> : null}
{activity.unread > 0 ? <Badge className="min-w-5 justify-center rounded-full border-0 bg-brand px-1.5 text-[10px] text-primary-foreground">{activity.unread > 99 ? '99+' : activity.unread}</Badge> : activity.pending > 0 ? <Badge className="min-w-5 justify-center rounded-full border-0 bg-amber-500 px-1.5 text-[10px] text-white">{activity.pending}</Badge> : null}
</span>
{activity.latestAt ? <span className="text-[10px] text-muted-foreground">{formatSessionTime(activity.latestAt)}</span> : null}
</span>
</button>
<div className="absolute right-1 top-1/2 hidden -translate-y-1/2 flex-col gap-0.5 rounded-md bg-background/90 p-0.5 shadow-soft group-hover/agent-card:flex">
<button type="button" className="rounded p-1.5 text-muted-foreground hover:bg-surface-subtle hover:text-brand" aria-label={agent.pinned ? '取消置顶联系人' : '置顶联系人'} title={agent.pinned ? '取消置顶' : '置顶'} onClick={() => void onTogglePinAgent(agent)}><Pin className={cn('h-3.5 w-3.5', agent.pinned && 'fill-current text-brand')} /></button>
<button type="button" className="rounded p-1.5 text-muted-foreground hover:bg-surface-subtle hover:text-destructive" aria-label={`归档联系人 ${agent.name || '未命名联系人'}`} title="归档联系人" onClick={() => setArchiveTarget({ kind: 'agent', agent })}><Archive className="h-3.5 w-3.5" /></button>
</div>
</div>
{selected ? renderAgentSessions(agent) : null}
</div>
);
})}
{activeAgents.length === 0 ? <div className="rounded-xl border border-dashed border-border/80 bg-background/60 p-5 text-center text-xs leading-5 text-muted-foreground"><br /> + </div> : null}
</div>
{archivedAgents.length > 0 ? <section className="mt-3 border-t border-border/70 pt-3"><button type="button" className="flex w-full items-center justify-between px-1 text-xs font-semibold text-muted-foreground" onClick={() => setShowArchived((current) => !current)}><span>{archivedAgents.length}</span><ChevronRight className={cn('h-3.5 w-3.5 transition-transform', showArchived && 'rotate-90')} /></button>{showArchived ? <div className="mt-1 space-y-1">{archivedAgents.map((agent) => <div key={agent.id} className="flex items-center gap-2 rounded-lg px-2.5 py-2 text-xs text-muted-foreground"><img src={getAgentAvatarSrc(agent.avatarId)} alt="" className="h-7 w-7 rounded-lg object-cover [image-rendering:pixelated]" /><span className="min-w-0 flex-1 truncate">{agent.name || '未命名联系人'}</span><button type="button" className="rounded p-1.5 hover:bg-background hover:text-brand" aria-label={`恢复联系人 ${agent.name || '未命名联系人'}`} onClick={() => void onRestoreAgent(agent)}><RotateCcw className="h-3.5 w-3.5" /></button>{!agent.builtIn ? <button type="button" className="rounded p-1.5 text-muted-foreground hover:bg-accent-soft hover:text-destructive" aria-label={`永久删除联系人 ${agent.name || '未命名联系人'}`} title="永久删除联系人" onClick={() => setDeleteTarget({ kind: 'agent', agent, sessionCount: getAgentSessions(agent.id).length })}><Trash2 className="h-3.5 w-3.5" /></button> : null}</div>)}</div> : null}</section> : null}
</div>
<AgentCreationDialog
open={createOpen}
onOpenChange={setCreateOpen}
modelOptions={modelOptions}
existingAgentNames={agents.map((agent) => agent.name)}
onCreate={onCreateAgent}
/>
<ConfirmDialog
open={Boolean(archiveTarget)}
title={archiveTarget?.kind === 'agent' ? `归档联系人“${archiveTarget.agent.name || '未命名联系人'}”?` : `归档会话“${archiveTarget?.title ?? ''}”?`}
message={archiveTarget?.kind === 'agent' ? '归档会停止这个联系人下正在运行的所有会话,但会保留联系人、会话记录和未完成内容,之后可以在底部恢复。' : '归档会停止这个会话并保留全部历史,不会影响同一联系人的其他会话。'}
confirmLabel="确认归档"
cancelLabel="取消"
onCancel={() => setArchiveTarget(null)}
onConfirm={() => void handleArchiveConfirm()}
onError={(error) => toast.error(error instanceof Error ? error.message : String(error))}
/>
<ConfirmDialog
open={Boolean(deleteTarget)}
title={deleteTarget?.kind === 'agent' ? `永久删除联系人“${deleteTarget.agent.name || '未命名联系人'}”?` : `永久删除会话“${deleteTarget?.title ?? ''}”?`}
message={deleteTarget?.kind === 'agent'
? `这会永久删除联系人及其 ${deleteTarget.sessionCount} 条会话记录,删除后无法恢复。`
: '这会永久删除会话及其全部历史记录,删除后无法恢复。'}
confirmLabel="永久删除"
cancelLabel="取消"
variant="destructive"
onCancel={() => setDeleteTarget(null)}
onConfirm={() => void handleDeleteConfirm()}
onError={(error) => toast.error(error instanceof Error ? error.message : String(error))}
/>
</aside>
);
}