Files
makelore/src/pages/Chat/CodingConversationHeader.tsx

251 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useMemo, useState } from 'react';
import {
Archive,
CircleDot,
GitFork,
LoaderCircle,
PanelRight,
Pencil,
RotateCcw,
Settings2,
Square,
Unplug,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Select } from '@/components/ui/select';
import {
abortCodingConversation,
compactCodingConversation,
setCodingConversationModel,
setCodingConversationThinking,
} from '@/lib/coding-conversations';
import {
buildCodingModelOptions,
codingModelKey,
parseCodingModelKey,
} from '@/lib/coding-model-options';
import { useProviderStore } from '@/stores/providers';
import type {
ConversationSnapshot,
ConversationThinkingLevel,
} from '@/types/coding-conversation';
import type { CodingConversationMetadata } from '@/types/coding-project';
const THINKING_OPTIONS: Array<{ value: ConversationThinkingLevel; label: string }> = [
{ value: 'off', label: '关闭思考' },
{ value: 'minimal', label: '极简思考' },
{ value: 'low', label: '低思考' },
{ value: 'medium', label: '中等思考' },
{ value: 'high', label: '高思考' },
];
function runLabel(snapshot: ConversationSnapshot | null): string {
if (!snapshot) return '正在准备';
const { run } = snapshot;
if (run.status === 'idle') return run.terminalReason === 'aborted' ? '已中止' : '空闲';
if (run.status === 'preparing') return '正在准备';
if (run.status === 'queued') return '排队中';
if (run.status === 'running') return '生成中';
if (run.status === 'retrying') return `重试中${run.retry ? ` · 第 ${run.retry.attempt}` : ''}`;
if (run.status === 'compacting') return '整理上下文';
if (run.status === 'aborting') return '正在中止';
return '需要处理';
}
export function CodingConversationHeader({
conversation,
snapshot,
connectionState,
onRename,
onArchive,
onToggleUnread,
onFork,
onRefresh,
onRecover,
onOpenInspector,
onOpenSettings,
}: {
conversation: CodingConversationMetadata | null;
snapshot: ConversationSnapshot | null;
connectionState: string;
onRename(title: string): Promise<void>;
onArchive(): Promise<void>;
onToggleUnread(): Promise<void>;
onFork(): Promise<void>;
onRefresh(): Promise<void>;
onRecover(): Promise<void>;
onOpenInspector(): void;
onOpenSettings?(): void;
}) {
const accounts = useProviderStore((state) => state.accounts);
const vendors = useProviderStore((state) => state.vendors);
const options = useMemo(() => buildCodingModelOptions(accounts, vendors), [accounts, vendors]);
const [busyAction, setBusyAction] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [renameOpen, setRenameOpen] = useState(false);
const [titleDraft, setTitleDraft] = useState(conversation?.title ?? '');
const model = snapshot?.conversation.model.model ?? conversation?.model ?? null;
const modelValue = model ? codingModelKey(model) : '';
const hasCurrentOption = options.some((option) => option.key === modelValue);
const thinkingLevel = model?.thinkingLevel ?? 'off';
const runStatus = snapshot?.run.status ?? 'preparing';
const running = ['queued', 'running', 'retrying', 'compacting', 'aborting'].includes(runStatus);
const queueCount = snapshot?.queue.items.length ?? 0;
const context = snapshot?.context;
const usedPercent = context?.contextWindow
? Math.min(100, Math.round((context.usedTokens / context.contextWindow) * 100))
: 0;
const recoverable = Boolean(snapshot?.run.error?.recoverable || snapshot?.worker.error?.recoverable);
const perform = (key: string, action: () => Promise<void>) => {
if (busyAction || !conversation) return;
setBusyAction(key);
setActionError(null);
void action()
.catch((error) => setActionError(error instanceof Error ? error.message : String(error)))
.finally(() => setBusyAction((current) => current === key ? null : current));
};
return (
<header className="shrink-0 border-b border-foreground/10 bg-background" data-testid="coding-conversation-header">
<div className="flex min-h-14 items-center gap-3 px-4 sm:px-5">
<div className="min-w-0 flex-1">
<button
type="button"
className="group flex max-w-full items-center gap-1.5 rounded-lg text-left"
disabled={!conversation}
onClick={() => {
setTitleDraft(conversation?.title ?? '');
setRenameOpen(true);
}}
>
<span className="truncate text-sm font-semibold">{conversation?.title ?? '新对话'}</span>
<Pencil className="h-3 w-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" aria-hidden="true" />
</button>
<p className="truncate text-xs text-muted-foreground">
{runLabel(snapshot)}
{queueCount > 0 ? ` · 队列 ${queueCount}` : ''}
{connectionState === 'reconnecting' ? ' · 正在重连' : ''}
</p>
</div>
{running && runStatus !== 'aborting' && (
<Button
type="button"
variant="outline"
className="min-h-10 rounded-xl px-3 text-xs"
disabled={Boolean(busyAction) || !conversation}
onClick={() => perform('abort', async () => {
await abortCodingConversation(conversation!.id);
})}
>
{busyAction === 'abort' ? <LoaderCircle className="mr-1.5 h-4 w-4 animate-spin" aria-hidden="true" /> : <Square className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />}
</Button>
)}
<Button type="button" variant="ghost" size="icon" className="h-10 w-10 rounded-xl" aria-label="打开编程工具" disabled={!conversation} onClick={onOpenInspector}>
<PanelRight className="h-4 w-4" aria-hidden="true" />
</Button>
<Button type="button" variant="ghost" size="icon" className="h-10 w-10 rounded-xl" aria-label="伙伴设置" onClick={onOpenSettings}>
<Settings2 className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
{conversation && (
<div className="flex min-h-12 items-center gap-2 overflow-x-auto border-t border-foreground/[0.06] px-4 py-1.5 sm:px-5">
<Select
value={modelValue}
aria-label="当前对话模型"
className="h-9 min-w-52 max-w-72 text-xs"
disabled={Boolean(busyAction)}
onChange={(event) => {
const selected = parseCodingModelKey(event.target.value);
if (!selected) return;
perform('model', async () => {
await setCodingConversationModel(conversation.id, {
...selected,
thinkingLevel,
});
await onRefresh();
});
}}
>
{!model && <option value=""></option>}
{model && !hasCurrentOption && <option value={modelValue}>{model.accountId} / {model.modelId}</option>}
{options.map((option) => <option key={option.key} value={option.key}>{option.label}</option>)}
</Select>
<Select
value={thinkingLevel}
aria-label="当前对话思考级别"
className="h-9 w-36 shrink-0 text-xs"
disabled={Boolean(busyAction) || !model}
onChange={(event) => perform('thinking', async () => {
await setCodingConversationThinking(
conversation.id,
event.target.value as ConversationThinkingLevel,
);
await onRefresh();
})}
>
{THINKING_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</Select>
<div className="flex min-w-44 items-center gap-2 rounded-xl bg-surface-subtle px-3 py-2 text-[11px] text-muted-foreground" title="当前 Conversation 的上下文使用量">
<span className="whitespace-nowrap"> {context?.usedTokens ?? 0}/{context?.contextWindow ?? 0}</span>
<span className="h-1.5 w-16 overflow-hidden rounded-full bg-foreground/10"><span className="block h-full rounded-full bg-brand" style={{ width: `${usedPercent}%` }} /></span>
{context?.recalculating && <LoaderCircle className="h-3.5 w-3.5 animate-spin" aria-label="正在重算上下文" />}
</div>
<Button type="button" variant="ghost" className="min-h-9 shrink-0 rounded-xl px-3 text-xs" disabled={Boolean(busyAction) || running} onClick={() => perform('compact', async () => compactCodingConversation(conversation.id))}>
<Unplug className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />
</Button>
{recoverable && (
<Button type="button" variant="ghost" className="min-h-9 shrink-0 rounded-xl px-3 text-xs" disabled={Boolean(busyAction)} onClick={() => perform('recover', onRecover)}>
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />
</Button>
)}
<Button type="button" variant="ghost" className="min-h-9 shrink-0 rounded-xl px-3 text-xs" disabled={Boolean(busyAction)} onClick={() => perform('fork', onFork)}>
<GitFork className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />
</Button>
<Button type="button" variant="ghost" className="min-h-9 shrink-0 rounded-xl px-3 text-xs" disabled={Boolean(busyAction)} onClick={() => perform('unread', onToggleUnread)}>
<CircleDot className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />{conversation.unread ? '标为已读' : '标为未读'}
</Button>
<Button type="button" variant="ghost" className="min-h-9 shrink-0 rounded-xl px-3 text-xs text-muted-foreground" disabled={Boolean(busyAction) || running} onClick={() => perform('archive', onArchive)}>
<Archive className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />
</Button>
</div>
)}
{actionError && <p className="border-t border-destructive/10 bg-destructive/5 px-5 py-2 text-xs text-destructive" role="alert">{actionError}</p>}
<Dialog open={renameOpen} onOpenChange={setRenameOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription> Conversation</DialogDescription>
</DialogHeader>
<Input value={titleDraft} maxLength={160} aria-label="对话标题" onChange={(event) => setTitleDraft(event.target.value)} />
<DialogFooter>
<Button type="button" variant="outline" className="min-h-10 rounded-xl" onClick={() => setRenameOpen(false)}></Button>
<Button
type="button"
className="min-h-10 rounded-xl"
disabled={!titleDraft.trim() || Boolean(busyAction)}
onClick={() => perform('rename', async () => {
await onRename(titleDraft.trim());
setRenameOpen(false);
})}
></Button>
</DialogFooter>
</DialogContent>
</Dialog>
</header>
);
}