Files
ai-video-fullstack/frontend/src/components/assistant-editor/editor-controls.tsx
T

533 lines
15 KiB
TypeScript
Raw Normal View History

"use client";
import type React from "react";
import { useEffect, useRef, useState } from "react";
import {
AudioLines,
Check,
ChevronLeft,
Copy,
Pencil,
Settings2,
Waypoints,
} from "lucide-react";
import { HelpHint } from "@/components/editor/section-card";
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,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import type { KnowledgeRetrievalConfig } from "@/lib/api";
import type { RuntimeMode } from "./types";
export function EditorBackButton({
onClick,
ariaLabel = "返回助手列表",
}: {
onClick: () => void;
ariaLabel?: string;
}) {
return (
<Button
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-foreground"
onClick={onClick}
aria-label={ariaLabel}
>
<ChevronLeft size={20} />
</Button>
);
}
2026-08-08 19:08:48 +08:00
export function AssistantIdentity({
assistantId,
entityLabel = "助手",
}: {
assistantId: string | null;
entityLabel?: string;
}) {
const [copied, setCopied] = useState(false);
async function copyId() {
if (!assistantId) return;
await navigator.clipboard.writeText(assistantId);
setCopied(true);
window.setTimeout(() => setCopied(false), 1600);
}
return (
<div className="ml-1 flex shrink-0 items-center rounded-full border border-hairline bg-canvas-soft pl-2.5 text-[11px] text-muted-foreground">
<span className="font-mono">
{assistantId ? `ID · ${assistantId}` : "ID · 保存后生成"}
</span>
{assistantId && (
<button
type="button"
onClick={() => void copyId()}
className="ml-1 flex h-7 w-7 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
2026-08-08 19:08:48 +08:00
aria-label={
copied ? `${entityLabel} ID 已复制` : `复制${entityLabel} ID`
}
title={copied ? "已复制" : "复制 ID"}
>
{copied ? <Check size={13} /> : <Copy size={13} />}
</button>
)}
{!assistantId && <span className="h-7 w-2" aria-hidden />}
</div>
);
}
export function EditableTitle({
value,
onChange,
placeholder = "未命名助手",
editLabel = "助手名称",
variant = "page",
allowEmpty = false,
maxLength,
}: {
value: string;
onChange: (value: string) => void;
placeholder?: string;
editLabel?: string;
variant?: "page" | "panel";
allowEmpty?: boolean;
maxLength?: number;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editing) {
inputRef.current?.focus();
inputRef.current?.select();
}
}, [editing]);
function startEdit() {
setDraft(value);
setEditing(true);
}
function commit() {
const next = draft.trim();
if (next || allowEmpty) {
onChange(next);
}
setEditing(false);
}
if (editing) {
return (
<input
ref={inputRef}
value={draft}
maxLength={maxLength}
onChange={(event) => setDraft(event.target.value)}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
commit();
} else if (event.key === "Escape") {
event.preventDefault();
setEditing(false);
}
}}
aria-label={`修改${editLabel}`}
className={
variant === "panel"
? "min-w-0 flex-1 border-b border-primary bg-transparent px-3 py-1.5 text-sm font-medium text-foreground outline-none"
: "font-display w-[min(60vw,420px)] border-b border-primary bg-transparent text-lg text-ink outline-none"
}
/>
);
}
return (
<button
type="button"
onClick={startEdit}
title={`点击修改${editLabel}`}
className={
variant === "panel"
? "group flex min-w-0 items-center gap-2 rounded-lg px-3 py-1.5 text-left transition-colors hover:bg-surface-strong"
: "group -mx-2 flex min-w-0 items-center gap-2 rounded-lg px-2 py-1 text-left transition-colors hover:bg-surface-strong"
}
>
<span
className={
variant === "panel"
? "truncate text-sm font-medium text-foreground"
: "font-display truncate text-lg text-ink"
}
>
{value || placeholder}
</span>
<Pencil
size={14}
className="shrink-0 text-muted-soft"
/>
</button>
);
}
export function RuntimeModeSelector({
value,
onChange,
}: {
value: RuntimeMode;
onChange: (mode: RuntimeMode) => void;
}) {
const options = [
{
value: "pipeline" as const,
label: "Pipeline 模式",
hint: "通过 ASR、LLM 和 TTS 级联组成语音管线,灵活选配各模块。",
icon: Waypoints,
},
{
value: "realtime" as const,
label: "Realtime 模式",
hint: "使用原生实时语音模型,模型直接处理音频输入并生成语音回复。",
icon: AudioLines,
},
];
return (
<div className="grid grid-cols-1 gap-3 border-b border-hairline-soft pb-4 md:grid-cols-2">
{options.map((option) => {
const Icon = option.icon;
const selected = value === option.value;
const select = () => onChange(option.value);
return (
<div
key={option.value}
role="button"
tabIndex={0}
onClick={select}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
select();
}
}}
className={[
"cursor-pointer rounded-xl border p-3.5 text-left transition-colors",
selected
? "border-primary bg-primary/5 ring-1 ring-primary"
: "border-hairline bg-canvas-soft hover:border-hairline-strong",
].join(" ")}
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
<Icon size={15} />
</div>
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium text-foreground">
{option.label}
</span>
<HelpHint text={option.hint} />
</div>
</div>
{selected && (
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Check size={12} />
</span>
)}
</div>
</div>
);
})}
</div>
);
}
export function TextAreaField({
label,
value,
placeholder,
rows = 4,
onChange,
}: {
label?: string;
value: string;
placeholder?: string;
rows?: number;
onChange: (value: string) => void;
}) {
return (
<label className="block">
{label && (
<div className="mb-1.5 text-sm font-medium text-foreground">{label}</div>
)}
<Textarea
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
rows={rows}
// Override ui/textarea's field-sizing-content so `rows` sets a real height
// instead of collapsing to min-h-16 when the value is short.
className="field-sizing-fixed min-h-28 resize-y border-hairline-strong bg-background text-sm text-foreground placeholder:text-muted-soft"
/>
</label>
);
}
// Radix Select 不允许空字符串 value,用哨兵表示"未选/无"
const NONE_VALUE = "__none__";
export function ResourceSelectField({
label,
value,
options,
noneLabel,
onChange,
}: {
label?: string;
value: string;
options: { value: string; label: string }[];
/** 提供则在顶部加一个"无/默认"选项,选中映射为空串 */
noneLabel?: string;
onChange: (value: string) => void;
}) {
return (
<div className="block">
{label && (
<div className="mb-1.5 text-sm font-medium text-foreground">{label}</div>
)}
<Select
value={value || NONE_VALUE}
onValueChange={(v) => onChange(v === NONE_VALUE ? "" : v)}
>
<SelectTrigger className="w-full border-hairline-strong bg-background text-foreground">
<SelectValue placeholder={label ? `请选择${label}` : "请选择"} />
</SelectTrigger>
<SelectContent className="border-hairline bg-popover text-popover-foreground">
{noneLabel && (
<SelectItem value={NONE_VALUE}>{noneLabel}</SelectItem>
)}
{options.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
export function KnowledgeRetrievalConfigDialog({
disabled,
value,
onChange,
}: {
disabled: boolean;
value: KnowledgeRetrievalConfig;
onChange: (config: KnowledgeRetrievalConfig) => void;
}) {
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState(value);
const [error, setError] = useState<string | null>(null);
function openDialog() {
setDraft(value);
setError(null);
setOpen(true);
}
function saveDraft() {
if (draft.topN === 0 || draft.topN < -1 || !Number.isInteger(draft.topN)) {
setError("Top N 必须为 -1 或大于 0 的整数");
return;
}
if (draft.scoreThreshold < 0 || draft.scoreThreshold > 1) {
setError("最低相关度必须在 0 到 1 之间");
return;
}
onChange(draft);
setOpen(false);
}
return (
<>
<button
type="button"
disabled={disabled}
onClick={openDialog}
aria-label="打开知识库高级配置"
title={
disabled
? "请先选择知识库"
: `${value.mode === "automatic" ? "自动检索" : "模型主动检索"} · Top N ${value.topN === -1 ? "不限" : value.topN} · 最低相关度 ${value.scoreThreshold}`
}
className="flex h-5 w-5 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40"
>
<Settings2 size={14} />
</button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>知识库高级配置</DialogTitle>
<DialogDescription>
设置检索触发方式、返回数量和相关度过滤条件。
</DialogDescription>
</DialogHeader>
<div className="space-y-5 py-2">
<div className="space-y-2">
<div className="text-sm font-medium text-foreground">检索方式</div>
<Select
value={draft.mode}
onValueChange={(mode: "automatic" | "on_demand") =>
setDraft({ ...draft, mode })
}
>
<SelectTrigger className="w-full border-hairline-strong bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="automatic">自动检索</SelectItem>
<SelectItem value="on_demand">模型主动检索</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{draft.mode === "automatic"
? "每轮用户提问后自动检索,响应行为更稳定。"
: "由大模型判断是否调用知识库,依赖模型的工具调用能力。"}
</p>
</div>
<label className="block">
<span className="mb-2 block text-sm font-medium text-foreground">
最多返回片段数
</span>
<Input
type="number"
step="1"
min="-1"
value={draft.topN}
onChange={(event) =>
setDraft({ ...draft, topN: Number(event.target.value) })
}
/>
<span className="mt-1.5 block text-xs text-muted-foreground">
填写 -1 时保留所有达到阈值的结果。
</span>
</label>
<label className="block">
<span className="mb-2 block text-sm font-medium text-foreground">
最低相关度
</span>
<Input
type="number"
step="0.01"
min="0"
max="1"
value={draft.scoreThreshold}
onChange={(event) =>
setDraft({
...draft,
scoreThreshold: Number(event.target.value),
})
}
/>
<span className="mt-1.5 block text-xs text-muted-foreground">
仅保留相关度达到该值的片段,范围 0–1。
</span>
</label>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
取消
</Button>
<Button type="button" onClick={saveDraft}>
保存配置
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
export function ToggleRow({
icon,
title,
description,
hint,
checked,
onChange,
}: {
icon?: React.ReactNode;
title: string;
description?: string;
hint?: string;
checked: boolean;
onChange: (checked: boolean) => void;
}) {
const hasIcon = Boolean(icon);
return (
<div
className={[
"flex items-center justify-between border border-hairline bg-canvas-soft",
hasIcon ? "rounded-xl p-3.5" : "rounded-xl px-3.5 py-3",
].join(" ")}
>
<div>
<div
className={[
"flex items-center text-sm font-medium text-foreground",
hasIcon ? "gap-2.5" : "gap-1.5",
].join(" ")}
>
{icon && (
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface-strong text-foreground">
{icon}
</span>
)}
<span className="flex items-center gap-1.5">
<span>{title}</span>
{hint && <HelpHint text={hint} />}
</span>
</div>
{description && (
<div className="mt-1 text-xs text-muted-foreground">
{description}
</div>
)}
</div>
<Switch checked={checked} onCheckedChange={onChange} />
</div>
);
}