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.
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
72
backend/migrations/versions/20260710_0002_add_tools.py
Normal file
72
backend/migrations/versions/20260710_0002_add_tools.py
Normal file
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
118
backend/routes/tools.py
Normal file
118
backend/routes/tools.py
Normal file
@@ -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}
|
||||
@@ -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
|
||||
|
||||
140
deploy/README.md
140
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 直接访问后端,
|
||||
|
||||
10
frontend/src/app/call/[id]/page.tsx
Normal file
10
frontend/src/app/call/[id]/page.tsx
Normal file
@@ -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 <MobileCallPage assistantId={id} />;
|
||||
}
|
||||
@@ -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 <AppShell>{children}</AppShell>;
|
||||
}
|
||||
|
||||
@@ -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<ModelResource[]>([]);
|
||||
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
||||
const [tools, setTools] = useState<Tool[]>([]);
|
||||
// 视图由路由模式决定;仅编辑模式需要先 loading,等拿到助手类型后切换
|
||||
const [view, setView] = useState<View>(() => {
|
||||
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) {
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
icon={<Wrench size={18} />}
|
||||
title="工具"
|
||||
description="配置该提示词助手可以调用的工具"
|
||||
>
|
||||
<ToolPicker
|
||||
tools={tools.filter((tool) => tool.status === "active")}
|
||||
selectedIds={form.toolIds}
|
||||
onChange={(toolIds) => updateForm("toolIds", toolIds)}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
icon={<Sparkles size={18} />}
|
||||
title="交互策略"
|
||||
@@ -2010,6 +2043,7 @@ function DebugDrawer({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<CallPreviewLink assistantId={assistantId} />
|
||||
{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 (
|
||||
<Popover
|
||||
onOpenChange={(open) => {
|
||||
if (open && assistantId) {
|
||||
setCallUrl(
|
||||
`${window.location.origin}/call/${encodeURIComponent(assistantId)}`,
|
||||
);
|
||||
setCopied(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!assistantId}
|
||||
aria-label="打开手机通话链接"
|
||||
title={assistantId ? "手机通话链接" : "请先保存助手"}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full border border-hairline bg-canvas-soft text-muted-foreground transition-colors hover:bg-surface-strong hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Smartphone size={15} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" side="bottom" className="w-80 space-y-3">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium text-foreground">手机通话预览</div>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
复制链接并在浏览器中打开,即可进入全屏视频通话界面。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
readOnly
|
||||
value={callUrl}
|
||||
aria-label="手机通话链接"
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
className="h-9 min-w-0 flex-1 text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
className="h-9 w-9"
|
||||
onClick={() => void copyLink()}
|
||||
aria-label="复制手机通话链接"
|
||||
>
|
||||
{copied ? <Check size={15} /> : <Copy size={15} />}
|
||||
</Button>
|
||||
</div>
|
||||
{copied && <p className="text-xs text-success">链接已复制</p>}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
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<string[]>(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 (
|
||||
<>
|
||||
<div className="flex min-h-9 flex-wrap items-center gap-2">
|
||||
{selectedTools.map((tool) => (
|
||||
<div
|
||||
key={tool.id}
|
||||
className="flex h-8 items-center gap-2 rounded-lg border border-hairline-strong bg-background px-2.5 text-sm"
|
||||
>
|
||||
{tool.type === "end_call" ? <PhoneOff size={14} /> : <Wrench size={14} />}
|
||||
<span className="max-w-48 truncate">{tool.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(selectedIds.filter((id) => id !== tool.id))}
|
||||
className="text-muted-soft transition-colors hover:text-foreground"
|
||||
aria-label={`移除工具 ${tool.name}`}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
className="border-hairline-strong text-muted-foreground hover:text-foreground"
|
||||
onClick={openPicker}
|
||||
aria-label="添加工具"
|
||||
title="添加工具"
|
||||
>
|
||||
<Plus size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>选择工具</DialogTitle>
|
||||
<DialogDescription>选择已经在工具资源中启用的工具。</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{tools.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-hairline-strong px-4 py-10 text-center text-sm text-muted-foreground">
|
||||
暂无可用工具
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-80 divide-y divide-hairline overflow-y-auto rounded-xl border border-hairline">
|
||||
{tools.map((tool) => {
|
||||
const checked = draftIds.includes(tool.id);
|
||||
return (
|
||||
<label
|
||||
key={tool.id}
|
||||
className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-surface-strong/40"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() =>
|
||||
setDraftIds((current) =>
|
||||
checked
|
||||
? current.filter((id) => id !== tool.id)
|
||||
: [...current, tool.id],
|
||||
)
|
||||
}
|
||||
className="size-4 accent-primary"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium text-foreground">
|
||||
{tool.name}
|
||||
</span>
|
||||
<Badge variant="secondary">
|
||||
{tool.type === "end_call" ? "End Call" : "HTTP"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-0.5 truncate font-mono text-xs text-muted-foreground">
|
||||
{tool.functionName}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
onChange(draftIds);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
确认
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
icon,
|
||||
title,
|
||||
|
||||
@@ -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<string, string> };
|
||||
base.secretHeaders = pretty(secrets.headers ?? {}, EMPTY_OBJECT);
|
||||
return base;
|
||||
}
|
||||
|
||||
function parseObject(value: string, label: string): Record<string, unknown> {
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, string>,
|
||||
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<Tool[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState<ToolFilter>("全部");
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>("newest");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Tool | null>(null);
|
||||
const [form, setForm] = useState<ToolForm>(blankForm);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [functionNameTouched, setFunctionNameTouched] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(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<Tool>[] = [
|
||||
{
|
||||
key: "name",
|
||||
header: "工具名称",
|
||||
width: "md:w-[360px]",
|
||||
cell: (tool) => (
|
||||
<>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-medium text-foreground">{tool.name}</span>
|
||||
</div>
|
||||
<div className="mt-1 truncate font-mono text-xs text-muted-soft">
|
||||
{tool.functionName}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
header: "类型",
|
||||
width: "md:w-[128px]",
|
||||
cell: (tool) => (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-6 bg-surface-strong px-3 text-muted-foreground"
|
||||
>
|
||||
{tool.type === "end_call" ? "End Call" : "HTTP"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "状态",
|
||||
width: "md:w-[156px]",
|
||||
cell: (tool) => (
|
||||
<Badge variant="outline" className="h-6 px-3">
|
||||
{tool.status === "active" ? "启用" : tool.status === "draft" ? "草稿" : "已归档"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "updated",
|
||||
width: "md:w-[176px]",
|
||||
header: (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSortOrder((current) => (current === "newest" ? "oldest" : "newest"));
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="caption-label -mx-2 inline-flex items-center gap-1 rounded-md px-2 py-1 text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
|
||||
aria-label={sortOrder === "newest" ? "当前按最近更新排序" : "当前按最早更新排序"}
|
||||
>
|
||||
更新时间
|
||||
{sortOrder === "newest" ? <ChevronDown size={13} /> : <ChevronUp size={13} />}
|
||||
</button>
|
||||
),
|
||||
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) => (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5 border-hairline-strong text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => openEdit(tool)}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
编辑
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
className="border-hairline-strong text-muted-foreground hover:text-foreground"
|
||||
aria-label={`${tool.name} 更多操作`}
|
||||
>
|
||||
<MoreHorizontal size={15} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-32 min-w-32 rounded-xl border border-hairline bg-popover p-1"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
className="rounded-lg"
|
||||
disabled={deletingId === tool.id}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
void removeTool(tool);
|
||||
}}
|
||||
>
|
||||
{deletingId === tool.id ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Trash2 size={14} />
|
||||
)}
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PlaceholderPage
|
||||
title="工具资源"
|
||||
description="统一管理大语言模型、语音识别、声音资源、知识库与工具插件。"
|
||||
/>
|
||||
<div className="mx-auto flex w-full max-w-[1440px] flex-col gap-8">
|
||||
<PageHeader
|
||||
title="工具资源"
|
||||
description="管理可复用的助手工具,并将启用的工具绑定到提示词助手。"
|
||||
action={
|
||||
<Button size="lg" className="w-full gap-2 sm:w-auto" onClick={openCreate}>
|
||||
<Plus size={16} />
|
||||
添加工具
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<section className="rounded-2xl border border-hairline bg-card p-6 shadow-sm">
|
||||
<ListToolbar
|
||||
filters={
|
||||
<FilterPills options={toolFilters} value={filter} onChange={changeFilter} />
|
||||
}
|
||||
search={
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={changeSearch}
|
||||
placeholder="搜索名称、函数名或描述"
|
||||
className="lg:w-[320px]"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<DataList<Tool>
|
||||
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} 个工具资源`,
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-h-[calc(100vh-3rem)] overflow-y-auto sm:max-w-6xl lg:overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? "编辑工具资源" : "添加工具资源"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
左侧配置工具的固定信息,右侧参数会随工具类型更新。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<FieldSection title="基本信息" tall>
|
||||
<Field label="工具名称" required>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(event) => {
|
||||
const name = event.target.value;
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
name,
|
||||
functionName: functionNameTouched
|
||||
? current.functionName
|
||||
: functionNameFrom(name),
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="函数名" required>
|
||||
<Input
|
||||
value={form.functionName}
|
||||
onChange={(event) => {
|
||||
setFunctionNameTouched(true);
|
||||
setForm((current) => ({ ...current, functionName: event.target.value }));
|
||||
}}
|
||||
placeholder="query_order"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="工具类型" required>
|
||||
<Select
|
||||
value={form.type}
|
||||
onValueChange={(type: ToolKind) =>
|
||||
setForm((current) => ({ ...current, type }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="end_call">End Call</SelectItem>
|
||||
<SelectItem value="http">HTTP</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="状态">
|
||||
<Select
|
||||
value={form.status}
|
||||
onValueChange={(status: ToolStatus) =>
|
||||
setForm((current) => ({ ...current, status }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">启用</SelectItem>
|
||||
<SelectItem value="draft">草稿</SelectItem>
|
||||
<SelectItem value="archived">归档</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="描述">
|
||||
<Textarea
|
||||
value={form.description}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, description: event.target.value }))
|
||||
}
|
||||
rows={5}
|
||||
/>
|
||||
</Field>
|
||||
</FieldSection>
|
||||
|
||||
<FieldSection title="参数配置" scrollable tall>
|
||||
{form.type === "end_call" ? (
|
||||
<EndCallFields form={form} setForm={setForm} />
|
||||
) : (
|
||||
<HttpFields form={form} setForm={setForm} />
|
||||
)}
|
||||
</FieldSection>
|
||||
</div>
|
||||
|
||||
{formError && <div className="text-sm text-destructive">{formError}</div>}
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button>
|
||||
<Button onClick={() => void saveTool()} disabled={saving}>
|
||||
{saving && <Loader2 size={15} className="animate-spin" />}
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EndCallFields({
|
||||
form,
|
||||
setForm,
|
||||
}: {
|
||||
form: ToolForm;
|
||||
setForm: React.Dispatch<React.SetStateAction<ToolForm>>;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Field label="结束语">
|
||||
<Select
|
||||
value={form.messageType}
|
||||
onValueChange={(messageType: "none" | "custom") =>
|
||||
setForm((current) => ({ ...current, messageType }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full border-hairline-strong bg-background"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">不播放固定结束语</SelectItem>
|
||||
<SelectItem value="custom">自定义结束语</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{form.messageType === "custom" && (
|
||||
<Field label="自定义结束语">
|
||||
<Textarea
|
||||
value={form.customMessage}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, customMessage: event.target.value }))
|
||||
}
|
||||
rows={3}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-4 rounded-lg border border-hairline-strong px-4 py-3">
|
||||
<div>
|
||||
<div className="font-medium text-foreground">记录结束原因</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">要求模型在调用时提供 reason 参数</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.captureReason}
|
||||
onCheckedChange={(captureReason) =>
|
||||
setForm((current) => ({ ...current, captureReason }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HttpFields({
|
||||
form,
|
||||
setForm,
|
||||
}: {
|
||||
form: ToolForm;
|
||||
setForm: React.Dispatch<React.SetStateAction<ToolForm>>;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-[140px_1fr]">
|
||||
<Field label="方法">
|
||||
<Select
|
||||
value={form.method}
|
||||
onValueChange={(method: HttpMethod) =>
|
||||
setForm((current) => ({ ...current, method }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full border-hairline-strong bg-background"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(["GET", "POST", "PUT", "PATCH", "DELETE"] as HttpMethod[]).map((method) => (
|
||||
<SelectItem key={method} value={method}>{method}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="URL">
|
||||
<Input
|
||||
value={form.url}
|
||||
onChange={(event) => setForm((current) => ({ ...current, url: event.target.value }))}
|
||||
placeholder="https://api.example.com/orders/{order_id}"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="超时时间(秒)">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={120}
|
||||
value={form.timeoutSeconds}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, timeoutSeconds: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<JsonField label="Header" value={form.headers} onChange={(headers) => setForm((current) => ({ ...current, headers }))} />
|
||||
<JsonField label="敏感 Header" value={form.secretHeaders} onChange={(secretHeaders) => setForm((current) => ({ ...current, secretHeaders }))} />
|
||||
<JsonField label="参数定义" value={form.parameters} onChange={(parameters) => setForm((current) => ({ ...current, parameters }))} rows={6} />
|
||||
<JsonField label="固定 Body" value={form.body} onChange={(body) => setForm((current) => ({ ...current, body }))} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
required,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{label}
|
||||
{required && <span className="ml-1 text-destructive">*</span>}
|
||||
</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldSection({
|
||||
title,
|
||||
scrollable,
|
||||
tall,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
scrollable?: boolean;
|
||||
tall?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className={[
|
||||
"rounded-xl border border-hairline bg-surface-strong/20",
|
||||
tall ? "lg:flex lg:h-[38rem] lg:flex-col" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="border-b border-hairline px-4 py-3 text-sm font-medium">
|
||||
{title}
|
||||
</div>
|
||||
<div
|
||||
className={[
|
||||
"space-y-4 p-4",
|
||||
scrollable ? "max-h-72 overflow-y-auto" : "",
|
||||
tall ? "lg:max-h-none lg:flex-1" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function JsonField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
rows = 4,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
rows?: number;
|
||||
}) {
|
||||
return (
|
||||
<Field label={label}>
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
rows={rows}
|
||||
className="font-mono text-xs"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
233
frontend/src/components/pages/MobileCallPage.tsx
Normal file
233
frontend/src/components/pages/MobileCallPage.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Camera, Loader2, Mic, Phone, PhoneOff, Video } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useCameraPreview } from "@/hooks/use-camera-preview";
|
||||
import { useVoicePreview } from "@/hooks/use-voice-preview";
|
||||
|
||||
function CallDeviceSelect({
|
||||
kind,
|
||||
value,
|
||||
devices,
|
||||
onSelect,
|
||||
disabled,
|
||||
}: {
|
||||
kind: "microphone" | "camera";
|
||||
value: string;
|
||||
devices: MediaDeviceInfo[];
|
||||
onSelect: (deviceId: string) => void | Promise<void>;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const isMic = kind === "microphone";
|
||||
const label = isMic ? "选择麦克风" : "选择摄像头";
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value || "default"}
|
||||
onValueChange={(next) =>
|
||||
void onSelect(next === "default" ? "" : next)
|
||||
}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="default"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className="w-12 justify-center overflow-hidden rounded-full border-white/15 bg-black/45 p-0 text-white shadow-xl backdrop-blur-md hover:bg-black/60 focus-visible:ring-white/40 data-[size=default]:h-12 min-[380px]:w-14 min-[380px]:data-[size=default]:h-14 [&>svg:last-child]:hidden"
|
||||
>
|
||||
<span className="sr-only">
|
||||
<SelectValue />
|
||||
</span>
|
||||
{isMic ? (
|
||||
<Mic className="size-5" />
|
||||
) : (
|
||||
<Camera className="size-5" />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
side="top"
|
||||
align={isMic ? "start" : "end"}
|
||||
className="max-w-[min(20rem,calc(100vw-2rem))]"
|
||||
>
|
||||
<SelectItem value="default">
|
||||
{isMic ? "默认麦克风" : "默认摄像头"}
|
||||
</SelectItem>
|
||||
{devices.map((device, index) => (
|
||||
<SelectItem key={device.deviceId} value={device.deviceId}>
|
||||
{device.label || `${isMic ? "麦克风" : "摄像头"} ${index + 1}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileCallPage({ assistantId }: { assistantId: string }) {
|
||||
const preview = useVoicePreview(assistantId);
|
||||
const camera = useCameraPreview();
|
||||
const {
|
||||
status,
|
||||
error: previewError,
|
||||
audioInputs,
|
||||
selectedDeviceId,
|
||||
selectDevice,
|
||||
connect,
|
||||
replaceVideoStream,
|
||||
disconnect,
|
||||
audioRef,
|
||||
} = preview;
|
||||
const {
|
||||
stream: cameraStream,
|
||||
error: cameraError,
|
||||
starting: cameraStarting,
|
||||
devices: cameraDevices,
|
||||
deviceId: cameraDeviceId,
|
||||
start: startCamera,
|
||||
stop: stopCamera,
|
||||
selectCamera: changeCamera,
|
||||
} = camera;
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const connecting = status === "connecting";
|
||||
const inCall = connecting || status === "connected";
|
||||
|
||||
useEffect(() => {
|
||||
if (videoRef.current) videoRef.current.srcObject = cameraStream;
|
||||
}, [cameraStream]);
|
||||
|
||||
const startCall = useCallback(async () => {
|
||||
const videoStream = await startCamera();
|
||||
await connect({
|
||||
visionEnabled: true,
|
||||
videoStream,
|
||||
});
|
||||
}, [connect, startCamera]);
|
||||
|
||||
const endCall = useCallback(() => {
|
||||
disconnect();
|
||||
stopCamera();
|
||||
}, [disconnect, stopCamera]);
|
||||
|
||||
const selectCamera = useCallback(
|
||||
async (deviceId: string) => {
|
||||
const nextStream = await changeCamera(deviceId);
|
||||
await replaceVideoStream(nextStream);
|
||||
},
|
||||
[changeCamera, replaceVideoStream],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void startCall();
|
||||
// 首次打开页面时自动发起通话;hooks 会在卸载时各自释放媒体资源。
|
||||
}, [startCall]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "idle" || status === "failed") stopCamera();
|
||||
}, [status, stopCamera]);
|
||||
|
||||
const error = previewError || cameraError;
|
||||
|
||||
return (
|
||||
<main className="relative isolate h-dvh min-h-80 w-full overflow-hidden bg-[#07101a] text-white">
|
||||
<audio ref={audioRef} autoPlay playsInline className="hidden" />
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
className={[
|
||||
"absolute inset-0 h-full w-full -scale-x-100 object-cover transition-opacity duration-300",
|
||||
cameraStream ? "opacity-100" : "opacity-0",
|
||||
].join(" ")}
|
||||
/>
|
||||
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-black/35 via-transparent to-black/65" />
|
||||
|
||||
<div className="absolute left-1/2 top-[max(1rem,env(safe-area-inset-top))] flex -translate-x-1/2 items-center gap-2 rounded-full border border-white/10 bg-black/30 px-3 py-1.5 text-xs text-white/85 backdrop-blur-md">
|
||||
<span
|
||||
className={[
|
||||
"h-2 w-2 rounded-full",
|
||||
status === "connected"
|
||||
? "animate-pulse bg-emerald-400"
|
||||
: connecting
|
||||
? "animate-pulse bg-amber-300"
|
||||
: "bg-white/40",
|
||||
].join(" ")}
|
||||
/>
|
||||
{status === "connected"
|
||||
? "通话中"
|
||||
: connecting
|
||||
? "正在连接…"
|
||||
: "通话已结束"}
|
||||
</div>
|
||||
|
||||
{!cameraStream && inCall && (
|
||||
<div className="absolute inset-0 flex items-center justify-center px-8 text-center">
|
||||
<div className="flex max-w-sm flex-col items-center gap-3">
|
||||
{cameraStarting ? (
|
||||
<Loader2 size={34} className="animate-spin text-white/70" />
|
||||
) : (
|
||||
<Video size={34} className="text-white/60" />
|
||||
)}
|
||||
<p className="text-sm leading-6 text-white/75">
|
||||
{cameraStarting
|
||||
? "正在开启摄像头…"
|
||||
: cameraError || "等待摄像头画面…"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!inCall && (
|
||||
<div className="absolute inset-0 flex items-center justify-center px-8">
|
||||
<div className="flex max-w-sm flex-col items-center gap-4 text-center">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void startCall()}
|
||||
className="h-12 gap-2 rounded-full bg-white px-6 text-[#07101a] shadow-2xl hover:bg-white/90"
|
||||
>
|
||||
<Phone size={19} />
|
||||
{status === "failed" ? "重新开始通话" : "开始通话"}
|
||||
</Button>
|
||||
{error && <p className="text-xs leading-5 text-white/65">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inCall && (
|
||||
<div className="absolute bottom-[max(1.25rem,env(safe-area-inset-bottom))] left-1/2 flex -translate-x-1/2 items-center gap-3 min-[380px]:gap-5 landscape:bottom-[max(1rem,env(safe-area-inset-bottom))]">
|
||||
<CallDeviceSelect
|
||||
kind="microphone"
|
||||
value={selectedDeviceId}
|
||||
devices={audioInputs}
|
||||
onSelect={selectDevice}
|
||||
disabled={connecting}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={endCall}
|
||||
aria-label="挂断电话"
|
||||
title="挂断电话"
|
||||
className="flex size-14 items-center justify-center rounded-full bg-red-500 text-white shadow-2xl transition-transform hover:scale-105 hover:bg-red-600 active:scale-95 min-[380px]:size-16"
|
||||
>
|
||||
<PhoneOff className="size-6 min-[380px]:size-7" />
|
||||
</button>
|
||||
<CallDeviceSelect
|
||||
kind="camera"
|
||||
value={cameraDeviceId}
|
||||
devices={cameraDevices}
|
||||
onSelect={selectCamera}
|
||||
disabled={connecting}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -159,6 +159,7 @@ export type Assistant = {
|
||||
visionModelResourceId: string | null;
|
||||
modelResourceIds: Partial<Record<ModelType, string>>;
|
||||
knowledgeBaseId: string | null;
|
||||
toolIds: string[];
|
||||
prompt: string;
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
@@ -189,6 +190,69 @@ export const assistantsApi = {
|
||||
request<{ ok: boolean }>(`/api/assistants/${id}`, { method: "DELETE" }),
|
||||
};
|
||||
|
||||
// ---------- 工具 ----------
|
||||
export type ToolStatus = "active" | "archived" | "draft";
|
||||
export type ToolParameter = {
|
||||
name: string;
|
||||
type: "string" | "number" | "integer" | "boolean" | "object" | "array";
|
||||
location: "path" | "query" | "body" | "header";
|
||||
description: string;
|
||||
required: boolean;
|
||||
};
|
||||
|
||||
export type EndCallToolDefinition = {
|
||||
schemaVersion: number;
|
||||
type: "end_call";
|
||||
config: {
|
||||
messageType: "none" | "custom";
|
||||
customMessage: string;
|
||||
captureReason: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type HttpToolDefinition = {
|
||||
schemaVersion: number;
|
||||
type: "http";
|
||||
config: {
|
||||
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
url: string;
|
||||
timeoutSeconds: number;
|
||||
headers: Record<string, string>;
|
||||
parameters: ToolParameter[];
|
||||
body: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
export type Tool = {
|
||||
id: string;
|
||||
name: string;
|
||||
functionName: string;
|
||||
type: "end_call" | "http";
|
||||
description: string;
|
||||
definition: EndCallToolDefinition | HttpToolDefinition;
|
||||
secrets: Record<string, unknown>;
|
||||
status: ToolStatus;
|
||||
updatedAt?: string | null;
|
||||
};
|
||||
|
||||
export type ToolUpsert = Omit<Tool, "id" | "type" | "updatedAt">;
|
||||
|
||||
export const toolsApi = {
|
||||
list: () => request<Tool[]>("/api/tools"),
|
||||
create: (body: ToolUpsert) =>
|
||||
request<Tool>("/api/tools", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
update: (id: string, body: ToolUpsert) =>
|
||||
request<Tool>(`/api/tools/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
remove: (id: string) =>
|
||||
request<{ ok: boolean }>(`/api/tools/${id}`, { method: "DELETE" }),
|
||||
};
|
||||
|
||||
// ---------- 知识库 ----------
|
||||
export type KnowledgeBase = {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user