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:
Xin Wang
2026-06-09 10:37:29 +08:00
parent 23e1cf5d42
commit 519cc0fefe
8 changed files with 197 additions and 185 deletions

View File

@@ -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 ## 清空后重新灌全部种子

View File

@@ -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(

View 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;

View File

@@ -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()

View File

@@ -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

View File

@@ -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),

View File

@@ -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>): 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<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,
});
}
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) => ({

View File

@@ -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<string, unknown>;
prompt: string;
apiUrl: string;
apiKey: string;
appId: string;
graph: 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 type AssistantUpsert = Omit<Assistant, "id" | "updatedAt">;
export const assistantsApi = {
list: () => request<Assistant[]>("/api/assistants"),