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

698 lines
21 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.
"use client";
import type React from "react";
import { useEffect, useRef, useState } from "react";
import {
AudioLines,
Check,
ChevronLeft,
Copy,
Pencil,
Plus,
ServerCog,
Settings2,
Sparkles,
Waypoints,
Wrench,
X,
} 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 {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import type {
KnowledgeRetrievalConfig,
Tool,
} from "@/lib/api";
import type { RuntimeMode } from "./types";
export function EditorBackButton({ onClick }: { onClick: () => void }) {
return (
<Button
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground hover:text-foreground"
onClick={onClick}
aria-label="返回助手列表"
>
<ChevronLeft size={20} />
</Button>
);
}
export function AssistantIdentity({ assistantId }: { assistantId: string | null }) {
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"
aria-label={copied ? "助手 ID 已复制" : "复制助手 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 display-sm w-[min(60vw,420px)] border-b border-primary bg-transparent 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 display-sm truncate text-ink"
}
>
{value || placeholder}
</span>
<Pencil
size={variant === "panel" ? 14 : 16}
className="shrink-0 text-muted-soft opacity-0 transition-opacity group-hover:opacity-100"
/>
</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 ToolPicker({
tools,
selectedIds,
onChange,
}: {
tools: Tool[];
selectedIds: string[];
onChange: (ids: string[]) => void;
}) {
const [open, setOpen] = useState(false);
const [activeTab, setActiveTab] = useState<Tool["type"]>("system");
const [draftIds, setDraftIds] = useState<string[]>(selectedIds);
const selectedTools = selectedIds
.map((id) => tools.find((tool) => tool.id === id))
.filter((tool): tool is Tool => Boolean(tool));
function openPicker() {
setDraftIds(selectedIds);
setOpen(true);
}
function toggleResourceTool(toolId: string) {
setDraftIds((current) =>
current.includes(toolId)
? current.filter((id) => id !== toolId)
: [...current, toolId],
);
}
const tabs: Array<{ value: Tool["type"]; label: string }> = [
{ value: "system", label: "System" },
{ value: "http", label: "HTTP" },
{ value: "client", label: "Client" },
{ value: "mcp", label: "MCP" },
];
return (
<>
<div className="flex min-h-9 flex-wrap items-center gap-2">
{selectedTools.map((tool) => (
<div
key={tool.id}
className="flex h-8 items-center gap-2 rounded-lg border border-hairline-strong bg-background px-2.5 text-sm"
>
{tool.type === "system" ? (
<Sparkles size={14} />
) : tool.type === "mcp" ? (
<ServerCog size={14} />
) : (
<Wrench size={14} />
)}
<span className="max-w-48 truncate">{tool.name}</span>
<button
type="button"
onClick={() => onChange(selectedIds.filter((id) => id !== tool.id))}
className="text-muted-soft transition-colors hover:text-foreground"
aria-label={`移除工具 ${tool.name}`}
>
<X size={13} />
</button>
</div>
))}
<Button
type="button"
variant="outline"
size="icon-sm"
className="border-hairline-strong text-muted-foreground hover:text-foreground"
onClick={openPicker}
aria-label="添加工具"
title="添加工具"
>
<Plus size={15} />
</Button>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>选择工具</DialogTitle>
<DialogDescription>
按类型选择已经启用的工具资源。
</DialogDescription>
</DialogHeader>
<Tabs
value={activeTab}
onValueChange={(value) => setActiveTab(value as Tool["type"])}
>
<TabsList
variant="line"
className="w-full justify-start border-b border-hairline px-1"
>
{tabs.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className="flex-none px-4"
>
{tab.label}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => {
const resources = tools.filter((tool) => tool.type === tab.value);
const isEmpty = resources.length === 0;
return (
<TabsContent key={tab.value} value={tab.value} className="pt-3">
{isEmpty ? (
<div className="rounded-xl border border-dashed border-hairline-strong px-4 py-10 text-center text-sm text-muted-foreground">
暂无可用的 {tab.label} 工具
</div>
) : (
<div className="max-h-[280px] divide-y divide-hairline overflow-y-auto rounded-xl border border-hairline">
{resources.map((tool) => {
const checked = draftIds.includes(tool.id);
return (
<label
key={tool.id}
className="flex h-14 cursor-pointer items-center gap-3 px-4 transition-colors hover:bg-surface-strong/40"
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleResourceTool(tool.id)}
className="size-4 accent-primary"
/>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-foreground">
{tool.name}
</div>
<div className="mt-0.5 truncate font-mono text-xs text-muted-foreground">
{tool.functionName}
</div>
</div>
</label>
);
})}
</div>
)}
</TabsContent>
);
})}
</Tabs>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
取消
</Button>
<Button
onClick={() => {
onChange(draftIds);
setOpen(false);
}}
>
确认
</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>
);
}