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:
18
Makefile
18
Makefile
@@ -7,7 +7,7 @@
|
|||||||
PSQL = docker compose exec -T postgres psql -U postgres -d postgres
|
PSQL = docker compose exec -T postgres psql -U postgres -d postgres
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.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: ## 列出所有可用目标
|
help: ## 列出所有可用目标
|
||||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
|
||||||
@@ -33,13 +33,19 @@ api-logs: ## 只看后端日志
|
|||||||
db: ## 进入交互式 psql
|
db: ## 进入交互式 psql
|
||||||
docker compose exec postgres psql -U postgres -d postgres
|
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, 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
|
$(PSQL) < backend/db/seed_credentials.sql
|
||||||
|
|
||||||
db-clear: ## 清空模型凭证表
|
db-seed-assistants: ## 灌入 知识库 + 助手 种子(幂等;依赖凭证已就绪)
|
||||||
$(PSQL) -c "TRUNCATE provider_credentials;"
|
$(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 ## 清空后重新灌全部种子
|
||||||
|
|||||||
@@ -95,8 +95,13 @@ class Assistant(Base):
|
|||||||
String(40), ForeignKey("knowledge_bases.id", ondelete="RESTRICT"), nullable=True
|
String(40), ForeignKey("knowledge_bases.id", ondelete="RESTRICT"), nullable=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# 类型专属字段(形态各异):prompt / graph / dify|fastgpt|opencode 端点+key(打码)
|
# ---- 瘦类型专属字段(真列,稀疏:按 type 用其中几列) ----
|
||||||
config: Mapped[dict] = mapped_column(JSON, default=dict)
|
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())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
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。前端「助手列表 / 创建 / 编辑」对接这里。
|
"""助手 CRUD。前端「助手列表 / 创建 / 编辑」对接这里。
|
||||||
|
|
||||||
模型/KB 以 FK 引用注册表;外部类型(dify/fastgpt/opencode)的 config.apiKey 是私有密钥,
|
模型/KB 以 FK 引用注册表;瘦类型字段直接是真列。外部类型(dify/fastgpt/opencode)的
|
||||||
读时打码、写时哨兵(复用 services/masking)。
|
api_key 是私有密钥,读时打码、写时哨兵(列级,复用 services/masking,与凭证表一致)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
@@ -9,7 +9,7 @@ import uuid
|
|||||||
from db.models import Assistant
|
from db.models import Assistant
|
||||||
from db.session import get_session
|
from db.session import get_session
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
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 services.masking import mask, resolve_incoming_key
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -17,25 +17,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
router = APIRouter(prefix="/api/assistants", tags=["assistants"])
|
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:
|
def _to_out(a: Assistant) -> AssistantOut:
|
||||||
return AssistantOut(
|
return AssistantOut(
|
||||||
id=a.id,
|
id=a.id,
|
||||||
@@ -49,7 +30,11 @@ def _to_out(a: Assistant) -> AssistantOut:
|
|||||||
tts_credential_id=a.tts_credential_id,
|
tts_credential_id=a.tts_credential_id,
|
||||||
realtime_credential_id=a.realtime_credential_id,
|
realtime_credential_id=a.realtime_credential_id,
|
||||||
knowledge_base_id=a.knowledge_base_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,
|
updated_at=a.updated_at.isoformat() if a.updated_at else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -87,7 +72,7 @@ async def get_assistant(
|
|||||||
async def duplicate_assistant(
|
async def duplicate_assistant(
|
||||||
assistant_id: str, session: AsyncSession = Depends(get_session)
|
assistant_id: str, session: AsyncSession = Depends(get_session)
|
||||||
):
|
):
|
||||||
"""服务端整行复制:含真实 config(真 key),DB→DB,密钥不经过浏览器,副本可直接用。"""
|
"""服务端整行复制:含真实 api_key,DB→DB,密钥不经过浏览器,副本可直接用。"""
|
||||||
src = await session.get(Assistant, assistant_id)
|
src = await session.get(Assistant, assistant_id)
|
||||||
if not src:
|
if not src:
|
||||||
raise HTTPException(404, "助手不存在")
|
raise HTTPException(404, "助手不存在")
|
||||||
@@ -103,7 +88,11 @@ async def duplicate_assistant(
|
|||||||
tts_credential_id=src.tts_credential_id,
|
tts_credential_id=src.tts_credential_id,
|
||||||
realtime_credential_id=src.realtime_credential_id,
|
realtime_credential_id=src.realtime_credential_id,
|
||||||
knowledge_base_id=src.knowledge_base_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)
|
session.add(a)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -121,8 +110,8 @@ async def update_assistant(
|
|||||||
if not a:
|
if not a:
|
||||||
raise HTTPException(404, "助手不存在")
|
raise HTTPException(404, "助手不存在")
|
||||||
data = body.model_dump()
|
data = body.model_dump()
|
||||||
# 外部类型 apiKey 写时哨兵:打码占位符 → 保留旧 key(在改 a.config 前用旧值)
|
# 写时哨兵(列级):回传打码/空 api_key → 保留旧 key
|
||||||
data["config"] = _merge_config(body.type, data["config"], a.config or {})
|
data["api_key"] = resolve_incoming_key(data["api_key"], a.api_key)
|
||||||
for k, v in data.items():
|
for k, v in data.items():
|
||||||
setattr(a, k, v)
|
setattr(a, k, v)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|||||||
@@ -31,43 +31,17 @@ class CamelModel(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------- 各类型的 config 形态(JSON 内嵌,按 type 校验) ----------
|
# 各 type 允许的瘦字段(其余字段写入时清零,防止跨类型脏数据)
|
||||||
class PromptConfig(CamelModel):
|
ALLOWED_FIELDS: dict[str, set[str]] = {
|
||||||
prompt: str = ""
|
"prompt": {"prompt"},
|
||||||
realtime_model: str = ""
|
"workflow": {"graph"},
|
||||||
|
"dify": {"api_url", "api_key"},
|
||||||
|
"fastgpt": {"app_id", "api_url", "api_key"},
|
||||||
class WorkflowConfig(CamelModel):
|
"opencode": {"prompt", "api_url", "api_key"},
|
||||||
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 可变) ----------
|
# ---------- 助手(单表 STI:瘦类型真列 + workflow 图 JSON 列) ----------
|
||||||
class AssistantUpsert(CamelModel):
|
class AssistantUpsert(CamelModel):
|
||||||
name: str
|
name: str
|
||||||
type: AssistantType = "prompt"
|
type: AssistantType = "prompt"
|
||||||
@@ -82,14 +56,22 @@ class AssistantUpsert(CamelModel):
|
|||||||
realtime_credential_id: str | None = None
|
realtime_credential_id: str | None = None
|
||||||
knowledge_base_id: str | None = None
|
knowledge_base_id: str | None = None
|
||||||
|
|
||||||
# 类型专属字段;校验后归一为 camelCase 存库
|
# 瘦类型专属(真列);按 type 取用,无关字段写入时清零
|
||||||
config: dict[str, Any] = {}
|
prompt: str = ""
|
||||||
|
api_url: str = ""
|
||||||
|
api_key: str = "" # 写时:占位符/空 → 保留旧(哨兵)
|
||||||
|
app_id: str = ""
|
||||||
|
# workflow 专属:图
|
||||||
|
graph: dict[str, Any] = {}
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _normalize_config(self):
|
def _strip_irrelevant_fields(self):
|
||||||
model = CONFIG_BY_TYPE[self.type]
|
allowed = ALLOWED_FIELDS[self.type]
|
||||||
# 按当前 type 校验并裁掉无关字段,统一回 camelCase 落库
|
for field in ("prompt", "api_url", "api_key", "app_id"):
|
||||||
self.config = model.model_validate(self.config).model_dump(by_alias=True)
|
if field not in allowed:
|
||||||
|
setattr(self, field, "")
|
||||||
|
if "graph" not in allowed:
|
||||||
|
self.graph = {}
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -48,20 +48,18 @@ async def resolve_runtime_config(
|
|||||||
tts = await _resolve(session, assistant.tts_credential_id, "TTS")
|
tts = await _resolve(session, assistant.tts_credential_id, "TTS")
|
||||||
realtime = await _resolve(session, assistant.realtime_credential_id, "Realtime")
|
realtime = await _resolve(session, assistant.realtime_credential_id, "Realtime")
|
||||||
|
|
||||||
cfg = assistant.config or {}
|
|
||||||
|
|
||||||
return AssistantConfig(
|
return AssistantConfig(
|
||||||
name=assistant.name,
|
name=assistant.name,
|
||||||
greeting=assistant.greeting,
|
greeting=assistant.greeting,
|
||||||
# 提示词/工作流类型把 prompt 放 config;外部类型由其平台编排,这里给个兜底
|
# prompt 现在是真列;外部类型由其平台编排,这里给个兜底
|
||||||
prompt=cfg.get("prompt") or "你是一个有帮助的助手。",
|
prompt=assistant.prompt or "你是一个有帮助的助手。",
|
||||||
runtimeMode=assistant.runtime_mode, # type: ignore[arg-type]
|
runtimeMode=assistant.runtime_mode, # type: ignore[arg-type]
|
||||||
enableInterrupt=assistant.enable_interrupt,
|
enableInterrupt=assistant.enable_interrupt,
|
||||||
# 模型/音色:凭证的模型ID优先
|
# 模型/音色:凭证的模型ID优先
|
||||||
model=(llm.model_id if llm else ""),
|
model=(llm.model_id if llm else ""),
|
||||||
asr=(stt.model_id if stt else ""),
|
asr=(stt.model_id if stt else ""),
|
||||||
voice=cfg.get("voice", ""), # 音色不再是独立列,若 config 带则用,否则 .env 兜底
|
voice="", # 音色无独立列,留空 → service_factory 回退 .env TTS_VOICE
|
||||||
realtimeModel=(realtime.model_id if realtime else cfg.get("realtimeModel", "")),
|
realtimeModel=(realtime.model_id if realtime else ""),
|
||||||
# 运行时连接信息(真 key + url):凭证优先,否则 .env 兜底
|
# 运行时连接信息(真 key + url):凭证优先,否则 .env 兜底
|
||||||
llm_api_key=(llm.api_key if llm else config.LLM_API_KEY),
|
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),
|
llm_base_url=(llm.api_url if llm else config.LLM_BASE_URL),
|
||||||
|
|||||||
@@ -339,7 +339,7 @@ export function AssistantPage() {
|
|||||||
setForm({
|
setForm({
|
||||||
name: a.name,
|
name: a.name,
|
||||||
greeting: a.greeting,
|
greeting: a.greeting,
|
||||||
prompt: typeof a.config.prompt === "string" ? a.config.prompt : "",
|
prompt: a.prompt,
|
||||||
runtimeMode: a.runtimeMode,
|
runtimeMode: a.runtimeMode,
|
||||||
realtimeModel: a.realtimeCredentialId ?? "",
|
realtimeModel: a.realtimeCredentialId ?? "",
|
||||||
model: a.llmCredentialId ?? "",
|
model: a.llmCredentialId ?? "",
|
||||||
@@ -464,70 +464,32 @@ export function AssistantPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存提示词助手(新建 POST / 编辑 PUT)
|
// 平铺字段的空白 upsert,各类型按需覆盖(后端会按 type 清掉无关字段)
|
||||||
async function handleSavePrompt() {
|
function baseUpsert(over: Partial<AssistantUpsert>): AssistantUpsert {
|
||||||
setSaving(true);
|
return {
|
||||||
setSaveError(null);
|
name: "",
|
||||||
const payload: AssistantUpsert = {
|
|
||||||
name: form.name.trim(),
|
|
||||||
type: "prompt",
|
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",
|
runtimeMode: "pipeline",
|
||||||
greeting: "",
|
greeting: "",
|
||||||
enableInterrupt: difyForm.enableInterrupt,
|
enableInterrupt: true,
|
||||||
llmCredentialId: null, // LLM 由 Dify 应用提供
|
llmCredentialId: null,
|
||||||
asrCredentialId: difyForm.asr || null,
|
asrCredentialId: null,
|
||||||
ttsCredentialId: difyForm.voice || null,
|
ttsCredentialId: null,
|
||||||
realtimeCredentialId: null,
|
realtimeCredentialId: null,
|
||||||
knowledgeBaseId: 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 {
|
try {
|
||||||
if (editingId) await assistantsApi.update(editingId, payload);
|
if (editingId) await assistantsApi.update(editingId, payload);
|
||||||
else await assistantsApi.create(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 ----
|
// ---- FastGPT ----
|
||||||
function fillFastGptForm(a: Assistant) {
|
function fillFastGptForm(a: Assistant) {
|
||||||
setFastGptForm({
|
setFastGptForm({
|
||||||
name: a.name,
|
name: a.name,
|
||||||
appId: cfgStr(a, "appId"),
|
appId: a.appId,
|
||||||
apiUrl: cfgStr(a, "apiUrl"),
|
apiUrl: a.apiUrl,
|
||||||
apiKey: cfgStr(a, "apiKey"),
|
apiKey: a.apiKey,
|
||||||
asr: a.asrCredentialId ?? "",
|
asr: a.asrCredentialId ?? "",
|
||||||
voice: a.ttsCredentialId ?? "",
|
voice: a.ttsCredentialId ?? "",
|
||||||
enableInterrupt: a.enableInterrupt,
|
enableInterrupt: a.enableInterrupt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSaveFastGpt() {
|
function handleSaveFastGpt() {
|
||||||
setSaving(true);
|
void save(
|
||||||
setSaveError(null);
|
baseUpsert({
|
||||||
const payload: AssistantUpsert = {
|
name: fastGptForm.name.trim(),
|
||||||
name: fastGptForm.name.trim(),
|
type: "fastgpt",
|
||||||
type: "fastgpt",
|
enableInterrupt: fastGptForm.enableInterrupt,
|
||||||
runtimeMode: "pipeline",
|
asrCredentialId: fastGptForm.asr || null,
|
||||||
greeting: "",
|
ttsCredentialId: fastGptForm.voice || null,
|
||||||
enableInterrupt: fastGptForm.enableInterrupt,
|
|
||||||
llmCredentialId: null,
|
|
||||||
asrCredentialId: fastGptForm.asr || null,
|
|
||||||
ttsCredentialId: fastGptForm.voice || null,
|
|
||||||
realtimeCredentialId: null,
|
|
||||||
knowledgeBaseId: null,
|
|
||||||
config: {
|
|
||||||
appId: fastGptForm.appId,
|
appId: fastGptForm.appId,
|
||||||
apiUrl: fastGptForm.apiUrl,
|
apiUrl: fastGptForm.apiUrl,
|
||||||
apiKey: fastGptForm.apiKey,
|
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) => ({
|
const listItems: AssistantListItem[] = assistants.map((a) => ({
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export type AssistantType =
|
|||||||
| "opencode";
|
| "opencode";
|
||||||
export type RuntimeMode = "pipeline" | "realtime";
|
export type RuntimeMode = "pipeline" | "realtime";
|
||||||
|
|
||||||
/** 后端 AssistantOut。config 形态随 type 变,故用宽松 record */
|
/** 后端 AssistantOut(宽表 STI:瘦字段平铺,workflow 用 graph)。apiKey 读时打码 */
|
||||||
export type Assistant = {
|
export type Assistant = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -95,23 +95,15 @@ export type Assistant = {
|
|||||||
ttsCredentialId: string | null;
|
ttsCredentialId: string | null;
|
||||||
realtimeCredentialId: string | null;
|
realtimeCredentialId: string | null;
|
||||||
knowledgeBaseId: string | null;
|
knowledgeBaseId: string | null;
|
||||||
config: Record<string, unknown>;
|
prompt: string;
|
||||||
|
apiUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
appId: string;
|
||||||
|
graph: Record<string, unknown>;
|
||||||
updatedAt?: string | null;
|
updatedAt?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AssistantUpsert = {
|
export type AssistantUpsert = Omit<Assistant, "id" | "updatedAt">;
|
||||||
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 = {
|
export const assistantsApi = {
|
||||||
list: () => request<Assistant[]>("/api/assistants"),
|
list: () => request<Assistant[]>("/api/assistants"),
|
||||||
|
|||||||
Reference in New Issue
Block a user