fix: show tool parameters and skip duplicate calls

This commit is contained in:
2026-09-27 14:47:55 +08:00
parent 40fd07a1bc
commit e5bf1cf250
7 changed files with 192 additions and 42 deletions
+6
View File
@@ -98,3 +98,9 @@ uv run pytest -q
后续排查确认“旧结果未保存”来自 6 号会话三条已持久化的工具摘要,而非前端旧版本;这些摘要现已改为简短的评分项数量,会话本身仍在。
隔离 PostgreSQL 测试库执行 `pytest -q -m 'not integration'`:35 通过、2 个集成测试未运行。前端 `npm run build` 通过,Chromium `npm run test:e2e`:3 通过。API 与 Web 镜像已重新构建并以 `docker compose up -d --wait api web` 更新;新评分工具对限定范围返回六项,序列化后 6,820 字符。用真实项目检索“视觉检测 Otsu 光照鲁棒性 第48页”返回六条带原文与链接的结果,其中包含第 48 页。前端产物仍有 Vite 大块警告。
## 2026-09-27 工具参数与重复调用
7 号会话的四次 `get_scores` 实际分别读取评分项偏移量 0、6、12、18,旧工具记录只保存了批次与项目 ID,造成界面上看似相同参数。新记录保存经校验的默认值、查询词、筛选及分页参数;旧评分记录从结果恢复实际偏移量。7 号会话旧检索记录的查询词没有持久化,无法准确还原,界面会显示“历史调用未保存查询词”。同一轮内同名工具的相同有效参数只执行一次,后续调用标为“已跳过”,不同分页或查询正常执行。复核评语与原因继续省略,未知字段及密钥不保存。
隔离 PostgreSQL 测试容器执行 `pytest -q -m 'not integration'`:37 通过、2 个集成测试未运行;末次后端微调另运行针对性测试,1 通过。前端 `npm run build` 通过,Playwright Chromium `npm run test:e2e`:3 通过。测试覆盖注册工具参数字段、查询词与敏感文本边界、相同参数跳过、不同分页和查询正常执行,以及浏览器中旧记录偏移量恢复与参数展示。API 与 Web 镜像已构建并更新,两个容器均健康;正式页面通过全新 Chromium 会话打开 7 号会话,四次评分调用显示偏移量 0、6、12、18,旧检索调用显示未保存查询词提示。
+68 -1
View File
@@ -267,7 +267,8 @@ def test_sse_persists_messages_and_tool_failure(session_factory, monkeypatch):
assert [m["role"] for m in restored["messages"]] == ["user", "assistant"]
assert len(restored["messages"][1]["tool_calls"]) == 9
assert len({call["id"] for call in restored["messages"][1]["tool_calls"]}) == 9
assert all(call["status"] == "completed" and call["result"] for call in restored["messages"][1]["tool_calls"])
assert [call["status"] for call in restored["messages"][1]["tool_calls"]] == ["completed", *["skipped"] * 8]
assert all(call["result"] for call in restored["messages"][1]["tool_calls"])
assert restored["runs"][0]["status"] == "completed"
monkeypatch.setattr(chat_api, "client_for_chat", lambda db: (_ for _ in ()).throw(ModelError("不支持工具调用")))
with client.stream("POST", f"/api/conversations/{created['id']}/messages", json={"content": "再试"}) as response:
@@ -285,6 +286,72 @@ def test_sse_persists_messages_and_tool_failure(session_factory, monkeypatch):
app.dependency_overrides.clear()
def test_tool_arguments_cover_registered_fields_without_review_prose():
numbers = {"course_version_id", "batch_id", "project_id", "score_id", "task_id", "rubric_version_id",
"score", "offset", "limit", "project_offset", "criterion_offset"}
strings = {"query", "criterion_id", "format", "status", "student_no", "relative_dir"}
redacted = {"comment", "reason"}
fields = set().union(*(tool.schema.model_fields for tool in agent_tools.TOOLS.values()))
assert fields == numbers | strings | redacted
args = {key: 8 for key in numbers} | {key: "电机控制" for key in strings} | {key: "学生原文" for key in redacted}
saved = chat_api._safe_arguments(args)
assert set(saved) == fields
assert saved["query"] == "电机控制" and saved["criterion_offset"] == 8
assert saved["comment"] == saved["reason"] == "[文本已省略]"
assert "学生原文" not in json.dumps(saved, ensure_ascii=False)
assert chat_api._safe_arguments({"api_key": "secret"}) == {"omitted_fields": 1}
def test_identical_tool_calls_are_skipped_but_distinct_pages_and_queries_run(session_factory, monkeypatch):
requested = [
("get_scores", {"batch_id": 3, "project_id": 1}),
("get_scores", {"batch_id": 3, "project_id": 1, "project_offset": 0, "criterion_offset": 0}),
("get_scores", {"batch_id": 3, "project_id": 1, "criterion_offset": 6}),
("search_project_material", {"project_id": 1, "query": "电机控制"}),
("search_project_material", {"query": "电机控制", "project_id": 1}),
("search_project_material", {"project_id": 1, "query": "视觉方案"}),
]
class FakeClient:
def tool_call(self, messages, _tools):
if any(message["role"] == "tool" for message in messages):
return {"role": "assistant", "content": "查询完成", "tool_calls": []}
return {"role": "assistant", "content": None, "tool_calls": [
{"id": f"call-{index}", "type": "function",
"function": {"name": name, "arguments": json.dumps(args, ensure_ascii=False)}}
for index, (name, args) in enumerate(requested)]}
executed = []
def fake_execute(_db, _conversation, name, arguments, _text):
executed.append((name, arguments))
return {"found": True, "citations": []} if name.startswith("search_") else {
"count": 1, "results": [], "project_offset": arguments.get("project_offset", 0),
"criterion_offset": arguments.get("criterion_offset", 0)}
monkeypatch.setattr(chat_api, "client_for_chat", lambda _db: FakeClient())
monkeypatch.setattr(chat_api, "execute", fake_execute)
def override_db():
with session_factory() as session:
yield session
app.dependency_overrides[get_db] = override_db
try:
client = TestClient(app)
created = client.post("/api/conversations", json={"title": "查重"}).json()
with client.stream("POST", f"/api/conversations/{created['id']}/messages", json={"content": "查询"}) as response:
body = "".join(response.iter_text())
assert "event: done" in body
assert [(name, args.get("criterion_offset", 0), args.get("query")) for name, args in executed] == [
("get_scores", 0, None), ("get_scores", 6, None),
("search_project_material", 0, "电机控制"), ("search_project_material", 0, "视觉方案")]
restored = client.get(f"/api/conversations/{created['id']}").json()
calls = restored["messages"][1]["tool_calls"]
assert [call["status"] for call in calls] == ["completed", "skipped", "completed", "completed", "skipped", "completed"]
assert calls[0]["arguments"]["criterion_offset"] == 0
assert calls[2]["arguments"]["criterion_offset"] == 6
assert calls[3]["arguments"]["query"] == "电机控制"
assert calls[4]["result"]["previous_call_id"] == calls[3]["id"]
assert restored["runs"][0]["tool_calls"] == calls
finally:
app.dependency_overrides.clear()
def test_failed_tool_calls_count_toward_limit(monkeypatch):
class RepeatingClient:
def tool_call(self, messages, tools):
+6 -4
View File
@@ -162,10 +162,12 @@ def test_background_runs_survive_no_stream_and_isolate_parallel_events(session_f
app.dependency_overrides.clear()
def test_tool_record_redacts_text_and_secrets():
saved = chat_api._safe_arguments({"project_id": 2, "query": "学生原文", "api_key": "secret", "comment": "敏感评语"})
def test_tool_record_keeps_bounded_query_but_redacts_review_text_and_secrets():
saved = chat_api._safe_arguments({"project_id": 2, "query": "电机控制", "api_key": "secret", "comment": "敏感评语"})
assert saved["project_id"] == 2
assert "学生原文" not in str(saved) and "secret" not in str(saved) and "敏感评语" not in str(saved)
assert saved["query"] == "电机控制"
assert "secret" not in str(saved) and "敏感评语" not in str(saved)
assert len(chat_api._safe_arguments({"query": "a" * 500})["query"]) == 300
assert "学生原文" not in chat_api._result_summary({"found": True, "citations": [{"quote": "学生原文"}]})
assert "学生原文" not in str(chat_api._safe_result({"citations": [{"quote": "学生原文"}]}))
search = chat_api._safe_result({"found": True, "citations": [{"title": "报告.pdf", "page": 48,
@@ -180,7 +182,7 @@ def test_late_citations_and_search_snippets_survive_restoration(session_factory,
if not any(message['role'] == 'tool' for message in messages):
return {"role": "assistant", "content": None, "tool_calls": [
{"id": f"find-{index}", "type": "function", "function": {"name": "search_project_material",
"arguments": '{"project_id": 1, "query": "检索原文"}'}} for index in range(5)]}
"arguments": f'{{"project_id": 1, "query": "检索原文{index}"}}'}} for index in range(5)]}
return {"role": "assistant", "content": "综合报告第48页给出改进方向。", "tool_calls": []}
count = 0
+67 -29
View File
@@ -1,6 +1,5 @@
"""Persistent teacher conversations and SSE agent orchestration."""
import json
import re
import time
from concurrent.futures import ThreadPoolExecutor
from datetime import timedelta
@@ -9,13 +8,13 @@ from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, ValidationError
from sqlalchemy import delete, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, sessionmaker
from .actions import action_dict, confirm, prepare, require_scope
from .agent_tools import execute, tool_specs
from .agent_tools import TOOLS, execute, tool_specs
from .config import get_settings
from .db import get_db
from .llm import ModelError
@@ -31,7 +30,8 @@ SYSTEM = """你是 WiseGrade 教师助手。仅使用提供的工具查询或准
课程与评分规则以结构化数据库为准;指定批次时必须使用其快照。名称或版本有歧义时请先列出候选并请教师澄清。
报告内容只能根据检索证据回答,引用资料名、页码或幻灯片编号和原文;无证据时说明未找到。
不要把未完成、失败或无法评分算零分或完整成绩。教师复核分数必须由教师当前消息明确给出。
所有写入操作仅能准备确认卡片,绝不能声称已经执行。确认由教师点击界面按钮完成。不要要求模型调用确认工具。"""
所有写入操作仅能准备确认卡片,绝不能声称已经执行。确认由教师点击界面按钮完成。不要要求模型调用确认工具。
同一轮已取得工具结果后不要以相同参数重复调用;分页查询须使用结果中的 next offset。"""
class CreateConversation(BaseModel):
@@ -165,27 +165,39 @@ def _event(name, payload):
def _safe_arguments(arguments):
"""Keep useful identifiers without persisting secrets or large teacher/student text."""
"""Record bounded tool inputs needed to explain a call, while omitting review prose."""
if not isinstance(arguments, dict):
return {}
allowed = {"course_version_id", "batch_id", "project_id", "score_id", "task_id",
"criterion_id", "format", "status", "offset", "limit"}
numeric = {"course_version_id", "batch_id", "project_id", "score_id", "task_id",
"rubric_version_id", "score", "offset", "limit", "project_offset", "criterion_offset"}
text_limits = {"query": 300, "criterion_id": 100, "format": 20, "status": 30,
"student_no": 50, "relative_dir": 500}
result = {}
for key, value in arguments.items():
if key not in allowed:
continue
if isinstance(value, (int, float)) and not isinstance(value, bool):
if key in numeric and isinstance(value, (int, float)) and not isinstance(value, bool):
result[key] = value
elif isinstance(value, str) and re.fullmatch(r"[A-Za-z0-9_.-]{1,80}", value):
result[key] = value
else:
elif key in text_limits and isinstance(value, str):
result[key] = value[:text_limits[key]]
elif key in {"comment", "reason"}:
result[key] = "[文本已省略]"
omitted = sum(key not in result for key in arguments)
omitted = sum(key not in result for key in arguments if key not in {"comment", "reason"})
if omitted:
result["omitted_text_fields"] = omitted
result["omitted_fields"] = omitted
return result
def _tool_key(name, arguments):
"""Use validated defaults so omitted and explicit pagination defaults match."""
tool = TOOLS.get(name)
if not tool:
return None
try:
normalized = tool.schema.model_validate(arguments).model_dump(mode="json", exclude_none=True)
except ValidationError:
return None
return json.dumps([name, normalized], sort_keys=True, ensure_ascii=False)
def _result_summary(result):
if not isinstance(result, dict):
return "工具已返回结果"
@@ -291,6 +303,7 @@ def _run_agent(factory, conversation_id, run_id, user_text):
run = db.get(AgentRun, run_id)
citations = []
used = []
successful_calls = {}
try:
client = client_for_chat(db)
if hasattr(client, "verify_tool_support"):
@@ -336,24 +349,49 @@ def _run_agent(factory, conversation_id, run_id, user_text):
call_id = call.get("id") or f"run-{run.id}-call-{ordinal}"
entry = {"id": f"{run.id}:{ordinal}:{call_id}", "name": name[:100],
"status": "running", "arguments": {}}
used.append(entry)
run.tool_calls = list(used)
yield _record(db, run, "tool", entry)
try:
arguments = json.loads(call.get("function", {}).get("arguments", "{}"))
if not isinstance(arguments, dict):
raise ValueError("工具参数须为对象")
entry = {**entry, "arguments": _safe_arguments(arguments)}
result = execute(db, conversation, name, arguments, user_text)
if isinstance(result, dict):
citations.extend(result.get("citations", []))
output = json.dumps(result, ensure_ascii=False, default=str)
if len(output) > 12000:
output = json.dumps({"notice": "结果已按结构压缩;可使用工具分页参数继续查询",
**_safe_result(result, name)}, ensure_ascii=False, default=str)
messages.append({"role": "tool", "tool_call_id": call_id, "content": output})
entry = {**entry, "status": "completed", "summary": _result_summary(result),
"result": _safe_result(result, name)}
visible_arguments = arguments
if name in TOOLS:
try:
visible_arguments = TOOLS[name].schema.model_validate(arguments).model_dump(exclude_none=True)
except ValidationError:
pass
entry["arguments"] = _safe_arguments(visible_arguments)
parse_error = None
except (TypeError, ValueError) as exc:
arguments = {}
parse_error = exc
used.append(entry)
run.tool_calls = list(used)
yield _record(db, run, "tool", entry)
try:
if parse_error:
raise parse_error
key = _tool_key(name, arguments)
previous = successful_calls.get(key) if key else None
if previous:
notice = {"notice": "同一轮已用相同参数调用此工具,请使用先前结果",
"previous_call_id": previous["tool_call_id"]}
messages.append({"role": "tool", "tool_call_id": call_id,
"content": json.dumps(notice, ensure_ascii=False)})
entry = {**entry, "status": "skipped", "summary": "重复调用已跳过",
"result": {"previous_call_id": previous["entry_id"]}}
else:
result = execute(db, conversation, name, arguments, user_text)
if isinstance(result, dict):
citations.extend(result.get("citations", []))
output = json.dumps(result, ensure_ascii=False, default=str)
if len(output) > 12000:
output = json.dumps({"notice": "结果已按结构压缩;可使用工具分页参数继续查询",
**_safe_result(result, name)}, ensure_ascii=False, default=str)
messages.append({"role": "tool", "tool_call_id": call_id, "content": output})
entry = {**entry, "status": "completed", "summary": _result_summary(result),
"result": _safe_result(result, name)}
if key:
successful_calls[key] = {"entry_id": entry["id"], "tool_call_id": call_id}
except Exception as exc:
# Tool errors are model-visible, but do not grant broader access or leak provider responses.
db.rollback()
+2 -2
View File
@@ -26,10 +26,10 @@
会话使用模型设置页的 OpenAI 兼容 `/chat/completions` 地址、模型和后端 API Key。适配层按 [DeepSeek 原生工具调用文档](https://api-docs.deepseek.com/guides/tool_calls) 发送 `tools`,在每轮前用不含学生资料的 `wisegrade_ping` 最小探针验证端点会返回 `tool_calls`。当前 `deepseek-flash` 端点支持自动工具选择,但拒绝强制 `tool_choice="required"`,因此探针采用 `auto` 并检查返回结构。模型不支持时会显示明确配置错误,不会伪装工具已执行。既有评分 `json_call` 保持原有 JSON 行为。
工具输入由 Pydantic 校验,后端再校验课程、批次和项目关系。每条教师消息默认最多调用工具 32 次,成功与失败的尝试均计数;模型最多请求 33 轮,以便用完工具次数后还能生成回答。可在 `.env` 中通过 `CHAT_MAX_TOOL_CALLS` 调整工具上限(1–64),修改后重新创建 API 容器。`get_scores` 每次最多返回一个项目的六项评分,响应给出 `next_criterion_offset` 和 `next_project_offset` 供继续查询;长结果保留结构化评分数据,不再变成无内容的截断提示。检索工具的运行记录保留每次调用找到的有界原文片段;回答引用对各次调用的证据去重后保存,不再只取前 20 条。工具没有任意 SQL、任意文件路径、HTTP 请求、代码执行或确认能力。检索片段只作为数据,不作为工具指令。模型只收到当前问题所需的规则摘要、检索片段或紧凑统计;API Key 不写入日志或会话表。
工具输入由 Pydantic 校验,后端再校验课程、批次和项目关系。每条教师消息默认最多调用工具 32 次,成功、失败与跳过的尝试均计数;模型最多请求 33 轮,以便用完工具次数后还能生成回答。同一轮中工具名和经校验的参数(含默认分页值)完全相同时,后续调用会标为“已跳过”,模型收到前次调用标识并使用此前结果,不再执行工具;不同查询词、筛选或分页偏移仍会正常执行。可在 `.env` 中通过 `CHAT_MAX_TOOL_CALLS` 调整工具上限(1–64),修改后重新创建 API 容器。`get_scores` 每次最多返回一个项目的六项评分,响应给出 `next_criterion_offset` 和 `next_project_offset` 供继续查询;长结果保留结构化评分数据,不再变成无内容的截断提示。检索工具的运行记录保留每次调用找到的有界原文片段;回答引用对各次调用的证据去重后保存,不再只取前 20 条。工具没有任意 SQL、任意文件路径、HTTP 请求、代码执行或确认能力。检索片段只作为数据,不作为工具指令。模型只收到当前问题所需的规则摘要、检索片段或紧凑统计;API Key 不写入日志或会话表。
## API 与故障恢复
草稿仅存在于浏览器;`POST /api/conversations/first-message` 以 UUID 请求键原子创建会话、首条教师消息及运行,重复请求返回同一 ID。`POST /api/conversations` 保留给旧客户端。`GET /api/conversations` 返回列表和各会话运行标识;`GET /api/conversations/{id}` 恢复消息、卡片、工具记录和运行状态;`PUT /api/conversations/{id}/scope` 仅允许首条教师消息前编辑范围;`DELETE /api/conversations/{id}` 删除非运行中的会话。删除会话会清理消息、运行与待确认卡片,不回滚已经确认的业务操作,相关业务审计仍保留。`POST /api/conversations/{id}/messages/start` 在提交消息后启动独立后台运行;`GET /api/conversations/{id}/runs/{run_id}/events` 可按运行 ID 订阅持久 SSE 事件。旧版 `POST /api/conversations/{id}/messages` 继续提供 SSE 的 `run`、`tool`、`message`、`error`、`done` 事件。确认、取消和导入编辑分别为 `POST /api/conversations/{id}/actions/{action_id}/confirm|cancel|edit-import`。索引任务可通过 `GET /api/index-tasks`、`GET /api/index-tasks/{id}` 查询,失败任务可重试。Nginx 对消息流关闭代理缓冲并设置 900 秒读取超时。
回答生成由后台线程执行,页面切换或 SSE 断开不会停止运行;前端按运行 ID 订阅 SSE,并轮询列表和当前会话详情恢复进度,可同时运行多个不同会话。同一会话仍拒绝并发发送。刷新后默认打开新草稿,点击历史会话即可查看正在运行的工具状态或最终结果。运行中的会话删除返回冲突,应待完成后重试。服务进程异常留下的 `running` 记录超过 15 分钟,在读取会话时标为中断。模型失败时,已提交的教师消息仍保留,可在同一会话继续提问。工具参数仅保存必要标识,文本和敏感字段以占位符代替;检索工具记录有界原文片段,评分工具记录有界评分行,其他工具记录结构摘要。回答按安全 Markdown 渲染,HTML 不执行,链接仅允许 HTTP(S);工具过程在回答上方,引用在回答最后,二者默认折叠。待确认卡片始终独立显示。若嵌入模型或 OCR 缺失,任务保留具体失败原因;补齐离线模型后重试。若课程资料缺失、原件哈希变化、课程或批次状态变化,应重新预览。
回答生成由后台线程执行,页面切换或 SSE 断开不会停止运行;前端按运行 ID 订阅 SSE,并轮询列表和当前会话详情恢复进度,可同时运行多个不同会话。同一会话仍拒绝并发发送。刷新后默认打开新草稿,点击历史会话即可查看正在运行的工具状态或最终结果。运行中的会话删除返回冲突,应待完成后重试。服务进程异常留下的 `running` 记录超过 15 分钟,在读取会话时标为中断。模型失败时,已提交的教师消息仍保留,可在同一会话继续提问。工具记录保存有界查询词、筛选、分页及必要标识;教师复核评语和原因只显示省略标记,未知字段与密钥不保存。旧记录中未保存的检索查询词无法还原,界面明确标明;旧评分记录可从工具结果恢复实际分页偏移。检索工具记录有界原文片段,评分工具记录有界评分行,其他工具记录结构摘要。回答按安全 Markdown 渲染,HTML 不执行,链接仅允许 HTTP(S);工具过程在回答上方,引用在回答最后,二者默认折叠。待确认卡片始终独立显示。若嵌入模型或 OCR 缺失,任务保留具体失败原因;补齐离线模型后重试。若课程资料缺失、原件哈希变化、课程或批次状态变化,应重新预览。
+29 -3
View File
@@ -7,7 +7,7 @@ import { api, post, put, subscribeRun } from './api';
const { Title, Text, Paragraph } = Typography;
const kinds: Record<string, string> = { import: '导入学生材料', course_ingest: '课程资料入库', create_batch: '创建评分批次', start_batch: '启动/重试评分批次', review: '教师复核', export: '导出成绩或反馈', index_retry: '重试索引任务' };
const categories: Record<string, string> = { stage1: '阶段报告1', stage2: '阶段报告2', stage3: '阶段报告3', final_report: '最终报告', presentation: '汇报PPT/PDF' };
const statusColor = (status: string) => status === 'completed' || status === 'ready' ? 'green' : status === 'failed' || status === 'stale' ? 'red' : 'orange';
const statusColor = (status: string) => status === 'completed' || status === 'ready' ? 'green' : status === 'failed' || status === 'stale' ? 'red' : status === 'skipped' ? 'default' : 'orange';
const reportError = (error: unknown) => message.error(error instanceof Error ? error.message : String(error));
function ImportEditor({ action, conversationId, reload }: { action: any; conversationId: number; reload: () => void }) {
@@ -108,6 +108,32 @@ function Citations({ items }: { items: any[] }) {
if (!unique.length) return null;
return <section className="chat-citations" aria-label="引用资料"><details className="chat-citation-group"><summary><Text strong>引用 · {unique.length}</Text></summary><CitationCards items={unique} /></details></section>;
}
const argumentLabels: Record<string, string> = {
query: '查询词 (query)', course_version_id: '课程版本 ID', batch_id: '评分批次 ID', project_id: '学生项目 ID',
score_id: '评分 ID', task_id: '任务 ID', rubric_version_id: '量规版本 ID', criterion_id: '评分项 ID',
project_offset: '项目偏移量', criterion_offset: '评分项偏移量', offset: '偏移量', limit: '数量上限',
status: '状态筛选', student_no: '学号', relative_dir: '目录', score: '分数', format: '格式',
};
function visibleArguments(call: any) {
const args: Record<string, unknown> = call.arguments && typeof call.arguments === 'object' && !Array.isArray(call.arguments)
? { ...call.arguments } : {};
let missing = Number(args.omitted_text_fields || args.omitted_fields || 0);
delete args.omitted_text_fields;
delete args.omitted_fields;
// Older score calls kept their effective offsets in the result but dropped them from arguments.
if (call.name === 'get_scores') for (const field of ['project_offset', 'criterion_offset']) {
if (!(field in args) && typeof call.result?.[field] === 'number') {
args[field] = call.result[field];
missing = Math.max(0, missing - 1);
}
}
if (['search_course_material', 'search_project_material'].includes(call.name) && !('query' in args) && missing > 0) {
args.query = '历史调用未保存查询词';
missing -= 1;
}
if (missing > 0) args['历史未保存参数'] = `${missing} 项`;
return Object.fromEntries(Object.entries(args).map(([field, value]) => [argumentLabels[field] || field, value]));
}
function Tools({ calls }: { calls: any[] }) {
if (!calls?.length) return null;
const active = calls.filter(call => call.status === 'running').length;
@@ -115,9 +141,9 @@ function Tools({ calls }: { calls: any[] }) {
return <section className="chat-tools" aria-label="工具调用"><details className="chat-tool-group"><summary><Text strong>工具调用 · {calls.length}</Text>
{active > 0 && <Tag color="processing">{active} 项进行中</Tag>}{failed > 0 && <Tag color="red">{failed} 项失败</Tag>}</summary>
<div className="chat-tool-list">{calls.map((call, index) => <details key={call.id || index} className="chat-tool"><summary><Space wrap size="small"><Text code>{call.name || '未知工具'}</Text>
<Tag color={statusColor(call.status || 'completed')}>{call.status === 'running' ? '进行中' : call.status === 'failed' ? '失败' : '成功'}</Tag>
<Tag color={statusColor(call.status || 'completed')}>{call.status === 'running' ? '进行中' : call.status === 'failed' ? '失败' : call.status === 'skipped' ? '已跳过' : '成功'}</Tag>
<Text type="secondary">{call.status === 'running' ? '正在执行' : call.error ? String(call.error).slice(0, 80) : call.summary || (typeof call.result === 'string' ? call.result : '已完成')}</Text></Space></summary>
<div className="tool-detail"><Text strong>调用参数</Text><pre>{JSON.stringify(call.arguments || {}, null, 2)}</pre>
<div className="tool-detail"><Text strong>调用参数</Text><pre>{JSON.stringify(visibleArguments(call), null, 2)}</pre>
<Text strong>{call.error ? '错误' : '调用结果'}</Text><pre>{call.error || (typeof call.result === 'string' ? call.result : JSON.stringify(call.result ?? '运行中', null, 2))}</pre></div>
</details>)}</div></details></section>;
}
+14 -3
View File
@@ -8,10 +8,14 @@ async function mockApi(page: Page) {
content: '# 标题\n\n段落 [安全链接](https://example.com) [危险链接](javascript:alert(1)) <img src=x onerror=alert(1)>\n\n> 引用\n\n- 列表\n\n| A | B |\n|---|---|\n| 1 | 2 |\n\n```js\nconst x = 1;\n```',
citations: [{ title: '资料.pdf', page: 2, quote: '原文摘录', url: '/api/pages/2' }, { title: '资料.pdf', page: 2, quote: '原文摘录', url: '/api/pages/2' },
{ chunk_id: 2977, title: '综合报告.pdf', page: 48, quote: '第48页原文片段', url: '/api/pages/48' }],
tool_calls: [{ id: 'call-one', name: 'search_project_material', status: 'completed', arguments: { project_id: 1 },
tool_calls: [{ id: 'call-one', name: 'search_project_material', status: 'completed', arguments: { project_id: 1, omitted_text_fields: 1 },
summary: '找到 1 条引用', result: { found: true, citations: [{ page: 48, quote: '第48页原文片段' }] } },
{ id: 'call-two', name: 'get_scores', status: 'completed', arguments: { batch_id: 3, criterion_offset: 0 },
summary: '返回 1 项评分', result: { next_criterion_offset: 6, results: [{ rows: [{ criterion_id: 'C1', final_score: 8 }] }] } }] }],
{ id: 'call-two', name: 'get_scores', status: 'completed', arguments: { batch_id: 3, omitted_text_fields: 1 },
summary: '返回 1 项评分', result: { project_offset: 0, criterion_offset: 6, next_criterion_offset: 12, results: [{ rows: [{ criterion_id: 'C1', final_score: 8 }] }] } },
{ id: 'call-three', name: 'search_project_material', status: 'completed', arguments: { project_id: 1, query: '视觉检测' },
summary: '找到 1 条引用', result: { found: true, citations: [] } },
{ id: 'call-four', name: 'search_project_material', status: 'skipped', arguments: { project_id: 1, query: '视觉检测' },
summary: '重复调用已跳过', result: { previous_call_id: 'call-three' } }] }],
actions: [], runs: [{ id: 1, status: 'completed', tool_calls: [] }] },
};
let nextId = 8;
@@ -103,6 +107,13 @@ test('markdown, citations, tool folding and narrow layout are safe', async ({ pa
await expect(page.locator('.tool-detail').first()).toContainText('第48页原文片段');
await page.locator('.chat-tool').nth(1).locator('summary').click();
await expect(page.locator('.chat-tool').nth(1).locator('.tool-detail')).toContainText('C1');
await expect(page.locator('.chat-tool').nth(1).locator('.tool-detail')).toContainText('评分项偏移量');
await expect(page.locator('.chat-tool').nth(1).locator('.tool-detail')).toContainText('6');
await expect(page.locator('.chat-tool').first().locator('.tool-detail')).toContainText('历史调用未保存查询词');
await page.locator('.chat-tool').nth(2).locator('summary').click();
await expect(page.locator('.chat-tool').nth(2).locator('.tool-detail')).toContainText('查询词 (query)');
await expect(page.locator('.chat-tool').nth(2).locator('.tool-detail')).toContainText('视觉检测');
await expect(page.locator('.chat-tool').nth(3).locator('summary')).toContainText('已跳过');
await page.locator('.chat-citation-group > summary').click();
await expect(page.locator('.chat-citation-group')).toHaveAttribute('open', '');
await expect(page.getByText('资料.pdf')).toBeVisible();