diff --git a/backend/routes/assistants.py b/backend/routes/assistants.py index a570732..fe25273 100644 --- a/backend/routes/assistants.py +++ b/backend/routes/assistants.py @@ -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, diff --git a/backend/routes/credentials.py b/backend/routes/credentials.py index 25940b3..5c300b4 100644 --- a/backend/routes/credentials.py +++ b/backend/routes/credentials.py @@ -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, diff --git a/frontend/src/components/pages/AssistantPage.tsx b/frontend/src/components/pages/AssistantPage.tsx index 97d7785..4d26a61 100644 --- a/frontend/src/components/pages/AssistantPage.tsx +++ b/frontend/src/components/pages/AssistantPage.tsx @@ -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 = { + 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({ @@ -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({ name: "Dify 知识库问答助手", apiUrl: "https://api.dify.ai/v1/chat-messages", apiKey: "", - asr: "SenseVoice", - voice: "晓宁", + asr: "", + voice: "", enableInterrupt: true, }); const [openCodeForm, setOpenCodeForm] = useState({ @@ -293,12 +241,20 @@ export function AssistantPage() { "你是一个代码助手的语音交互界面,请用简洁、口语化的方式回答用户关于代码与工程的问题。", apiUrl: "http://localhost:4096", apiKey: "", - asr: "SenseVoice", - voice: "晓宁", + asr: "", + voice: "", enableInterrupt: true, }); - const [assistants, setAssistants] = - useState(mockAssistants); + const [assistants, setAssistants] = useState([]); + const [listLoading, setListLoading] = useState(true); + const [listError, setListError] = useState(null); + // 编辑中的助手 id;null = 新建 + const [editingId, setEditingId] = useState(null); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + // 下拉数据源:模型凭证 + 知识库 + const [credentials, setCredentials] = useState([]); + const [knowledgeBases, setKnowledgeBases] = useState([]); const [view, setView] = useState< | "list" | "choose" @@ -317,23 +273,115 @@ export function AssistantPage() { const [draftName, setDraftName] = useState(""); const [draftType, setDraftType] = useState(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) + 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() { { + event.preventDefault(); + void handleDelete(assistant.id); + }} > 删除 @@ -617,13 +814,41 @@ export function AssistantPage() { ))} - {filteredAssistants.length === 0 && ( + {listLoading && ( +
+ + 正在加载助手列表… +
+ )} + + {!listLoading && listError && ( +
+
加载失败
+
+ {listError} +
+ +
+ )} + + {!listLoading && !listError && filteredAssistants.length === 0 && (
- 未找到匹配的助手 + {listItems.length === 0 + ? "暂无助手" + : "未找到匹配的助手"}
- 请调整关键词或筛选条件后再试。 + {listItems.length === 0 + ? "点击右上角「创建助手」开始。" + : "请调整关键词或筛选条件后再试。"}
)} @@ -884,8 +1109,21 @@ export function AssistantPage() { 返回 - @@ -916,19 +1154,21 @@ export function AssistantPage() { } title="语音配置" - description="配置本平台的语音识别与播报音色。大模型、知识库与开场白由 Dify 应用提供,请前往 Dify 平台配置。" + description="从「模型资源」中选择语音识别与语音合成。大模型、知识库与开场白由 Dify 应用提供,请前往 Dify 平台配置。" > - updateDifyForm("asr", value)} - options={["SenseVoice", "Paraformer", "Whisper", "FunASR"]} + options={credOptions("ASR")} + noneLabel="无" /> - updateDifyForm("voice", value)} - options={["晓宁", "晓美", "晓宇", "晓晨"]} + options={credOptions("TTS")} + noneLabel="无" /> @@ -975,8 +1215,21 @@ export function AssistantPage() { 返回 - @@ -1013,19 +1266,21 @@ export function AssistantPage() { } title="语音配置" - description="配置本平台的语音识别与播报音色。大模型、知识库与开场白由 FastGPT 应用提供,请前往 FastGPT 平台配置。" + description="从「模型资源」中选择语音识别与语音合成。大模型、知识库与开场白由 FastGPT 应用提供,请前往 FastGPT 平台配置。" > - updateFastGptForm("asr", value)} - options={["SenseVoice", "Paraformer", "Whisper", "FunASR"]} + options={credOptions("ASR")} + noneLabel="无" /> - updateFastGptForm("voice", value)} - options={["晓宁", "晓美", "晓宇", "晓晨"]} + options={credOptions("TTS")} + noneLabel="无" /> @@ -1175,8 +1430,21 @@ export function AssistantPage() { 返回 - @@ -1275,25 +1543,28 @@ export function AssistantPage() { } title="模型配置" - description="配置语音管线的大语言模型、语音识别与播报音色" + description="从「模型资源」中选择大语言模型、语音识别与语音合成" > - updateForm("model", value)} - options={["DeepSeek-V3", "Qwen-Max", "Kimi-K2", "Doubao-Pro", "GPT-4o"]} + options={credOptions("LLM")} + noneLabel="无" /> - updateForm("asr", value)} - options={["SenseVoice", "Paraformer", "Whisper", "FunASR"]} + options={credOptions("ASR")} + noneLabel="无" /> - updateForm("voice", value)} - options={["晓宁", "晓美", "晓宇", "晓晨"]} + options={credOptions("TTS")} + noneLabel="无" /> ) : ( @@ -1302,10 +1573,12 @@ export function AssistantPage() { title="模型配置" description="当前模式下 ASR 与 TTS 由 Realtime 模型内置完成" > - updateForm("realtimeModel", value)} - options={["gpt-realtime-2", "gpt-realtime", "gpt-4o-realtime-preview"]} + options={credOptions("Realtime")} + noneLabel="无" /> )} @@ -1327,10 +1600,11 @@ export function AssistantPage() { title="知识库配置" description="选择助手回答时可检索的业务知识来源" > - updateForm("knowledgeBase", value)} - options={["无", "政务政策知识库", "售后知识库", "教育课程知识库", "医疗问答知识库"]} + options={kbOptions} + noneLabel="无" /> @@ -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 ( +
+ {label && ( +
{label}
+ )} + + +
+ ); +} + function ToggleRow({ title, description, diff --git a/frontend/src/components/pages/ComponentsModelsPage.tsx b/frontend/src/components/pages/ComponentsModelsPage.tsx index bc7a7bd..3bc793e 100644 --- a/frontend/src/components/pages/ComponentsModelsPage.tsx +++ b/frontend/src/components/pages/ComponentsModelsPage.tsx @@ -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(null); const [deletingId, setDeletingId] = useState(null); + const [duplicatingId, setDuplicatingId] = useState(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" > + { + event.preventDefault(); + void handleDuplicate(model.id); + }} + > + {duplicatingId === model.id ? ( + + ) : ( + + )} + 复制 + + request(`/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; + 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; +}; + +export const assistantsApi = { + list: () => request("/api/assistants"), + get: (id: string) => request(`/api/assistants/${id}`), + create: (body: AssistantUpsert) => + request("/api/assistants", { + method: "POST", + body: JSON.stringify(body), + }), + update: (id: string, body: AssistantUpsert) => + request(`/api/assistants/${id}`, { + method: "PUT", + body: JSON.stringify(body), + }), + // 服务端整行复制(含真 key,密钥不经浏览器) + duplicate: (id: string) => + request(`/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("/api/knowledge-bases"), +};