feat: add secure agent and MCP extensions

This commit is contained in:
hailin
2026-07-27 19:02:37 +08:00
parent 07c9ea1cc4
commit d93cb75c10
29 changed files with 3186 additions and 21 deletions

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.15 on 2026-07-27 04:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('agents', '0004_agentrunevent'),
]
operations = [
migrations.AddField(
model_name='agentversion',
name='allowed_actions',
field=models.JSONField(blank=True, default=list),
),
]

View File

@@ -0,0 +1,34 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("agents", "0005_agentversion_allowed_actions"),
("modelhub", "0002_modelprovider_image_egress_approved_and_more"),
]
operations = [
migrations.AddField(
model_name="agentstepbinding",
name="model_deployment",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="agent_step_bindings",
to="modelhub.modeldeployment",
),
),
migrations.AddField(
model_name="agentrunstep",
name="model_deployment",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="agent_run_steps",
to="modelhub.modeldeployment",
),
),
]

View File

@@ -158,7 +158,7 @@ def create_deployment(request, payload: ModelDeploymentIn):
raise HttpError(400, "Temperature must be between 0 and 2")
if not 1 <= payload.max_output_tokens <= 32768:
raise HttpError(400, "Max output tokens are out of range")
if payload.capability not in {"text_chat", "vision_chat"}:
if payload.capability not in {"text_chat", "vision_chat", "embedding", "rerank"}:
raise HttpError(400, "Unknown model capability")
deployment = ModelDeployment.objects.create(**payload.dict())
deployment = ModelDeployment.objects.select_related("provider").get(id=deployment.id)
@@ -177,7 +177,12 @@ def update_deployment(request, deployment_id: int, payload: ModelDeploymentUpdat
require_permission(request, "model.manage")
deployment = ModelDeployment.objects.select_related("provider").get(id=deployment_id)
values = payload.dict(exclude_unset=True)
if "capability" in values and values["capability"] not in {"text_chat", "vision_chat"}:
if "capability" in values and values["capability"] not in {
"text_chat",
"vision_chat",
"embedding",
"rerank",
}:
raise HttpError(400, "Unknown model capability")
for field, value in values.items():
setattr(deployment, field, value)
@@ -196,6 +201,8 @@ def update_deployment(request, deployment_id: int, payload: ModelDeploymentUpdat
def test_deployment(request, deployment_id: int, payload: ModelTestIn):
require_permission(request, "model.manage")
deployment = ModelDeployment.objects.select_related("provider").get(id=deployment_id)
if deployment.capability == "embedding":
raise HttpError(400, "Embedding 模型请通过知识检索验证")
invocation = create_model_test(deployment, payload.prompt, request.user)
run_model_test.delay(invocation.id)
invocation.refresh_from_db()

View File

@@ -1,3 +1,9 @@
"""模型调用执行器。
统一处理超时预算、可重试错误、凭据脱敏、调用快照和 Token/耗时记录。
"""
from collections.abc import Callable
from dataclasses import dataclass
from time import monotonic, sleep
from typing import Any
@@ -38,12 +44,18 @@ def _request_completion(
*,
temperature: float,
max_output_tokens: int,
timeout_seconds: float | None = None,
) -> CompletionResult:
provider = deployment.provider
request_timeout = min(max(provider.default_timeout_seconds, 1), 300)
if timeout_seconds is not None:
request_timeout = min(request_timeout, max(timeout_seconds, 1))
client = OpenAI(
api_key=decrypt_secret(provider.api_key_encrypted),
base_url=provider.base_url,
timeout=min(max(provider.default_timeout_seconds, 1), 300),
timeout=request_timeout,
# Retry here so the platform can expose it instead of waiting on opaque SDK retries.
max_retries=0,
)
response = client.chat.completions.create(
model=deployment.model_name,
@@ -84,7 +96,11 @@ def execute_invocation(
*,
temperature: float | None = None,
max_output_tokens: int | None = None,
on_retry: Callable[[int, str], None] | None = None,
total_timeout_seconds: float | None = None,
) -> ModelInvocation:
"""在总时间预算内调用模型,分类错误、限制重试并记录脱敏终态。"""
deployment = invocation.deployment
if deployment is None or not deployment.enabled or not deployment.provider.enabled:
invocation.status = "failed"
@@ -111,6 +127,12 @@ def execute_invocation(
result: CompletionResult | None = None
last_error: Exception | None = None
for attempt in range(2):
elapsed = monotonic() - started
remaining = total_timeout_seconds - elapsed if total_timeout_seconds is not None else None
if remaining is not None and remaining <= 0:
invocation.error_code = "timeout"
invocation.error_message = "模型请求超过 Agent 时间预算"
break
try:
result = _request_completion(
deployment,
@@ -119,6 +141,7 @@ def execute_invocation(
if temperature is not None
else deployment.default_temperature,
max_output_tokens=max_output_tokens or deployment.max_output_tokens,
timeout_seconds=remaining,
)
invocation.retry_count = attempt
break
@@ -130,6 +153,18 @@ def execute_invocation(
invocation.error_code = code
invocation.error_message = message
break
if total_timeout_seconds is not None:
remaining_after_error = total_timeout_seconds - (monotonic() - started)
if remaining_after_error <= 1:
invocation.error_code = code
invocation.error_message = message
break
invocation.retry_count = attempt + 1
invocation.error_code = code
invocation.error_message = message
invocation.save(update_fields=["retry_count", "error_code", "error_message"])
if on_retry:
on_retry(attempt + 1, code)
sleep(1)
invocation.duration_ms = max(int((monotonic() - started) * 1000), 0)
@@ -156,6 +191,8 @@ def create_model_test(
prompt: str,
user=None,
) -> ModelInvocation:
"""创建可审计的异步模型连通测试,不绕过正式模型执行器。"""
return ModelInvocation.objects.create(
deployment=deployment,
invocation_type="model_test",

View File

@@ -89,6 +89,45 @@ def test_model_test_runs_through_recorded_invocation(client, db, monkeypatch):
assert response.json()["output_tokens"] == 4
def test_embedding_deployment_can_be_configured_but_uses_knowledge_search_for_test(client, db):
login_admin(client)
provider_id = create_provider(client).json()["id"]
deployment = client.post(
"/api/modelhub/deployments",
data={
"provider_id": provider_id,
"display_name": "知识向量模型",
"model_name": "embedding-model",
"capability": "embedding",
},
content_type="application/json",
)
assert deployment.status_code == 200
assert deployment.json()["capability"] == "embedding"
response = client.post(
f"/api/modelhub/deployments/{deployment.json()['id']}/test",
data={"prompt": "测试连接"},
content_type="application/json",
)
assert response.status_code == 400
assert "知识检索" in response.json()["detail"]
rerank = client.post(
"/api/modelhub/deployments",
data={
"provider_id": provider_id,
"display_name": "知识重排模型",
"model_name": "rerank-model",
"capability": "rerank",
},
content_type="application/json",
)
assert rerank.status_code == 200
assert rerank.json()["capability"] == "rerank"
def test_non_ai_admin_cannot_manage_modelhub(client, db):
user = get_user_model().objects.create_user(username="requester", password="password")
client.force_login(user)

View File

@@ -27,6 +27,7 @@ def create_invocation():
def test_transient_error_retries_once(db, monkeypatch):
invocation = create_invocation()
calls = []
retries = []
def request(*args, **kwargs):
calls.append(1)
@@ -37,12 +38,17 @@ def test_transient_error_retries_once(db, monkeypatch):
monkeypatch.setattr("apps.modelhub.services._request_completion", request)
monkeypatch.setattr("apps.modelhub.services.sleep", lambda *_: None)
execute_invocation(invocation, invocation.input_snapshot["messages"])
execute_invocation(
invocation,
invocation.input_snapshot["messages"],
on_retry=lambda retry_number, code: retries.append((retry_number, code)),
)
invocation.refresh_from_db()
assert len(calls) == 2
assert invocation.status == "succeeded"
assert invocation.retry_count == 1
assert retries == [(1, "timeout")]
def test_non_transient_error_fails_without_exposing_raw_error(db, monkeypatch):
@@ -61,6 +67,31 @@ def test_non_transient_error_fails_without_exposing_raw_error(db, monkeypatch):
assert "secret provider detail" not in invocation.error_message
def test_agent_time_budget_does_not_repeat_a_full_timeout(db, monkeypatch):
invocation = create_invocation()
calls = []
clock = iter([0.0, 0.0, 19.5, 20.0])
def request(*args, **kwargs):
calls.append(kwargs["timeout_seconds"])
raise APITimeoutError(request=None)
monkeypatch.setattr("apps.modelhub.services._request_completion", request)
monkeypatch.setattr("apps.modelhub.services.monotonic", lambda: next(clock))
execute_invocation(
invocation,
invocation.input_snapshot["messages"],
total_timeout_seconds=20,
)
invocation.refresh_from_db()
assert calls == [20]
assert invocation.status == "failed"
assert invocation.error_code == "timeout"
assert invocation.retry_count == 0
def provider_error(error_type, status_code):
request = httpx.Request("POST", "https://models.example.com/v1/chat/completions")
response = httpx.Response(status_code, request=request)

View File

@@ -0,0 +1 @@

273
backend/apps/tools/api.py Normal file
View File

@@ -0,0 +1,273 @@
from django.db import transaction
from ninja import Router
from ninja.errors import HttpError
from apps.accounts.api import require_permission
from apps.agents.models import AgentRun
from apps.modelhub.crypto import encrypt_secret
from apps.skills.services import SkillValidationError
from apps.tools.models import (
ToolAuthorization,
ToolCall,
ToolProvider,
UnifiedTool,
)
from apps.tools.schemas import (
ToolAuthorizationIn,
ToolAuthorizationOut,
ToolCallIn,
ToolCallOut,
ToolConfirmationIn,
ToolProviderIn,
ToolProviderOut,
ToolProviderUpdateIn,
UnifiedToolOut,
UnifiedToolUpdateIn,
)
from apps.tools.services import (
ToolExecutionError,
decide_tool_confirmation,
request_tool_call,
sync_mcp_tools,
)
router = Router(tags=["tools"])
def serialize_provider(provider: ToolProvider) -> dict:
return {
"id": provider.id,
"name": provider.name,
"provider_type": provider.provider_type,
"base_url": provider.base_url,
"credential_set": bool(provider.token_encrypted),
"enabled": provider.enabled,
"timeout_seconds": provider.timeout_seconds,
"created_at": provider.created_at,
"updated_at": provider.updated_at,
}
def serialize_call(call: ToolCall) -> dict:
confirmation = getattr(call, "confirmation", None)
return {
"public_id": call.public_id,
"tool_code": call.tool.code,
"requested_by_id": call.requested_by_id,
"requested_by_name": (
call.requested_by.get_full_name() or call.requested_by.username
if call.requested_by
else ""
),
"status": call.status,
"input_payload": call.input_payload,
"output_payload": call.output_payload,
"authorization_snapshot": call.authorization_snapshot,
"risk_snapshot": call.risk_snapshot,
"error_code": call.error_code,
"error_message": call.error_message,
"confirmation_status": confirmation.status if confirmation else None,
"confirmation_reason": confirmation.reason if confirmation else "",
"created_at": call.created_at,
"started_at": call.started_at,
"finished_at": call.finished_at,
}
@router.get("/providers", response=list[ToolProviderOut])
def list_tool_providers(request):
require_permission(request, "tool.manage")
return [serialize_provider(item) for item in ToolProvider.objects.all()]
@router.post("/providers", response=ToolProviderOut)
def create_tool_provider(request, payload: ToolProviderIn):
require_permission(request, "tool.manage")
if payload.provider_type == "mcp_http" and not payload.base_url:
raise HttpError(400, "HTTP MCP 必须配置服务地址")
provider = ToolProvider.objects.create(
name=payload.name,
provider_type=payload.provider_type,
base_url=payload.base_url,
token_encrypted=encrypt_secret(payload.token) if payload.token else "",
enabled=payload.enabled,
timeout_seconds=min(max(payload.timeout_seconds, 1), 300),
created_by=request.user,
)
return serialize_provider(provider)
@router.patch("/providers/{provider_id}", response=ToolProviderOut)
def update_tool_provider(
request,
provider_id: int,
payload: ToolProviderUpdateIn,
):
require_permission(request, "tool.manage")
provider = ToolProvider.objects.filter(id=provider_id).first()
if provider is None:
raise HttpError(404, "工具服务不存在")
values = payload.dict(exclude_unset=True)
if "base_url" in values and provider.provider_type == "mcp_http":
if not values["base_url"]:
raise HttpError(400, "HTTP MCP 必须配置服务地址")
token = values.pop("token", None)
if token:
provider.token_encrypted = encrypt_secret(token)
if "timeout_seconds" in values:
values["timeout_seconds"] = min(max(values["timeout_seconds"], 1), 300)
for field, value in values.items():
setattr(provider, field, value)
provider.save()
return serialize_provider(provider)
@router.post("/providers/{provider_id}/sync", response=list[UnifiedToolOut])
def sync_tool_provider(request, provider_id: int):
require_permission(request, "tool.manage")
provider = ToolProvider.objects.filter(id=provider_id).first()
if provider is None:
raise HttpError(404, "工具服务不存在")
try:
return sync_mcp_tools(provider)
except ToolExecutionError as error:
raise HttpError(400, str(error)) from error
@router.get("", response=list[UnifiedToolOut])
def list_tools(request):
require_permission(request, "tool.view")
return UnifiedTool.objects.select_related("provider").all()
@router.get(
"/{tool_id}/authorizations",
response=list[ToolAuthorizationOut],
)
def list_tool_authorizations(request, tool_id: int):
require_permission(request, "tool.manage")
if not UnifiedTool.objects.filter(id=tool_id).exists():
raise HttpError(404, "工具不存在")
return ToolAuthorization.objects.filter(tool_id=tool_id)
@router.post(
"/{tool_id}/authorizations",
response=ToolAuthorizationOut,
)
def create_tool_authorization(request, tool_id: int, payload: ToolAuthorizationIn):
require_permission(request, "tool.manage")
if payload.principal_type not in {"user", "group", "agent"}:
raise HttpError(400, "授权主体必须是 user、group 或 agent")
tool = UnifiedTool.objects.filter(id=tool_id).first()
if tool is None:
raise HttpError(404, "工具不存在")
authorization, _ = ToolAuthorization.objects.update_or_create(
tool=tool,
principal_type=payload.principal_type,
principal_id=payload.principal_id,
defaults={"allowed": payload.allowed, "created_by": request.user},
)
return authorization
@router.get("/calls", response=list[ToolCallOut])
def list_tool_calls(request, status: str = "", tool_code: str = ""):
require_permission(request, "tool.invoke")
queryset = ToolCall.objects.select_related(
"tool",
"requested_by",
"confirmation",
)
can_confirm = request.user.is_superuser or request.user.has_perm(
"system.tool.confirm"
)
if not can_confirm:
queryset = queryset.filter(requested_by=request.user)
if status:
queryset = queryset.filter(status=status)
if tool_code:
queryset = queryset.filter(tool__code=tool_code)
return [serialize_call(call) for call in queryset[:100]]
@router.post("/calls", response=ToolCallOut)
def create_tool_call(request, payload: ToolCallIn):
require_permission(request, "tool.invoke")
tool = UnifiedTool.objects.filter(code=payload.tool_code).first()
if tool is None:
raise HttpError(404, "工具不存在")
run = None
if payload.agent_run_id:
run = AgentRun.objects.filter(public_id=payload.agent_run_id).first()
if run is None:
raise HttpError(404, "Agent 运行不存在")
try:
with transaction.atomic():
call = request_tool_call(
tool,
payload.input,
user=request.user,
run=run,
idempotency_key=payload.idempotency_key,
)
except (ToolExecutionError, SkillValidationError) as error:
raise HttpError(400, str(error)) from error
call = ToolCall.objects.select_related("tool", "confirmation").get(id=call.id)
return serialize_call(call)
@router.get("/calls/{public_id}", response=ToolCallOut)
def get_tool_call(request, public_id: str):
require_permission(request, "tool.invoke")
call = ToolCall.objects.select_related("tool", "confirmation").filter(
public_id=public_id
).first()
if call is None:
raise HttpError(404, "工具调用不存在")
if not request.user.is_superuser and call.requested_by_id != request.user.id:
require_permission(request, "tool.confirm")
return serialize_call(call)
@router.post("/calls/{public_id}/confirmation", response=ToolCallOut)
def confirm_tool_call(request, public_id: str, payload: ToolConfirmationIn):
require_permission(request, "tool.confirm")
call = ToolCall.objects.select_related("tool", "confirmation").filter(
public_id=public_id
).first()
if call is None:
raise HttpError(404, "工具调用不存在")
try:
with transaction.atomic():
decide_tool_confirmation(
call,
reviewer=request.user,
decision=payload.decision,
reason=payload.reason,
)
except ToolExecutionError as error:
raise HttpError(400, str(error)) from error
call.refresh_from_db()
return serialize_call(
ToolCall.objects.select_related("tool", "confirmation").get(id=call.id)
)
@router.patch("/{tool_id}", response=UnifiedToolOut)
def update_tool(request, tool_id: int, payload: UnifiedToolUpdateIn):
require_permission(request, "tool.manage")
tool = UnifiedTool.objects.filter(id=tool_id).first()
if tool is None:
raise HttpError(404, "工具不存在")
values = payload.dict(exclude_unset=True)
if "risk_level" in values and values["risk_level"] not in {
"read_only",
"low_write",
"high_risk",
}:
raise HttpError(400, "未知工具风险等级")
for field, value in values.items():
setattr(tool, field, value)
tool.save()
return tool

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ToolsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.tools"

View File

@@ -0,0 +1,137 @@
import json
import secrets
from django.conf import settings
from django.db.models import Q
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from apps.tickets.models import Ticket, TicketStatus
TICKET_SEARCH_TOOL = {
"name": "search_tickets",
"title": "内部工单查询",
"description": "按编号、标题或问题描述查询未归档工单,可选状态过滤。",
"inputSchema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "工单编号、标题或问题关键词",
},
"status": {
"type": "string",
"enum": [value for value, _label in TicketStatus.choices],
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 20,
},
},
},
}
def _rpc_error(rpc_id, code: int, message: str, *, status: int = 200):
return JsonResponse(
{
"jsonrpc": "2.0",
"id": rpc_id,
"error": {"code": code, "message": message},
},
status=status,
)
def _authorized(request) -> bool:
expected = settings.INTERNAL_MCP_TOKEN
authorization = request.headers.get("Authorization", "")
if not expected or not authorization.startswith("Bearer "):
return False
return secrets.compare_digest(authorization.removeprefix("Bearer "), expected)
def _search_tickets(arguments: dict) -> dict:
query = str(arguments.get("query") or "").strip()
status = str(arguments.get("status") or "").strip()
try:
limit = min(max(int(arguments.get("limit", 10)), 1), 20)
except (TypeError, ValueError) as error:
raise ValueError("limit 必须是 1 到 20 的整数") from error
if status and status not in TicketStatus.values:
raise ValueError("status 不是有效工单状态")
queryset = Ticket.objects.select_related("category").filter(archived_at__isnull=True)
if query:
queryset = queryset.filter(
Q(ticket_no__icontains=query)
| Q(title__icontains=query)
| Q(description__icontains=query)
)
if status:
queryset = queryset.filter(status=status)
tickets = [
{
"id": ticket.id,
"ticket_no": ticket.ticket_no,
"title": ticket.title,
"description": ticket.description[:500],
"status": ticket.status,
"priority": ticket.priority,
"category_code": ticket.category.code if ticket.category else None,
"updated_at": ticket.updated_at.isoformat(),
}
for ticket in queryset[:limit]
]
return {
"query": query,
"status": status or None,
"count": len(tickets),
"tickets": tickets,
}
@csrf_exempt
def internal_mcp_view(request):
if request.method != "POST":
return _rpc_error(None, -32600, "仅支持 POST", status=405)
if not settings.INTERNAL_MCP_TOKEN:
return _rpc_error(None, -32001, "内部 MCP 尚未配置访问令牌", status=503)
if not _authorized(request):
return _rpc_error(None, -32000, "未授权的内部 MCP 请求", status=401)
try:
payload = json.loads(request.body)
except (json.JSONDecodeError, UnicodeDecodeError):
return _rpc_error(None, -32700, "JSON 解析失败", status=400)
if not isinstance(payload, dict):
return _rpc_error(None, -32600, "JSON-RPC 请求结构无效", status=400)
rpc_id = payload.get("id")
method = payload.get("method")
params = payload.get("params")
if not isinstance(params, dict):
return _rpc_error(rpc_id, -32602, "params 必须是对象")
if method == "tools/list":
result = {"tools": [TICKET_SEARCH_TOOL]}
elif method == "tools/call":
if params.get("name") != TICKET_SEARCH_TOOL["name"]:
return _rpc_error(rpc_id, -32601, "工具不存在")
arguments = params.get("arguments", {})
if not isinstance(arguments, dict):
return _rpc_error(rpc_id, -32602, "arguments 必须是对象")
try:
structured = _search_tickets(arguments)
except ValueError as error:
return _rpc_error(rpc_id, -32602, str(error))
result = {
"content": [
{
"type": "text",
"text": json.dumps(structured, ensure_ascii=False),
}
],
"structuredContent": structured,
"isError": False,
}
else:
return _rpc_error(rpc_id, -32601, "不支持的 MCP 方法")
return JsonResponse({"jsonrpc": "2.0", "id": rpc_id, "result": result})

View File

@@ -0,0 +1,117 @@
# Generated by Django 5.2.15 on 2026-07-27 05:53
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('agents', '0005_agentversion_allowed_actions'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ToolCall',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('status', models.CharField(choices=[('pending_confirmation', '待确认'), ('running', '执行中'), ('succeeded', '成功'), ('failed', '失败'), ('rejected', '已拒绝')], max_length=32)),
('input_payload', models.JSONField(default=dict)),
('output_payload', models.JSONField(blank=True, null=True)),
('authorization_snapshot', models.JSONField(default=dict)),
('risk_snapshot', models.JSONField(default=dict)),
('idempotency_key', models.CharField(blank=True, max_length=160)),
('error_code', models.CharField(blank=True, max_length=80)),
('error_message', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('started_at', models.DateTimeField(blank=True, null=True)),
('finished_at', models.DateTimeField(blank=True, null=True)),
('agent_run', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tool_calls', to='agents.agentrun')),
('requested_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='requested_tool_calls', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='ToolConfirmation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(default='pending', max_length=24)),
('reason', models.TextField(blank=True)),
('decided_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('call', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='confirmation', to='tools.toolcall')),
('decided_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='decided_tool_confirmations', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='ToolProvider',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=120, unique=True)),
('provider_type', models.CharField(choices=[('native', '原生工具'), ('mcp_http', 'HTTP MCP')], default='mcp_http', max_length=24)),
('base_url', models.URLField(blank=True, max_length=500)),
('token_encrypted', models.TextField(blank=True)),
('enabled', models.BooleanField(default=True)),
('timeout_seconds', models.PositiveIntegerField(default=30)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_tool_providers', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='UnifiedTool',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=160, unique=True)),
('external_name', models.CharField(blank=True, max_length=200)),
('name', models.CharField(max_length=160)),
('description', models.TextField(blank=True)),
('input_schema', models.JSONField(default=dict)),
('risk_level', models.CharField(choices=[('read_only', '只读'), ('low_write', '低风险写'), ('high_risk', '高风险')], default='read_only', max_length=24)),
('enabled', models.BooleanField(default=True)),
('synced_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('provider', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='tools', to='tools.toolprovider')),
],
options={
'ordering': ['code'],
},
),
migrations.AddField(
model_name='toolcall',
name='tool',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='calls', to='tools.unifiedtool'),
),
migrations.CreateModel(
name='ToolAuthorization',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('principal_type', models.CharField(max_length=24)),
('principal_id', models.PositiveBigIntegerField()),
('allowed', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_tool_authorizations', to=settings.AUTH_USER_MODEL)),
('tool', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='authorizations', to='tools.unifiedtool')),
],
),
migrations.AddConstraint(
model_name='toolcall',
constraint=models.UniqueConstraint(condition=models.Q(('idempotency_key__gt', '')), fields=('tool', 'idempotency_key'), name='unique_tool_call_idempotency_key'),
),
migrations.AddConstraint(
model_name='toolauthorization',
constraint=models.UniqueConstraint(fields=('tool', 'principal_type', 'principal_id'), name='unique_tool_authorization_principal'),
),
]

View File

@@ -0,0 +1,161 @@
import uuid
from django.conf import settings
from django.db import models
class ToolProviderType(models.TextChoices):
NATIVE = "native", "原生工具"
MCP_HTTP = "mcp_http", "HTTP MCP"
class ToolRiskLevel(models.TextChoices):
READ_ONLY = "read_only", "只读"
LOW_WRITE = "low_write", "低风险写"
HIGH_RISK = "high_risk", "高风险"
class ToolProvider(models.Model):
name = models.CharField(max_length=120, unique=True)
provider_type = models.CharField(
max_length=24,
choices=ToolProviderType.choices,
default=ToolProviderType.MCP_HTTP,
)
base_url = models.URLField(max_length=500, blank=True)
token_encrypted = models.TextField(blank=True)
enabled = models.BooleanField(default=True)
timeout_seconds = models.PositiveIntegerField(default=30)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_tool_providers",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["name"]
class UnifiedTool(models.Model):
code = models.CharField(max_length=160, unique=True)
provider = models.ForeignKey(
ToolProvider,
null=True,
blank=True,
on_delete=models.PROTECT,
related_name="tools",
)
external_name = models.CharField(max_length=200, blank=True)
name = models.CharField(max_length=160)
description = models.TextField(blank=True)
input_schema = models.JSONField(default=dict)
risk_level = models.CharField(
max_length=24,
choices=ToolRiskLevel.choices,
default=ToolRiskLevel.READ_ONLY,
)
enabled = models.BooleanField(default=True)
synced_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["code"]
class ToolAuthorization(models.Model):
tool = models.ForeignKey(
UnifiedTool,
on_delete=models.CASCADE,
related_name="authorizations",
)
principal_type = models.CharField(max_length=24)
principal_id = models.PositiveBigIntegerField()
allowed = models.BooleanField(default=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="created_tool_authorizations",
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["tool", "principal_type", "principal_id"],
name="unique_tool_authorization_principal",
)
]
class ToolCallStatus(models.TextChoices):
PENDING_CONFIRMATION = "pending_confirmation", "待确认"
RUNNING = "running", "执行中"
SUCCEEDED = "succeeded", "成功"
FAILED = "failed", "失败"
REJECTED = "rejected", "已拒绝"
class ToolCall(models.Model):
public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
tool = models.ForeignKey(UnifiedTool, on_delete=models.PROTECT, related_name="calls")
agent_run = models.ForeignKey(
"agents.AgentRun",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="tool_calls",
)
requested_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="requested_tool_calls",
)
status = models.CharField(max_length=32, choices=ToolCallStatus.choices)
input_payload = models.JSONField(default=dict)
output_payload = models.JSONField(null=True, blank=True)
authorization_snapshot = models.JSONField(default=dict)
risk_snapshot = models.JSONField(default=dict)
idempotency_key = models.CharField(max_length=160, blank=True)
error_code = models.CharField(max_length=80, blank=True)
error_message = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
started_at = models.DateTimeField(null=True, blank=True)
finished_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ["-created_at"]
constraints = [
models.UniqueConstraint(
fields=["tool", "idempotency_key"],
condition=models.Q(idempotency_key__gt=""),
name="unique_tool_call_idempotency_key",
)
]
class ToolConfirmation(models.Model):
call = models.OneToOneField(
ToolCall,
on_delete=models.CASCADE,
related_name="confirmation",
)
status = models.CharField(max_length=24, default="pending")
reason = models.TextField(blank=True)
decided_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="decided_tool_confirmations",
)
decided_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)

View File

@@ -0,0 +1,99 @@
from datetime import datetime
from typing import Any
from uuid import UUID
from ninja import Schema
class ToolProviderIn(Schema):
name: str
provider_type: str = "mcp_http"
base_url: str = ""
token: str = ""
enabled: bool = True
timeout_seconds: int = 30
class ToolProviderOut(Schema):
id: int
name: str
provider_type: str
base_url: str
credential_set: bool
enabled: bool
timeout_seconds: int
created_at: datetime
updated_at: datetime
class ToolProviderUpdateIn(Schema):
name: str | None = None
base_url: str | None = None
token: str | None = None
enabled: bool | None = None
timeout_seconds: int | None = None
class UnifiedToolOut(Schema):
id: int
code: str
provider_id: int | None
external_name: str
name: str
description: str
input_schema: dict[str, Any]
risk_level: str
enabled: bool
synced_at: datetime | None
class UnifiedToolUpdateIn(Schema):
name: str | None = None
description: str | None = None
risk_level: str | None = None
enabled: bool | None = None
class ToolAuthorizationIn(Schema):
principal_type: str
principal_id: int
allowed: bool = True
class ToolAuthorizationOut(Schema):
id: int
tool_id: int
principal_type: str
principal_id: int
allowed: bool
class ToolCallIn(Schema):
tool_code: str
input: dict[str, Any]
agent_run_id: UUID | None = None
idempotency_key: str = ""
class ToolCallOut(Schema):
public_id: UUID
tool_code: str
requested_by_id: int | None
requested_by_name: str
status: str
input_payload: dict[str, Any]
output_payload: Any | None
authorization_snapshot: dict[str, Any]
risk_snapshot: dict[str, Any]
error_code: str
error_message: str
confirmation_status: str | None
confirmation_reason: str
created_at: datetime
started_at: datetime | None
finished_at: datetime | None
class ToolConfirmationIn(Schema):
decision: str
reason: str = ""

View File

@@ -0,0 +1,284 @@
"""统一 Tool/MCP 同步、授权、风险确认和调用审计服务。"""
import json
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
from urllib.request import Request, urlopen
from django.db import transaction
from django.utils import timezone
from apps.agents.models import AgentRun
from apps.audit.models import OperationAuditLog
from apps.modelhub.crypto import decrypt_secret
from apps.skills.services import validate_payload
from apps.tools.models import (
ToolAuthorization,
ToolCall,
ToolCallStatus,
ToolConfirmation,
ToolProvider,
ToolProviderType,
ToolRiskLevel,
UnifiedTool,
)
class ToolExecutionError(ValueError):
pass
def _validate_http_url(value: str) -> None:
parsed = urlparse(value)
if parsed.scheme not in {"http", "https"} or not parsed.netloc or parsed.username:
raise ToolExecutionError("MCP 地址必须是无内嵌凭证的 HTTP(S) URL")
def _json_rpc(provider: ToolProvider, method: str, params: dict) -> dict:
_validate_http_url(provider.base_url)
headers = {"Content-Type": "application/json", "Accept": "application/json"}
if provider.token_encrypted:
headers["Authorization"] = f"Bearer {decrypt_secret(provider.token_encrypted)}"
payload = json.dumps(
{"jsonrpc": "2.0", "id": "ai-ticket-platform", "method": method, "params": params}
).encode()
request = Request(provider.base_url, data=payload, headers=headers, method="POST")
try:
with urlopen(request, timeout=provider.timeout_seconds) as response:
result = json.loads(response.read().decode("utf-8"))
except (HTTPError, URLError, TimeoutError, json.JSONDecodeError) as error:
raise ToolExecutionError("HTTP MCP 调用失败") from error
if not isinstance(result, dict) or result.get("error"):
raise ToolExecutionError("HTTP MCP 返回错误")
rpc_result = result.get("result")
if not isinstance(rpc_result, dict):
raise ToolExecutionError("HTTP MCP 返回结构无效")
return rpc_result
@transaction.atomic
def sync_mcp_tools(provider: ToolProvider) -> list[UnifiedTool]:
"""从 HTTP MCP 同步工具定义,但保留本地风险等级、开关和授权治理。"""
provider = ToolProvider.objects.select_for_update().get(id=provider.id)
if not provider.enabled or provider.provider_type != ToolProviderType.MCP_HTTP:
raise ToolExecutionError("MCP 服务未启用或类型不正确")
result = _json_rpc(provider, "tools/list", {})
remote_tools = result.get("tools")
if not isinstance(remote_tools, list):
raise ToolExecutionError("MCP tools/list 未返回工具列表")
synced = []
now = timezone.now()
for item in remote_tools:
if not isinstance(item, dict) or not str(item.get("name") or "").strip():
continue
external_name = str(item["name"]).strip()
code = f"mcp.{provider.id}.{external_name}"
tool, _ = UnifiedTool.objects.update_or_create(
code=code,
defaults={
"provider": provider,
"external_name": external_name,
"name": str(item.get("title") or external_name),
"description": str(item.get("description") or ""),
"input_schema": item.get("inputSchema")
if isinstance(item.get("inputSchema"), dict)
else {"type": "object"},
"synced_at": now,
},
)
synced.append(tool)
OperationAuditLog.objects.create(
module="tools",
action="mcp_tools_synced",
target_type="tool_provider",
target_id=str(provider.id),
request_snapshot={"tool_count": len(synced)},
result="success",
)
return synced
def _authorization_snapshot(tool: UnifiedTool, user, run: AgentRun | None) -> dict:
"""计算并冻结用户、用户组、Agent 动作与 Agent 授权的交集。"""
if not user or not user.is_authenticated:
raise ToolExecutionError("工具调用需要登录用户")
if not (user.is_superuser or user.has_perm("system.tool.invoke")):
raise ToolExecutionError("用户没有工具调用权限")
authorizations = ToolAuthorization.objects.filter(tool=tool)
group_ids = user.groups.values_list("id", flat=True)
explicitly_denied = authorizations.filter(
allowed=False,
principal_type="user",
principal_id=user.id,
).exists() or authorizations.filter(
allowed=False,
principal_type="group",
principal_id__in=group_ids,
).exists()
grants = authorizations.filter(allowed=True)
user_allowed = not explicitly_denied and (
user.is_superuser or grants.filter(
principal_type="user", principal_id=user.id
).exists() or grants.filter(
principal_type="group", principal_id__in=group_ids
).exists())
if not user_allowed:
raise ToolExecutionError("用户或用户组未获得该工具授权")
agent_allowed = True
agent_grant = True
if run is not None:
action = f"tool.call:{tool.code}"
agent_allowed = action in run.config_snapshot.get("agent", {}).get(
"allowed_actions", []
)
agent_grant = grants.filter(
principal_type="agent",
principal_id=run.definition_id,
).exists()
if not agent_allowed or not agent_grant:
raise ToolExecutionError("Agent 版本或 Agent 授权未允许该工具")
return {
"user_id": user.id,
"user_permission": True,
"principal_grant": user_allowed,
"agent_run_id": str(run.public_id) if run else None,
"agent_action_allowed": agent_allowed,
"agent_grant": agent_grant,
}
def execute_tool_call(call: ToolCall) -> ToolCall:
"""执行已通过授权和风险确认的工具调用,并持久化终态审计。"""
call = ToolCall.objects.select_related("tool__provider").get(id=call.id)
if call.status not in {ToolCallStatus.RUNNING, ToolCallStatus.PENDING_CONFIRMATION}:
return call
call.status = ToolCallStatus.RUNNING
call.started_at = timezone.now()
call.save(update_fields=["status", "started_at"])
try:
if not call.tool.provider_id:
raise ToolExecutionError("原生工具尚未注册执行器")
result = _json_rpc(
call.tool.provider,
"tools/call",
{"name": call.tool.external_name, "arguments": call.input_payload},
)
except Exception as error:
call.status = ToolCallStatus.FAILED
call.error_code = "tool_call_failed"
call.error_message = str(error)
else:
call.status = ToolCallStatus.SUCCEEDED
call.output_payload = result
call.finished_at = timezone.now()
call.save()
OperationAuditLog.objects.create(
user=call.requested_by,
module="tools",
action="tool_call_finished",
target_type="tool_call",
target_id=str(call.public_id),
request_snapshot={
"tool_code": call.tool.code,
"status": call.status,
"risk_level": call.tool.risk_level,
"agent_run_id": str(call.agent_run.public_id) if call.agent_run_id else None,
},
result="success" if call.status == ToolCallStatus.SUCCEEDED else "failed",
)
return call
@transaction.atomic
def request_tool_call(
tool: UnifiedTool,
payload: dict,
*,
user,
run: AgentRun | None = None,
idempotency_key: str = "",
) -> ToolCall:
"""创建幂等工具调用;高风险调用先进入独立人工确认,不直接执行。"""
tool = UnifiedTool.objects.select_for_update().select_related("provider").get(id=tool.id)
if not tool.enabled or (tool.provider_id and not tool.provider.enabled):
raise ToolExecutionError("工具或工具服务未启用")
if idempotency_key:
existing = ToolCall.objects.filter(tool=tool, idempotency_key=idempotency_key).first()
if existing is not None:
return existing
validate_payload(tool.input_schema, payload, "工具输入")
authorization = _authorization_snapshot(tool, user, run)
needs_confirmation = tool.risk_level == ToolRiskLevel.HIGH_RISK
call_values = {
"agent_run": run,
"requested_by": user,
"status": (
ToolCallStatus.PENDING_CONFIRMATION
if needs_confirmation
else ToolCallStatus.RUNNING
),
"input_payload": payload,
"authorization_snapshot": authorization,
"risk_snapshot": {
"risk_level": tool.risk_level,
"confirmation_required": needs_confirmation,
},
}
normalized_key = idempotency_key.strip()
if normalized_key:
call, created = ToolCall.objects.get_or_create(
tool=tool,
idempotency_key=normalized_key,
defaults=call_values,
)
if not created:
return call
else:
call = ToolCall.objects.create(
tool=tool,
idempotency_key="",
**call_values,
)
if needs_confirmation:
ToolConfirmation.objects.create(call=call)
from apps.workitems.services import sync_tool_confirmation_work_item
sync_tool_confirmation_work_item(call, actor=user)
return call
transaction.on_commit(lambda: execute_tool_call(call))
return call
@transaction.atomic
def decide_tool_confirmation(call: ToolCall, *, reviewer, decision: str, reason: str = ""):
"""由非申请人审批高风险调用,并仅在事务提交后开始执行。"""
call = ToolCall.objects.select_for_update().select_related("confirmation").get(id=call.id)
if call.status != ToolCallStatus.PENDING_CONFIRMATION:
raise ToolExecutionError("工具调用不在待确认状态")
if call.requested_by_id == reviewer.id:
raise ToolExecutionError("高风险工具调用不能由申请人自审")
if decision not in {"approve", "reject"}:
raise ToolExecutionError("确认决定必须是 approve 或 reject")
confirmation = call.confirmation
confirmation.status = "approved" if decision == "approve" else "rejected"
confirmation.reason = reason.strip()
confirmation.decided_by = reviewer
confirmation.decided_at = timezone.now()
confirmation.save()
if decision == "reject":
call.status = ToolCallStatus.REJECTED
call.finished_at = timezone.now()
call.save(update_fields=["status", "finished_at"])
else:
call.status = ToolCallStatus.RUNNING
call.save(update_fields=["status"])
transaction.on_commit(lambda: execute_tool_call(call))
from apps.workitems.services import sync_tool_confirmation_work_item
sync_tool_confirmation_work_item(call, actor=reviewer)
return call

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,354 @@
import json
import pytest
from django.contrib.auth import get_user_model
from django.db import transaction
from django.test import override_settings
from apps.agents.models import AgentRun
from apps.agents.tests.helpers import create_agent
from apps.audit.models import OperationAuditLog
from apps.modelhub.crypto import encrypt_secret
from apps.tickets.models import Ticket
from apps.tools.models import (
ToolAuthorization,
ToolCall,
ToolCallStatus,
ToolProvider,
ToolRiskLevel,
UnifiedTool,
)
from apps.tools.services import (
ToolExecutionError,
decide_tool_confirmation,
request_tool_call,
sync_mcp_tools,
)
from apps.workitems.models import WorkItem, WorkItemType
class FakeResponse:
def __init__(self, payload):
self.payload = payload
def __enter__(self):
return self
def __exit__(self, *args):
return None
def read(self):
return json.dumps(self.payload).encode()
@pytest.mark.django_db
def test_http_mcp_sync_and_read_only_call_are_audited_and_idempotent(
django_capture_on_commit_callbacks,
monkeypatch,
):
responses = iter(
[
{
"jsonrpc": "2.0",
"id": "ai-ticket-platform",
"result": {
"tools": [
{
"name": "lookup_customer",
"description": "只读查询客户状态",
"inputSchema": {
"type": "object",
"properties": {"customer_id": {"type": "string"}},
"required": ["customer_id"],
},
}
]
},
},
{
"jsonrpc": "2.0",
"id": "ai-ticket-platform",
"result": {"content": [{"type": "text", "text": "active"}]},
},
]
)
monkeypatch.setattr(
"apps.tools.services.urlopen",
lambda *args, **kwargs: FakeResponse(next(responses)),
)
provider = ToolProvider.objects.create(
name="内部查询 MCP",
base_url="https://mcp.internal.example/rpc",
)
tools = sync_mcp_tools(provider)
assert len(tools) == 1
tool = tools[0]
assert tool.code == f"mcp.{provider.id}.lookup_customer"
actor = get_user_model().objects.create_superuser(
username="tool-admin",
email="tool-admin@example.com",
password="password",
)
with django_capture_on_commit_callbacks(execute=True):
first = request_tool_call(
tool,
{"customer_id": "C-001"},
user=actor,
idempotency_key="lookup-C-001",
)
first.refresh_from_db()
second = request_tool_call(
tool,
{"customer_id": "C-001"},
user=actor,
idempotency_key="lookup-C-001",
)
assert first.status == ToolCallStatus.SUCCEEDED
assert first.output_payload["content"][0]["text"] == "active"
assert second.id == first.id
assert ToolCall.objects.count() == 1
assert OperationAuditLog.objects.filter(
module="tools",
action="tool_call_finished",
target_id=str(first.public_id),
).exists()
@pytest.mark.django_db
def test_high_risk_tool_requires_independent_confirmation(
django_capture_on_commit_callbacks,
monkeypatch,
):
monkeypatch.setattr(
"apps.tools.services.urlopen",
lambda *args, **kwargs: FakeResponse(
{
"jsonrpc": "2.0",
"id": "ai-ticket-platform",
"result": {"content": [{"type": "text", "text": "done"}]},
}
),
)
provider = ToolProvider.objects.create(
name="变更 MCP",
base_url="https://mcp.internal.example/rpc",
)
tool = UnifiedTool.objects.create(
code="mcp.change.restart",
provider=provider,
external_name="restart_service",
name="重启服务",
input_schema={"type": "object"},
risk_level=ToolRiskLevel.HIGH_RISK,
)
requester = get_user_model().objects.create_superuser(
username="tool-requester",
email="requester@example.com",
password="password",
)
reviewer = get_user_model().objects.create_superuser(
username="tool-reviewer",
email="reviewer@example.com",
password="password",
)
call = request_tool_call(tool, {}, user=requester)
assert call.status == ToolCallStatus.PENDING_CONFIRMATION
assert call.confirmation.status == "pending"
task = WorkItem.objects.get(task_type=WorkItemType.TOOL_CONFIRMATION)
assert task.status == "active"
with pytest.raises(ToolExecutionError, match="不能由申请人自审"):
decide_tool_confirmation(call, reviewer=requester, decision="approve")
with django_capture_on_commit_callbacks(execute=True):
decide_tool_confirmation(
call,
reviewer=reviewer,
decision="approve",
reason="已核对变更窗口",
)
call.refresh_from_db()
call.confirmation.refresh_from_db()
assert call.status == ToolCallStatus.SUCCEEDED
assert call.confirmation.status == "approved"
assert call.confirmation.decided_by == reviewer
task.refresh_from_db()
assert task.status == "completed"
@pytest.mark.django_db
def test_tool_api_hides_credentials_and_blocks_unprivileged_invocation(
admin_client,
client,
):
response = admin_client.post(
"/api/tools/providers",
{
"name": "受控 MCP",
"provider_type": "mcp_http",
"base_url": "https://mcp.internal.example/rpc",
"token": "top-secret-token",
},
content_type="application/json",
)
assert response.status_code == 200
assert response.json()["credential_set"] is True
assert "token" not in response.json()
user = get_user_model().objects.create_user(
username="unprivileged-tool-user",
password="password",
)
client.force_login(user)
response = client.post(
"/api/tools/calls",
{"tool_code": "missing", "input": {}},
content_type="application/json",
)
assert response.status_code == 403
@pytest.mark.django_db
def test_tool_governance_api_lists_grants_and_calls(admin_client):
provider = ToolProvider.objects.create(
name="治理 MCP",
base_url="https://mcp.internal.example/rpc",
)
tool = UnifiedTool.objects.create(
code="mcp.governance.lookup",
provider=provider,
external_name="lookup",
name="治理查询",
input_schema={"type": "object"},
)
response = admin_client.post(
f"/api/tools/{tool.id}/authorizations",
{"principal_type": "group", "principal_id": 7, "allowed": True},
content_type="application/json",
)
assert response.status_code == 200
response = admin_client.get(f"/api/tools/{tool.id}/authorizations")
assert response.status_code == 200
assert response.json()[0]["principal_type"] == "group"
response = admin_client.patch(
f"/api/tools/providers/{provider.id}",
{"timeout_seconds": 45, "enabled": False},
content_type="application/json",
)
assert response.status_code == 200
assert response.json()["timeout_seconds"] == 45
assert response.json()["enabled"] is False
actor = get_user_model().objects.filter(is_superuser=True).first()
call = ToolCall.objects.create(
tool=tool,
requested_by=actor,
status=ToolCallStatus.SUCCEEDED,
input_payload={"query": "status"},
output_payload={"status": "ok"},
)
response = admin_client.get("/api/tools/calls")
assert response.status_code == 200
payload = response.json()
assert payload[0]["public_id"] == str(call.public_id)
assert payload[0]["requested_by_id"] == actor.id
@pytest.mark.django_db
@override_settings(INTERNAL_MCP_TOKEN="internal-ticket-token")
def test_internal_ticket_mcp_requires_bearer_and_queries_real_tickets(client):
Ticket.objects.create(
ticket_no="TK-MCP-001",
title="客户无法登录控制台",
description="登录后立即返回认证失败",
status="pending",
)
rpc = {
"jsonrpc": "2.0",
"id": "gateway-test",
"method": "tools/call",
"params": {
"name": "search_tickets",
"arguments": {"query": "登录", "limit": 5},
},
}
unauthorized = client.post(
"/internal-mcp",
rpc,
content_type="application/json",
)
assert unauthorized.status_code == 401
response = client.post(
"/internal-mcp",
rpc,
content_type="application/json",
HTTP_AUTHORIZATION="Bearer internal-ticket-token",
)
assert response.status_code == 200
structured = response.json()["result"]["structuredContent"]
assert structured["count"] == 1
assert structured["tickets"][0]["ticket_no"] == "TK-MCP-001"
@pytest.mark.django_db(transaction=True)
@override_settings(INTERNAL_MCP_TOKEN="real-http-ticket-token")
def test_internal_ticket_mcp_real_http_agent_call_end_to_end(live_server):
ticket = Ticket.objects.create(
ticket_no="TK-MCP-E2E",
title="生产订单同步延迟",
description="订单同步任务积压,需要查询同类工单",
status="in_progress",
)
actor = get_user_model().objects.create_superuser(
username="mcp-e2e-user",
email="mcp-e2e@example.com",
password="password",
)
definition, version = create_agent(published=True, two_steps=False)
provider = ToolProvider.objects.create(
name="平台内部工单 MCP",
base_url=f"{live_server.url}/internal-mcp",
token_encrypted=encrypt_secret("real-http-ticket-token"),
)
tools = sync_mcp_tools(provider)
assert len(tools) == 1
tool = tools[0]
assert tool.external_name == "search_tickets"
version.allowed_actions = [f"tool.call:{tool.code}"]
version.save(update_fields=["allowed_actions"])
run = AgentRun.objects.create(
definition=definition,
agent_version=version,
created_by=actor,
config_snapshot={
"agent": {"allowed_actions": [f"tool.call:{tool.code}"]}
},
)
ToolAuthorization.objects.create(
tool=tool,
principal_type="agent",
principal_id=definition.id,
allowed=True,
)
with transaction.atomic():
call = request_tool_call(
tool,
{"query": "订单同步", "status": "in_progress"},
user=actor,
run=run,
idempotency_key="internal-ticket-mcp-e2e",
)
call.refresh_from_db()
assert call.status == ToolCallStatus.SUCCEEDED
structured = call.output_payload["structuredContent"]
assert structured["count"] == 1
assert structured["tickets"][0]["id"] == ticket.id
assert call.authorization_snapshot["agent_grant"] is True
assert call.risk_snapshot["risk_level"] == ToolRiskLevel.READ_ONLY

View File

@@ -0,0 +1,261 @@
import * as React from "react"
import { ContextMenu as ContextMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuTrigger({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger
data-slot="context-menu-trigger"
className={cn("select-none", className)}
{...props}
/>
)
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn("z-50 max-h-(--radix-context-menu-content-available-height) min-w-36 origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/context-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn("z-50 min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-lg border bg-popover p-1 text-popover-foreground shadow-lg duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute right-2">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/context-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}

View File

@@ -13,6 +13,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { FieldGroup } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea'
import { JsonEditor, parseJsonObject } from '@/features/ai-platform/components/JsonEditor'
@@ -28,7 +29,12 @@ import { formatDateTime } from '@/lib/format'
import { ensureAgentDraft, getAgent, publishAgent, rollbackAgent, startAgentRun, updateAgent, updateAgentDraft } from './api'
import type { AgentDetail, AgentVersion } from './types'
type EditableStep = { code: string; skill_version_id: string; mapping: string }
type EditableStep = {
code: string
skill_version_id: string
model_deployment_id: string
mapping: string
}
type SkillOption = SkillVersion & { definitionName: string }
export function AgentDetailPage({ agentId }: { agentId: number }) {
@@ -41,6 +47,7 @@ export function AgentDetailPage({ agentId }: { agentId: number }) {
const [systemPrompt, setSystemPrompt] = React.useState('')
const [temperature, setTemperature] = React.useState('')
const [maxTokens, setMaxTokens] = React.useState('')
const [allowTicketResolve, setAllowTicketResolve] = React.useState(false)
const [inputSchema, setInputSchema] = React.useState('{}')
const [exampleInput, setExampleInput] = React.useState('{}')
const [steps, setSteps] = React.useState<EditableStep[]>([])
@@ -54,8 +61,14 @@ export function AgentDetailPage({ agentId }: { agentId: number }) {
setModelId(String(version.model_deployment_id)); setSystemPrompt(version.system_prompt)
setTemperature(version.temperature_override === null ? '' : String(version.temperature_override))
setMaxTokens(version.max_output_tokens_override === null ? '' : String(version.max_output_tokens_override))
setAllowTicketResolve(version.allowed_actions.includes('ticket.resolve'))
setInputSchema(JSON.stringify(version.input_schema, null, 2)); setExampleInput(JSON.stringify(version.example_input, null, 2)); setTestInput(JSON.stringify(version.example_input, null, 2))
setSteps(version.steps.map((step) => ({ code: step.code, skill_version_id: String(step.skill_version_id), mapping: JSON.stringify(step.input_mapping, null, 2) })))
setSteps(version.steps.map((step) => ({
code: step.code,
skill_version_id: String(step.skill_version_id),
model_deployment_id: step.model_deployment_id === null ? '' : String(step.model_deployment_id),
mapping: JSON.stringify(step.input_mapping, null, 2),
})))
}, [])
const load = React.useCallback(async () => {
@@ -78,7 +91,7 @@ export function AgentDetailPage({ agentId }: { agentId: number }) {
try {
await Promise.all([
updateAgent(agentId, meta),
updateAgentDraft(agentId, { model_deployment_id: Number(modelId), system_prompt: systemPrompt, input_schema: parseJsonObject(inputSchema, 'Agent 输入 Schema'), example_input: parseJsonObject(exampleInput, '示例输入'), temperature_override: temperature === '' ? null : Number(temperature), max_output_tokens_override: maxTokens === '' ? null : Number(maxTokens), steps: steps.map((step) => ({ code: step.code, skill_version_id: Number(step.skill_version_id), input_mapping: parseJsonObject(step.mapping, `步骤 ${step.code} 输入映射`) })) })
updateAgentDraft(agentId, { model_deployment_id: Number(modelId), system_prompt: systemPrompt, input_schema: parseJsonObject(inputSchema, 'Agent 输入 Schema'), example_input: parseJsonObject(exampleInput, '示例输入'), temperature_override: temperature === '' ? null : Number(temperature), max_output_tokens_override: maxTokens === '' ? null : Number(maxTokens), allowed_actions: allowTicketResolve ? ['ticket.resolve'] : [], steps: steps.map((step) => ({ code: step.code, skill_version_id: Number(step.skill_version_id), model_deployment_id: step.model_deployment_id === '' ? null : Number(step.model_deployment_id), input_mapping: parseJsonObject(step.mapping, `步骤 ${step.code} 输入映射`) })) })
])
toast.success('Agent 草稿已保存'); await load()
} catch (reason) { toast.error(reason instanceof Error ? reason.message : '保存失败') }
@@ -92,11 +105,11 @@ export function AgentDetailPage({ agentId }: { agentId: number }) {
if (!detail) return <AppPage header={<AppPageHeader title="Agent 详情" />}><div className="h-64 animate-pulse rounded-md bg-muted" /></AppPage>
return <AppPage header={<AppPageHeader title={detail.name} breadcrumbLabel={detail.code} description={`${detail.code} · 有序步骤执行,任一步失败后立即终止。`} meta={detail.active_version ? <Badge variant="success">当前 v{detail.active_version}</Badge> : <Badge variant="secondary">尚未发布</Badge>} actions={<>{draft ? <><Button variant="outline" onClick={() => void save()}><Save data-icon="inline-start" />保存草稿</Button><Button onClick={() => void publish()}><Send data-icon="inline-start" />发布 v{draft.version}</Button></> : <Button onClick={() => void createDraft()}><CopyPlus data-icon="inline-start" />创建新草稿</Button>}</>} />}>
<Tabs defaultValue="basic"><TabsList variant="line" className="max-w-full overflow-x-auto"><TabsTrigger value="basic">基础与模型</TabsTrigger><TabsTrigger value="steps">步骤</TabsTrigger><TabsTrigger value="schema">输入 Schema</TabsTrigger><TabsTrigger value="test">测试</TabsTrigger><TabsTrigger value="versions">版本</TabsTrigger></TabsList>
<TabsContent value="basic" className="pt-4"><MobileEditGuard>{draft ? <div className="grid gap-5"><FieldGroup className="grid gap-3 md:grid-cols-2"><AppField label="名称"><Input value={meta.name} onChange={(e) => setMeta({ ...meta, name: e.target.value })} /></AppField><AppField label="描述"><Input value={meta.description} onChange={(e) => setMeta({ ...meta, description: e.target.value })} /></AppField><AppField label="模型"><Select value={modelId} onValueChange={setModelId}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent>{deployments.map((item) => <SelectItem key={item.id} value={String(item.id)}>{item.display_name}</SelectItem>)}</SelectContent></Select></AppField><AppField label="Temperature 覆盖" description="留空使用模型默认值"><Input type="number" min="0" max="2" step="0.1" value={temperature} onChange={(e) => setTemperature(e.target.value)} /></AppField><AppField label="最大输出 Token 覆盖" description="留空使用模型默认值"><Input type="number" min="1" max="32768" value={maxTokens} onChange={(e) => setMaxTokens(e.target.value)} /></AppField></FieldGroup><AppField label="系统 Prompt"><Textarea className="min-h-40" value={systemPrompt} onChange={(e) => setSystemPrompt(e.target.value)} /></AppField></div> : <AppEmptyState title="无可编辑草稿" />}</MobileEditGuard></TabsContent>
<TabsContent value="steps" className="pt-4"><MobileEditGuard>{draft ? <div className="grid gap-3">{steps.map((step, index) => <Card key={`${index}-${step.code}`}><CardHeader className="flex-row items-center justify-between"><div><CardTitle className="text-sm">{index + 1}. {step.code}</CardTitle><CardDescription>输入映射后传递给 Skill</CardDescription></div><div className="flex gap-1"><Button variant="ghost" size="icon-sm" disabled={index === 0} onClick={() => moveStep(index, -1)}><ArrowUp /><span className="sr-only">上移</span></Button><Button variant="ghost" size="icon-sm" disabled={index === steps.length - 1} onClick={() => moveStep(index, 1)}><ArrowDown /><span className="sr-only">下移</span></Button><Button variant="ghost" size="icon-sm" disabled={steps.length === 1} onClick={() => setSteps((current) => current.filter((_, i) => i !== index))}><Trash2 /><span className="sr-only">删除</span></Button></div></CardHeader><CardContent className="grid gap-3 md:grid-cols-2"><AppField label="步骤代码"><Input value={step.code} onChange={(e) => updateStep(index, { code: e.target.value })} /></AppField><AppField label="Skill 版本"><Select value={step.skill_version_id} onValueChange={(value) => updateStep(index, { skill_version_id: value })}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent>{skills.map((item) => <SelectItem key={item.id} value={String(item.id)}>{item.definitionName} · v{item.version}({item.status})</SelectItem>)}</SelectContent></Select></AppField><div className="md:col-span-2"><JsonEditor id={`agent-step-mapping-${index}`} label="输入映射" value={step.mapping} onChange={(value) => updateStep(index, { mapping: value })} rows={6} /></div></CardContent></Card>)}<Button variant="outline" disabled={steps.length >= 10} onClick={() => setSteps((current) => [...current, { code: `step_${current.length + 1}`, skill_version_id: String(skills[0]?.id ?? ''), mapping: '{}' }])}><Plus data-icon="inline-start" />添加步骤</Button></div> : <AppEmptyState title="无可编辑草稿" />}</MobileEditGuard></TabsContent>
<TabsContent value="basic" className="pt-4"><MobileEditGuard>{draft ? <div className="grid gap-5"><FieldGroup className="grid gap-3 md:grid-cols-2"><AppField label="名称"><Input value={meta.name} onChange={(e) => setMeta({ ...meta, name: e.target.value })} /></AppField><AppField label="描述"><Input value={meta.description} onChange={(e) => setMeta({ ...meta, description: e.target.value })} /></AppField><AppField label="模型"><Select value={modelId} onValueChange={setModelId}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent>{deployments.map((item) => <SelectItem key={item.id} value={String(item.id)}>{item.display_name}</SelectItem>)}</SelectContent></Select></AppField><AppField label="Temperature 覆盖" description="留空使用模型默认值"><Input type="number" min="0" max="2" step="0.1" value={temperature} onChange={(e) => setTemperature(e.target.value)} /></AppField><AppField label="最大输出 Token 覆盖" description="留空使用模型默认值"><Input type="number" min="1" max="32768" value={maxTokens} onChange={(e) => setMaxTokens(e.target.value)} /></AppField></FieldGroup><Card><CardHeader className="flex-row items-start justify-between gap-4"><div className="grid gap-1"><CardTitle className="text-sm">允许自动解决工单</CardTitle><CardDescription>授予已发布版本执行 ticket.resolve 的能力。实际执行仍需同时通过全局开关、发起人权限、方案范围、风险和状态机检查。</CardDescription></div><Switch checked={allowTicketResolve} onCheckedChange={setAllowTicketResolve} aria-label="允许自动解决工单" /></CardHeader></Card><AppField label="系统 Prompt"><Textarea className="min-h-40" value={systemPrompt} onChange={(e) => setSystemPrompt(e.target.value)} /></AppField></div> : <AppEmptyState title="无可编辑草稿" />}</MobileEditGuard></TabsContent>
<TabsContent value="steps" className="pt-4"><MobileEditGuard>{draft ? <div className="grid gap-3">{steps.map((step, index) => <Card key={`${index}-${step.code}`}><CardHeader className="flex-row items-center justify-between"><div><CardTitle className="text-sm">{index + 1}. {step.code}</CardTitle><CardDescription>输入映射后传递给 Skill;模型未指定时继承 Agent 默认值</CardDescription></div><div className="flex gap-1"><Button variant="ghost" size="icon-sm" disabled={index === 0} onClick={() => moveStep(index, -1)}><ArrowUp /><span className="sr-only">上移</span></Button><Button variant="ghost" size="icon-sm" disabled={index === steps.length - 1} onClick={() => moveStep(index, 1)}><ArrowDown /><span className="sr-only">下移</span></Button><Button variant="ghost" size="icon-sm" disabled={steps.length === 1} onClick={() => setSteps((current) => current.filter((_, i) => i !== index))}><Trash2 /><span className="sr-only">删除</span></Button></div></CardHeader><CardContent className="grid gap-3 md:grid-cols-2"><AppField label="步骤代码"><Input value={step.code} onChange={(e) => updateStep(index, { code: e.target.value })} /></AppField><AppField label="Skill 版本"><Select value={step.skill_version_id} onValueChange={(value) => updateStep(index, { skill_version_id: value })}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent>{skills.map((item) => <SelectItem key={item.id} value={String(item.id)}>{item.definitionName} · v{item.version}({item.status})</SelectItem>)}</SelectContent></Select></AppField><AppField label="步骤模型" description="仅文本与视觉模型可用于 Agent 步骤"><Select value={step.model_deployment_id || 'inherit'} onValueChange={(value) => updateStep(index, { model_deployment_id: value === 'inherit' ? '' : value })}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="inherit">继承默认模型 · {deployments.find((item) => String(item.id) === modelId)?.display_name ?? draft.model_display_name}</SelectItem>{deployments.filter((item) => ['text_chat', 'vision_chat'].includes(item.capability)).map((item) => <SelectItem key={item.id} value={String(item.id)} disabled={!item.enabled}>{item.display_name} · {item.capability}{item.enabled ? '' : '(已停用)'}</SelectItem>)}</SelectContent></Select></AppField><div className="md:col-span-2"><JsonEditor id={`agent-step-mapping-${index}`} label="输入映射" value={step.mapping} onChange={(value) => updateStep(index, { mapping: value })} rows={6} /></div></CardContent></Card>)}<Button variant="outline" disabled={steps.length >= 10} onClick={() => setSteps((current) => [...current, { code: `step_${current.length + 1}`, skill_version_id: String(skills[0]?.id ?? ''), model_deployment_id: '', mapping: '{}' }])}><Plus data-icon="inline-start" />添加步骤</Button></div> : <AppEmptyState title="无可编辑草稿" />}</MobileEditGuard></TabsContent>
<TabsContent value="schema" className="pt-4"><MobileEditGuard>{draft ? <div className="grid gap-5 xl:grid-cols-2"><JsonEditor id="agent-input-schema" label="Agent 输入 JSON Schema" value={inputSchema} onChange={setInputSchema} /><JsonEditor id="agent-example-input" label="示例输入" value={exampleInput} onChange={setExampleInput} rows={10} /></div> : <AppEmptyState title="无可编辑草稿" />}</MobileEditGuard></TabsContent>
<TabsContent value="test" className="grid gap-4 pt-4"><Alert><Play /><AlertTitle>异步沙箱与发布版本测试</AlertTitle><AlertDescription>草稿运行会标记为沙箱;正式运行只能选择已发布版本。启动后跳转到运行轨迹并自动轮询。</AlertDescription></Alert><AppField label="运行版本"><Select value={testVersion} onValueChange={(value) => { setTestVersion(value); const selected = value === 'draft' ? draft : detail.versions.find((item) => String(item.version) === value); if (selected) setTestInput(JSON.stringify(selected.example_input, null, 2)) }}><SelectTrigger className="w-full md:w-80"><SelectValue /></SelectTrigger><SelectContent>{draft ? <SelectItem value="draft">草稿 v{draft.version}(沙箱)</SelectItem> : null}{detail.versions.filter((item) => item.status === 'published').map((item) => <SelectItem key={item.id} value={String(item.version)}>已发布 v{item.version}</SelectItem>)}</SelectContent></Select></AppField><SchemaInputEditor schema={(testVersion === 'draft' ? draft?.input_schema : detail.versions.find((item) => String(item.version) === testVersion)?.input_schema) ?? {}} value={testInput} onChange={setTestInput} /><div><Button onClick={() => void run()} disabled={!testVersion}><Play data-icon="inline-start" />启动异步运行</Button></div></TabsContent>
<TabsContent value="versions" className="grid gap-3 pt-4">{detail.versions.map((version) => <Card key={version.id}><CardHeader className="flex-row items-center justify-between"><div><CardTitle>版本 v{version.version}</CardTitle><CardDescription>{version.model_display_name} · {version.steps.length} 个步骤 · {version.published_at ? formatDateTime(version.published_at) : formatDateTime(version.updated_at)}</CardDescription></div><div className="flex items-center gap-2"><StatusBadge status={version.status} />{version.status === 'published' && detail.active_version !== version.version ? <Button variant="outline" size="sm" onClick={() => void rollback(version.version)}>回滚至此版本</Button> : null}</div></CardHeader></Card>)}</TabsContent>
<TabsContent value="versions" className="grid gap-3 pt-4">{detail.versions.map((version) => <Card key={version.id}><CardHeader className="flex-row items-center justify-between"><div><CardTitle>版本 v{version.version}</CardTitle><CardDescription>{version.model_display_name} · {version.steps.length} 个步骤 · {version.steps.filter((step) => step.model_deployment_id !== null).length} 个独立模型绑定 · {version.published_at ? formatDateTime(version.published_at) : formatDateTime(version.updated_at)}</CardDescription></div><div className="flex items-center gap-2"><StatusBadge status={version.status} />{version.status === 'published' && detail.active_version !== version.version ? <Button variant="outline" size="sm" onClick={() => void rollback(version.version)}>回滚至此版本</Button> : null}</div></CardHeader></Card>)}</TabsContent>
</Tabs>
</AppPage>
}

View File

@@ -45,10 +45,11 @@ export function AgentRunDetailPage({ publicId }: { publicId: string }) {
if (!run) return <AppPage header={<AppPageHeader title="运行记录详情" />}><div className="h-72 animate-pulse rounded-md bg-muted" /></AppPage>
const selected = run.steps.find((step) => step.id === selectedStepId) ?? null
return <AppPage maxWidth="7xl" header={<AppPageHeader title={run.agent_name} breadcrumbLabel={run.public_id} description={`运行 ${run.public_id} · 创建于 ${formatDateTime(run.created_at)}`} meta={<div className="flex gap-1"><StatusBadge status={run.status} />{run.sandbox ? <Badge variant="warning">草稿沙箱</Badge> : <Badge variant="secondary">发布 v{run.agent_version}</Badge>}</div>} actions={<><Button variant="outline" onClick={() => void load()}><RotateCw data-icon="inline-start" />刷新</Button>{['queued', 'running'].includes(run.status) ? <Button variant="destructive" onClick={() => void cancel()}><Ban data-icon="inline-start" />尽力取消</Button> : null}</>} />}>
{['queued', 'running'].includes(run.status) ? <Alert><RotateCw className="animate-spin" /><AlertTitle>{run.progress_message || 'Agent 正在运行'}</AlertTitle><AlertDescription>当前步骤:{run.current_step || '等待调度'}{run.retry_count ? `;已重试 ${run.retry_count} 次` : ''}</AlertDescription></Alert> : null}
{run.error_message ? <Alert variant="destructive"><AlertTitle>{run.error_code || '运行失败'}</AlertTitle><AlertDescription>{run.error_message}</AlertDescription></Alert> : null}
<div className="grid gap-4 lg:grid-cols-[280px_minmax(0,1fr)_300px]">
<Card className="gap-0 py-0"><CardHeader className="border-b border-border py-4"><CardTitle className="text-sm">执行脊柱</CardTitle><CardDescription>{run.steps.length}/{run.total_steps} 个步骤已创建</CardDescription></CardHeader><CardContent className="max-h-[640px] overflow-auto p-2"><ol className="grid gap-1">{run.steps.map((step) => <li key={step.id}><button type="button" onClick={() => setSelectedStepId(step.id)} className={cn('grid w-full grid-cols-[28px_minmax(0,1fr)] gap-2 rounded-md p-2 text-left transition-colors hover:bg-muted', selectedStepId === step.id && 'bg-muted')}><span className={cn('flex size-7 items-center justify-center rounded-full border text-xs font-medium', step.status === 'succeeded' && 'border-success/40 bg-success/10 text-success', step.status === 'failed' && 'border-destructive/40 bg-destructive/10 text-destructive')}>{step.order}</span><span className="min-w-0"><span className="block truncate font-medium">{step.name}</span><span className="mt-1 flex items-center justify-between gap-2"><span className="font-mono text-xs text-muted-foreground">{step.code}</span><StatusBadge status={step.status} /></span></span></button>{step.order < run.steps.length ? <span className="ml-[21px] block h-3 border-l border-border" /> : null}</li>)}</ol></CardContent></Card>
<Card className="gap-0 py-0"><CardHeader className="border-b border-border py-4"><CardTitle className="text-sm">{selected ? `${selected.order}. ${selected.name}` : '运行输入与最终输出'}</CardTitle><CardDescription>{selected ? selected.code : '尚未生成步骤记录'}</CardDescription></CardHeader><CardContent className="p-4"><Tabs defaultValue="input"><TabsList><TabsTrigger value="input">输入</TabsTrigger><TabsTrigger value="output">输出</TabsTrigger><TabsTrigger value="attempts">调用尝试</TabsTrigger></TabsList><TabsContent value="input" className="pt-3"><JsonBlock value={selected?.input_payload ?? run.input_payload} /></TabsContent><TabsContent value="output" className="pt-3"><JsonBlock value={selected?.output_payload ?? run.final_output} /></TabsContent><TabsContent value="attempts" className="grid gap-3 pt-3">{selected?.attempts.length ? selected.attempts.map((attempt) => <Card key={attempt.id}><CardHeader><div className="flex items-center justify-between"><CardTitle className="text-sm">{attempt.attempt_type}</CardTitle><StatusBadge status={attempt.status} /></div><CardDescription>{attempt.input_tokens + attempt.output_tokens} tokens · {attempt.duration_ms} ms</CardDescription></CardHeader><CardContent className="grid gap-2">{attempt.validation_error ? <Alert variant="destructive"><AlertTitle>结构校验错误</AlertTitle><AlertDescription>{attempt.validation_error}</AlertDescription></Alert> : null}<JsonBlock value={attempt.raw_output} /></CardContent></Card>) : <AppEmptyState title="暂无调用尝试" description="排队步骤或尚未开始的步骤不会产生调用记录。" />}</TabsContent></Tabs></CardContent></Card>
<Card className="gap-0 py-0"><CardHeader className="border-b border-border py-4"><CardTitle className="text-sm">{selected ? `${selected.order}. ${selected.name}` : '运行输入与最终输出'}</CardTitle><CardDescription>{selected ? `${selected.code} · ${selected.model_display_name}(${selected.model_capability})` : '尚未生成步骤记录'}</CardDescription></CardHeader><CardContent className="p-4"><Tabs defaultValue="input"><TabsList><TabsTrigger value="input">输入</TabsTrigger><TabsTrigger value="output">输出</TabsTrigger><TabsTrigger value="attempts">调用尝试</TabsTrigger></TabsList><TabsContent value="input" className="pt-3"><JsonBlock value={selected?.input_payload ?? run.input_payload} /></TabsContent><TabsContent value="output" className="pt-3"><JsonBlock value={selected?.output_payload ?? run.final_output} /></TabsContent><TabsContent value="attempts" className="grid gap-3 pt-3">{selected?.attempts.length ? selected.attempts.map((attempt) => <Card key={attempt.id}><CardHeader><div className="flex items-center justify-between"><CardTitle className="text-sm">{attempt.attempt_type}</CardTitle><StatusBadge status={attempt.status} /></div><CardDescription>{attempt.input_tokens + attempt.output_tokens} tokens · {attempt.duration_ms} ms</CardDescription></CardHeader><CardContent className="grid gap-2">{attempt.validation_error ? <Alert variant="destructive"><AlertTitle>结构校验错误</AlertTitle><AlertDescription>{attempt.validation_error}</AlertDescription></Alert> : null}<JsonBlock value={attempt.raw_output} /></CardContent></Card>) : <AppEmptyState title="暂无调用尝试" description="排队步骤或尚未开始的步骤不会产生调用记录。" />}</TabsContent></Tabs></CardContent></Card>
<div className="grid content-start gap-4"><Card><CardHeader><CardTitle className="text-sm">运行指标</CardTitle></CardHeader><CardContent className="grid gap-3 text-sm"><Metric icon={Coins} label="输入 Token" value={String(run.input_tokens)} /><Metric icon={Coins} label="输出 Token" value={String(run.output_tokens)} /><Metric icon={Clock3} label="总耗时" value={`${run.duration_ms} ms`} />{selected ? <><Separator /><Metric icon={Coins} label="步骤 Token" value={String(selected.input_tokens + selected.output_tokens)} /><Metric icon={Clock3} label="步骤耗时" value={`${selected.duration_ms} ms`} /></> : null}</CardContent></Card><Card><CardHeader><CardTitle className="text-sm">配置快照</CardTitle><CardDescription>历史记录不受后续配置修改影响</CardDescription></CardHeader><CardContent><JsonBlock value={run.config_snapshot} /></CardContent></Card>{run.final_output !== null ? <Card><CardHeader><CardTitle className="flex items-center gap-2 text-sm"><Braces className="size-4" />最终结构化输出</CardTitle></CardHeader><CardContent><JsonBlock value={run.final_output} /></CardContent></Card> : null}</div>
</div>
</AppPage>

View File

@@ -22,7 +22,7 @@ export function AgentRunListPage() {
{ key: 'agent', header: 'Agent', cell: (item) => <div><p className="font-medium">{item.agent_name}</p><p className="font-mono text-xs text-muted-foreground">{item.public_id.slice(0, 8)}…</p></div> },
{ key: 'version', header: '版本', cell: (item) => <div className="flex gap-1"><Badge variant="outline">v{item.agent_version}</Badge>{item.sandbox ? <Badge variant="warning">沙箱</Badge> : <Badge variant="secondary">正式</Badge>}</div> },
{ key: 'status', header: '状态', cell: (item) => <StatusBadge status={item.status} /> },
{ key: 'progress', header: '当前步骤', cell: (item) => <span className="text-muted-foreground">{item.current_step || '-'} · {item.total_steps} 步</span> },
{ key: 'progress', header: '当前阶段', cell: (item) => <div><p className="text-foreground">{item.progress_message || item.current_step || '-'}</p><p className="text-xs text-muted-foreground">{item.current_step || '等待执行'} · {item.total_steps} 步{item.retry_count ? ` · 重试 ${item.retry_count}` : ''}</p></div> },
{ key: 'usage', header: 'Token / 耗时', cell: (item) => <span className="text-muted-foreground">{item.input_tokens + item.output_tokens} · {item.duration_ms} ms</span> },
{ key: 'created', header: '创建时间', cell: (item) => <span className="text-muted-foreground">{formatDateTime(item.created_at)}</span> }
]

View File

@@ -6,6 +6,9 @@ export type AgentStep = {
skill_name: string
skill_version: number
skill_status: string
model_deployment_id: number | null
model_display_name: string
model_capability: string
input_mapping: Record<string, string>
}
@@ -20,6 +23,7 @@ export type AgentVersion = {
example_input: Record<string, unknown>
temperature_override: number | null
max_output_tokens_override: number | null
allowed_actions: string[]
steps: AgentStep[]
created_at: string
updated_at: string
@@ -55,6 +59,9 @@ export type AgentRunStep = {
code: string
name: string
order: number
model_deployment_id: number | null
model_display_name: string
model_capability: string
status: string
input_payload: Record<string, unknown>
output_payload: unknown
@@ -74,6 +81,9 @@ export type AgentRunSummary = {
status: string
sandbox: boolean
current_step: string
progress_stage: string
progress_message: string
retry_count: number
total_steps: number
input_tokens: number
output_tokens: number

View File

@@ -1,6 +1,7 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AgentDetailPage } from '@/features/agents/AgentDetailPage'
import { AgentRunDetailPage } from '@/features/agents/AgentRunDetailPage'
import { ModelProviderPage } from '@/features/modelhub/ModelProviderPage'
import { SkillDetailPage } from '@/features/skills/SkillDetailPage'
@@ -11,7 +12,11 @@ const mocks = vi.hoisted(() => ({
testDeployment: vi.fn(),
getModelTest: vi.fn(),
getSkill: vi.fn(),
listSkills: vi.fn(),
publishSkill: vi.fn(),
getAgent: vi.fn(),
updateAgent: vi.fn(),
updateAgentDraft: vi.fn(),
getAgentRun: vi.fn(),
cancelAgentRun: vi.fn()
}))
@@ -29,6 +34,7 @@ vi.mock('@/features/modelhub/api', () => ({
vi.mock('@/features/skills/api', () => ({
getSkill: mocks.getSkill,
listSkills: mocks.listSkills,
publishSkill: mocks.publishSkill,
ensureSkillDraft: vi.fn(),
updateSkill: vi.fn(),
@@ -37,6 +43,13 @@ vi.mock('@/features/skills/api', () => ({
}))
vi.mock('@/features/agents/api', () => ({
getAgent: mocks.getAgent,
updateAgent: mocks.updateAgent,
updateAgentDraft: mocks.updateAgentDraft,
ensureAgentDraft: vi.fn(),
publishAgent: vi.fn(),
rollbackAgent: vi.fn(),
startAgentRun: vi.fn(),
getAgentRun: mocks.getAgentRun,
cancelAgentRun: mocks.cancelAgentRun
}))
@@ -59,7 +72,7 @@ const deployment = {
provider_name: '企业渠道',
display_name: '支持模型',
model_name: 'support-model',
capability: 'text',
capability: 'text_chat',
default_temperature: 0.2,
max_output_tokens: 2048,
enabled: true
@@ -70,8 +83,8 @@ const queuedTest = {
}
const run = {
public_id: '11111111-1111-1111-1111-111111111111', agent_id: 1, agent_name: '技术支持分析 Agent', agent_version: 1, status: 'running', sandbox: true, current_step: 'structure', total_steps: 1, input_tokens: 3, output_tokens: 2, duration_ms: 120, error_code: '', error_message: '', created_at: '2026-07-14T00:00:00Z', started_at: '2026-07-14T00:00:00Z', finished_at: null, input_payload: { problem: '无法登录' }, final_output: null, config_snapshot: { agent: { version: 1 } }, cancellation_requested_at: null,
steps: [{ id: 10, code: 'structure', name: '问题结构化', order: 1, status: 'running', input_payload: { problem: '无法登录' }, output_payload: null, input_tokens: 3, output_tokens: 2, duration_ms: 120, error_code: '', error_message: '', attempts: [] }]
public_id: '11111111-1111-1111-1111-111111111111', agent_id: 1, agent_name: '技术支持分析 Agent', agent_version: 1, status: 'running', sandbox: true, current_step: 'structure', progress_stage: 'validating', progress_message: '正在校验结构化结果', retry_count: 0, total_steps: 1, input_tokens: 3, output_tokens: 2, duration_ms: 120, error_code: '', error_message: '', created_at: '2026-07-14T00:00:00Z', started_at: '2026-07-14T00:00:00Z', finished_at: null, input_payload: { problem: '无法登录' }, final_output: null, config_snapshot: { agent: { version: 1 } }, cancellation_requested_at: null,
steps: [{ id: 10, code: 'structure', name: '问题结构化', order: 1, model_deployment_id: 2, model_display_name: '支持模型', model_capability: 'text_chat', status: 'running', input_payload: { problem: '无法登录' }, output_payload: null, input_tokens: 3, output_tokens: 2, duration_ms: 120, error_code: '', error_message: '', attempts: [] }]
}
afterEach(() => {
@@ -138,9 +151,87 @@ describe('AI 平台真实页面', () => {
render(<AgentRunDetailPage publicId={run.public_id} />)
expect(await screen.findByText('执行脊柱')).toBeInTheDocument()
expect(screen.getByText('问题结构化')).toBeInTheDocument()
expect(screen.getByText('structure · 支持模型(text_chat)')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '尽力取消' }))
await waitFor(() => expect(mocks.cancelAgentRun).toHaveBeenCalledWith(run.public_id))
expect(await screen.findByText('已取消')).toBeInTheDocument()
})
it('保存步骤级模型绑定', async () => {
const visionDeployment = {
...deployment,
id: 3,
display_name: '视觉模型',
model_name: 'vision-model',
capability: 'vision_chat'
}
const skillVersion = {
id: 11,
version: 1,
status: 'published',
prompt_template: '{{ input.problem }}',
input_schema: { type: 'object' },
output_schema: { type: 'object' },
example_input: {},
created_at: '2026-07-14T00:00:00Z',
updated_at: '2026-07-14T00:00:00Z',
published_at: '2026-07-14T00:00:00Z'
}
const agent = {
id: 1,
code: 'support-agent',
name: '支持 Agent',
description: '',
active_version: null,
draft_version: 1,
updated_at: '2026-07-14T00:00:00Z',
versions: [{
id: 21,
version: 1,
status: 'draft',
model_deployment_id: 2,
model_display_name: '支持模型',
system_prompt: '',
input_schema: { type: 'object' },
example_input: {},
temperature_override: null,
max_output_tokens_override: null,
allowed_actions: [],
steps: [{
id: 31,
code: 'structure',
order: 1,
skill_version_id: 11,
skill_name: '问题结构化',
skill_version: 1,
skill_status: 'published',
model_deployment_id: 3,
model_display_name: '视觉模型',
model_capability: 'vision_chat',
input_mapping: {}
}],
created_at: '2026-07-14T00:00:00Z',
updated_at: '2026-07-14T00:00:00Z',
published_at: null
}]
}
mocks.getAgent.mockResolvedValue(agent)
mocks.listDeployments.mockResolvedValue([deployment, visionDeployment])
mocks.listSkills.mockResolvedValue([{ id: 1 }])
mocks.getSkill.mockResolvedValue({
id: 1,
code: 'structure',
name: '问题结构化',
versions: [skillVersion]
})
mocks.updateAgent.mockResolvedValue(agent)
mocks.updateAgentDraft.mockResolvedValue(agent.versions[0])
render(<AgentDetailPage agentId={1} />)
fireEvent.click(await screen.findByRole('button', { name: '保存草稿' }))
await waitFor(() => expect(mocks.updateAgentDraft).toHaveBeenCalled())
expect(mocks.updateAgentDraft.mock.calls[0][1].steps[0].model_deployment_id).toBe(3)
})
})

View File

@@ -2,6 +2,7 @@ import { Badge } from '@/components/ui/badge'
const labels: Record<string, string> = {
draft: '草稿', published: '已发布', archived: '已归档',
pending_review: '待审核', rejected: '已驳回', offline: '已下线',
queued: '排队中', running: '执行中', succeeded: '成功', failed: '失败',
cancelled: '已取消', timed_out: '已超时'
}
@@ -9,11 +10,11 @@ const labels: Record<string, string> = {
export function StatusBadge({ status }: { status: string }) {
const variant = status === 'succeeded' || status === 'published'
? 'success'
: status === 'failed' || status === 'timed_out'
: status === 'failed' || status === 'timed_out' || status === 'rejected'
? 'danger'
: status === 'running'
? 'info'
: status === 'draft'
: status === 'draft' || status === 'pending_review'
? 'warning'
: 'secondary'
return <Badge variant={variant}>{labels[status] ?? status}</Badge>

View File

@@ -168,10 +168,10 @@ export function ModelProviderPage() {
const deploymentColumns: AppDataTableColumn<ModelDeployment>[] = [
{ key: 'name', header: '模型', cell: (item) => <div><p className="font-medium">{item.display_name}</p><p className="text-xs text-muted-foreground">{item.model_name}</p></div> },
{ key: 'provider', header: '渠道', cell: (item) => item.provider_name },
{ key: 'capability', header: '能力', cell: (item) => <Badge variant="outline">{item.capability === 'vision_chat' ? '文本 + 视觉' : '纯文本'}</Badge> },
{ key: 'capability', header: '能力', cell: (item) => <Badge variant="outline">{item.capability === 'vision_chat' ? '文本 + 视觉' : item.capability === 'embedding' ? 'Embedding' : item.capability === 'rerank' ? '重排' : '纯文本'}</Badge> },
{ key: 'params', header: '默认参数', cell: (item) => <span className="text-muted-foreground">T {item.default_temperature} · {item.max_output_tokens} tokens</span> },
{ key: 'status', header: '状态', cell: (item) => <Badge variant={item.enabled ? 'success' : 'secondary'}>{item.enabled ? '可用' : '停用'}</Badge> },
{ key: 'action', header: '操作', cell: (item) => <div className="flex gap-1"><Button variant="ghost" size="sm" onClick={() => setEditingDeployment({ ...item })}><Settings2 data-icon="inline-start" />配置</Button><Button variant="outline" size="sm" onClick={() => { setTesting(item); setTestResult(null) }}><Activity data-icon="inline-start" />连通测试</Button></div> }
{ key: 'action', header: '操作', cell: (item) => <div className="flex gap-1"><Button variant="ghost" size="sm" onClick={() => setEditingDeployment({ ...item })}><Settings2 data-icon="inline-start" />配置</Button><Button variant="outline" size="sm" disabled={item.capability === 'embedding'} title={item.capability === 'embedding' ? '请通过知识检索验证向量模型' : undefined} onClick={() => { setTesting(item); setTestResult(null) }}><Activity data-icon="inline-start" />连通测试</Button></div> }
]
return (
@@ -254,7 +254,7 @@ export function ModelProviderPage() {
<AppField label="实际模型名" htmlFor="deployment-model" description="填写渠道可识别的模型标识。" required>
<Input id="deployment-model" required value={deploymentForm.model_name} onChange={(event) => setDeploymentForm({ ...deploymentForm, model_name: event.target.value })} placeholder="例如:gpt-4.1-mini" />
</AppField>
<AppField label="模型能力" required><Select value={deploymentForm.capability} onValueChange={(value) => setDeploymentForm({ ...deploymentForm, capability: value })}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="text_chat">纯文本</SelectItem><SelectItem value="vision_chat">文本 + 视觉</SelectItem></SelectContent></Select></AppField>
<AppField label="模型能力" required><Select value={deploymentForm.capability} onValueChange={(value) => setDeploymentForm({ ...deploymentForm, capability: value })}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="text_chat">纯文本</SelectItem><SelectItem value="vision_chat">文本 + 视觉</SelectItem><SelectItem value="embedding">Embedding</SelectItem><SelectItem value="rerank">重排</SelectItem></SelectGroup></SelectContent></Select></AppField>
<AppField label="Temperature" htmlFor="deployment-temperature" description="0 更稳定,数值越高结果越发散。">
<Input id="deployment-temperature" type="number" min="0" max="2" step="0.1" value={deploymentForm.default_temperature} onChange={(event) => setDeploymentForm({ ...deploymentForm, default_temperature: event.target.value })} />
</AppField>
@@ -276,7 +276,7 @@ export function ModelProviderPage() {
<Dialog open={Boolean(editingDeployment)} onOpenChange={(open) => !open && setEditingDeployment(null)}><DialogContent><DialogHeader><DialogTitle>编辑模型配置</DialogTitle></DialogHeader>{editingDeployment ? <FieldGroup>
<AppField label="显示名称"><Input value={editingDeployment.display_name} onChange={(e) => setEditingDeployment({ ...editingDeployment, display_name: e.target.value })} /></AppField>
<AppField label="模型名"><Input value={editingDeployment.model_name} onChange={(e) => setEditingDeployment({ ...editingDeployment, model_name: e.target.value })} /></AppField>
<AppField label="模型能力"><Select value={editingDeployment.capability} onValueChange={(value) => setEditingDeployment({ ...editingDeployment, capability: value })}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="text_chat">纯文本</SelectItem><SelectItem value="vision_chat">文本 + 视觉</SelectItem></SelectContent></Select></AppField>
<AppField label="模型能力"><Select value={editingDeployment.capability} onValueChange={(value) => setEditingDeployment({ ...editingDeployment, capability: value })}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="text_chat">纯文本</SelectItem><SelectItem value="vision_chat">文本 + 视觉</SelectItem><SelectItem value="embedding">Embedding</SelectItem><SelectItem value="rerank">重排</SelectItem></SelectGroup></SelectContent></Select></AppField>
<AppField label="Temperature"><Input type="number" min="0" max="2" step="0.1" value={editingDeployment.default_temperature} onChange={(e) => setEditingDeployment({ ...editingDeployment, default_temperature: Number(e.target.value) })} /></AppField>
<AppField label="最大输出 Token"><Input type="number" min="1" max="32768" value={editingDeployment.max_output_tokens} onChange={(e) => setEditingDeployment({ ...editingDeployment, max_output_tokens: Number(e.target.value) })} /></AppField>
<AppField label="启用状态"><div className="flex h-8 items-center gap-2"><Switch checked={editingDeployment.enabled} onCheckedChange={(checked) => setEditingDeployment({ ...editingDeployment, enabled: checked })} /><span className="text-sm text-muted-foreground">{editingDeployment.enabled ? '已启用' : '已停用'}</span></div></AppField>

View File

@@ -0,0 +1,116 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ToolManagementPage } from './ToolManagementPage'
const mocks = vi.hoisted(() => ({
listTools: vi.fn(),
listToolProviders: vi.fn(),
listToolCalls: vi.fn(),
listToolAuthorizations: vi.fn(),
confirmToolCall: vi.fn()
}))
vi.mock('@/features/auth/AuthProvider', () => ({
useAuth: () => ({
user: {
id: 1,
username: 'admin',
display_name: '平台管理员',
is_superuser: true,
permissions: []
}
})
}))
vi.mock('./api', () => ({
listTools: mocks.listTools,
listToolProviders: mocks.listToolProviders,
listToolCalls: mocks.listToolCalls,
listToolAuthorizations: mocks.listToolAuthorizations,
confirmToolCall: mocks.confirmToolCall,
createToolCall: vi.fn(),
createToolProvider: vi.fn(),
saveToolAuthorization: vi.fn(),
syncToolProvider: vi.fn(),
updateTool: vi.fn(),
updateToolProvider: vi.fn()
}))
const highRiskTool = {
id: 9,
code: 'mcp.ops.restart',
provider_id: 3,
external_name: 'restart',
name: '重启服务',
description: '重启指定内部服务',
input_schema: { type: 'object' },
risk_level: 'high_risk',
enabled: true,
synced_at: '2026-07-27T08:00:00Z'
}
const pendingCall = {
public_id: '11111111-1111-1111-1111-111111111111',
tool_code: highRiskTool.code,
requested_by_id: 2,
requested_by_name: '支持负责人',
status: 'pending_confirmation',
input_payload: { service: 'ticket-api' },
output_payload: null,
authorization_snapshot: { allowed: true },
risk_snapshot: { risk_level: 'high_risk' },
error_code: '',
error_message: '',
confirmation_status: 'pending',
confirmation_reason: '',
created_at: '2026-07-27T08:10:00Z',
started_at: null,
finished_at: null
}
beforeEach(() => {
vi.stubGlobal('ResizeObserver', class {
observe() {}
unobserve() {}
disconnect() {}
})
mocks.listTools.mockResolvedValue([highRiskTool])
mocks.listToolProviders.mockResolvedValue([])
mocks.listToolCalls.mockResolvedValue([pendingCall])
mocks.listToolAuthorizations.mockResolvedValue([])
mocks.confirmToolCall.mockResolvedValue({
...pendingCall,
status: 'succeeded',
confirmation_status: 'approved'
})
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
vi.clearAllMocks()
})
describe('工具治理页面', () => {
it('展示风险分层并完成高风险调用的独立确认', async () => {
render(<ToolManagementPage />)
expect((await screen.findAllByText('重启服务')).length).toBeGreaterThan(0)
expect(screen.getAllByText('高风险').length).toBeGreaterThan(0)
const callsTab = screen.getByRole('tab', { name: '调用审计' })
fireEvent.mouseDown(callsTab, { button: 0, ctrlKey: false })
expect(await screen.findByText('待独立确认')).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('独立确认意见'), {
target: { value: '已核对变更窗口' }
})
fireEvent.click(screen.getByRole('button', { name: '批准并执行' }))
await waitFor(() => expect(mocks.confirmToolCall).toHaveBeenCalledWith(
pendingCall.public_id,
'approve',
'已核对变更窗口'
))
})
})

View File

@@ -0,0 +1,896 @@
import * as React from 'react'
import {
Activity,
BadgeCheck,
Cable,
Check,
CircleAlert,
Clock3,
KeyRound,
LockKeyhole,
Play,
Plus,
RefreshCw,
RotateCw,
ShieldAlert,
ShieldCheck,
ShieldQuestion,
X
} from 'lucide-react'
import { toast } from 'sonner'
import { AppCreateDialog } from '@/components/app/AppCreateDialog'
import { AppEmptyState } from '@/components/app/AppEmptyState'
import { AppField } from '@/components/app/AppField'
import { AppPage } from '@/components/app/AppPage'
import { AppPageHeader } from '@/components/app/AppPageHeader'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle
} from '@/components/ui/card'
import { FieldGroup } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { Switch } from '@/components/ui/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea'
import { JsonEditor, parseJsonObject } from '@/features/ai-platform/components/JsonEditor'
import { useAuth } from '@/features/auth/AuthProvider'
import { cn } from '@/lib/utils'
import {
confirmToolCall,
createToolCall,
createToolProvider,
listToolAuthorizations,
listToolCalls,
listToolProviders,
listTools,
saveToolAuthorization,
syncToolProvider,
updateTool,
updateToolProvider
} from './api'
import type {
ToolAuthorization,
ToolCall,
ToolCallStatus,
ToolPrincipalType,
ToolProvider,
ToolRiskLevel,
UnifiedTool
} from './types'
const RISK_META: Record<ToolRiskLevel, {
label: string
description: string
badge: 'success' | 'warning' | 'danger'
icon: typeof ShieldCheck
}> = {
read_only: {
label: '只读',
description: '查询数据,不改变外部状态',
badge: 'success',
icon: ShieldCheck
},
low_write: {
label: '低风险写',
description: '受审计约束的可逆写入',
badge: 'warning',
icon: ShieldQuestion
},
high_risk: {
label: '高风险',
description: '执行前必须由他人独立确认',
badge: 'danger',
icon: ShieldAlert
}
}
const CALL_STATUS: Record<ToolCallStatus, {
label: string
badge: 'secondary' | 'info' | 'success' | 'danger' | 'warning'
}> = {
pending_confirmation: { label: '待独立确认', badge: 'warning' },
running: { label: '执行中', badge: 'info' },
succeeded: { label: '已成功', badge: 'success' },
failed: { label: '失败', badge: 'danger' },
rejected: { label: '已拒绝', badge: 'secondary' }
}
const PRINCIPAL_LABELS: Record<ToolPrincipalType, string> = {
user: '用户',
group: '用户组',
agent: 'Agent'
}
const providerInitial = {
name: '',
base_url: '',
token: '',
timeout_seconds: '30'
}
function formatDateTime(value: string | null) {
if (!value) return '—'
return new Intl.DateTimeFormat('zh-CN', {
dateStyle: 'short',
timeStyle: 'medium'
}).format(new Date(value))
}
function compactJson(value: unknown) {
return JSON.stringify(value, null, 2)
}
export function ToolManagementPage() {
const { user } = useAuth()
const [tools, setTools] = React.useState<UnifiedTool[]>([])
const [providers, setProviders] = React.useState<ToolProvider[]>([])
const [calls, setCalls] = React.useState<ToolCall[]>([])
const [authorizations, setAuthorizations] = React.useState<ToolAuthorization[]>([])
const [selectedId, setSelectedId] = React.useState<number | null>(null)
const [loading, setLoading] = React.useState(true)
const [error, setError] = React.useState('')
const [busy, setBusy] = React.useState('')
const [providerOpen, setProviderOpen] = React.useState(false)
const [providerForm, setProviderForm] = React.useState(providerInitial)
const [providerError, setProviderError] = React.useState('')
const [callInput, setCallInput] = React.useState('{}')
const [grantType, setGrantType] = React.useState<ToolPrincipalType>('group')
const [grantId, setGrantId] = React.useState('')
const [grantAllowed, setGrantAllowed] = React.useState(true)
const [confirmationReasons, setConfirmationReasons] = React.useState<Record<string, string>>({})
const hasPermission = React.useCallback((permission: string) => Boolean(
user?.is_superuser || user?.permissions.includes(permission)
), [user])
const canManage = hasPermission('tool.manage')
const canInvoke = hasPermission('tool.invoke')
const canConfirm = hasPermission('tool.confirm')
const load = React.useCallback(async () => {
setLoading(true)
try {
const [nextTools, nextProviders, nextCalls] = await Promise.all([
listTools(),
canManage ? listToolProviders() : Promise.resolve([]),
canInvoke ? listToolCalls() : Promise.resolve([])
])
setTools(nextTools)
setProviders(nextProviders)
setCalls(nextCalls)
setSelectedId((current) => (
current && nextTools.some((tool) => tool.id === current)
? current
: nextTools[0]?.id ?? null
))
setError('')
} catch (reason) {
setError(reason instanceof Error ? reason.message : '工具治理数据加载失败')
} finally {
setLoading(false)
}
}, [canInvoke, canManage])
React.useEffect(() => {
void load()
}, [load])
const selected = tools.find((tool) => tool.id === selectedId) ?? null
React.useEffect(() => {
setCallInput('{}')
setGrantId('')
if (!selected || !canManage) {
setAuthorizations([])
return
}
listToolAuthorizations(selected.id)
.then(setAuthorizations)
.catch(() => setAuthorizations([]))
}, [canManage, selected])
async function execute(key: string, action: () => Promise<unknown>, message: string) {
setBusy(key)
try {
await action()
toast.success(message)
await load()
} catch (reason) {
toast.error(reason instanceof Error ? reason.message : '操作未完成')
} finally {
setBusy('')
}
}
async function handleCreateProvider(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setBusy('provider-create')
setProviderError('')
try {
await createToolProvider({
name: providerForm.name,
provider_type: 'mcp_http',
base_url: providerForm.base_url,
token: providerForm.token,
timeout_seconds: Number(providerForm.timeout_seconds),
enabled: true
})
setProviderForm(providerInitial)
setProviderOpen(false)
toast.success('MCP 服务已接入')
await load()
} catch (reason) {
setProviderError(reason instanceof Error ? reason.message : 'MCP 服务创建失败')
} finally {
setBusy('')
}
}
async function handleSaveGrant(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!selected) return
const principalId = Number(grantId)
if (!Number.isInteger(principalId) || principalId <= 0) {
toast.error('授权主体 ID 必须是正整数')
return
}
setBusy('grant')
try {
await saveToolAuthorization(selected.id, {
principal_type: grantType,
principal_id: principalId,
allowed: grantAllowed
})
setAuthorizations(await listToolAuthorizations(selected.id))
setGrantId('')
toast.success(grantAllowed ? '授权规则已保存' : '拒绝规则已保存')
} catch (reason) {
toast.error(reason instanceof Error ? reason.message : '授权规则保存失败')
} finally {
setBusy('')
}
}
async function handleInvoke() {
if (!selected) return
try {
const input = parseJsonObject(callInput, '工具输入')
const idempotencyKey = `console-${selected.id}-${crypto.randomUUID()}`
setBusy('invoke')
const call = await createToolCall(selected.code, input, idempotencyKey)
setCalls((current) => [call, ...current.filter((item) => item.public_id !== call.public_id)])
toast.success(
call.status === 'pending_confirmation'
? '调用已进入独立确认队列'
: '工具调用已执行'
)
} catch (reason) {
toast.error(reason instanceof Error ? reason.message : '工具调用失败')
} finally {
setBusy('')
}
}
async function handleConfirmation(call: ToolCall, decision: 'approve' | 'reject') {
const reason = confirmationReasons[call.public_id]?.trim() ?? ''
if (decision === 'reject' && !reason) {
toast.error('拒绝高风险调用时必须填写原因')
return
}
await execute(
`confirm-${call.public_id}`,
() => confirmToolCall(call.public_id, decision, reason),
decision === 'approve' ? '高风险调用已批准并执行' : '高风险调用已拒绝'
)
}
if (!hasPermission('tool.view')) {
return (
<AppPage
maxWidth="7xl"
header={<AppPageHeader title="工具治理" description="统一管理 Tool、MCP、授权和调用风险。" />}
>
<Alert variant="destructive">
<LockKeyhole />
<AlertTitle>没有查看权限</AlertTitle>
<AlertDescription>请联系管理员授予“查看工具目录”权限。</AlertDescription>
</Alert>
</AppPage>
)
}
return (
<AppPage
maxWidth="7xl"
contentClassName="gap-5"
header={(
<AppPageHeader
title="工具治理"
description="统一接入 HTTP MCP,按用户、用户组和 Agent 计算授权交集,并留存每次调用证据。"
meta={<Badge variant="ai"><BadgeCheck />受控执行</Badge>}
actions={(
<>
<Button variant="outline" onClick={() => void load()} disabled={loading}>
<RefreshCw data-icon="inline-start" />
刷新
</Button>
{canManage ? (
<Button onClick={() => setProviderOpen(true)}>
<Plus data-icon="inline-start" />
接入 MCP
</Button>
) : null}
</>
)}
/>
)}
>
<div className="grid gap-3 md:grid-cols-3">
{(Object.entries(RISK_META) as Array<[ToolRiskLevel, typeof RISK_META[ToolRiskLevel]]>)
.map(([risk, meta]) => {
const Icon = meta.icon
return (
<Card
key={risk}
size="sm"
className={cn(
'border-l-4',
risk === 'read_only' && 'border-l-success',
risk === 'low_write' && 'border-l-warning',
risk === 'high_risk' && 'border-l-danger'
)}
>
<CardHeader>
<div className="flex items-center justify-between gap-3">
<CardTitle className="flex items-center gap-2">
<Icon />
{meta.label}
</CardTitle>
<strong className="text-xl tabular-nums">
{tools.filter((tool) => tool.risk_level === risk).length}
</strong>
</div>
<CardDescription>{meta.description}</CardDescription>
</CardHeader>
</Card>
)
})}
</div>
{error ? (
<Alert variant="destructive">
<CircleAlert />
<AlertTitle>工具治理数据加载失败</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{loading ? (
<div className="grid gap-3">
<Skeleton className="h-10 w-80" />
<Skeleton className="h-96 w-full" />
</div>
) : (
<Tabs defaultValue="catalog">
<TabsList variant="line">
<TabsTrigger value="catalog">工具目录</TabsTrigger>
{canManage ? <TabsTrigger value="providers">MCP 服务</TabsTrigger> : null}
{canInvoke ? <TabsTrigger value="calls">调用审计</TabsTrigger> : null}
</TabsList>
<TabsContent value="catalog" className="mt-4">
<div className="grid gap-4 xl:grid-cols-[340px_minmax(0,1fr)]">
<Card className="min-h-[560px]">
<CardHeader>
<CardTitle>统一工具目录</CardTitle>
<CardDescription>选择工具查看输入契约、授权边界和运行入口。</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{tools.length === 0 ? (
<AppEmptyState
title="还没有可用工具"
description={canManage ? '先接入 MCP 服务并同步工具目录。' : '等待管理员接入并授权工具。'}
/>
) : tools.map((tool) => (
<button
key={tool.id}
type="button"
onClick={() => setSelectedId(tool.id)}
className={cn(
'flex flex-col gap-2 rounded-lg border p-3 text-left transition-colors hover:bg-muted/60',
selectedId === tool.id && 'border-primary/40 bg-primary/5 ring-1 ring-primary/15'
)}
>
<span className="flex items-center justify-between gap-2">
<strong className="truncate text-sm">{tool.name}</strong>
<Badge variant={RISK_META[tool.risk_level].badge}>
{RISK_META[tool.risk_level].label}
</Badge>
</span>
<code className="truncate text-xs text-muted-foreground">{tool.code}</code>
<span className="line-clamp-2 text-xs text-muted-foreground">
{tool.description || '暂无工具说明'}
</span>
</button>
))}
</CardContent>
</Card>
{!selected ? (
<Card className="min-h-[560px]">
<CardContent className="flex min-h-[560px] items-center justify-center">
<AppEmptyState title="选择一个工具" description="查看风险等级、授权规则和输入契约。" />
</CardContent>
</Card>
) : (
<div className="flex flex-col gap-4">
<Card>
<CardHeader>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex flex-col gap-2">
<span className="flex flex-wrap items-center gap-2">
<Badge variant={RISK_META[selected.risk_level].badge}>
{RISK_META[selected.risk_level].label}
</Badge>
<Badge variant={selected.enabled ? 'success' : 'secondary'}>
{selected.enabled ? '已启用' : '已停用'}
</Badge>
</span>
<CardTitle>{selected.name}</CardTitle>
<CardDescription>{selected.description || '暂无工具说明'}</CardDescription>
</div>
{canManage ? (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">启用</span>
<Switch
checked={selected.enabled}
disabled={busy === `tool-${selected.id}`}
onCheckedChange={(enabled) => void execute(
`tool-${selected.id}`,
() => updateTool(selected.id, { enabled }),
enabled ? '工具已启用' : '工具已停用'
)}
/>
</div>
) : null}
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-lg bg-muted/45 p-3">
<span className="text-xs text-muted-foreground">统一编码</span>
<code className="mt-1 block break-all text-xs">{selected.code}</code>
</div>
<div className="rounded-lg bg-muted/45 p-3">
<span className="text-xs text-muted-foreground">最近同步</span>
<strong className="mt-1 block text-sm">{formatDateTime(selected.synced_at)}</strong>
</div>
</div>
{canManage ? (
<AppField label="风险等级" description="风险等级在调用创建时固化为审计快照。">
<Select
value={selected.risk_level}
onValueChange={(riskLevel: ToolRiskLevel) => void execute(
`tool-${selected.id}`,
() => updateTool(selected.id, { risk_level: riskLevel }),
'工具风险等级已更新'
)}
>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectGroup>
{(Object.keys(RISK_META) as ToolRiskLevel[]).map((risk) => (
<SelectItem key={risk} value={risk}>
{RISK_META[risk].label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</AppField>
) : null}
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">输入契约</span>
<pre className="max-h-72 overflow-auto rounded-lg bg-muted/55 p-3 text-xs leading-5">
{compactJson(selected.input_schema)}
</pre>
</div>
</CardContent>
</Card>
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>授权交集</CardTitle>
<CardDescription>
用户、用户组和 Agent 规则共同生效;任一明确拒绝都会阻止调用。
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{!canManage ? (
<Alert>
<LockKeyhole />
<AlertTitle>授权规则受保护</AlertTitle>
<AlertDescription>当前账号可以查看工具,但不能维护授权主体。</AlertDescription>
</Alert>
) : (
<>
{authorizations.length === 0 ? (
<AppEmptyState
title="尚未配置主体规则"
description="Agent 调用还需要运行快照和显式 Agent 授权。"
/>
) : (
<div className="flex flex-col gap-2">
{authorizations.map((authorization) => (
<div
key={authorization.id}
className="flex items-center justify-between gap-3 rounded-lg border p-3"
>
<span className="text-sm">
{PRINCIPAL_LABELS[authorization.principal_type]} #{authorization.principal_id}
</span>
<Badge variant={authorization.allowed ? 'success' : 'danger'}>
{authorization.allowed ? '允许' : '明确拒绝'}
</Badge>
</div>
))}
</div>
)}
<form onSubmit={handleSaveGrant}>
<FieldGroup>
<div className="grid gap-3 sm:grid-cols-2">
<AppField label="授权主体">
<Select
value={grantType}
onValueChange={(value: ToolPrincipalType) => setGrantType(value)}
>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectGroup>
{(Object.keys(PRINCIPAL_LABELS) as ToolPrincipalType[]).map((type) => (
<SelectItem key={type} value={type}>
{PRINCIPAL_LABELS[type]}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</AppField>
<AppField label="主体 ID" htmlFor="tool-principal-id">
<Input
id="tool-principal-id"
type="number"
min="1"
required
value={grantId}
onChange={(event) => setGrantId(event.target.value)}
placeholder="输入数据库 ID"
/>
</AppField>
</div>
<AppField label="规则结果">
<div className="flex items-center gap-2">
<Switch checked={grantAllowed} onCheckedChange={setGrantAllowed} />
<span className="text-sm text-muted-foreground">
{grantAllowed ? '允许该主体调用' : '明确拒绝该主体调用'}
</span>
</div>
</AppField>
<Button type="submit" variant="outline" disabled={busy === 'grant'}>
<KeyRound data-icon="inline-start" />
保存授权规则
</Button>
</FieldGroup>
</form>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>受控试运行</CardTitle>
<CardDescription>
使用真实授权和输入校验;高风险调用只创建确认任务,不会直接执行。
</CardDescription>
</CardHeader>
<CardContent>
{canInvoke ? (
<JsonEditor
id="tool-call-input"
label="工具输入"
value={callInput}
onChange={setCallInput}
description="必须符合该工具的 JSON Schema。"
rows={10}
/>
) : (
<Alert>
<LockKeyhole />
<AlertTitle>没有调用权限</AlertTitle>
<AlertDescription>查看工具不代表可以执行工具。</AlertDescription>
</Alert>
)}
</CardContent>
{canInvoke ? (
<CardFooter className="justify-end">
<Button onClick={() => void handleInvoke()} disabled={busy === 'invoke' || !selected.enabled}>
<Play data-icon="inline-start" />
{selected.risk_level === 'high_risk' ? '提交确认' : '执行试运行'}
</Button>
</CardFooter>
) : null}
</Card>
</div>
</div>
)}
</div>
</TabsContent>
{canManage ? (
<TabsContent value="providers" className="mt-4">
<div className="grid gap-4 lg:grid-cols-2">
{providers.length === 0 ? (
<Card className="lg:col-span-2">
<CardContent className="py-12">
<AppEmptyState
title="还没有 MCP 服务"
description="接入一个支持 tools/list 与 tools/call 的 HTTP MCP 服务。"
/>
</CardContent>
</Card>
) : providers.map((provider) => (
<Card key={provider.id}>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2">
<Cable />
{provider.name}
</CardTitle>
<CardDescription className="mt-1 break-all">{provider.base_url}</CardDescription>
</div>
<Badge variant={provider.enabled ? 'success' : 'secondary'}>
{provider.enabled ? '已启用' : '已停用'}
</Badge>
</div>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-3">
<div className="rounded-lg bg-muted/45 p-3">
<span className="text-xs text-muted-foreground">协议</span>
<strong className="mt-1 block text-sm">HTTP MCP</strong>
</div>
<div className="rounded-lg bg-muted/45 p-3">
<span className="text-xs text-muted-foreground">凭证</span>
<strong className="mt-1 block text-sm">
{provider.credential_set ? '已加密保存' : '未配置'}
</strong>
</div>
<div className="rounded-lg bg-muted/45 p-3">
<span className="text-xs text-muted-foreground">超时</span>
<strong className="mt-1 block text-sm">{provider.timeout_seconds} 秒</strong>
</div>
</CardContent>
<CardFooter className="justify-between">
<div className="flex items-center gap-2">
<Switch
checked={provider.enabled}
onCheckedChange={(enabled) => void execute(
`provider-${provider.id}`,
() => updateToolProvider(provider.id, { enabled }),
enabled ? 'MCP 服务已启用' : 'MCP 服务已停用'
)}
/>
<span className="text-xs text-muted-foreground">服务开关</span>
</div>
<Button
variant="outline"
onClick={() => void execute(
`sync-${provider.id}`,
() => syncToolProvider(provider.id),
'工具目录同步完成'
)}
disabled={busy === `sync-${provider.id}` || !provider.enabled}
>
<RotateCw data-icon="inline-start" />
同步 tools/list
</Button>
</CardFooter>
</Card>
))}
</div>
</TabsContent>
) : null}
{canInvoke ? (
<TabsContent value="calls" className="mt-4">
<Card>
<CardHeader>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle>调用审计</CardTitle>
<CardDescription>
固化工具、授权、风险、输入与输出快照;最多显示最近 100 条。
</CardDescription>
</div>
<Badge variant="outline">{calls.length} 条记录</Badge>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{calls.length === 0 ? (
<AppEmptyState title="还没有工具调用" description="从工具目录发起一次受控试运行。" />
) : calls.map((call) => {
const status = CALL_STATUS[call.status]
const isSelfReview = call.requested_by_id === user?.id
return (
<div key={call.public_id} className="rounded-lg border p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-1">
<span className="flex flex-wrap items-center gap-2">
<code className="truncate text-sm font-medium">{call.tool_code}</code>
<Badge variant={status.badge}>{status.label}</Badge>
</span>
<span className="text-xs text-muted-foreground">
{call.requested_by_name || '系统运行'} · {formatDateTime(call.created_at)}
</span>
</div>
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
{call.finished_at ? <Activity /> : <Clock3 />}
{call.finished_at ? `完成于 ${formatDateTime(call.finished_at)}` : '尚未完成'}
</span>
</div>
<div className="mt-3 grid gap-3 lg:grid-cols-2">
<div className="flex flex-col gap-1">
<span className="text-xs font-medium text-muted-foreground">输入快照</span>
<pre className="max-h-44 overflow-auto rounded-md bg-muted/45 p-3 text-xs">
{compactJson(call.input_payload)}
</pre>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs font-medium text-muted-foreground">输出 / 错误</span>
<pre className="max-h-44 overflow-auto rounded-md bg-muted/45 p-3 text-xs">
{call.error_message || compactJson(call.output_payload)}
</pre>
</div>
</div>
{call.status === 'pending_confirmation' ? (
<div className="mt-3 rounded-lg border border-warning/30 bg-warning/5 p-3">
<AppField
label="独立确认意见"
htmlFor={`confirmation-${call.public_id}`}
description={isSelfReview ? '申请人不能确认自己的高风险调用。' : '拒绝时必须填写原因。'}
>
<Textarea
id={`confirmation-${call.public_id}`}
value={confirmationReasons[call.public_id] ?? ''}
disabled={!canConfirm || isSelfReview}
onChange={(event) => setConfirmationReasons((current) => ({
...current,
[call.public_id]: event.target.value
}))}
placeholder="记录核对范围、变更窗口和风险判断。"
/>
</AppField>
<div className="mt-3 flex justify-end gap-2">
<Button
variant="outline"
disabled={!canConfirm || isSelfReview || busy === `confirm-${call.public_id}`}
onClick={() => void handleConfirmation(call, 'reject')}
>
<X data-icon="inline-start" />
拒绝
</Button>
<Button
disabled={!canConfirm || isSelfReview || busy === `confirm-${call.public_id}`}
onClick={() => void handleConfirmation(call, 'approve')}
>
<Check data-icon="inline-start" />
批准并执行
</Button>
</div>
</div>
) : null}
{call.confirmation_reason ? (
<p className="mt-3 text-xs text-muted-foreground">
确认意见:{call.confirmation_reason}
</p>
) : null}
</div>
)
})}
</CardContent>
</Card>
</TabsContent>
) : null}
</Tabs>
)}
<AppCreateDialog
open={providerOpen}
onOpenChange={(open) => {
setProviderOpen(open)
if (!open) setProviderError('')
}}
title="接入 HTTP MCP"
description="平台将通过 JSON-RPC 调用 tools/list 和 tools/call;访问令牌由后端加密保存。"
submitLabel="接入服务"
onSubmit={handleCreateProvider}
pending={busy === 'provider-create'}
error={providerError}
>
<FieldGroup>
<AppField label="服务名称" htmlFor="tool-provider-name" required>
<Input
id="tool-provider-name"
autoFocus
required
value={providerForm.name}
onChange={(event) => setProviderForm({ ...providerForm, name: event.target.value })}
placeholder="例如:内部客户查询 MCP"
/>
</AppField>
<AppField
label="JSON-RPC 地址"
htmlFor="tool-provider-url"
description="填写可直接接收 MCP JSON-RPC 请求的完整 HTTPS 地址。"
required
>
<Input
id="tool-provider-url"
type="url"
required
value={providerForm.base_url}
onChange={(event) => setProviderForm({ ...providerForm, base_url: event.target.value })}
placeholder="https://mcp.internal.example/rpc"
/>
</AppField>
<AppField
label="Bearer Token"
htmlFor="tool-provider-token"
description="可留空;保存后不会再次返回明文。"
>
<Input
id="tool-provider-token"
type="password"
autoComplete="new-password"
value={providerForm.token}
onChange={(event) => setProviderForm({ ...providerForm, token: event.target.value })}
placeholder="输入服务访问令牌"
/>
</AppField>
<AppField label="请求超时(秒)" htmlFor="tool-provider-timeout">
<Input
id="tool-provider-timeout"
type="number"
min="1"
max="300"
value={providerForm.timeout_seconds}
onChange={(event) => setProviderForm({
...providerForm,
timeout_seconds: event.target.value
})}
/>
</AppField>
</FieldGroup>
</AppCreateDialog>
</AppPage>
)
}

View File

@@ -0,0 +1,116 @@
import { apiRequest } from '@/lib/api'
import type {
ToolAuthorization,
ToolCall,
ToolPrincipalType,
ToolProvider,
ToolRiskLevel,
UnifiedTool
} from './types'
export function listToolProviders() {
return apiRequest<ToolProvider[]>('/tools/providers')
}
export function createToolProvider(payload: {
name: string
provider_type: 'mcp_http'
base_url: string
token: string
timeout_seconds: number
enabled: boolean
}) {
return apiRequest<ToolProvider>('/tools/providers', {
method: 'POST',
body: JSON.stringify(payload)
})
}
export function updateToolProvider(
providerId: number,
payload: Partial<Pick<ToolProvider, 'name' | 'base_url' | 'enabled' | 'timeout_seconds'>>
) {
return apiRequest<ToolProvider>(`/tools/providers/${providerId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
})
}
export function syncToolProvider(providerId: number) {
return apiRequest<UnifiedTool[]>(`/tools/providers/${providerId}/sync`, {
method: 'POST'
})
}
export function listTools() {
return apiRequest<UnifiedTool[]>('/tools')
}
export function updateTool(
toolId: number,
payload: Partial<Pick<UnifiedTool, 'name' | 'description' | 'risk_level' | 'enabled'>>
) {
return apiRequest<UnifiedTool>(`/tools/${toolId}`, {
method: 'PATCH',
body: JSON.stringify(payload)
})
}
export function listToolAuthorizations(toolId: number) {
return apiRequest<ToolAuthorization[]>(`/tools/${toolId}/authorizations`)
}
export function saveToolAuthorization(
toolId: number,
payload: {
principal_type: ToolPrincipalType
principal_id: number
allowed: boolean
}
) {
return apiRequest<ToolAuthorization>(`/tools/${toolId}/authorizations`, {
method: 'POST',
body: JSON.stringify(payload)
})
}
export function listToolCalls(filters?: { status?: string; tool_code?: string }) {
const search = new URLSearchParams()
if (filters?.status) search.set('status', filters.status)
if (filters?.tool_code) search.set('tool_code', filters.tool_code)
const query = search.toString()
return apiRequest<ToolCall[]>(`/tools/calls${query ? `?${query}` : ''}`)
}
export function createToolCall(
toolCode: string,
input: Record<string, unknown>,
idempotencyKey: string
) {
return apiRequest<ToolCall>('/tools/calls', {
method: 'POST',
body: JSON.stringify({
tool_code: toolCode,
input,
idempotency_key: idempotencyKey
})
})
}
export function confirmToolCall(
publicId: string,
decision: 'approve' | 'reject',
reason: string
) {
return apiRequest<ToolCall>(`/tools/calls/${publicId}/confirmation`, {
method: 'POST',
body: JSON.stringify({ decision, reason })
})
}
export const TOOL_RISK_LEVELS: ToolRiskLevel[] = [
'read_only',
'low_write',
'high_risk'
]

View File

@@ -0,0 +1,61 @@
export type ToolRiskLevel = 'read_only' | 'low_write' | 'high_risk'
export type ToolProviderType = 'native' | 'mcp_http'
export type ToolPrincipalType = 'user' | 'group' | 'agent'
export type ToolCallStatus =
| 'pending_confirmation'
| 'running'
| 'succeeded'
| 'failed'
| 'rejected'
export type ToolProvider = {
id: number
name: string
provider_type: ToolProviderType
base_url: string
credential_set: boolean
enabled: boolean
timeout_seconds: number
created_at: string
updated_at: string
}
export type UnifiedTool = {
id: number
code: string
provider_id: number | null
external_name: string
name: string
description: string
input_schema: Record<string, unknown>
risk_level: ToolRiskLevel
enabled: boolean
synced_at: string | null
}
export type ToolAuthorization = {
id: number
tool_id: number
principal_type: ToolPrincipalType
principal_id: number
allowed: boolean
}
export type ToolCall = {
public_id: string
tool_code: string
requested_by_id: number | null
requested_by_name: string
status: ToolCallStatus
input_payload: Record<string, unknown>
output_payload: unknown
authorization_snapshot: Record<string, unknown>
risk_snapshot: Record<string, unknown>
error_code: string
error_message: string
confirmation_status: string | null
confirmation_reason: string
created_at: string
started_at: string | null
finished_at: string | null
}