251 lines
11 KiB
TypeScript
251 lines
11 KiB
TypeScript
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>
|
||
);
|
||
}
|