2026-07-14 12:59:41 +08:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
|
|
import type React from "react";
|
|
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
|
|
|
|
import {
|
|
|
|
|
|
AudioLines,
|
|
|
|
|
|
Check,
|
|
|
|
|
|
ChevronLeft,
|
|
|
|
|
|
Copy,
|
|
|
|
|
|
Pencil,
|
|
|
|
|
|
PhoneOff,
|
|
|
|
|
|
Plus,
|
2026-07-18 00:00:06 +08:00
|
|
|
|
ServerCog,
|
2026-07-14 12:59:41 +08:00
|
|
|
|
Settings2,
|
|
|
|
|
|
Waypoints,
|
|
|
|
|
|
Wrench,
|
|
|
|
|
|
X,
|
|
|
|
|
|
} from "lucide-react";
|
|
|
|
|
|
|
|
|
|
|
|
import { HelpHint } from "@/components/editor/section-card";
|
|
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
|
|
|
|
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, 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,
|
2026-08-03 10:52:25 +08:00
|
|
|
|
placeholder = "未命名助手",
|
|
|
|
|
|
editLabel = "助手名称",
|
|
|
|
|
|
variant = "page",
|
2026-07-14 12:59:41 +08:00
|
|
|
|
}: {
|
|
|
|
|
|
value: string;
|
|
|
|
|
|
onChange: (value: string) => void;
|
2026-08-03 10:52:25 +08:00
|
|
|
|
placeholder?: string;
|
|
|
|
|
|
editLabel?: string;
|
|
|
|
|
|
variant?: "page" | "panel";
|
2026-07-14 12:59:41 +08:00
|
|
|
|
}) {
|
|
|
|
|
|
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) {
|
|
|
|
|
|
onChange(next);
|
|
|
|
|
|
}
|
|
|
|
|
|
setEditing(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (editing) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<input
|
|
|
|
|
|
ref={inputRef}
|
|
|
|
|
|
value={draft}
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
2026-08-03 10:52:25 +08:00
|
|
|
|
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"
|
|
|
|
|
|
}
|
2026-07-14 12:59:41 +08:00
|
|
|
|
/>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={startEdit}
|
2026-08-03 10:52:25 +08:00
|
|
|
|
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"
|
|
|
|
|
|
}
|
2026-07-14 12:59:41 +08:00
|
|
|
|
>
|
2026-08-03 10:52:25 +08:00
|
|
|
|
<span
|
|
|
|
|
|
className={
|
|
|
|
|
|
variant === "panel"
|
|
|
|
|
|
? "truncate text-sm font-medium text-foreground"
|
|
|
|
|
|
: "font-display display-sm truncate text-ink"
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
{value || placeholder}
|
2026-07-14 12:59:41 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
<Pencil
|
2026-08-03 10:52:25 +08:00
|
|
|
|
size={variant === "panel" ? 14 : 16}
|
2026-07-14 12:59:41 +08:00
|
|
|
|
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 [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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
|
>
|
2026-07-18 00:00:06 +08:00
|
|
|
|
{tool.type === "end_call" ? (
|
|
|
|
|
|
<PhoneOff size={14} />
|
|
|
|
|
|
) : tool.type === "mcp" ? (
|
|
|
|
|
|
<ServerCog size={14} />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Wrench size={14} />
|
|
|
|
|
|
)}
|
2026-07-14 12:59:41 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
|
|
{tools.length === 0 ? (
|
|
|
|
|
|
<div className="rounded-xl border border-dashed border-hairline-strong px-4 py-10 text-center text-sm text-muted-foreground">
|
|
|
|
|
|
暂无可用工具
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="max-h-80 divide-y divide-hairline overflow-y-auto rounded-xl border border-hairline">
|
|
|
|
|
|
{tools.map((tool) => {
|
|
|
|
|
|
const checked = draftIds.includes(tool.id);
|
|
|
|
|
|
return (
|
|
|
|
|
|
<label
|
|
|
|
|
|
key={tool.id}
|
|
|
|
|
|
className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-surface-strong/40"
|
|
|
|
|
|
>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
checked={checked}
|
|
|
|
|
|
onChange={() =>
|
|
|
|
|
|
setDraftIds((current) =>
|
|
|
|
|
|
checked
|
|
|
|
|
|
? current.filter((id) => id !== tool.id)
|
|
|
|
|
|
: [...current, tool.id],
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
className="size-4 accent-primary"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<span className="truncate font-medium text-foreground">
|
|
|
|
|
|
{tool.name}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<Badge variant="secondary">
|
2026-07-18 00:00:06 +08:00
|
|
|
|
{tool.type === "end_call"
|
|
|
|
|
|
? "End Call"
|
|
|
|
|
|
: tool.type === "mcp"
|
|
|
|
|
|
? "MCP"
|
2026-07-30 19:06:03 +08:00
|
|
|
|
: tool.type === "client"
|
|
|
|
|
|
? "Client"
|
|
|
|
|
|
: "HTTP"}
|
2026-07-14 12:59:41 +08:00
|
|
|
|
</Badge>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="mt-0.5 truncate font-mono text-xs text-muted-foreground">
|
|
|
|
|
|
{tool.functionName}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|