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.
This commit is contained in:
@@ -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(
|
||||
|
||||
51
backend/db/seed_assistants.sql
Normal file
51
backend/db/seed_assistants.sql
Normal file
@@ -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;
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user