"""助手 CRUD。前端「助手列表 / 创建 / 编辑」对接这里。 模型/KB 以 FK 引用注册表;外部类型(dify/fastgpt/opencode)的 config.apiKey 是私有密钥, 读时打码、写时哨兵(复用 services/masking)。 """ 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 services.masking import mask, resolve_incoming_key from sqlalchemy import select 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, 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, config=_mask_config(a.type, a.config or {}), updated_at=a.updated_at.isoformat() if a.updated_at else None, ) @router.get("", response_model=list[AssistantOut]) 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] @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) await session.commit() await session.refresh(a) return _to_out(a) @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: raise HTTPException(404, "助手不存在") return _to_out(a) @router.post("/{assistant_id}/duplicate", response_model=AssistantOut) async def duplicate_assistant( assistant_id: str, session: AsyncSession = Depends(get_session) ): """服务端整行复制:含真实 config(真 key),DB→DB,密钥不经过浏览器,副本可直接用。""" src = await session.get(Assistant, assistant_id) if not src: raise HTTPException(404, "助手不存在") a = 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, config=dict(src.config or {}), # 浅拷贝,避免与源行共享同一 dict ) session.add(a) await session.commit() await session.refresh(a) return _to_out(a) @router.put("/{assistant_id}", response_model=AssistantOut) async def update_assistant( assistant_id: str, body: AssistantUpsert, session: AsyncSession = Depends(get_session), ): a = await session.get(Assistant, assistant_id) 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 {}) for k, v in data.items(): setattr(a, k, v) await session.commit() await session.refresh(a) return _to_out(a) @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: raise HTTPException(404, "助手不存在") await session.delete(a) await session.commit() return {"ok": True}