From 3ed9e1b38870dd6325b3253a0d3abb3e2b09b214 Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Fri, 10 Jul 2026 10:05:41 +0800 Subject: [PATCH] Add reusable tools and assistant bindings - Introduce a new `Tool` model and `AssistantToolBinding` for managing reusable tools within the application. - Implement CRUD operations for tools in the new `tools` route, allowing for the creation, retrieval, updating, and deletion of tools. - Update the `Assistant` model to include a list of tool IDs, enabling assistants to utilize these tools. - Enhance the backend routes to synchronize tool bindings with assistants, ensuring proper management of tool associations. - Add frontend components for tool management, including a tool picker in the assistant configuration, improving user experience in tool selection. - Create a mobile call page to facilitate video calls, integrating camera and microphone selection for enhanced communication capabilities. - Update API definitions to include tool-related types and operations, ensuring consistency across the application. - Add a migration script to create the necessary database tables for tools and bindings, supporting the new functionality. --- AGENTS.md | 1 + backend/app.py | 2 + backend/db/models.py | 42 + .../versions/20260710_0002_add_tools.py | 72 ++ backend/routes/assistants.py | 56 +- backend/routes/tools.py | 118 +++ backend/schemas.py | 74 +- deploy/README.md | 140 ++- frontend/src/app/call/[id]/page.tsx | 10 + frontend/src/components/auth/AuthGate.tsx | 6 + .../src/components/pages/AssistantPage.tsx | 224 ++++- .../components/pages/ComponentsToolsPage.tsx | 795 +++++++++++++++++- .../src/components/pages/MobileCallPage.tsx | 233 +++++ frontend/src/lib/api.ts | 64 ++ 14 files changed, 1815 insertions(+), 22 deletions(-) create mode 100644 backend/migrations/versions/20260710_0002_add_tools.py create mode 100644 backend/routes/tools.py create mode 100644 frontend/src/app/call/[id]/page.tsx create mode 100644 frontend/src/components/pages/MobileCallPage.tsx diff --git a/AGENTS.md b/AGENTS.md index e69de29..1f85a5a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -0,0 +1 @@ +你编写具有可维护性和高可读性的代码,尽量不去对pipecat框架本身修改 \ No newline at end of file diff --git a/backend/app.py b/backend/app.py index a80b016..60d0d4f 100644 --- a/backend/app.py +++ b/backend/app.py @@ -27,6 +27,7 @@ from routes import ( knowledge_bases, model_registry, node_types, + tools, voice_webrtc, voice_ws, ) @@ -54,6 +55,7 @@ app.include_router(assistants.router) app.include_router(knowledge_bases.router) app.include_router(model_registry.router) app.include_router(node_types.router) +app.include_router(tools.router) app.include_router(voice_webrtc.router) app.include_router(voice_ws.router) diff --git a/backend/db/models.py b/backend/db/models.py index a245fd4..a5242da 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -145,3 +145,45 @@ class AssistantModelBinding(Base): updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now() ) + + +class Tool(Base): + """Reusable LLM tool definition. Runtime execution is added separately.""" + + __tablename__ = "tools" + + id: Mapped[str] = mapped_column(String(40), primary_key=True) + name: Mapped[str] = mapped_column(String(128)) + function_name: Mapped[str] = mapped_column(String(64), unique=True, index=True) + type: Mapped[str] = mapped_column(String(24), index=True) + description: Mapped[str] = mapped_column(String(2048), default="") + definition: Mapped[dict] = mapped_column(JSONB, default=dict) + secrets: Mapped[dict] = mapped_column(JSONB, default=dict) + status: Mapped[str] = mapped_column(String(16), index=True, default="active") + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + +class AssistantToolBinding(Base): + """Prompt assistant to reusable tool many-to-many binding.""" + + __tablename__ = "assistant_tool_bindings" + + assistant_id: Mapped[str] = mapped_column( + String(40), + ForeignKey("assistants.id", ondelete="CASCADE"), + primary_key=True, + ) + tool_id: Mapped[str] = mapped_column( + String(40), + ForeignKey("tools.id", ondelete="RESTRICT"), + primary_key=True, + index=True, + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now() + ) diff --git a/backend/migrations/versions/20260710_0002_add_tools.py b/backend/migrations/versions/20260710_0002_add_tools.py new file mode 100644 index 0000000..07d058e --- /dev/null +++ b/backend/migrations/versions/20260710_0002_add_tools.py @@ -0,0 +1,72 @@ +"""add reusable tools and assistant bindings + +Revision ID: 20260710_0002 +Revises: 20260709_0001 +Create Date: 2026-07-10 00:00:00.000000 +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +revision: str = "20260710_0002" +down_revision: str | Sequence[str] | None = "20260709_0001" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "tools", + sa.Column("id", sa.String(length=40), nullable=False), + sa.Column("name", sa.String(length=128), nullable=False), + sa.Column("function_name", sa.String(length=64), nullable=False), + sa.Column("type", sa.String(length=24), nullable=False), + sa.Column("description", sa.String(length=2048), server_default="", nullable=False), + sa.Column( + "definition", + postgresql.JSONB(astext_type=sa.Text()), + server_default=sa.text("'{}'::jsonb"), + nullable=False, + ), + sa.Column( + "secrets", + postgresql.JSONB(astext_type=sa.Text()), + server_default=sa.text("'{}'::jsonb"), + nullable=False, + ), + sa.Column("status", sa.String(length=16), server_default="active", nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_tools_function_name", "tools", ["function_name"], unique=True) + op.create_index("ix_tools_type", "tools", ["type"]) + op.create_index("ix_tools_status", "tools", ["status"]) + + op.create_table( + "assistant_tool_bindings", + sa.Column("assistant_id", sa.String(length=40), nullable=False), + sa.Column("tool_id", sa.String(length=40), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["assistant_id"], ["assistants.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["tool_id"], ["tools.id"], ondelete="RESTRICT"), + sa.PrimaryKeyConstraint("assistant_id", "tool_id"), + ) + op.create_index( + "ix_assistant_tool_bindings_tool_id", + "assistant_tool_bindings", + ["tool_id"], + ) + + +def downgrade() -> None: + op.drop_index("ix_assistant_tool_bindings_tool_id", table_name="assistant_tool_bindings") + op.drop_table("assistant_tool_bindings") + op.drop_index("ix_tools_status", table_name="tools") + op.drop_index("ix_tools_type", table_name="tools") + op.drop_index("ix_tools_function_name", table_name="tools") + op.drop_table("tools") diff --git a/backend/routes/assistants.py b/backend/routes/assistants.py index db08229..e67c2b9 100644 --- a/backend/routes/assistants.py +++ b/backend/routes/assistants.py @@ -2,7 +2,13 @@ import uuid -from db.models import Assistant, AssistantModelBinding, ModelResource +from db.models import ( + Assistant, + AssistantModelBinding, + AssistantToolBinding, + ModelResource, + Tool, +) from db.session import get_session from fastapi import APIRouter, Depends, HTTPException from schemas import AssistantOut, AssistantUpsert @@ -83,6 +89,46 @@ async def _resource_ids(session: AsyncSession, assistant_id: str) -> dict[str, s return {binding.capability: binding.model_resource_id for binding in bindings} +async def _sync_tool_bindings( + session: AsyncSession, assistant_id: str, assistant_type: str, tool_ids: list[str] +) -> None: + requested = list(dict.fromkeys(tool_ids)) if assistant_type == "prompt" else [] + if requested: + tools = ( + await session.execute(select(Tool).where(Tool.id.in_(requested))) + ).scalars().all() + found = {tool.id for tool in tools if tool.status == "active"} + missing = [tool_id for tool_id in requested if tool_id not in found] + if missing: + raise HTTPException(400, f"工具不存在或未启用: {', '.join(missing)}") + + existing = ( + await session.execute( + select(AssistantToolBinding).where( + AssistantToolBinding.assistant_id == assistant_id + ) + ) + ).scalars().all() + existing_by_id = {binding.tool_id: binding for binding in existing} + for tool_id, binding in existing_by_id.items(): + if tool_id not in requested: + await session.delete(binding) + for tool_id in requested: + if tool_id not in existing_by_id: + session.add(AssistantToolBinding(assistant_id=assistant_id, tool_id=tool_id)) + + +async def _tool_ids(session: AsyncSession, assistant_id: str) -> list[str]: + rows = ( + await session.execute( + select(AssistantToolBinding.tool_id) + .where(AssistantToolBinding.assistant_id == assistant_id) + .order_by(AssistantToolBinding.created_at) + ) + ).scalars().all() + return list(rows) + + async def _to_out(session: AsyncSession, assistant: Assistant) -> AssistantOut: return AssistantOut( id=assistant.id, @@ -95,6 +141,7 @@ async def _to_out(session: AsyncSession, assistant: Assistant) -> AssistantOut: vision_model_resource_id=assistant.vision_model_resource_id, model_resource_ids=await _resource_ids(session, assistant.id), knowledge_base_id=assistant.knowledge_base_id, + tool_ids=await _tool_ids(session, assistant.id), prompt=assistant.prompt, api_url=assistant.api_url, api_key=mask(assistant.api_key), @@ -120,10 +167,12 @@ async def create_assistant( await _validate_vision_model(session, body) data = body.model_dump() resource_ids = data.pop("model_resource_ids") + tool_ids = data.pop("tool_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 _sync_tool_bindings(session, assistant.id, assistant.type, tool_ids) await session.commit() await session.refresh(assistant) return await _to_out(session, assistant) @@ -165,6 +214,9 @@ async def duplicate_assistant( session.add(assistant) await session.flush() await _sync_bindings(session, assistant.id, await _resource_ids(session, source.id)) + await _sync_tool_bindings( + session, assistant.id, assistant.type, await _tool_ids(session, source.id) + ) await session.commit() await session.refresh(assistant) return await _to_out(session, assistant) @@ -183,10 +235,12 @@ async def update_assistant( await _validate_vision_model(session, body) data = body.model_dump() resource_ids = data.pop("model_resource_ids") + tool_ids = data.pop("tool_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 _sync_tool_bindings(session, assistant.id, assistant.type, tool_ids) await session.commit() await session.refresh(assistant) return await _to_out(session, assistant) diff --git a/backend/routes/tools.py b/backend/routes/tools.py new file mode 100644 index 0000000..29baf59 --- /dev/null +++ b/backend/routes/tools.py @@ -0,0 +1,118 @@ +"""Reusable tool CRUD. Tool execution is intentionally not wired to pipelines yet.""" + +import uuid + +from db.models import AssistantToolBinding, Tool +from db.session import get_session +from fastapi import APIRouter, Depends, HTTPException +from schemas import ToolOut, ToolUpsert +from services.auth import require_admin +from services.masking import mask_secrets, merge_secrets +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + + +router = APIRouter( + prefix="/api/tools", + tags=["tools"], + dependencies=[Depends(require_admin)], +) + + +def _to_out(tool: Tool) -> ToolOut: + return ToolOut( + id=tool.id, + name=tool.name, + function_name=tool.function_name, + type=tool.type, + description=tool.description, + definition=tool.definition, + secrets=mask_secrets(tool.secrets or {}), + status=tool.status, + updated_at=tool.updated_at.isoformat() if tool.updated_at else None, + ) + + +def _payload(body: ToolUpsert, stored_secrets: dict | None = None) -> dict: + definition = body.definition.model_dump() + secrets = ( + merge_secrets(body.secrets, stored_secrets or {}) + if definition["type"] == "http" + else {} + ) + return { + "name": body.name.strip(), + "function_name": body.function_name, + "type": definition["type"], + "description": body.description.strip(), + "definition": definition, + "secrets": secrets, + "status": body.status, + } + + +async def _commit(session: AsyncSession, tool: Tool) -> ToolOut: + try: + await session.commit() + except IntegrityError as exc: + await session.rollback() + raise HTTPException(409, "工具函数名已存在") from exc + await session.refresh(tool) + return _to_out(tool) + + +@router.get("", response_model=list[ToolOut]) +async def list_tools(session: AsyncSession = Depends(get_session)): + rows = ( + await session.execute(select(Tool).order_by(Tool.updated_at.desc())) + ).scalars().all() + return [_to_out(tool) for tool in rows] + + +@router.post("", response_model=ToolOut) +async def create_tool(body: ToolUpsert, session: AsyncSession = Depends(get_session)): + tool = Tool(id=f"tool_{uuid.uuid4().hex[:12]}", **_payload(body)) + session.add(tool) + return await _commit(session, tool) + + +@router.get("/{tool_id}", response_model=ToolOut) +async def get_tool(tool_id: str, session: AsyncSession = Depends(get_session)): + tool = await session.get(Tool, tool_id) + if not tool: + raise HTTPException(404, "工具不存在") + return _to_out(tool) + + +@router.put("/{tool_id}", response_model=ToolOut) +async def update_tool( + tool_id: str, + body: ToolUpsert, + session: AsyncSession = Depends(get_session), +): + tool = await session.get(Tool, tool_id) + if not tool: + raise HTTPException(404, "工具不存在") + for key, value in _payload(body, tool.secrets or {}).items(): + setattr(tool, key, value) + return await _commit(session, tool) + + +@router.delete("/{tool_id}") +async def delete_tool(tool_id: str, session: AsyncSession = Depends(get_session)): + tool = await session.get(Tool, tool_id) + if not tool: + raise HTTPException(404, "工具不存在") + in_use = ( + await session.execute( + select(AssistantToolBinding.assistant_id) + .where(AssistantToolBinding.tool_id == tool_id) + .limit(1) + ) + ).scalar_one_or_none() + if in_use: + raise HTTPException(409, "工具正被助手引用,请先解绑") + await session.delete(tool) + await session.commit() + return {"ok": True} diff --git a/backend/schemas.py b/backend/schemas.py index a1d55c0..7d7ae7b 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -7,14 +7,18 @@ JSON 用 camelCase(modelId/interfaceType/apiUrl/apiKey),Python 内部用 snake_c from __future__ import annotations -from typing import Any, Literal +from typing import Annotated, Any, Literal, Union -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from pydantic.alias_generators import to_camel RuntimeMode = Literal["pipeline", "realtime"] ModelType = Literal["LLM", "ASR", "TTS", "Realtime", "Embedding", "Agent"] AssistantType = Literal["prompt", "workflow", "dify", "fastgpt", "opencode"] +ToolType = Literal["end_call", "http"] +ToolStatus = Literal["active", "archived", "draft"] +ToolParameterType = Literal["string", "number", "integer", "boolean", "object", "array"] +ToolParameterLocation = Literal["path", "query", "body", "header"] # 外部应用类型:其 config.apiKey 是该助手私有密钥,读时打码 / 写时哨兵 EXTERNAL_TYPES = {"dify", "fastgpt", "opencode"} @@ -57,6 +61,7 @@ class AssistantUpsert(CamelModel): model_resource_ids: dict[ModelType, str] = Field(default_factory=dict) knowledge_base_id: str | None = None + tool_ids: list[str] = Field(default_factory=list) # 瘦类型专属(真列);按 type 取用,无关字段写入时清零 prompt: str = "" @@ -74,6 +79,8 @@ class AssistantUpsert(CamelModel): setattr(self, field, "") if "graph" not in allowed: self.graph = {} + if self.type != "prompt": + self.tool_ids = [] # 外部托管大脑只能 cascade,拦住不兼容的 realtime if self.runtime_mode == "realtime" and self.type not in REALTIME_CAPABLE_TYPES: raise ValueError(f"类型 {self.type} 不支持 realtime 运行模式") @@ -85,6 +92,69 @@ class AssistantOut(AssistantUpsert): updated_at: str | None = None +# ---------- 可复用工具 ---------- +class ToolParameter(CamelModel): + name: str + type: ToolParameterType = "string" + location: ToolParameterLocation = "body" + description: str = "" + required: bool = True + + +class EndCallToolConfig(CamelModel): + message_type: Literal["none", "custom"] = "none" + custom_message: str = "" + capture_reason: bool = True + + +class HttpToolConfig(CamelModel): + method: Literal["GET", "POST", "PUT", "PATCH", "DELETE"] = "GET" + url: str + timeout_seconds: int = Field(default=15, ge=1, le=120) + headers: dict[str, str] = Field(default_factory=dict) + parameters: list[ToolParameter] = Field(default_factory=list) + body: dict[str, Any] = Field(default_factory=dict) + + @field_validator("url") + @classmethod + def validate_url(cls, value: str) -> str: + if not value.startswith(("http://", "https://")): + raise ValueError("HTTP 工具 URL 必须使用 http:// 或 https://") + return value + + +class EndCallToolDefinition(CamelModel): + schema_version: int = 1 + type: Literal["end_call"] = "end_call" + config: EndCallToolConfig = Field(default_factory=EndCallToolConfig) + + +class HttpToolDefinition(CamelModel): + schema_version: int = 1 + type: Literal["http"] = "http" + config: HttpToolConfig + + +ToolDefinition = Annotated[ + Union[EndCallToolDefinition, HttpToolDefinition], Field(discriminator="type") +] + + +class ToolUpsert(CamelModel): + name: str = Field(min_length=1, max_length=128) + function_name: str = Field(pattern=r"^[a-z][a-z0-9_]{0,63}$") + description: str = Field(default="", max_length=2048) + definition: ToolDefinition + secrets: dict[str, Any] = Field(default_factory=dict) + status: ToolStatus = "active" + + +class ToolOut(ToolUpsert): + id: str + type: ToolType + updated_at: str | None = None + + # ---------- 知识库 ---------- class KnowledgeBaseUpsert(CamelModel): name: str diff --git a/deploy/README.md b/deploy/README.md index 8a17203..2202aed 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -98,6 +98,19 @@ uv run --with-requirements requirements.txt \ **写浏览器地址栏里访问 nginx 的同源地址**。如果地址栏带端口,这里也必须带端口; 如果地址栏不带端口,这里也不带端口。不要写后端直连端口 `:8000`。 +Next dev 还会校验开发资源的来源。远程访问时,把浏览器地址栏里的 host 加到 +`frontend/next.config.ts` 的 `allowedDevOrigins`,并重启前端 dev server: + +```ts +// frontend/next.config.ts +const nextConfig = { + allowedDevOrigins: ["127.0.0.1", "<远程机器IP或域名>"], +}; +``` + +如果漏掉这一步,页面 HTML 可能能打开,但客户端 JS 无法接管:按钮无响应, +列表一直停在 `正在加载`,同时 Next dev 内部资源可能返回 `403 Unauthorized`。 + ```bash cd frontend npm install @@ -152,27 +165,108 @@ docker run --rm --name ai-video-nginx \ https://<远程机器IP或域名>:18189 ``` -### 6. 远程语音 / WebRTC 注意事项 +### 6. 部署 coturn(TURN 中继) -- 云安全组至少放行 `80/tcp`、`443/tcp`。 -- 如果 HTTPS 映射到 `18189`,云安全组要放行 `18189/tcp`。 -- 如果浏览器和后端不在同一内网,WebRTC 媒体链路通常需要 TURN。 -- 启动 coturn: +如果浏览器和后端不在同一内网,WebRTC 媒体链路通常需要 TURN。项目里的 +`docker-compose.yaml` 已经带了 coturn,用 `remote` profile 启动即可。 -```bash -# 项目根 .env: -# PUBLIC_IP=<远程机器公网IP> -# TURN_SECRET=<和 backend/.env 一致的密钥> +#### 6.1 配项目根目录 `.env` -docker compose --profile remote up -d coturn +项目根目录的 `.env` 给 coturn 容器用: + +```text +PUBLIC_IP=<远程机器公网IP> +TURN_SECRET=<一串足够长的随机密钥> ``` -- 云安全组放行 `3478/tcp`、`3478/udp`、`49152-49200/udp`。 -- `backend/.env` 中配置: +示例: + +```text +PUBLIC_IP=118.89.89.89 +TURN_SECRET=change-me-to-a-long-random-string +``` + +#### 6.2 配后端 `backend/.env` + +后端用同一个 `TURN_SECRET` 签发短时 TURN 凭证: ```text TURN_URLS=turn:<远程机器公网IP>:3478?transport=udp,turn:<远程机器公网IP>:3478?transport=tcp -TURN_SECRET=<同上> +TURN_SECRET=<和项目根 .env 一致的密钥> +``` + +示例: + +```text +TURN_URLS=turn:118.89.89.89:3478?transport=udp,turn:118.89.89.89:3478?transport=tcp +TURN_SECRET=change-me-to-a-long-random-string +``` + +改完 `backend/.env` 后,重启后端 `uvicorn`。 + +#### 6.3 启动 coturn + +```bash +docker compose --profile remote up -d coturn +``` + +查看状态和日志: + +```bash +docker compose ps coturn +docker compose logs -f coturn +``` + +#### 6.4 放行端口 + +云安全组至少放行: + +```text +80/tcp +443/tcp +3478/tcp +3478/udp +49152-49200/udp +``` + +如果 HTTPS 映射到 `18189`,还要放行: + +```text +18189/tcp +``` + +如果远程机器启用了 `ufw`: + +```bash +sudo ufw allow 80/tcp +sudo ufw allow 443/tcp +sudo ufw allow 18189/tcp +sudo ufw allow 3478/tcp +sudo ufw allow 3478/udp +sudo ufw allow 49152:49200/udp +``` + +#### 6.5 验证后端已下发 TURN + +后端和 nginx 都跑起来后: + +```bash +curl -k https://<远程机器IP或域名>:18189/api/webrtc/ice-servers +``` + +应该能看到 `stun:` 和 `turn:`: + +```json +{ + "iceServers": [ + {"urls": "stun:stun.l.google.com:19302"}, + { + "urls": "turn:<远程机器公网IP>:3478?transport=udp", + "username": "...", + "credential": "..." + } + ] +} ``` ## 前端怎么连后端 @@ -217,6 +311,26 @@ Docker 容器里的 `127.0.0.1` 是容器自己,不是宿主机。远程开发 检查 nginx 配置里是否保留了 `Upgrade`、`Connection` 头,以及 `/ws/` 的 `proxy_read_timeout 3600s`。本仓库的两份开发 nginx 配置都已经包含。 +### 页面一直正在加载,但 API 直接访问正常? + +先确认前端 dev server 已用浏览器访问同源地址启动: + +```bash +NEXT_PUBLIC_API_BASE_URL=https://<远程机器IP或域名>:18189 \ + npm run dev -- --hostname 0.0.0.0 +``` + +再确认 `frontend/next.config.ts` 的 `allowedDevOrigins` 包含同一个 host: + +```ts +allowedDevOrigins: ["127.0.0.1", "<远程机器IP或域名>"], +``` + +修改 `NEXT_PUBLIC_API_BASE_URL` 或 `allowedDevOrigins` 后都必须重启 +`npm run dev`。若 API 直接返回 `200`,但页面按钮无响应、弹窗打不开、列表 +仍停在 `正在加载`,通常是 Next dev 的 `_next/*` 客户端资源被跨源保护拦截, +不是后端接口没有数据。 + ### API 请求跨域报错? 走 nginx 时前端和后端同源,通常不需要跨源。若你绕过 nginx 直接访问后端, diff --git a/frontend/src/app/call/[id]/page.tsx b/frontend/src/app/call/[id]/page.tsx new file mode 100644 index 0000000..6d94275 --- /dev/null +++ b/frontend/src/app/call/[id]/page.tsx @@ -0,0 +1,10 @@ +import { MobileCallPage } from "@/components/pages/MobileCallPage"; + +export default async function Page({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + return ; +} diff --git a/frontend/src/components/auth/AuthGate.tsx b/frontend/src/components/auth/AuthGate.tsx index c23e4ad..6e5ed06 100644 --- a/frontend/src/components/auth/AuthGate.tsx +++ b/frontend/src/components/auth/AuthGate.tsx @@ -11,6 +11,7 @@ export function AuthGate({ children }: { children: ReactNode }) { const router = useRouter(); const { user, loading } = useAuth(); const isLoginPage = pathname === "/login"; + const isCallPage = pathname.startsWith("/call/"); useEffect(() => { if (!loading && !user && !isLoginPage) { @@ -30,5 +31,10 @@ export function AuthGate({ children }: { children: ReactNode }) { ); } + // 手机通话预览沿用登录态,但不显示管理台导航框架。 + if (isCallPage) { + return <>{children}; + } + return {children}; } diff --git a/frontend/src/components/pages/AssistantPage.tsx b/frontend/src/components/pages/AssistantPage.tsx index e37db04..5d632cc 100644 --- a/frontend/src/components/pages/AssistantPage.tsx +++ b/frontend/src/components/pages/AssistantPage.tsx @@ -33,6 +33,9 @@ import { Waves, Bug, Video, + Smartphone, + Wrench, + X, } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -40,6 +43,14 @@ import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Switch } from "@/components/ui/switch"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Sheet, SheetContent, @@ -87,11 +98,13 @@ import { assistantsApi, knowledgeBasesApi, modelResourcesApi, + toolsApi, type Assistant, type AssistantType as ApiAssistantType, type AssistantUpsert, type KnowledgeBase, type ModelResource, + type Tool, } from "@/lib/api"; import { useVoicePreview, @@ -127,6 +140,7 @@ type AssistantForm = { enableInterrupt: boolean; visionEnabled: boolean; visionModelResourceId: string; + toolIds: string[]; }; type FastGptForm = { @@ -216,6 +230,7 @@ function blankPromptForm(name: string): AssistantForm { enableInterrupt: true, visionEnabled: false, visionModelResourceId: "", + toolIds: [], }; } @@ -350,6 +365,7 @@ export function AssistantPage(props: AssistantPageProps) { // 下拉数据源:模型资源 + 知识库 const [modelResources, setModelResources] = useState([]); const [knowledgeBases, setKnowledgeBases] = useState([]); + const [tools, setTools] = useState([]); // 视图由路由模式决定;仅编辑模式需要先 loading,等拿到助手类型后切换 const [view, setView] = useState(() => { if (props.mode === "choose") return "choose"; @@ -403,12 +419,14 @@ export function AssistantPage(props: AssistantPageProps) { // 进入创建/编辑前加载下拉数据源(模型资源 + 知识库) const loadResources = useCallback(async () => { try { - const [creds, kbs] = await Promise.all([ + const [creds, kbs, toolRows] = await Promise.all([ modelResourcesApi.list(), knowledgeBasesApi.list(), + toolsApi.list(), ]); setModelResources(creds); setKnowledgeBases(kbs); + setTools(toolRows); } catch { // 拉取失败时下拉为空,不阻塞表单 } @@ -488,6 +506,7 @@ export function AssistantPage(props: AssistantPageProps) { enableInterrupt: a.enableInterrupt, visionEnabled: a.visionEnabled, visionModelResourceId: a.visionModelResourceId ?? "", + toolIds: a.toolIds ?? [], }; setForm(next); return next; @@ -552,6 +571,7 @@ export function AssistantPage(props: AssistantPageProps) { visionModelResourceId: null, modelResourceIds: {}, knowledgeBaseId: null, + toolIds: [], prompt: "", apiUrl: "", apiKey: "", @@ -617,6 +637,7 @@ export function AssistantPage(props: AssistantPageProps) { ...(form.realtimeModel ? { Realtime: form.realtimeModel } : {}), }, knowledgeBaseId: form.knowledgeBase || null, + toolIds: form.toolIds, prompt: form.prompt, }), ); @@ -1863,6 +1884,18 @@ export function AssistantPage(props: AssistantPageProps) { /> + } + title="工具" + description="配置该提示词助手可以调用的工具" + > + tool.status === "active")} + selectedIds={form.toolIds} + onChange={(toolIds) => updateForm("toolIds", toolIds)} + /> + + } title="交互策略" @@ -2010,6 +2043,7 @@ function DebugDrawer({ />
+ {SHOW_VOICE_VIZ && view === "chat" && ( <> {!showTranscript && ( @@ -2081,6 +2115,71 @@ function DebugDrawer({ ); } +function CallPreviewLink({ assistantId }: { assistantId: string | null }) { + const [callUrl, setCallUrl] = useState(""); + const [copied, setCopied] = useState(false); + + const copyLink = useCallback(async () => { + if (!callUrl) return; + await navigator.clipboard.writeText(callUrl); + setCopied(true); + window.setTimeout(() => setCopied(false), 1600); + }, [callUrl]); + + return ( + { + if (open && assistantId) { + setCallUrl( + `${window.location.origin}/call/${encodeURIComponent(assistantId)}`, + ); + setCopied(false); + } + }} + > + + + + +
+
手机通话预览
+

+ 复制链接并在浏览器中打开,即可进入全屏视频通话界面。 +

+
+
+ event.currentTarget.select()} + className="h-9 min-w-0 flex-1 text-xs" + /> + +
+ {copied &&

链接已复制

} +
+
+ ); +} + function DeviceSelectField({ icon, ariaLabel, @@ -3055,6 +3154,129 @@ function ResourceSelectField({ ); } +function ToolPicker({ + tools, + selectedIds, + onChange, +}: { + tools: Tool[]; + selectedIds: string[]; + onChange: (ids: string[]) => void; +}) { + const [open, setOpen] = useState(false); + const [draftIds, setDraftIds] = useState(selectedIds); + const selectedTools = selectedIds + .map((id) => tools.find((tool) => tool.id === id)) + .filter((tool): tool is Tool => Boolean(tool)); + + function openPicker() { + setDraftIds(selectedIds); + setOpen(true); + } + + return ( + <> +
+ {selectedTools.map((tool) => ( +
+ {tool.type === "end_call" ? : } + {tool.name} + +
+ ))} + +
+ + + + + 选择工具 + 选择已经在工具资源中启用的工具。 + + + {tools.length === 0 ? ( +
+ 暂无可用工具 +
+ ) : ( +
+ {tools.map((tool) => { + const checked = draftIds.includes(tool.id); + return ( + + ); + })} +
+ )} + + + + + +
+
+ + ); +} + function ToggleRow({ icon, title, diff --git a/frontend/src/components/pages/ComponentsToolsPage.tsx b/frontend/src/components/pages/ComponentsToolsPage.tsx index 6401eb3..d535ca3 100644 --- a/frontend/src/components/pages/ComponentsToolsPage.tsx +++ b/frontend/src/components/pages/ComponentsToolsPage.tsx @@ -1,10 +1,795 @@ -import { PlaceholderPage } from "./PlaceholderPage"; +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + ChevronDown, + ChevronUp, + Loader2, + MoreHorizontal, + Pencil, + Plus, + Trash2, +} from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { DataList, type DataListColumn } from "@/components/ui/data-list"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { FilterPills } from "@/components/ui/filter-pills"; +import { ListToolbar } from "@/components/ui/list-toolbar"; +import { PageHeader } from "@/components/ui/page-header"; +import { SearchInput } from "@/components/ui/search-input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { + toolsApi, + type HttpToolDefinition, + type Tool, + type ToolParameter, + type ToolStatus, + type ToolUpsert, +} from "@/lib/api"; + +type ToolKind = "end_call" | "http"; +type HttpMethod = HttpToolDefinition["config"]["method"]; +type ToolFilter = "全部" | "End Call" | "HTTP"; +type SortOrder = "newest" | "oldest"; + +const toolFilters: readonly ToolFilter[] = ["全部", "End Call", "HTTP"]; + +type ToolForm = { + name: string; + functionName: string; + type: ToolKind; + description: string; + status: ToolStatus; + messageType: "none" | "custom"; + customMessage: string; + captureReason: boolean; + method: HttpMethod; + url: string; + timeoutSeconds: string; + headers: string; + secretHeaders: string; + parameters: string; + body: string; +}; + +const EMPTY_OBJECT = "{}"; +const EMPTY_ARRAY = "[]"; + +function blankForm(): ToolForm { + return { + name: "", + functionName: "", + type: "end_call", + description: "", + status: "active", + messageType: "none", + customMessage: "", + captureReason: true, + method: "GET", + url: "", + timeoutSeconds: "15", + headers: EMPTY_OBJECT, + secretHeaders: EMPTY_OBJECT, + parameters: EMPTY_ARRAY, + body: EMPTY_OBJECT, + }; +} + +function pretty(value: unknown, fallback: string): string { + return JSON.stringify(value ?? JSON.parse(fallback), null, 2); +} + +function formFromTool(tool: Tool): ToolForm { + const base = { ...blankForm(), name: tool.name, functionName: tool.functionName }; + base.type = tool.type; + base.description = tool.description; + base.status = tool.status; + if (tool.definition.type === "end_call") { + base.messageType = tool.definition.config.messageType; + base.customMessage = tool.definition.config.customMessage; + base.captureReason = tool.definition.config.captureReason; + return base; + } + base.method = tool.definition.config.method; + base.url = tool.definition.config.url; + base.timeoutSeconds = String(tool.definition.config.timeoutSeconds); + base.headers = pretty(tool.definition.config.headers, EMPTY_OBJECT); + base.parameters = pretty(tool.definition.config.parameters, EMPTY_ARRAY); + base.body = pretty(tool.definition.config.body, EMPTY_OBJECT); + const secrets = tool.secrets as { headers?: Record }; + base.secretHeaders = pretty(secrets.headers ?? {}, EMPTY_OBJECT); + return base; +} + +function parseObject(value: string, label: string): Record { + let parsed: unknown; + try { + parsed = JSON.parse(value || EMPTY_OBJECT); + } catch { + throw new Error(`${label}不是有效的 JSON`); + } + if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") { + throw new Error(`${label}必须是 JSON 对象`); + } + return parsed as Record; +} + +function parseParameters(value: string): ToolParameter[] { + let parsed: unknown; + try { + parsed = JSON.parse(value || EMPTY_ARRAY); + } catch { + throw new Error("参数定义不是有效的 JSON"); + } + if (!Array.isArray(parsed)) throw new Error("参数定义必须是 JSON 数组"); + return parsed as ToolParameter[]; +} + +function functionNameFrom(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") + .slice(0, 64); +} + +function payloadFromForm(form: ToolForm): ToolUpsert { + if (!form.name.trim()) throw new Error("请输入工具名称"); + if (!/^[a-z][a-z0-9_]{0,63}$/.test(form.functionName)) { + throw new Error("函数名必须以小写字母开头,且只能包含小写字母、数字和下划线"); + } + + if (form.type === "end_call") { + return { + name: form.name.trim(), + functionName: form.functionName, + description: form.description.trim(), + status: form.status, + secrets: {}, + definition: { + schemaVersion: 1, + type: "end_call", + config: { + messageType: form.messageType, + customMessage: form.messageType === "custom" ? form.customMessage : "", + captureReason: form.captureReason, + }, + }, + }; + } + + const timeoutSeconds = Number(form.timeoutSeconds); + if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 1 || timeoutSeconds > 120) { + throw new Error("超时时间必须是 1 到 120 秒之间的整数"); + } + const headers = parseObject(form.headers, "Header"); + const secretHeaders = parseObject(form.secretHeaders, "敏感 Header"); + const body = parseObject(form.body, "固定 Body"); + const parameters = parseParameters(form.parameters); + return { + name: form.name.trim(), + functionName: form.functionName, + description: form.description.trim(), + status: form.status, + definition: { + schemaVersion: 1, + type: "http", + config: { + method: form.method, + url: form.url.trim(), + timeoutSeconds, + headers: headers as Record, + parameters, + body, + }, + }, + secrets: { headers: secretHeaders }, + }; +} + +function formatTimestamp(value?: string | null): string { + if (!value) return "—"; + return new Intl.DateTimeFormat("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(value)); +} + +function updatedAtValue(value?: string | null): number { + const time = value ? new Date(value).getTime() : NaN; + return Number.isNaN(time) ? 0 : time; +} export function ComponentsToolsPage() { + const [tools, setTools] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [search, setSearch] = useState(""); + const [filter, setFilter] = useState("全部"); + const [sortOrder, setSortOrder] = useState("newest"); + const [currentPage, setCurrentPage] = useState(1); + const [dialogOpen, setDialogOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form, setForm] = useState(blankForm); + const [saving, setSaving] = useState(false); + const [formError, setFormError] = useState(null); + const [functionNameTouched, setFunctionNameTouched] = useState(false); + const [deletingId, setDeletingId] = useState(null); + + const loadTools = useCallback(async () => { + setLoading(true); + setError(null); + try { + setTools(await toolsApi.list()); + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : "加载工具失败"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + void loadTools(); + }, [loadTools]); + + const filteredTools = useMemo(() => { + const query = search.trim().toLowerCase(); + return tools.filter((tool) => + (filter === "全部" || + (filter === "End Call" && tool.type === "end_call") || + (filter === "HTTP" && tool.type === "http")) && + (!query || + [tool.name, tool.functionName, tool.description].some((value) => + value.toLowerCase().includes(query), + )), + ); + }, [filter, search, tools]); + + const sortedTools = useMemo(() => { + return [...filteredTools].sort((a, b) => { + const diff = updatedAtValue(b.updatedAt) - updatedAtValue(a.updatedAt); + if (diff !== 0) return sortOrder === "newest" ? diff : -diff; + return a.id.localeCompare(b.id); + }); + }, [filteredTools, sortOrder]); + + const pageSize = 5; + const totalPages = Math.max(1, Math.ceil(sortedTools.length / pageSize)); + const safeCurrentPage = Math.min(currentPage, totalPages); + const pageStart = (safeCurrentPage - 1) * pageSize; + const pageEnd = pageStart + pageSize; + const paginatedTools = sortedTools.slice(pageStart, pageEnd); + + function changeFilter(value: ToolFilter) { + setFilter(value); + setCurrentPage(1); + } + + function changeSearch(value: string) { + setSearch(value); + setCurrentPage(1); + } + + function openCreate() { + setEditing(null); + setForm(blankForm()); + setFunctionNameTouched(false); + setFormError(null); + setDialogOpen(true); + } + + function openEdit(tool: Tool) { + setEditing(tool); + setForm(formFromTool(tool)); + setFunctionNameTouched(true); + setFormError(null); + setDialogOpen(true); + } + + async function saveTool() { + if (saving) return; + setSaving(true); + setFormError(null); + try { + const payload = payloadFromForm(form); + if (editing) await toolsApi.update(editing.id, payload); + else await toolsApi.create(payload); + setDialogOpen(false); + await loadTools(); + } catch (saveError) { + setFormError(saveError instanceof Error ? saveError.message : "保存失败"); + } finally { + setSaving(false); + } + } + + async function removeTool(tool: Tool) { + if (!window.confirm(`确认删除工具“${tool.name}”?`)) return; + setDeletingId(tool.id); + try { + await toolsApi.remove(tool.id); + await loadTools(); + } catch (removeError) { + setError(removeError instanceof Error ? removeError.message : "删除失败"); + } finally { + setDeletingId(null); + } + } + + const columns: DataListColumn[] = [ + { + key: "name", + header: "工具名称", + width: "md:w-[360px]", + cell: (tool) => ( + <> +
+ {tool.name} +
+
+ {tool.functionName} +
+ + ), + }, + { + key: "type", + header: "类型", + width: "md:w-[128px]", + cell: (tool) => ( + + {tool.type === "end_call" ? "End Call" : "HTTP"} + + ), + }, + { + key: "status", + header: "状态", + width: "md:w-[156px]", + cell: (tool) => ( + + {tool.status === "active" ? "启用" : tool.status === "draft" ? "草稿" : "已归档"} + + ), + }, + { + key: "updated", + width: "md:w-[176px]", + header: ( + + ), + cellClassName: "whitespace-nowrap tabular-nums text-muted-foreground", + cell: (tool) => formatTimestamp(tool.updatedAt), + }, + { + key: "actions", + header: "操作", + align: "right", + cellClassName: "flex justify-end gap-2", + cell: (tool) => ( + <> + + + + + + + { + event.preventDefault(); + void removeTool(tool); + }} + > + {deletingId === tool.id ? ( + + ) : ( + + )} + 删除 + + + + + ), + }, + ]; + return ( - +
+ + + 添加工具 + + } + /> + +
+ + } + search={ + + } + /> + + columns={columns} + rows={paginatedTools} + rowKey={(tool) => tool.id} + loading={loading} + loadingText="正在加载工具…" + error={error} + onRetry={() => void loadTools()} + empty={{ + title: tools.length === 0 ? "暂无工具资源" : "未找到匹配的工具资源", + description: + tools.length === 0 + ? "点击右上角「添加工具」开始。" + : "请调整关键词或筛选条件后再试。", + }} + pagination={{ + page: safeCurrentPage, + totalPages, + onPageChange: setCurrentPage, + summary: + filteredTools.length === 0 + ? "没有数据" + : `显示 ${pageStart + 1}-${Math.min(pageEnd, filteredTools.length)} / 共 ${filteredTools.length} 个工具资源`, + }} + /> +
+ + + + + {editing ? "编辑工具资源" : "添加工具资源"} + + 左侧配置工具的固定信息,右侧参数会随工具类型更新。 + + + +
+ + + { + const name = event.target.value; + setForm((current) => ({ + ...current, + name, + functionName: functionNameTouched + ? current.functionName + : functionNameFrom(name), + })); + }} + /> + + + { + setFunctionNameTouched(true); + setForm((current) => ({ ...current, functionName: event.target.value })); + }} + placeholder="query_order" + /> + + + + + + + + +