feat: add secure agent and MCP extensions
This commit is contained in:
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
1
backend/apps/tools/__init__.py
Normal file
1
backend/apps/tools/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
273
backend/apps/tools/api.py
Normal file
273
backend/apps/tools/api.py
Normal 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
|
||||
6
backend/apps/tools/apps.py
Normal file
6
backend/apps/tools/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ToolsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.tools"
|
||||
137
backend/apps/tools/mcp_gateway.py
Normal file
137
backend/apps/tools/mcp_gateway.py
Normal 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})
|
||||
117
backend/apps/tools/migrations/0001_initial.py
Normal file
117
backend/apps/tools/migrations/0001_initial.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
0
backend/apps/tools/migrations/__init__.py
Normal file
0
backend/apps/tools/migrations/__init__.py
Normal file
161
backend/apps/tools/models.py
Normal file
161
backend/apps/tools/models.py
Normal 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)
|
||||
99
backend/apps/tools/schemas.py
Normal file
99
backend/apps/tools/schemas.py
Normal 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 = ""
|
||||
284
backend/apps/tools/services.py
Normal file
284
backend/apps/tools/services.py
Normal 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
|
||||
1
backend/apps/tools/tests/__init__.py
Normal file
1
backend/apps/tools/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
354
backend/apps/tools/tests/test_tool_runtime.py
Normal file
354
backend/apps/tools/tests/test_tool_runtime.py
Normal 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
|
||||
261
frontend/src/components/ui/context-menu.tsx
Normal file
261
frontend/src/components/ui/context-menu.tsx
Normal 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,
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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> }
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
116
frontend/src/features/tools/ToolManagementPage.test.tsx
Normal file
116
frontend/src/features/tools/ToolManagementPage.test.tsx
Normal 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',
|
||||
'已核对变更窗口'
|
||||
))
|
||||
})
|
||||
})
|
||||
896
frontend/src/features/tools/ToolManagementPage.tsx
Normal file
896
frontend/src/features/tools/ToolManagementPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
116
frontend/src/features/tools/api.ts
Normal file
116
frontend/src/features/tools/api.ts
Normal 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'
|
||||
]
|
||||
61
frontend/src/features/tools/types.ts
Normal file
61
frontend/src/features/tools/types.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user