Refactor backend to support interface-definition driven model resources

- Introduce a new model structure for managing interface definitions and model resources, enhancing the backend's capability to handle various service integrations.
- Update the Makefile to reflect changes in database seeding and resource management commands.
- Remove the deprecated credentials management routes and replace them with a unified model registry API.
- Modify existing routes and schemas to align with the new model structure, ensuring seamless integration with the frontend.
- Enhance database seeding scripts to populate new model resources and their configurations.
- Update README documentation to reflect the new architecture and usage instructions for model resources and interface definitions.
This commit is contained in:
Xin Wang
2026-06-14 19:36:12 +08:00
parent e25dfd4003
commit 90e3e8a0c0
32 changed files with 2577 additions and 1765 deletions

View File

@@ -1,12 +1,8 @@
"""助手 CRUD。前端「助手列表 / 创建 / 编辑」对接这里。
模型/KB 以 FK 引用注册表;瘦类型字段直接是真列。外部类型(dify/fastgpt/opencode)的
api_key 是私有密钥,读时打码、写时哨兵(列级,复用 services/masking,与凭证表一致)。
"""
"""Assistant CRUD backed by capability-to-model-resource bindings."""
import uuid
from db.models import Assistant
from db.models import Assistant, AssistantModelBinding, ModelResource
from db.session import get_session
from fastapi import APIRouter, Depends, HTTPException
from schemas import AssistantOut, AssistantUpsert
@@ -15,27 +11,62 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter(prefix="/api/assistants", tags=["assistants"])
CAPABILITIES = ("LLM", "ASR", "TTS", "Realtime", "Embedding")
def _to_out(a: Assistant) -> AssistantOut:
async def _sync_bindings(
session: AsyncSession, assistant_id: str, resource_ids: dict[str, str]
) -> None:
for capability in CAPABILITIES:
resource_id = resource_ids.get(capability)
binding = await session.get(AssistantModelBinding, (assistant_id, capability))
if not resource_id:
if binding:
await session.delete(binding)
continue
resource = await session.get(ModelResource, resource_id)
if not resource or resource.capability != capability:
raise HTTPException(400, f"{capability} 绑定必须引用同能力的模型资源")
if binding:
binding.model_resource_id = resource_id
else:
session.add(
AssistantModelBinding(
assistant_id=assistant_id,
capability=capability,
model_resource_id=resource_id,
config={},
)
)
async def _resource_ids(session: AsyncSession, assistant_id: str) -> dict[str, str]:
bindings = (
await session.execute(
select(AssistantModelBinding).where(
AssistantModelBinding.assistant_id == assistant_id
)
)
).scalars().all()
return {binding.capability: binding.model_resource_id for binding in bindings}
async def _to_out(session: AsyncSession, assistant: Assistant) -> AssistantOut:
return AssistantOut(
id=a.id,
name=a.name,
type=a.type, # type: ignore[arg-type]
runtime_mode=a.runtime_mode, # type: ignore[arg-type]
greeting=a.greeting,
enable_interrupt=a.enable_interrupt,
llm_credential_id=a.llm_credential_id,
asr_credential_id=a.asr_credential_id,
tts_credential_id=a.tts_credential_id,
realtime_credential_id=a.realtime_credential_id,
knowledge_base_id=a.knowledge_base_id,
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,
id=assistant.id,
name=assistant.name,
type=assistant.type, # type: ignore[arg-type]
runtime_mode=assistant.runtime_mode, # type: ignore[arg-type]
greeting=assistant.greeting,
enable_interrupt=assistant.enable_interrupt,
model_resource_ids=await _resource_ids(session, assistant.id),
knowledge_base_id=assistant.knowledge_base_id,
prompt=assistant.prompt,
api_url=assistant.api_url,
api_key=mask(assistant.api_key),
app_id=assistant.app_id,
graph=assistant.graph or {},
updated_at=assistant.updated_at.isoformat() if assistant.updated_at else None,
)
@@ -44,60 +75,61 @@ async def list_assistants(session: AsyncSession = Depends(get_session)):
rows = (
await session.execute(select(Assistant).order_by(Assistant.updated_at.desc()))
).scalars().all()
return [_to_out(a) for a in rows]
return [await _to_out(session, assistant) for assistant in rows]
@router.post("", response_model=AssistantOut)
async def create_assistant(
body: AssistantUpsert, session: AsyncSession = Depends(get_session)
):
a = Assistant(id=f"asst_{uuid.uuid4().hex[:12]}", **body.model_dump())
session.add(a)
data = body.model_dump()
resource_ids = data.pop("model_resource_ids")
assistant = Assistant(id=f"asst_{uuid.uuid4().hex[:12]}", **data)
session.add(assistant)
await session.flush()
await _sync_bindings(session, assistant.id, resource_ids)
await session.commit()
await session.refresh(a)
return _to_out(a)
await session.refresh(assistant)
return await _to_out(session, assistant)
@router.get("/{assistant_id}", response_model=AssistantOut)
async def get_assistant(
assistant_id: str, session: AsyncSession = Depends(get_session)
):
a = await session.get(Assistant, assistant_id)
if not a:
assistant = await session.get(Assistant, assistant_id)
if not assistant:
raise HTTPException(404, "助手不存在")
return _to_out(a)
return await _to_out(session, assistant)
@router.post("/{assistant_id}/duplicate", response_model=AssistantOut)
async def duplicate_assistant(
assistant_id: str, session: AsyncSession = Depends(get_session)
):
"""服务端整行复制:含真实 api_key,DB→DB,密钥不经过浏览器,副本可直接用。"""
src = await session.get(Assistant, assistant_id)
if not src:
source = await session.get(Assistant, assistant_id)
if not source:
raise HTTPException(404, "助手不存在")
a = Assistant(
assistant = 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,
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
name=f"{source.name} 副本",
type=source.type,
runtime_mode=source.runtime_mode,
greeting=source.greeting,
enable_interrupt=source.enable_interrupt,
knowledge_base_id=source.knowledge_base_id,
prompt=source.prompt,
api_url=source.api_url,
api_key=source.api_key,
app_id=source.app_id,
graph=dict(source.graph or {}),
)
session.add(a)
session.add(assistant)
await session.flush()
await _sync_bindings(session, assistant.id, await _resource_ids(session, source.id))
await session.commit()
await session.refresh(a)
return _to_out(a)
await session.refresh(assistant)
return await _to_out(session, assistant)
@router.put("/{assistant_id}", response_model=AssistantOut)
@@ -106,26 +138,27 @@ async def update_assistant(
body: AssistantUpsert,
session: AsyncSession = Depends(get_session),
):
a = await session.get(Assistant, assistant_id)
if not a:
assistant = await session.get(Assistant, assistant_id)
if not assistant:
raise HTTPException(404, "助手不存在")
data = body.model_dump()
# 写时哨兵(列级):回传打码/空 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)
resource_ids = data.pop("model_resource_ids")
data["api_key"] = resolve_incoming_key(data["api_key"], assistant.api_key)
for key, value in data.items():
setattr(assistant, key, value)
await _sync_bindings(session, assistant.id, resource_ids)
await session.commit()
await session.refresh(a)
return _to_out(a)
await session.refresh(assistant)
return await _to_out(session, assistant)
@router.delete("/{assistant_id}")
async def delete_assistant(
assistant_id: str, session: AsyncSession = Depends(get_session)
):
a = await session.get(Assistant, assistant_id)
if not a:
assistant = await session.get(Assistant, assistant_id)
if not assistant:
raise HTTPException(404, "助手不存在")
await session.delete(a)
await session.delete(assistant)
await session.commit()
return {"ok": True}