Add duplicate functionality for assistants and credentials
Implement server-side duplication for both assistants and credentials, allowing users to create copies with unique IDs and modified names. Update the respective API routes and frontend components to handle duplication requests, ensuring sensitive information is securely managed. Enhance the AssistantPage and ComponentsModelsPage to support this new feature, including loading and error handling for the duplication process.
This commit is contained in:
@@ -83,6 +83,34 @@ async def get_assistant(
|
||||
return _to_out(a)
|
||||
|
||||
|
||||
@router.post("/{assistant_id}/duplicate", response_model=AssistantOut)
|
||||
async def duplicate_assistant(
|
||||
assistant_id: str, session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
"""服务端整行复制:含真实 config(真 key),DB→DB,密钥不经过浏览器,副本可直接用。"""
|
||||
src = await session.get(Assistant, assistant_id)
|
||||
if not src:
|
||||
raise HTTPException(404, "助手不存在")
|
||||
a = Assistant(
|
||||
id=f"asst_{uuid.uuid4().hex[:12]}",
|
||||
name=f"{src.name} 副本",
|
||||
type=src.type,
|
||||
runtime_mode=src.runtime_mode,
|
||||
greeting=src.greeting,
|
||||
enable_interrupt=src.enable_interrupt,
|
||||
llm_credential_id=src.llm_credential_id,
|
||||
asr_credential_id=src.asr_credential_id,
|
||||
tts_credential_id=src.tts_credential_id,
|
||||
realtime_credential_id=src.realtime_credential_id,
|
||||
knowledge_base_id=src.knowledge_base_id,
|
||||
config=dict(src.config or {}), # 浅拷贝,避免与源行共享同一 dict
|
||||
)
|
||||
session.add(a)
|
||||
await session.commit()
|
||||
await session.refresh(a)
|
||||
return _to_out(a)
|
||||
|
||||
|
||||
@router.put("/{assistant_id}", response_model=AssistantOut)
|
||||
async def update_assistant(
|
||||
assistant_id: str,
|
||||
|
||||
@@ -70,6 +70,30 @@ async def create_credential(
|
||||
return _to_out(c)
|
||||
|
||||
|
||||
@router.post("/{cred_id}/duplicate", response_model=CredentialOut)
|
||||
async def duplicate_credential(
|
||||
cred_id: str, session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
"""服务端整行复制:含真实 api_key,DB→DB,密钥不经浏览器。副本不继承默认标记。"""
|
||||
src = await session.get(ProviderCredential, cred_id)
|
||||
if not src:
|
||||
raise HTTPException(404, "凭证不存在")
|
||||
c = ProviderCredential(
|
||||
id=f"model_{uuid.uuid4().hex[:12]}",
|
||||
name=f"{src.name} 副本",
|
||||
model_id=src.model_id,
|
||||
type=src.type,
|
||||
interface_type=src.interface_type,
|
||||
api_url=src.api_url,
|
||||
api_key=src.api_key, # 真 key,DB→DB
|
||||
is_default=False, # 副本不继承默认,避免抢走源的默认标记
|
||||
)
|
||||
session.add(c)
|
||||
await session.commit()
|
||||
await session.refresh(c)
|
||||
return _to_out(c)
|
||||
|
||||
|
||||
@router.put("/{cred_id}", response_model=CredentialOut)
|
||||
async def update_credential(
|
||||
cred_id: str,
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
AudioLines,
|
||||
Square,
|
||||
Terminal,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -59,7 +60,17 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
assistantsApi,
|
||||
credentialsApi,
|
||||
knowledgeBasesApi,
|
||||
type Assistant,
|
||||
type AssistantType as ApiAssistantType,
|
||||
type AssistantUpsert,
|
||||
type Credential,
|
||||
type KnowledgeBase,
|
||||
} from "@/lib/api";
|
||||
|
||||
type RuntimeMode = "pipeline" | "realtime";
|
||||
|
||||
@@ -115,6 +126,24 @@ const assistantTypes: AssistantType[] = [
|
||||
"OpenCode",
|
||||
];
|
||||
|
||||
// 后端 type(英文) ↔ 列表展示标签(中文)
|
||||
const typeToLabel: Record<ApiAssistantType, AssistantType> = {
|
||||
prompt: "提示词",
|
||||
workflow: "工作流",
|
||||
dify: "Dify",
|
||||
fastgpt: "FastGPT",
|
||||
opencode: "OpenCode",
|
||||
};
|
||||
function formatTimestamp(iso?: string | null): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(
|
||||
d.getHours(),
|
||||
)}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
type AssistantTypeOption = {
|
||||
type: AssistantType;
|
||||
label: string;
|
||||
@@ -169,87 +198,6 @@ type AssistantListItem = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
const mockAssistants: AssistantListItem[] = [
|
||||
{
|
||||
id: "asst_001",
|
||||
name: "政务视频咨询助手",
|
||||
type: "提示词",
|
||||
updatedAt: "2026-06-05 18:20",
|
||||
},
|
||||
{
|
||||
id: "asst_002",
|
||||
name: "热线工单辅助助手",
|
||||
type: "工作流",
|
||||
updatedAt: "2026-06-04 15:12",
|
||||
},
|
||||
{
|
||||
id: "asst_003",
|
||||
name: "Dify 知识库问答助手",
|
||||
type: "Dify",
|
||||
updatedAt: "2026-06-02 09:48",
|
||||
},
|
||||
{
|
||||
id: "asst_004",
|
||||
name: "FastGPT 售后咨询助手",
|
||||
type: "FastGPT",
|
||||
updatedAt: "2026-05-29 11:06",
|
||||
},
|
||||
{
|
||||
id: "asst_005",
|
||||
name: "医院挂号问询助手",
|
||||
type: "提示词",
|
||||
updatedAt: "2026-05-25 14:30",
|
||||
},
|
||||
{
|
||||
id: "asst_006",
|
||||
name: "政务办事进度助手",
|
||||
type: "工作流",
|
||||
updatedAt: "2026-05-22 10:15",
|
||||
},
|
||||
{
|
||||
id: "asst_007",
|
||||
name: "Dify 智能客服助手",
|
||||
type: "Dify",
|
||||
updatedAt: "2026-05-18 09:02",
|
||||
},
|
||||
{
|
||||
id: "asst_008",
|
||||
name: "FastGPT 智能催收助手",
|
||||
type: "FastGPT",
|
||||
updatedAt: "2026-05-15 17:20",
|
||||
},
|
||||
{
|
||||
id: "asst_009",
|
||||
name: "高校课程咨询助手",
|
||||
type: "提示词",
|
||||
updatedAt: "2026-05-10 12:48",
|
||||
},
|
||||
{
|
||||
id: "asst_010",
|
||||
name: "企业入驻流程助手",
|
||||
type: "工作流",
|
||||
updatedAt: "2026-05-06 08:43",
|
||||
},
|
||||
{
|
||||
id: "asst_011",
|
||||
name: "Dify 政策助手",
|
||||
type: "Dify",
|
||||
updatedAt: "2026-05-01 16:45",
|
||||
},
|
||||
{
|
||||
id: "asst_012",
|
||||
name: "FastGPT 报修助手",
|
||||
type: "FastGPT",
|
||||
updatedAt: "2026-04-28 19:34",
|
||||
},
|
||||
{
|
||||
id: "asst_013",
|
||||
name: "OpenCode 代码助手",
|
||||
type: "OpenCode",
|
||||
updatedAt: "2026-06-07 14:22",
|
||||
},
|
||||
];
|
||||
|
||||
type DebugMode = "text" | "voice";
|
||||
|
||||
type TypeFilter = "全部" | AssistantType;
|
||||
@@ -263,11 +211,11 @@ export function AssistantPage() {
|
||||
prompt:
|
||||
"你是一名专业的政务视频咨询助手,负责为市民提供政策解读、办事指南和常见问题解答。\n\n请遵循以下原则:\n1. 回答准确、简洁,使用通俗易懂的语言\n2. 不确定的信息应明确告知,不编造政策内容\n3. 涉及具体办事流程时,引导用户前往官方渠道核实",
|
||||
runtimeMode: "pipeline",
|
||||
realtimeModel: "gpt-realtime-2",
|
||||
model: "DeepSeek-V3",
|
||||
asr: "SenseVoice",
|
||||
voice: "晓宁",
|
||||
knowledgeBase: "无",
|
||||
realtimeModel: "",
|
||||
model: "",
|
||||
asr: "",
|
||||
voice: "",
|
||||
knowledgeBase: "",
|
||||
enableInterrupt: true,
|
||||
});
|
||||
const [fastGptForm, setFastGptForm] = useState<FastGptForm>({
|
||||
@@ -275,16 +223,16 @@ export function AssistantPage() {
|
||||
appId: "",
|
||||
apiUrl: "https://api.fastgpt.in/api/v1/chat/completions",
|
||||
apiKey: "",
|
||||
asr: "SenseVoice",
|
||||
voice: "晓宁",
|
||||
asr: "",
|
||||
voice: "",
|
||||
enableInterrupt: true,
|
||||
});
|
||||
const [difyForm, setDifyForm] = useState<DifyForm>({
|
||||
name: "Dify 知识库问答助手",
|
||||
apiUrl: "https://api.dify.ai/v1/chat-messages",
|
||||
apiKey: "",
|
||||
asr: "SenseVoice",
|
||||
voice: "晓宁",
|
||||
asr: "",
|
||||
voice: "",
|
||||
enableInterrupt: true,
|
||||
});
|
||||
const [openCodeForm, setOpenCodeForm] = useState<OpenCodeForm>({
|
||||
@@ -293,12 +241,20 @@ export function AssistantPage() {
|
||||
"你是一个代码助手的语音交互界面,请用简洁、口语化的方式回答用户关于代码与工程的问题。",
|
||||
apiUrl: "http://localhost:4096",
|
||||
apiKey: "",
|
||||
asr: "SenseVoice",
|
||||
voice: "晓宁",
|
||||
asr: "",
|
||||
voice: "",
|
||||
enableInterrupt: true,
|
||||
});
|
||||
const [assistants, setAssistants] =
|
||||
useState<AssistantListItem[]>(mockAssistants);
|
||||
const [assistants, setAssistants] = useState<Assistant[]>([]);
|
||||
const [listLoading, setListLoading] = useState(true);
|
||||
const [listError, setListError] = useState<string | null>(null);
|
||||
// 编辑中的助手 id;null = 新建
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
// 下拉数据源:模型凭证 + 知识库
|
||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
||||
const [view, setView] = useState<
|
||||
| "list"
|
||||
| "choose"
|
||||
@@ -317,23 +273,115 @@ export function AssistantPage() {
|
||||
const [draftName, setDraftName] = useState("");
|
||||
const [draftType, setDraftType] = useState<AssistantType | null>(null);
|
||||
|
||||
const loadAssistants = useCallback(async () => {
|
||||
setListLoading(true);
|
||||
setListError(null);
|
||||
try {
|
||||
setAssistants(await assistantsApi.list());
|
||||
} catch (error) {
|
||||
setListError(error instanceof Error ? error.message : "加载失败");
|
||||
} finally {
|
||||
setListLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 挂载时拉取助手列表(与后端同步)
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void loadAssistants();
|
||||
}, [loadAssistants]);
|
||||
|
||||
// 进入创建/编辑前加载下拉数据源(模型凭证 + 知识库)
|
||||
const loadResources = useCallback(async () => {
|
||||
try {
|
||||
const [creds, kbs] = await Promise.all([
|
||||
credentialsApi.list(),
|
||||
knowledgeBasesApi.list(),
|
||||
]);
|
||||
setCredentials(creds);
|
||||
setKnowledgeBases(kbs);
|
||||
} catch {
|
||||
// 拉取失败时下拉为空,不阻塞表单
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 按资源类型生成 {value:id, label:name} 选项
|
||||
const credOptions = (type: Credential["type"]) =>
|
||||
credentials
|
||||
.filter((c) => c.type === type)
|
||||
.map((c) => ({ value: c.id, label: c.name }));
|
||||
const kbOptions = knowledgeBases.map((k) => ({ value: k.id, label: k.name }));
|
||||
|
||||
function startCreate() {
|
||||
setDraftName("");
|
||||
setDraftType(null);
|
||||
setView("choose");
|
||||
}
|
||||
|
||||
// 编辑:根据助手类型进入对应的构建/编辑页,并把名称带入相应表单
|
||||
function handleEdit(assistant: AssistantListItem) {
|
||||
// 提示词类型的空白模板(新建用)
|
||||
function blankPromptForm(name: string): AssistantForm {
|
||||
return {
|
||||
name,
|
||||
greeting: "",
|
||||
prompt: "",
|
||||
runtimeMode: "pipeline",
|
||||
realtimeModel: "",
|
||||
model: "",
|
||||
asr: "",
|
||||
voice: "",
|
||||
knowledgeBase: "",
|
||||
enableInterrupt: true,
|
||||
};
|
||||
}
|
||||
|
||||
// 把后端 Assistant 回填进提示词表单(注意:model/asr/voice 等存的是凭证 id)
|
||||
function fillPromptForm(a: Assistant) {
|
||||
setForm({
|
||||
name: a.name,
|
||||
greeting: a.greeting,
|
||||
prompt: typeof a.config.prompt === "string" ? a.config.prompt : "",
|
||||
runtimeMode: a.runtimeMode,
|
||||
realtimeModel: a.realtimeCredentialId ?? "",
|
||||
model: a.llmCredentialId ?? "",
|
||||
asr: a.asrCredentialId ?? "",
|
||||
voice: a.ttsCredentialId ?? "",
|
||||
knowledgeBase: a.knowledgeBaseId ?? "",
|
||||
enableInterrupt: a.enableInterrupt,
|
||||
});
|
||||
}
|
||||
|
||||
// 编辑:根据助手类型进入对应的构建/编辑页
|
||||
async function handleEdit(assistant: AssistantListItem) {
|
||||
if (assistant.type === "提示词") {
|
||||
updateForm("name", assistant.name);
|
||||
setView("create");
|
||||
void loadResources();
|
||||
setSaveError(null);
|
||||
setEditingId(assistant.id);
|
||||
try {
|
||||
fillPromptForm(await assistantsApi.get(assistant.id));
|
||||
setView("create");
|
||||
} catch (error) {
|
||||
setListError(error instanceof Error ? error.message : "加载助手失败");
|
||||
}
|
||||
} else if (assistant.type === "FastGPT") {
|
||||
updateFastGptForm("name", assistant.name);
|
||||
setView("create-fastgpt");
|
||||
void loadResources();
|
||||
setSaveError(null);
|
||||
setEditingId(assistant.id);
|
||||
try {
|
||||
fillFastGptForm(await assistantsApi.get(assistant.id));
|
||||
setView("create-fastgpt");
|
||||
} catch (error) {
|
||||
setListError(error instanceof Error ? error.message : "加载助手失败");
|
||||
}
|
||||
} else if (assistant.type === "Dify") {
|
||||
updateDifyForm("name", assistant.name);
|
||||
setView("create-dify");
|
||||
void loadResources();
|
||||
setSaveError(null);
|
||||
setEditingId(assistant.id);
|
||||
try {
|
||||
fillDifyForm(await assistantsApi.get(assistant.id));
|
||||
setView("create-dify");
|
||||
} catch (error) {
|
||||
setListError(error instanceof Error ? error.message : "加载助手失败");
|
||||
}
|
||||
} else if (assistant.type === "OpenCode") {
|
||||
updateOpenCodeForm("name", assistant.name);
|
||||
setView("create-opencode");
|
||||
@@ -351,16 +399,40 @@ export function AssistantPage() {
|
||||
}
|
||||
|
||||
if (draftType === "提示词") {
|
||||
// 提示词类型:复用现有创建表单,并把已填的名称带过去
|
||||
updateForm("name", draftName.trim());
|
||||
// 提示词类型:新建,空白模板 + 带入名称
|
||||
void loadResources();
|
||||
setEditingId(null);
|
||||
setSaveError(null);
|
||||
setForm(blankPromptForm(draftName.trim()));
|
||||
setView("create");
|
||||
} else if (draftType === "FastGPT") {
|
||||
// FastGPT 类型:进入 FastGPT 构建表单,并把已填的名称带过去
|
||||
updateFastGptForm("name", draftName.trim());
|
||||
// FastGPT 类型:新建,清空表单 + 带入名称
|
||||
void loadResources();
|
||||
setEditingId(null);
|
||||
setSaveError(null);
|
||||
setFastGptForm({
|
||||
name: draftName.trim(),
|
||||
appId: "",
|
||||
apiUrl: "",
|
||||
apiKey: "",
|
||||
asr: "",
|
||||
voice: "",
|
||||
enableInterrupt: true,
|
||||
});
|
||||
setView("create-fastgpt");
|
||||
} else if (draftType === "Dify") {
|
||||
// Dify 类型:进入 Dify 构建表单,并把已填的名称带过去
|
||||
updateDifyForm("name", draftName.trim());
|
||||
// Dify 类型:新建,清空表单 + 带入名称
|
||||
void loadResources();
|
||||
setEditingId(null);
|
||||
setSaveError(null);
|
||||
setDifyForm({
|
||||
name: draftName.trim(),
|
||||
apiUrl: "",
|
||||
apiKey: "",
|
||||
asr: "",
|
||||
voice: "",
|
||||
enableInterrupt: true,
|
||||
});
|
||||
setView("create-dify");
|
||||
} else if (draftType === "OpenCode") {
|
||||
// OpenCode 类型:进入 OpenCode 构建表单,并把已填的名称带过去
|
||||
@@ -372,34 +444,155 @@ export function AssistantPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// 复制助手:在原助手之后插入一份拷贝,名称追加“副本”,并刷新更新时间
|
||||
function handleDuplicate(assistant: AssistantListItem) {
|
||||
const now = new Date();
|
||||
const pad = (value: number) => String(value).padStart(2, "0");
|
||||
const timestamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(
|
||||
now.getDate(),
|
||||
)} ${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
||||
// 复制助手:服务端整行复制(含真 key,密钥不经浏览器)
|
||||
async function handleDuplicate(assistant: AssistantListItem) {
|
||||
try {
|
||||
await assistantsApi.duplicate(assistant.id);
|
||||
await loadAssistants();
|
||||
} catch (error) {
|
||||
setListError(error instanceof Error ? error.message : "复制失败");
|
||||
}
|
||||
}
|
||||
|
||||
setAssistants((prev) => {
|
||||
const index = prev.findIndex((item) => item.id === assistant.id);
|
||||
const copy: AssistantListItem = {
|
||||
id: `asst_${Date.now().toString().slice(-6)}`,
|
||||
name: `${assistant.name} 副本`,
|
||||
type: assistant.type,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
// 删除助手
|
||||
async function handleDelete(id: string) {
|
||||
try {
|
||||
await assistantsApi.remove(id);
|
||||
await loadAssistants();
|
||||
} catch (error) {
|
||||
setListError(error instanceof Error ? error.message : "删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
if (index === -1) {
|
||||
return [copy, ...prev];
|
||||
// 保存提示词助手(新建 POST / 编辑 PUT)
|
||||
async function handleSavePrompt() {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
const payload: AssistantUpsert = {
|
||||
name: form.name.trim(),
|
||||
type: "prompt",
|
||||
runtimeMode: form.runtimeMode,
|
||||
greeting: form.greeting,
|
||||
enableInterrupt: form.enableInterrupt,
|
||||
llmCredentialId: form.model || null,
|
||||
asrCredentialId: form.asr || null,
|
||||
ttsCredentialId: form.voice || null,
|
||||
realtimeCredentialId: form.realtimeModel || null,
|
||||
knowledgeBaseId: form.knowledgeBase || null,
|
||||
config: { prompt: form.prompt },
|
||||
};
|
||||
try {
|
||||
if (editingId) {
|
||||
await assistantsApi.update(editingId, payload);
|
||||
} else {
|
||||
await assistantsApi.create(payload);
|
||||
}
|
||||
await loadAssistants();
|
||||
setView("list");
|
||||
} catch (error) {
|
||||
setSaveError(error instanceof Error ? error.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const next = [...prev];
|
||||
next.splice(index + 1, 0, copy);
|
||||
return next;
|
||||
// config 里取字符串字段(后端回传是 Record<string, unknown>)
|
||||
const cfgStr = (a: Assistant, key: string) =>
|
||||
typeof a.config[key] === "string" ? (a.config[key] as string) : "";
|
||||
|
||||
// ---- Dify ----
|
||||
function fillDifyForm(a: Assistant) {
|
||||
setDifyForm({
|
||||
name: a.name,
|
||||
apiUrl: cfgStr(a, "apiUrl"),
|
||||
apiKey: cfgStr(a, "apiKey"), // 编辑时为打码值,不改则原样回传(后端哨兵保留旧 key)
|
||||
asr: a.asrCredentialId ?? "",
|
||||
voice: a.ttsCredentialId ?? "",
|
||||
enableInterrupt: a.enableInterrupt,
|
||||
});
|
||||
}
|
||||
|
||||
const filteredAssistants = assistants.filter((assistant) => {
|
||||
async function handleSaveDify() {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
const payload: AssistantUpsert = {
|
||||
name: difyForm.name.trim(),
|
||||
type: "dify",
|
||||
runtimeMode: "pipeline",
|
||||
greeting: "",
|
||||
enableInterrupt: difyForm.enableInterrupt,
|
||||
llmCredentialId: null, // LLM 由 Dify 应用提供
|
||||
asrCredentialId: difyForm.asr || null,
|
||||
ttsCredentialId: difyForm.voice || null,
|
||||
realtimeCredentialId: null,
|
||||
knowledgeBaseId: null,
|
||||
config: { apiUrl: difyForm.apiUrl, apiKey: difyForm.apiKey },
|
||||
};
|
||||
try {
|
||||
if (editingId) await assistantsApi.update(editingId, payload);
|
||||
else await assistantsApi.create(payload);
|
||||
await loadAssistants();
|
||||
setView("list");
|
||||
} catch (error) {
|
||||
setSaveError(error instanceof Error ? error.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- FastGPT ----
|
||||
function fillFastGptForm(a: Assistant) {
|
||||
setFastGptForm({
|
||||
name: a.name,
|
||||
appId: cfgStr(a, "appId"),
|
||||
apiUrl: cfgStr(a, "apiUrl"),
|
||||
apiKey: cfgStr(a, "apiKey"),
|
||||
asr: a.asrCredentialId ?? "",
|
||||
voice: a.ttsCredentialId ?? "",
|
||||
enableInterrupt: a.enableInterrupt,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSaveFastGpt() {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
const payload: AssistantUpsert = {
|
||||
name: fastGptForm.name.trim(),
|
||||
type: "fastgpt",
|
||||
runtimeMode: "pipeline",
|
||||
greeting: "",
|
||||
enableInterrupt: fastGptForm.enableInterrupt,
|
||||
llmCredentialId: null,
|
||||
asrCredentialId: fastGptForm.asr || null,
|
||||
ttsCredentialId: fastGptForm.voice || null,
|
||||
realtimeCredentialId: null,
|
||||
knowledgeBaseId: null,
|
||||
config: {
|
||||
appId: fastGptForm.appId,
|
||||
apiUrl: fastGptForm.apiUrl,
|
||||
apiKey: fastGptForm.apiKey,
|
||||
},
|
||||
};
|
||||
try {
|
||||
if (editingId) await assistantsApi.update(editingId, payload);
|
||||
else await assistantsApi.create(payload);
|
||||
await loadAssistants();
|
||||
setView("list");
|
||||
} catch (error) {
|
||||
setSaveError(error instanceof Error ? error.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const listItems: AssistantListItem[] = assistants.map((a) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
type: typeToLabel[a.type],
|
||||
updatedAt: formatTimestamp(a.updatedAt),
|
||||
}));
|
||||
|
||||
const filteredAssistants = listItems.filter((assistant) => {
|
||||
if (typeFilter !== "全部" && assistant.type !== typeFilter) {
|
||||
return false;
|
||||
}
|
||||
@@ -607,6 +800,10 @@ export function AssistantPage() {
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
className="rounded-lg"
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
void handleDelete(assistant.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
删除
|
||||
@@ -617,13 +814,41 @@ export function AssistantPage() {
|
||||
</div>
|
||||
))}
|
||||
|
||||
{filteredAssistants.length === 0 && (
|
||||
{listLoading && (
|
||||
<div className="flex items-center justify-center gap-2 px-5 py-12 text-sm text-muted-foreground">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
正在加载助手列表…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!listLoading && listError && (
|
||||
<div className="px-5 py-12 text-center">
|
||||
<div className="font-medium text-destructive">加载失败</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
{listError}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-4 border-hairline-strong text-muted-foreground hover:text-foreground"
|
||||
onClick={() => void loadAssistants()}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!listLoading && !listError && filteredAssistants.length === 0 && (
|
||||
<div className="px-5 py-12 text-center">
|
||||
<div className="font-medium text-foreground">
|
||||
未找到匹配的助手
|
||||
{listItems.length === 0
|
||||
? "暂无助手"
|
||||
: "未找到匹配的助手"}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
请调整关键词或筛选条件后再试。
|
||||
{listItems.length === 0
|
||||
? "点击右上角「创建助手」开始。"
|
||||
: "请调整关键词或筛选条件后再试。"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -884,8 +1109,21 @@ export function AssistantPage() {
|
||||
返回
|
||||
</Button>
|
||||
|
||||
<Button className="gap-2">
|
||||
<Save size={16} />
|
||||
{saveError && (
|
||||
<span className="self-center text-xs text-destructive">
|
||||
{saveError}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={saving || !difyForm.name.trim()}
|
||||
onClick={() => void handleSaveDify()}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Save size={16} />
|
||||
)}
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
@@ -916,19 +1154,21 @@ export function AssistantPage() {
|
||||
<SectionCard
|
||||
icon={<Brain size={18} />}
|
||||
title="语音配置"
|
||||
description="配置本平台的语音识别与播报音色。大模型、知识库与开场白由 Dify 应用提供,请前往 Dify 平台配置。"
|
||||
description="从「模型资源」中选择语音识别与语音合成。大模型、知识库与开场白由 Dify 应用提供,请前往 Dify 平台配置。"
|
||||
>
|
||||
<SelectField
|
||||
<ResourceSelectField
|
||||
label="语音识别"
|
||||
value={difyForm.asr}
|
||||
onChange={(value) => updateDifyForm("asr", value)}
|
||||
options={["SenseVoice", "Paraformer", "Whisper", "FunASR"]}
|
||||
options={credOptions("ASR")}
|
||||
noneLabel="无"
|
||||
/>
|
||||
<SelectField
|
||||
label="播报声音"
|
||||
<ResourceSelectField
|
||||
label="语音合成"
|
||||
value={difyForm.voice}
|
||||
onChange={(value) => updateDifyForm("voice", value)}
|
||||
options={["晓宁", "晓美", "晓宇", "晓晨"]}
|
||||
options={credOptions("TTS")}
|
||||
noneLabel="无"
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
@@ -975,8 +1215,21 @@ export function AssistantPage() {
|
||||
返回
|
||||
</Button>
|
||||
|
||||
<Button className="gap-2">
|
||||
<Save size={16} />
|
||||
{saveError && (
|
||||
<span className="self-center text-xs text-destructive">
|
||||
{saveError}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={saving || !fastGptForm.name.trim()}
|
||||
onClick={() => void handleSaveFastGpt()}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Save size={16} />
|
||||
)}
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1013,19 +1266,21 @@ export function AssistantPage() {
|
||||
<SectionCard
|
||||
icon={<Brain size={18} />}
|
||||
title="语音配置"
|
||||
description="配置本平台的语音识别与播报音色。大模型、知识库与开场白由 FastGPT 应用提供,请前往 FastGPT 平台配置。"
|
||||
description="从「模型资源」中选择语音识别与语音合成。大模型、知识库与开场白由 FastGPT 应用提供,请前往 FastGPT 平台配置。"
|
||||
>
|
||||
<SelectField
|
||||
<ResourceSelectField
|
||||
label="语音识别"
|
||||
value={fastGptForm.asr}
|
||||
onChange={(value) => updateFastGptForm("asr", value)}
|
||||
options={["SenseVoice", "Paraformer", "Whisper", "FunASR"]}
|
||||
options={credOptions("ASR")}
|
||||
noneLabel="无"
|
||||
/>
|
||||
<SelectField
|
||||
label="播报声音"
|
||||
<ResourceSelectField
|
||||
label="语音合成"
|
||||
value={fastGptForm.voice}
|
||||
onChange={(value) => updateFastGptForm("voice", value)}
|
||||
options={["晓宁", "晓美", "晓宇", "晓晨"]}
|
||||
options={credOptions("TTS")}
|
||||
noneLabel="无"
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
@@ -1175,8 +1430,21 @@ export function AssistantPage() {
|
||||
返回
|
||||
</Button>
|
||||
|
||||
<Button className="gap-2">
|
||||
<Save size={16} />
|
||||
{saveError && (
|
||||
<span className="self-center text-xs text-destructive">
|
||||
{saveError}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={saving || !form.name.trim()}
|
||||
onClick={() => void handleSavePrompt()}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Save size={16} />
|
||||
)}
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1275,25 +1543,28 @@ export function AssistantPage() {
|
||||
<SectionCard
|
||||
icon={<Brain size={18} />}
|
||||
title="模型配置"
|
||||
description="配置语音管线的大语言模型、语音识别与播报音色"
|
||||
description="从「模型资源」中选择大语言模型、语音识别与语音合成"
|
||||
>
|
||||
<SelectField
|
||||
<ResourceSelectField
|
||||
label="大语言模型"
|
||||
value={form.model}
|
||||
onChange={(value) => updateForm("model", value)}
|
||||
options={["DeepSeek-V3", "Qwen-Max", "Kimi-K2", "Doubao-Pro", "GPT-4o"]}
|
||||
options={credOptions("LLM")}
|
||||
noneLabel="无"
|
||||
/>
|
||||
<SelectField
|
||||
<ResourceSelectField
|
||||
label="语音识别"
|
||||
value={form.asr}
|
||||
onChange={(value) => updateForm("asr", value)}
|
||||
options={["SenseVoice", "Paraformer", "Whisper", "FunASR"]}
|
||||
options={credOptions("ASR")}
|
||||
noneLabel="无"
|
||||
/>
|
||||
<SelectField
|
||||
label="播报声音"
|
||||
<ResourceSelectField
|
||||
label="语音合成"
|
||||
value={form.voice}
|
||||
onChange={(value) => updateForm("voice", value)}
|
||||
options={["晓宁", "晓美", "晓宇", "晓晨"]}
|
||||
options={credOptions("TTS")}
|
||||
noneLabel="无"
|
||||
/>
|
||||
</SectionCard>
|
||||
) : (
|
||||
@@ -1302,10 +1573,12 @@ export function AssistantPage() {
|
||||
title="模型配置"
|
||||
description="当前模式下 ASR 与 TTS 由 Realtime 模型内置完成"
|
||||
>
|
||||
<SelectField
|
||||
<ResourceSelectField
|
||||
label="Realtime 模型"
|
||||
value={form.realtimeModel}
|
||||
onChange={(value) => updateForm("realtimeModel", value)}
|
||||
options={["gpt-realtime-2", "gpt-realtime", "gpt-4o-realtime-preview"]}
|
||||
options={credOptions("Realtime")}
|
||||
noneLabel="无"
|
||||
/>
|
||||
</SectionCard>
|
||||
)}
|
||||
@@ -1327,10 +1600,11 @@ export function AssistantPage() {
|
||||
title="知识库配置"
|
||||
description="选择助手回答时可检索的业务知识来源"
|
||||
>
|
||||
<SelectField
|
||||
<ResourceSelectField
|
||||
value={form.knowledgeBase}
|
||||
onChange={(value) => updateForm("knowledgeBase", value)}
|
||||
options={["无", "政务政策知识库", "售后知识库", "教育课程知识库", "医疗问答知识库"]}
|
||||
options={kbOptions}
|
||||
noneLabel="无"
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
@@ -1715,6 +1989,52 @@ function SelectField({
|
||||
);
|
||||
}
|
||||
|
||||
// Radix Select 不允许空字符串 value,用哨兵表示"未选/无"
|
||||
const NONE_VALUE = "__none__";
|
||||
|
||||
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-2 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>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
title,
|
||||
description,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
HelpCircle,
|
||||
@@ -109,6 +110,7 @@ export function ComponentsModelsPage() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [duplicatingId, setDuplicatingId] = useState<string | null>(null);
|
||||
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<"idle" | "ok" | "fail">("idle");
|
||||
@@ -233,6 +235,19 @@ export function ComponentsModelsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// 服务端整行复制(含真 key,密钥不经浏览器)
|
||||
async function handleDuplicate(id: string) {
|
||||
setDuplicatingId(id);
|
||||
try {
|
||||
await credentialsApi.duplicate(id);
|
||||
await loadModels();
|
||||
} catch (error) {
|
||||
setLoadError(error instanceof Error ? error.message : "复制失败");
|
||||
} finally {
|
||||
setDuplicatingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const filteredModels = models.filter((model) => {
|
||||
if (typeFilter !== "全部" && model.type !== typeFilter) {
|
||||
return false;
|
||||
@@ -404,6 +419,21 @@ export function ComponentsModelsPage() {
|
||||
align="end"
|
||||
className="w-32 min-w-32 rounded-xl border border-hairline bg-popover p-1"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="rounded-lg"
|
||||
disabled={duplicatingId === model.id}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
void handleDuplicate(model.id);
|
||||
}}
|
||||
>
|
||||
{duplicatingId === model.id ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Copy size={14} />
|
||||
)}
|
||||
复制
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
className="rounded-lg"
|
||||
|
||||
@@ -66,6 +66,83 @@ export const credentialsApi = {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
// 服务端整行复制(含真 key,密钥不经浏览器)
|
||||
duplicate: (id: string) =>
|
||||
request<Credential>(`/api/credentials/${id}/duplicate`, { method: "POST" }),
|
||||
remove: (id: string) =>
|
||||
request<{ ok: boolean }>(`/api/credentials/${id}`, { method: "DELETE" }),
|
||||
};
|
||||
|
||||
// ---------- 助手 ----------
|
||||
export type AssistantType =
|
||||
| "prompt"
|
||||
| "workflow"
|
||||
| "dify"
|
||||
| "fastgpt"
|
||||
| "opencode";
|
||||
export type RuntimeMode = "pipeline" | "realtime";
|
||||
|
||||
/** 后端 AssistantOut。config 形态随 type 变,故用宽松 record */
|
||||
export type Assistant = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: AssistantType;
|
||||
runtimeMode: RuntimeMode;
|
||||
greeting: string;
|
||||
enableInterrupt: boolean;
|
||||
llmCredentialId: string | null;
|
||||
asrCredentialId: string | null;
|
||||
ttsCredentialId: string | null;
|
||||
realtimeCredentialId: string | null;
|
||||
knowledgeBaseId: string | null;
|
||||
config: Record<string, unknown>;
|
||||
updatedAt?: string | null;
|
||||
};
|
||||
|
||||
export type AssistantUpsert = {
|
||||
name: string;
|
||||
type: AssistantType;
|
||||
runtimeMode: RuntimeMode;
|
||||
greeting: string;
|
||||
enableInterrupt: boolean;
|
||||
llmCredentialId: string | null;
|
||||
asrCredentialId: string | null;
|
||||
ttsCredentialId: string | null;
|
||||
realtimeCredentialId: string | null;
|
||||
knowledgeBaseId: string | null;
|
||||
config: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export const assistantsApi = {
|
||||
list: () => request<Assistant[]>("/api/assistants"),
|
||||
get: (id: string) => request<Assistant>(`/api/assistants/${id}`),
|
||||
create: (body: AssistantUpsert) =>
|
||||
request<Assistant>("/api/assistants", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
update: (id: string, body: AssistantUpsert) =>
|
||||
request<Assistant>(`/api/assistants/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
// 服务端整行复制(含真 key,密钥不经浏览器)
|
||||
duplicate: (id: string) =>
|
||||
request<Assistant>(`/api/assistants/${id}/duplicate`, { method: "POST" }),
|
||||
remove: (id: string) =>
|
||||
request<{ ok: boolean }>(`/api/assistants/${id}`, { method: "DELETE" }),
|
||||
};
|
||||
|
||||
// ---------- 知识库 ----------
|
||||
export type KnowledgeBase = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
embeddingCredentialId: string | null;
|
||||
status: string;
|
||||
updatedAt?: string | null;
|
||||
};
|
||||
|
||||
export const knowledgeBasesApi = {
|
||||
list: () => request<KnowledgeBase[]>("/api/knowledge-bases"),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user