From 519cc0fefe07fbdefdc50dfcf1468da76d4296fc Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Tue, 9 Jun 2026 10:37:29 +0800 Subject: [PATCH] Refactor assistant configuration and database seeding - Update Makefile to include new database seed commands for assistants and credentials. - Refactor assistant model to use explicit fields instead of a config dictionary, improving data integrity and clarity. - Implement new seeding SQL script for assistants, ensuring dependencies on credentials are respected. - Modify backend routes and frontend components to accommodate the new assistant structure, including direct field access for prompt, API URL, and keys. - Enhance the AssistantPage component to handle the new data structure and streamline the save process for different assistant types. --- Makefile | 18 +- backend/db/models.py | 9 +- backend/db/seed_assistants.sql | 51 ++++++ backend/routes/assistants.py | 43 ++--- backend/schemas.py | 62 +++---- backend/services/config_resolver.py | 10 +- .../src/components/pages/AssistantPage.tsx | 167 ++++++++---------- frontend/src/lib/api.ts | 22 +-- 8 files changed, 197 insertions(+), 185 deletions(-) create mode 100644 backend/db/seed_assistants.sql diff --git a/Makefile b/Makefile index d1fc398..e051e99 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ PSQL = docker compose exec -T postgres psql -U postgres -d postgres .DEFAULT_GOAL := help -.PHONY: help up down restart logs api-logs db db-list db-seed db-clear db-reset +.PHONY: help up down restart logs api-logs db db-list db-seed db-seed-credentials db-seed-assistants db-clear db-reset help: ## 列出所有可用目标 @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ @@ -33,13 +33,19 @@ api-logs: ## 只看后端日志 db: ## 进入交互式 psql docker compose exec postgres psql -U postgres -d postgres -db-list: ## 列出模型凭证(key 明文,仅本地调试用) +db-list: ## 列出凭证与助手(key 明文,仅本地调试用) @$(PSQL) -c "SELECT id, name, type, interface_type, is_default FROM provider_credentials ORDER BY id;" + @$(PSQL) -c "SELECT id, name, type FROM assistants ORDER BY id;" -db-seed: ## 灌入 12 条模型凭证种子(幂等) +db-seed-credentials: ## 灌入 12 条模型凭证种子(幂等) $(PSQL) < backend/db/seed_credentials.sql -db-clear: ## 清空模型凭证表 - $(PSQL) -c "TRUNCATE provider_credentials;" +db-seed-assistants: ## 灌入 知识库 + 助手 种子(幂等;依赖凭证已就绪) + $(PSQL) < backend/db/seed_assistants.sql -db-reset: db-clear db-seed ## 清空后重新灌种子 +db-seed: db-seed-credentials db-seed-assistants ## 全量灌种子(凭证→知识库→助手,幂等,可重复执行) + +db-clear: ## 清空 助手/知识库/凭证 三表(按依赖顺序) + $(PSQL) -c "TRUNCATE assistants, knowledge_bases, provider_credentials CASCADE;" + +db-reset: db-clear db-seed ## 清空后重新灌全部种子 diff --git a/backend/db/models.py b/backend/db/models.py index 506c1ee..746016c 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -95,8 +95,13 @@ class Assistant(Base): String(40), ForeignKey("knowledge_bases.id", ondelete="RESTRICT"), nullable=True ) - # 类型专属字段(形态各异):prompt / graph / dify|fastgpt|opencode 端点+key(打码) - config: Mapped[dict] = mapped_column(JSON, default=dict) + # ---- 瘦类型专属字段(真列,稀疏:按 type 用其中几列) ---- + prompt: Mapped[str] = mapped_column(String(8192), default="") # prompt / opencode + api_url: Mapped[str] = mapped_column(String(512), default="") # dify / fastgpt / opencode + api_key: Mapped[str] = mapped_column(String(512), default="") # dify / fastgpt / opencode(打码/哨兵,同凭证) + app_id: Mapped[str] = mapped_column(String(128), default="") # fastgpt + # workflow 专属:图(nodes/edges)。要版本化时再迁出到 assistant_workflow 表 + graph: Mapped[dict] = mapped_column(JSON, default=dict) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) updated_at: Mapped[datetime] = mapped_column( diff --git a/backend/db/seed_assistants.sql b/backend/db/seed_assistants.sql new file mode 100644 index 0000000..69008f7 --- /dev/null +++ b/backend/db/seed_assistants.sql @@ -0,0 +1,51 @@ +-- 知识库 + 助手种子数据(依赖 seed_credentials.sql 先灌入 model_xxx 凭证)。 +-- +-- 用法(从仓库根目录): +-- docker compose exec -T postgres psql -U postgres -d postgres < backend/db/seed_assistants.sql +-- 或:make db-seed-assistants(凭证已就绪时);make db-seed 会按顺序全灌。 +-- +-- 说明: +-- * id 固定(kb_001 / asst_001..005)+ ON CONFLICT 幂等,可重复执行。 +-- * 引用 seed_credentials 的 model_001(LLM)/003(ASR)/005(TTS)/010(Embedding)。 +-- * 宽表 STI:瘦类型用真列(prompt/api_url/api_key/app_id),workflow 用 graph 列。 +-- * api_key 在库里明文(读取走 API 才打码),这里填示例占位。 + +-- 知识库(引用 Embedding 凭证 model_010) +INSERT INTO knowledge_bases (id, name, description, embedding_credential_id, status) +VALUES + ('kb_001', '政务政策知识库', '政策解读 / 办事指南示例库', 'model_010', 'active') +ON CONFLICT (id) DO NOTHING; + +-- 助手(一种类型一条) +INSERT INTO assistants ( + id, name, type, runtime_mode, greeting, enable_interrupt, + llm_credential_id, asr_credential_id, tts_credential_id, + realtime_credential_id, knowledge_base_id, + prompt, api_url, api_key, app_id, graph +) VALUES + -- 提示词:llm/asr/tts + 知识库,prompt 真列 + ('asst_001', '政务咨询助手', 'prompt', 'pipeline', '您好,我是政务助手,请问有什么可以帮您?', TRUE, + 'model_001', 'model_003', 'model_005', NULL, 'kb_001', + '你是一名专业的政务咨询助手,回答准确、简洁,不编造政策内容。', '', '', '', '{}'), + + -- 工作流:asr/tts + graph 列(最小图) + ('asst_002', '热线工单助手', 'workflow', 'pipeline', '', TRUE, + NULL, 'model_003', 'model_005', NULL, NULL, + '', '', '', '', + '{"nodes":[{"id":"1","type":"startCall","position":{"x":0,"y":0},"data":{"name":"开场","prompt":"你好,请问需要办理什么业务?"}}],"edges":[]}'), + + -- Dify:asr/tts + api_url/api_key + ('asst_003', 'Dify 客服助手', 'dify', 'pipeline', '', TRUE, + NULL, 'model_003', 'model_005', NULL, NULL, + '', 'https://api.dify.ai/v1', 'app-dify-demo-key', '', '{}'), + + -- FastGPT:asr/tts + app_id/api_url/api_key + ('asst_004', 'FastGPT 售后助手', 'fastgpt', 'pipeline', '', TRUE, + NULL, 'model_003', 'model_005', NULL, NULL, + '', 'https://api.fastgpt.in/api/v1/chat/completions', 'fastgpt-demo-key', 'app-fastgpt-001', '{}'), + + -- OpenCode:asr/tts + prompt/api_url/api_key + ('asst_005', 'OpenCode 代码助手', 'opencode', 'pipeline', '', TRUE, + NULL, 'model_003', 'model_005', NULL, NULL, + '你是一个代码助手的语音界面,用简洁口语回答工程问题。', 'http://localhost:4096', 'opencode-demo-key', '', '{}') +ON CONFLICT (id) DO NOTHING; diff --git a/backend/routes/assistants.py b/backend/routes/assistants.py index fe25273..ffc808e 100644 --- a/backend/routes/assistants.py +++ b/backend/routes/assistants.py @@ -1,7 +1,7 @@ """助手 CRUD。前端「助手列表 / 创建 / 编辑」对接这里。 -模型/KB 以 FK 引用注册表;外部类型(dify/fastgpt/opencode)的 config.apiKey 是私有密钥, -读时打码、写时哨兵(复用 services/masking)。 +模型/KB 以 FK 引用注册表;瘦类型字段直接是真列。外部类型(dify/fastgpt/opencode)的 +api_key 是私有密钥,读时打码、写时哨兵(列级,复用 services/masking,与凭证表一致)。 """ import uuid @@ -9,7 +9,7 @@ import uuid from db.models import Assistant from db.session import get_session from fastapi import APIRouter, Depends, HTTPException -from schemas import EXTERNAL_TYPES, AssistantOut, AssistantUpsert +from schemas import AssistantOut, AssistantUpsert from services.masking import mask, resolve_incoming_key from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -17,25 +17,6 @@ from sqlalchemy.ext.asyncio import AsyncSession router = APIRouter(prefix="/api/assistants", tags=["assistants"]) -def _mask_config(type_: str, config: dict) -> dict: - """读取返回前:外部类型的 apiKey 打码,其余原样。""" - if type_ in EXTERNAL_TYPES and config.get("apiKey"): - return {**config, "apiKey": mask(config["apiKey"])} - return config - - -def _merge_config(type_: str, incoming: dict, stored: dict) -> dict: - """写入时:外部类型若回传打码占位符/空 apiKey → 保留旧 key。""" - if type_ in EXTERNAL_TYPES and "apiKey" in incoming: - incoming = { - **incoming, - "apiKey": resolve_incoming_key( - incoming.get("apiKey"), stored.get("apiKey", "") - ), - } - return incoming - - def _to_out(a: Assistant) -> AssistantOut: return AssistantOut( id=a.id, @@ -49,7 +30,11 @@ def _to_out(a: Assistant) -> AssistantOut: tts_credential_id=a.tts_credential_id, realtime_credential_id=a.realtime_credential_id, knowledge_base_id=a.knowledge_base_id, - config=_mask_config(a.type, a.config or {}), + prompt=a.prompt, + api_url=a.api_url, + api_key=mask(a.api_key), # 仅外部类型有值;空串 mask 仍是空串 + app_id=a.app_id, + graph=a.graph or {}, updated_at=a.updated_at.isoformat() if a.updated_at else None, ) @@ -87,7 +72,7 @@ async def get_assistant( async def duplicate_assistant( assistant_id: str, session: AsyncSession = Depends(get_session) ): - """服务端整行复制:含真实 config(真 key),DB→DB,密钥不经过浏览器,副本可直接用。""" + """服务端整行复制:含真实 api_key,DB→DB,密钥不经过浏览器,副本可直接用。""" src = await session.get(Assistant, assistant_id) if not src: raise HTTPException(404, "助手不存在") @@ -103,7 +88,11 @@ async def duplicate_assistant( 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 + prompt=src.prompt, + api_url=src.api_url, + api_key=src.api_key, # 真 key,DB→DB + app_id=src.app_id, + graph=dict(src.graph or {}), # 浅拷贝,避免与源行共享同一 dict ) session.add(a) await session.commit() @@ -121,8 +110,8 @@ async def update_assistant( if not a: raise HTTPException(404, "助手不存在") data = body.model_dump() - # 外部类型 apiKey 写时哨兵:打码占位符 → 保留旧 key(在改 a.config 前用旧值) - data["config"] = _merge_config(body.type, data["config"], a.config or {}) + # 写时哨兵(列级):回传打码/空 api_key → 保留旧 key + data["api_key"] = resolve_incoming_key(data["api_key"], a.api_key) for k, v in data.items(): setattr(a, k, v) await session.commit() diff --git a/backend/schemas.py b/backend/schemas.py index 191a5ed..d727c5f 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -31,43 +31,17 @@ class CamelModel(BaseModel): ) -# ---------- 各类型的 config 形态(JSON 内嵌,按 type 校验) ---------- -class PromptConfig(CamelModel): - prompt: str = "" - realtime_model: str = "" - - -class WorkflowConfig(CamelModel): - graph: dict[str, Any] = {} # {nodes, edges, viewport};节点 data 可带 *CredentialId 覆盖 - - -class DifyConfig(CamelModel): - api_url: str = "" - api_key: str = "" # 写时:占位符/空 → 保留旧 - - -class FastgptConfig(CamelModel): - app_id: str = "" - api_url: str = "" - api_key: str = "" - - -class OpencodeConfig(CamelModel): - prompt: str = "" - api_url: str = "" - api_key: str = "" - - -CONFIG_BY_TYPE: dict[str, type[CamelModel]] = { - "prompt": PromptConfig, - "workflow": WorkflowConfig, - "dify": DifyConfig, - "fastgpt": FastgptConfig, - "opencode": OpencodeConfig, +# 各 type 允许的瘦字段(其余字段写入时清零,防止跨类型脏数据) +ALLOWED_FIELDS: dict[str, set[str]] = { + "prompt": {"prompt"}, + "workflow": {"graph"}, + "dify": {"api_url", "api_key"}, + "fastgpt": {"app_id", "api_url", "api_key"}, + "opencode": {"prompt", "api_url", "api_key"}, } -# ---------- 助手(单表,无版本化;type 可变) ---------- +# ---------- 助手(单表 STI:瘦类型真列 + workflow 图 JSON 列) ---------- class AssistantUpsert(CamelModel): name: str type: AssistantType = "prompt" @@ -82,14 +56,22 @@ class AssistantUpsert(CamelModel): realtime_credential_id: str | None = None knowledge_base_id: str | None = None - # 类型专属字段;校验后归一为 camelCase 存库 - config: dict[str, Any] = {} + # 瘦类型专属(真列);按 type 取用,无关字段写入时清零 + prompt: str = "" + api_url: str = "" + api_key: str = "" # 写时:占位符/空 → 保留旧(哨兵) + app_id: str = "" + # workflow 专属:图 + graph: dict[str, Any] = {} @model_validator(mode="after") - def _normalize_config(self): - model = CONFIG_BY_TYPE[self.type] - # 按当前 type 校验并裁掉无关字段,统一回 camelCase 落库 - self.config = model.model_validate(self.config).model_dump(by_alias=True) + def _strip_irrelevant_fields(self): + allowed = ALLOWED_FIELDS[self.type] + for field in ("prompt", "api_url", "api_key", "app_id"): + if field not in allowed: + setattr(self, field, "") + if "graph" not in allowed: + self.graph = {} return self diff --git a/backend/services/config_resolver.py b/backend/services/config_resolver.py index e905d89..c769644 100644 --- a/backend/services/config_resolver.py +++ b/backend/services/config_resolver.py @@ -48,20 +48,18 @@ async def resolve_runtime_config( tts = await _resolve(session, assistant.tts_credential_id, "TTS") realtime = await _resolve(session, assistant.realtime_credential_id, "Realtime") - cfg = assistant.config or {} - return AssistantConfig( name=assistant.name, greeting=assistant.greeting, - # 提示词/工作流类型把 prompt 放 config;外部类型由其平台编排,这里给个兜底 - prompt=cfg.get("prompt") or "你是一个有帮助的助手。", + # prompt 现在是真列;外部类型由其平台编排,这里给个兜底 + prompt=assistant.prompt or "你是一个有帮助的助手。", runtimeMode=assistant.runtime_mode, # type: ignore[arg-type] enableInterrupt=assistant.enable_interrupt, # 模型/音色:凭证的模型ID优先 model=(llm.model_id if llm else ""), asr=(stt.model_id if stt else ""), - voice=cfg.get("voice", ""), # 音色不再是独立列,若 config 带则用,否则 .env 兜底 - realtimeModel=(realtime.model_id if realtime else cfg.get("realtimeModel", "")), + voice="", # 音色无独立列,留空 → service_factory 回退 .env TTS_VOICE + realtimeModel=(realtime.model_id if realtime else ""), # 运行时连接信息(真 key + url):凭证优先,否则 .env 兜底 llm_api_key=(llm.api_key if llm else config.LLM_API_KEY), llm_base_url=(llm.api_url if llm else config.LLM_BASE_URL), diff --git a/frontend/src/components/pages/AssistantPage.tsx b/frontend/src/components/pages/AssistantPage.tsx index 4d26a61..578e81a 100644 --- a/frontend/src/components/pages/AssistantPage.tsx +++ b/frontend/src/components/pages/AssistantPage.tsx @@ -339,7 +339,7 @@ export function AssistantPage() { setForm({ name: a.name, greeting: a.greeting, - prompt: typeof a.config.prompt === "string" ? a.config.prompt : "", + prompt: a.prompt, runtimeMode: a.runtimeMode, realtimeModel: a.realtimeCredentialId ?? "", model: a.llmCredentialId ?? "", @@ -464,70 +464,32 @@ export function AssistantPage() { } } - // 保存提示词助手(新建 POST / 编辑 PUT) - async function handleSavePrompt() { - setSaving(true); - setSaveError(null); - const payload: AssistantUpsert = { - name: form.name.trim(), + // 平铺字段的空白 upsert,各类型按需覆盖(后端会按 type 清掉无关字段) + function baseUpsert(over: Partial): AssistantUpsert { + return { + name: "", 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); - } - } - - // 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, - }); - } - - 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, + enableInterrupt: true, + llmCredentialId: null, + asrCredentialId: null, + ttsCredentialId: null, realtimeCredentialId: null, knowledgeBaseId: null, - config: { apiUrl: difyForm.apiUrl, apiKey: difyForm.apiKey }, + prompt: "", + apiUrl: "", + apiKey: "", + appId: "", + graph: {}, + ...over, }; + } + + // 统一的保存:新建 POST / 编辑 PUT + async function save(payload: AssistantUpsert) { + setSaving(true); + setSaveError(null); try { if (editingId) await assistantsApi.update(editingId, payload); else await assistantsApi.create(payload); @@ -540,49 +502,76 @@ export function AssistantPage() { } } + function handleSavePrompt() { + void save( + baseUpsert({ + 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, + prompt: form.prompt, + }), + ); + } + + // ---- Dify ---- + function fillDifyForm(a: Assistant) { + setDifyForm({ + name: a.name, + apiUrl: a.apiUrl, + apiKey: a.apiKey, // 编辑时为打码值,不改则原样回传(后端哨兵保留旧 key) + asr: a.asrCredentialId ?? "", + voice: a.ttsCredentialId ?? "", + enableInterrupt: a.enableInterrupt, + }); + } + + function handleSaveDify() { + void save( + baseUpsert({ + name: difyForm.name.trim(), + type: "dify", + enableInterrupt: difyForm.enableInterrupt, + asrCredentialId: difyForm.asr || null, + ttsCredentialId: difyForm.voice || null, + apiUrl: difyForm.apiUrl, + apiKey: difyForm.apiKey, + }), + ); + } + // ---- FastGPT ---- function fillFastGptForm(a: Assistant) { setFastGptForm({ name: a.name, - appId: cfgStr(a, "appId"), - apiUrl: cfgStr(a, "apiUrl"), - apiKey: cfgStr(a, "apiKey"), + appId: a.appId, + apiUrl: a.apiUrl, + apiKey: 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: { + function handleSaveFastGpt() { + void save( + baseUpsert({ + name: fastGptForm.name.trim(), + type: "fastgpt", + enableInterrupt: fastGptForm.enableInterrupt, + asrCredentialId: fastGptForm.asr || null, + ttsCredentialId: fastGptForm.voice || null, 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) => ({ diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index a3dc05e..4247700 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -82,7 +82,7 @@ export type AssistantType = | "opencode"; export type RuntimeMode = "pipeline" | "realtime"; -/** 后端 AssistantOut。config 形态随 type 变,故用宽松 record */ +/** 后端 AssistantOut(宽表 STI:瘦字段平铺,workflow 用 graph)。apiKey 读时打码 */ export type Assistant = { id: string; name: string; @@ -95,23 +95,15 @@ export type Assistant = { ttsCredentialId: string | null; realtimeCredentialId: string | null; knowledgeBaseId: string | null; - config: Record; + prompt: string; + apiUrl: string; + apiKey: string; + appId: string; + graph: 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 type AssistantUpsert = Omit; export const assistantsApi = { list: () => request("/api/assistants"),