diff --git a/.env.template b/.env.template index cf98ca6f5..210bde130 100644 --- a/.env.template +++ b/.env.template @@ -72,3 +72,9 @@ YUXI_CORS_ORIGINS= # KUBECONFIG_PATH=/root/.kube/config # THREAD_PVC=yuxi-thread # SKILLS_PVC=yuxi-skills # 当前代码会读取,但 Pod 挂载实际仍只使用 THREAD_PVC + +# ===== Docker Compose Profiles ===== +# GPU 文档解析服务 (mineru-api / paddlex) 的启动开关。 +# 有 NVIDIA GPU:保持 gpu,docker compose up -d 会自动带上它们。 +# 无 GPU 机器:置空 (COMPOSE_PROFILES=) 即可跳过这两个服务,避免启动失败。 +COMPOSE_PROFILES=gpu diff --git a/MinerU/docker/Dockerfile b/MinerU/docker/Dockerfile new file mode 100644 index 000000000..00c95c0e3 --- /dev/null +++ b/MinerU/docker/Dockerfile @@ -0,0 +1,27 @@ +# Use DaoCloud mirrored vllm image for China region for gpu with Volta、Turing、Ampere、Ada Lovelace、Hopper、Blackwell architecture (7.0 <= Compute Capability <= 12.1) +# The default base image uses vLLM 0.21.0 with CUDA 13.0. For CUDA 12.9 environments, switch to the commented cu129 image below. +# Compute Capability version query (https://developer.nvidia.com/cuda-gpus) +# support x86_64 architecture and ARM(AArch64) architecture +FROM docker.m.daocloud.io/vllm/vllm-openai:v0.21.0 +# FROM docker.m.daocloud.io/vllm/vllm-openai:v0.21.0-cu129 + +# Install libgl for opencv support & Noto fonts for Chinese characters +RUN apt-get update && \ + apt-get install -y \ + fonts-noto-core \ + fonts-noto-cjk \ + fontconfig \ + libgl1 && \ + fc-cache -fv && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +# Install mineru latest +RUN python3 -m pip install -U 'mineru[core]>=3.2.1' -i https://mirrors.aliyun.com/pypi/simple --break-system-packages && \ + python3 -m pip cache purge + +# Download models and update the configuration file +RUN /bin/bash -c "mineru-models-download -s modelscope -m all" + +# Set the entry point to activate the virtual environment and run the command line tool +ENTRYPOINT ["/bin/bash", "-c", "export MINERU_MODEL_SOURCE=local && exec \"$@\"", "--"] diff --git a/MinerU/docker/compose.yaml b/MinerU/docker/compose.yaml new file mode 100644 index 000000000..56b58c9e6 --- /dev/null +++ b/MinerU/docker/compose.yaml @@ -0,0 +1,122 @@ +services: + mineru-openai-server: + image: mineru:latest + container_name: mineru-openai-server + restart: always + profiles: ["openai-server"] + ports: + - 30000:30000 + environment: + MINERU_MODEL_SOURCE: local + entrypoint: mineru-openai-server + command: + --host 0.0.0.0 + --port 30000 + --gpu-memory-utilization 0.5 # If encountering VRAM shortage, reduce the KV cache size by this parameter; if VRAM issues persist, try lowering it further to `0.4` or below. + ulimits: + memlock: -1 + stack: 67108864 + ipc: host + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:30000/health || exit 1"] + deploy: + resources: + reservations: + devices: + - driver: nvidia + device_ids: ["0"] # Modify for multiple GPUs: ["0", "1"] + capabilities: [gpu] + + mineru-api: + image: mineru:latest + container_name: mineru-api + restart: always + profiles: ["api"] + ports: + - 8000:8000 + environment: + MINERU_MODEL_SOURCE: local + entrypoint: mineru-api + command: + --host 0.0.0.0 + --port 8000 + # --allow-public-http-client # Disabled by default; when binding to 0.0.0.0 or ::, this re-enables *-http-client backends and server_url. Enable only if you accept the SSRF risk. + # parameters for vllm-engine + # --gpu-memory-utilization 0.5 # If encountering VRAM shortage, reduce the KV cache size by this parameter; if VRAM issues persist, try lowering it further to `0.4` or below. + ulimits: + memlock: -1 + stack: 67108864 + ipc: host + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8000/health || exit 1"] + deploy: + resources: + reservations: + devices: + - driver: nvidia + device_ids: ["0"] # Modify for multiple GPUs: ["0", "1"] + capabilities: [gpu] + + mineru-router: + image: mineru:latest + container_name: mineru-router + restart: always + profiles: ["router"] + ports: + - 8002:8002 + environment: + MINERU_MODEL_SOURCE: local + entrypoint: mineru-router + command: + --host 0.0.0.0 + --port 8002 + --local-gpus auto + # --allow-public-http-client # Disabled by default; when binding to 0.0.0.0 or ::, this re-enables *-http-client backends and server_url. Enable only if you accept the SSRF risk. + # To aggregate existing mineru-api services instead of starting local workers: + # --local-gpus none + # --upstream-url http://mineru-api:8000 + # --upstream-url http://mineru-api-2:8000 + # parameters for vllm-engine + # --gpu-memory-utilization 0.5 # If encountering VRAM shortage, reduce the KV cache size by this parameter; if VRAM issues persist, try lowering it further to `0.4` or below. + ulimits: + memlock: -1 + stack: 67108864 + ipc: host + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8002/health || exit 1"] + deploy: + resources: + reservations: + devices: + - driver: nvidia + device_ids: ["0"] # Modify for multiple GPUs: ["0", "1"] + capabilities: [gpu] + + mineru-gradio: + image: mineru:latest + container_name: mineru-gradio + restart: always + profiles: ["gradio"] + ports: + - 7860:7860 + environment: + MINERU_MODEL_SOURCE: local + entrypoint: mineru-gradio + command: + --server-name 0.0.0.0 + --server-port 7860 + # --enable-api false # If you want to disable the API, set this to false + # --max-convert-pages 20 # If you want to limit the number of pages for conversion, set this to a specific number + # parameters for vllm-engine + # --gpu-memory-utilization 0.5 # If encountering VRAM shortage, reduce the KV cache size by this parameter; if VRAM issues persist, try lowering it further to `0.4` or below. + ulimits: + memlock: -1 + stack: 67108864 + ipc: host + deploy: + resources: + reservations: + devices: + - driver: nvidia + device_ids: ["0"] # Modify for multiple GPUs: ["0", "1"] + capabilities: [gpu] diff --git a/MinerU/docker/mineru.compose.yml b/MinerU/docker/mineru.compose.yml new file mode 100644 index 000000000..b90b6bdf0 --- /dev/null +++ b/MinerU/docker/mineru.compose.yml @@ -0,0 +1,122 @@ +services: + mineru-openai-server: + image: mineru:latest + container_name: mineru-openai-server + restart: always + profiles: ["openai-server"] + ports: + - 30000:30000 + environment: + MINERU_MODEL_SOURCE: local + entrypoint: mineru-openai-server + command: + --host 0.0.0.0 + --port 30000 + # --gpu-memory-utilization 0.5 # If encountering VRAM shortage, reduce the KV cache size by this parameter; if VRAM issues persist, try lowering it further to `0.4` or below. + ulimits: + memlock: -1 + stack: 67108864 + ipc: host + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:30000/health || exit 1"] + deploy: + resources: + reservations: + devices: + - driver: nvidia + device_ids: ["0"] # Modify for multiple GPUs: ["0", "1"] + capabilities: [gpu] + + mineru-api: + image: mineru:latest + container_name: mineru-api + restart: always + profiles: ["api"] + ports: + - 8000:8000 + environment: + MINERU_MODEL_SOURCE: local + entrypoint: mineru-api + command: + --host 0.0.0.0 + --port 8000 + # --allow-public-http-client # Disabled by default; when binding to 0.0.0.0 or ::, this re-enables *-http-client backends and server_url. Enable only if you accept the SSRF risk. + # parameters for vllm-engine + # --gpu-memory-utilization 0.5 # If encountering VRAM shortage, reduce the KV cache size by this parameter; if VRAM issues persist, try lowering it further to `0.4` or below. + ulimits: + memlock: -1 + stack: 67108864 + ipc: host + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8000/health || exit 1"] + deploy: + resources: + reservations: + devices: + - driver: nvidia + device_ids: ["0"] # Modify for multiple GPUs: ["0", "1"] + capabilities: [gpu] + + mineru-router: + image: mineru:latest + container_name: mineru-router + restart: always + profiles: ["router"] + ports: + - 8002:8002 + environment: + MINERU_MODEL_SOURCE: local + entrypoint: mineru-router + command: + --host 0.0.0.0 + --port 8002 + --local-gpus auto + # --allow-public-http-client # Disabled by default; when binding to 0.0.0.0 or ::, this re-enables *-http-client backends and server_url. Enable only if you accept the SSRF risk. + # To aggregate existing mineru-api services instead of starting local workers: + # --local-gpus none + # --upstream-url http://mineru-api:8000 + # --upstream-url http://mineru-api-2:8000 + # parameters for vllm-engine + # --gpu-memory-utilization 0.5 # If encountering VRAM shortage, reduce the KV cache size by this parameter; if VRAM issues persist, try lowering it further to `0.4` or below. + ulimits: + memlock: -1 + stack: 67108864 + ipc: host + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8002/health || exit 1"] + deploy: + resources: + reservations: + devices: + - driver: nvidia + device_ids: ["0"] # Modify for multiple GPUs: ["0", "1"] + capabilities: [gpu] + + mineru-gradio: + image: mineru:latest + container_name: mineru-gradio + restart: always + profiles: ["gradio"] + ports: + - 7860:7860 + environment: + MINERU_MODEL_SOURCE: local + entrypoint: mineru-gradio + command: + --server-name 0.0.0.0 + --server-port 7860 + # --enable-api false # If you want to disable the API, set this to false + # --max-convert-pages 20 # If you want to limit the number of pages for conversion, set this to a specific number + # parameters for vllm-engine + # --gpu-memory-utilization 0.5 # If encountering VRAM shortage, reduce the KV cache size by this parameter; if VRAM issues persist, try lowering it further to `0.4` or below. + ulimits: + memlock: -1 + stack: 67108864 + ipc: host + deploy: + resources: + reservations: + devices: + - driver: nvidia + device_ids: ["0"] # Modify for multiple GPUs: ["0", "1"] + capabilities: [gpu] diff --git a/backend/package/yuxi/config/static/info.template.yaml b/backend/package/yuxi/config/static/info.template.yaml index 65665e010..03fdfc57f 100644 --- a/backend/package/yuxi/config/static/info.template.yaml +++ b/backend/package/yuxi/config/static/info.template.yaml @@ -3,7 +3,7 @@ # 组织信息 organization: - name: "江南语析" # 完整组织名称 + name: "楚天智航" # 完整组织名称 logo: "/favicon.svg" # Logo文件路径(放在 web/public 目录下) avatar: "/avatar.jpg" # 头像文件路径(放在 web/public 目录下) login_bg: "/login-bg.jpg" # 登录背景图片路径(放在 web/public 目录下) diff --git a/backend/package/yuxi/models/providers/builtin.py b/backend/package/yuxi/models/providers/builtin.py index 77208a915..f9e7c0971 100644 --- a/backend/package/yuxi/models/providers/builtin.py +++ b/backend/package/yuxi/models/providers/builtin.py @@ -46,6 +46,7 @@ "type": "embedding", "display_name": "text-embedding-v4", "dimension": 1024, + "batch_size": 40, }, { "id": "qwen3-rerank", diff --git a/backend/package/yuxi/services/agent_run_service.py b/backend/package/yuxi/services/agent_run_service.py index d8f662636..80c2dac2d 100644 --- a/backend/package/yuxi/services/agent_run_service.py +++ b/backend/package/yuxi/services/agent_run_service.py @@ -146,6 +146,8 @@ def _compact_stream_chunk(chunk: dict) -> dict: "interrupt_info", "source", "agent_state", + "action_requests", + "review_configs", ) if chunk.get(key) is not None and chunk.get(key) != "" } diff --git a/backend/package/yuxi/services/chat_service.py b/backend/package/yuxi/services/chat_service.py index b005942fb..0c3ee0e90 100644 --- a/backend/package/yuxi/services/chat_service.py +++ b/backend/package/yuxi/services/chat_service.py @@ -593,6 +593,45 @@ def _build_ask_user_question_payload(info: Any, thread_id: str) -> dict[str, Any } +def _is_human_approval_payload(payload: dict) -> bool: + """判断 interrupt 是否为 HumanInTheLoopMiddleware 的工具审批载荷。 + + HIL 中间件产生的 interrupt value 含 ``action_requests``(待审批的工具调用) + 与 ``review_configs``(每个工具允许的决策类型),与 ask_user_question 的 + ``questions`` 结构不同。用 ``action_requests`` 作为判别依据。 + """ + action_requests = payload.get("action_requests") + return isinstance(action_requests, list) and len(action_requests) > 0 + + +def _build_human_approval_payload(info: Any, thread_id: str) -> dict[str, Any]: + """将 HIL 工具审批 interrupt 标准化为 human_approval_required 载荷。""" + payload = _coerce_interrupt_payload(info) + + action_requests = payload.get("action_requests") or [] + review_configs = payload.get("review_configs") or [] + + # 为每个 action_request 补齐 description(供前端展示),保留原始字段 + normalized_actions: list[dict[str, Any]] = [] + for action in action_requests: + if not isinstance(action, dict): + continue + action = dict(action) + if not action.get("description"): + action["description"] = "操作需要确认\n\nTool: {name}\nArgs: {args}".format( + name=action.get("name", ""), + args=action.get("args", {}), + ) + normalized_actions.append(action) + + return { + "action_requests": normalized_actions, + "review_configs": review_configs, + "source": "human_approval", + "thread_id": thread_id, + } + + def _ensure_full_msg(full_msg: AIMessage | None, accumulated_content: list[str]) -> AIMessage | None: """如果 full_msg 为空且有累积内容,构建 AIMessage""" if not full_msg and accumulated_content: @@ -673,9 +712,15 @@ async def check_and_handle_interrupts( interrupt_info = _extract_interrupt_info(state) if interrupt_info: - question_payload = _build_ask_user_question_payload(interrupt_info, thread_id) - meta["interrupt"] = question_payload - yield make_chunk(status="ask_user_question_required", meta=meta, **question_payload) + payload = _coerce_interrupt_payload(interrupt_info) + if _is_human_approval_payload(payload): + approval_payload = _build_human_approval_payload(interrupt_info, thread_id) + meta["interrupt"] = approval_payload + yield make_chunk(status="human_approval_required", meta=meta, **approval_payload) + else: + question_payload = _build_ask_user_question_payload(interrupt_info, thread_id) + meta["interrupt"] = question_payload + yield make_chunk(status="ask_user_question_required", meta=meta, **question_payload) except Exception as e: logger.exception(f"Error checking interrupts: {e}") diff --git a/backend/package/yuxi/services/run_worker.py b/backend/package/yuxi/services/run_worker.py index 32e56f668..74d50a5e3 100644 --- a/backend/package/yuxi/services/run_worker.py +++ b/backend/package/yuxi/services/run_worker.py @@ -229,6 +229,35 @@ def _chunk_thread_id(chunk: dict, fallback: str | None) -> str | None: return _thread_id_from_mapping(chunk) or fallback +def _interrupt_summary(chunk: dict) -> str: + """从 interrupt chunk 提取人类可读的摘要。 + + 兼容两类 interrupt 载荷:ask_user_question 的 ``questions`` 与 + HumanInTheLoop 的 ``action_requests``(工具审批)。无可用信息时返回空串。 + """ + if not isinstance(chunk, dict): + return "" + + questions = chunk.get("questions") + if isinstance(questions, list) and questions: + first = questions[0] + if isinstance(first, dict): + return str(first.get("question") or "").strip() + + action_requests = chunk.get("action_requests") + if isinstance(action_requests, list) and action_requests: + first = action_requests[0] + if isinstance(first, dict): + name = str(first.get("name") or "").strip() + args = first.get("args") + if name: + if args: + return f"操作需要确认: {name}({args})" + return f"操作需要确认: {name}" + + return "" + + def _map_chunk_to_run_event(chunk: dict) -> tuple[str, dict]: status = chunk.get("status") or "event" if status == "loading": @@ -407,12 +436,7 @@ async def process_agent_run(ctx, run_id: str): await _append_end_event(run_id, status_value, thread_id=thread_id, payload={"chunk": chunk}) terminal_set = True elif status in {"ask_user_question_required", "human_approval_required"}: - questions = chunk.get("questions") if isinstance(chunk, dict) else None - first_question = "" - if isinstance(questions, list) and questions: - first = questions[0] - if isinstance(first, dict): - first_question = str(first.get("question") or "").strip() + first_question = _interrupt_summary(chunk) await mark_run_terminal( run_id, diff --git a/backend/test/unit/services/test_agent_run_service.py b/backend/test/unit/services/test_agent_run_service.py index e92dab05f..7552ea630 100644 --- a/backend/test/unit/services/test_agent_run_service.py +++ b/backend/test/unit/services/test_agent_run_service.py @@ -1145,3 +1145,46 @@ async def set_input_message(self, run_id: str, message_id: int): ) assert captured["input_payload"]["model_spec"] == "parent-model" + + +class TestCompactStreamChunkHil: + """测试 _compact_stream_chunk 在 verbose=false 精简时保留 HIL 工具审批字段。 + + 回归:修复前白名单漏掉 action_requests/review_configs,前端收到空弹窗。 + """ + + def test_compact_keeps_human_approval_action_requests(self): + chunk = { + "status": "human_approval_required", + "action_requests": [{"name": "delete_file", "args": {"path": "/x"}}], + "review_configs": [{"action_name": "delete_file", "allowed_decisions": ["approve", "reject"]}], + "source": "human_approval", + } + compact = agent_run_service._compact_stream_chunk(chunk) + + assert compact["status"] == "human_approval_required" + assert compact["action_requests"] == chunk["action_requests"] + assert compact["review_configs"] == chunk["review_configs"] + + def test_compact_keeps_ask_user_question_questions(self): + """ask_user_question 的 questions 仍保留(未回归)。""" + chunk = { + "status": "ask_user_question_required", + "questions": [{"question": "选择一个", "options": ["A"]}], + } + compact = agent_run_service._compact_stream_chunk(chunk) + + assert compact["questions"] == chunk["questions"] + + def test_compact_drops_missing_fields(self): + """白名单外的字段被丢弃;白名单内但未提供的字段不出现。""" + chunk = { + "status": "human_approval_required", + "extra_field": "should be dropped", + } + compact = agent_run_service._compact_stream_chunk(chunk) + + assert compact["status"] == "human_approval_required" + assert "extra_field" not in compact + assert "action_requests" not in compact + assert "review_configs" not in compact diff --git a/backend/test/unit/services/test_chat_stream_interrupt.py b/backend/test/unit/services/test_chat_stream_interrupt.py index b8e7edf74..72c40d75f 100644 --- a/backend/test/unit/services/test_chat_stream_interrupt.py +++ b/backend/test/unit/services/test_chat_stream_interrupt.py @@ -12,10 +12,14 @@ from yuxi.services.chat_service import ( _normalize_interrupt_questions, _build_ask_user_question_payload, + _build_human_approval_payload, + _is_human_approval_payload, _coerce_interrupt_payload, + check_and_handle_interrupts, stream_agent_resume, ) from yuxi.services import chat_service as svc +from yuxi.services import run_worker from yuxi.utils.question_utils import normalize_options @@ -215,9 +219,12 @@ class FakeAgent: context_schema = FakeContext async def stream_resume_with_state(self, resume_command, input_context=None, **kwargs): - yield "messages", ( - {"content": "child token", "id": "msg-child"}, - {"namespace": ["task:1"], "thread_id": "child-thread"}, + yield ( + "messages", + ( + {"content": "child token", "id": "msg-child"}, + {"namespace": ["task:1"], "thread_id": "child-thread"}, + ), ) async def get_graph(self, context=None): @@ -290,3 +297,132 @@ def test_string_input(self): def test_none_input(self): result = _coerce_interrupt_payload(None) assert isinstance(result, dict) + + +class TestHumanApprovalPayloadDetection: + """测试 HIL 工具审批 interrupt 的判别与载荷构建。""" + + def test_is_human_approval_payload_true_for_action_requests(self): + payload = {"action_requests": [{"name": "delete_file", "args": {"path": "/x"}}], "review_configs": []} + assert _is_human_approval_payload(payload) is True + + def test_is_human_approval_payload_false_for_questions(self): + assert _is_human_approval_payload({"questions": [{"question": "Q"}]}) is False + + def test_is_human_approval_payload_false_for_empty(self): + assert _is_human_approval_payload({}) is False + assert _is_human_approval_payload({"action_requests": []}) is False + + def test_build_human_approval_payload_preserves_action_requests(self): + info = { + "action_requests": [{"name": "send_email", "args": {"to": "a@b.com"}, "description": "需要确认"}], + "review_configs": [{"action_name": "send_email", "allowed_decisions": ["approve", "reject"]}], + } + result = _build_human_approval_payload(info, "thread-hil-1") + + assert result["source"] == "human_approval" + assert result["thread_id"] == "thread-hil-1" + assert len(result["action_requests"]) == 1 + assert result["action_requests"][0]["name"] == "send_email" + assert result["action_requests"][0]["description"] == "需要确认" + assert result["review_configs"][0]["allowed_decisions"] == ["approve", "reject"] + + def test_build_human_approval_payload_fills_missing_description(self): + """HIL payload 缺 description 时补默认提示,便于前端展示。""" + info = {"action_requests": [{"name": "delete_file", "args": {"path": "/tmp/x"}}], "review_configs": []} + result = _build_human_approval_payload(info, "thread-hil-2") + + desc = result["action_requests"][0]["description"] + assert "delete_file" in desc + assert "/tmp/x" in desc + + +class TestCheckAndHandleInterruptsRouting: + """测试 check_and_handle_interrupts 按载荷类型分流到 ask_user_question / human_approval。""" + + @pytest.mark.asyncio + async def test_routes_human_approval_interrupt_to_human_approval_required(self): + """HIL interrupt(含 action_requests)应发 human_approval_required,不再塞默认空问题。""" + hil_value = { + "action_requests": [{"name": "delete_file", "args": {"path": "/x"}}], + "review_configs": [{"action_name": "delete_file", "allowed_decisions": ["approve", "reject"]}], + } + + class FakeGraph: + async def aget_state(self, _config): + return SimpleNamespace( + values={"__interrupt__": [SimpleNamespace(value=hil_value)]}, + tasks=[], + ) + + class FakeAgent: + async def get_graph(self): + return FakeGraph() + + chunks: list[dict] = [] + + def make_chunk(status, meta, **kwargs): + return json.dumps({"status": status, "meta": meta, **kwargs}, ensure_ascii=False).encode("utf-8") + + stream = check_and_handle_interrupts(FakeAgent(), {"configurable": {"thread_id": "t"}}, make_chunk, {}, "t") + async for raw in stream: + chunks.append(json.loads(raw.decode("utf-8"))) + + assert len(chunks) == 1 + assert chunks[0]["status"] == "human_approval_required" + assert "action_requests" in chunks[0] + assert chunks[0]["action_requests"][0]["name"] == "delete_file" + assert "review_configs" in chunks[0] + # 不应再出现被误塞的默认空问题 + assert "questions" not in chunks[0] + + @pytest.mark.asyncio + async def test_routes_ask_user_question_interrupt_unchanged(self): + """ask_user_question interrupt(含 questions)仍走原有 ask_user_question_required 通道。""" + info_value = {"questions": [{"question": "选择一个", "options": ["A", "B"]}]} + + class FakeGraph: + async def aget_state(self, _config): + return SimpleNamespace( + values={"__interrupt__": [SimpleNamespace(value=info_value)]}, + tasks=[], + ) + + class FakeAgent: + async def get_graph(self): + return FakeGraph() + + chunks: list[dict] = [] + + def make_chunk(status, meta, **kwargs): + return json.dumps({"status": status, "meta": meta, **kwargs}, ensure_ascii=False).encode("utf-8") + + stream = check_and_handle_interrupts(FakeAgent(), {"configurable": {"thread_id": "t"}}, make_chunk, {}, "t") + async for raw in stream: + chunks.append(json.loads(raw.decode("utf-8"))) + + assert len(chunks) == 1 + assert chunks[0]["status"] == "ask_user_question_required" + assert chunks[0]["questions"][0]["question"] == "选择一个" + + +class TestInterruptSummary: + """测试 run_worker._interrupt_summary 兼容两类 interrupt 载荷。""" + + def test_summary_from_questions(self): + chunk = {"questions": [{"question": "是否继续?"}]} + assert run_worker._interrupt_summary(chunk) == "是否继续?" + + def test_summary_from_action_requests_with_args(self): + chunk = {"action_requests": [{"name": "delete_file", "args": {"path": "/x"}}]} + summary = run_worker._interrupt_summary(chunk) + assert "delete_file" in summary + assert "/x" in summary + + def test_summary_from_action_requests_without_args(self): + chunk = {"action_requests": [{"name": "send_email", "args": {}}]} + assert run_worker._interrupt_summary(chunk) == "操作需要确认: send_email" + + def test_summary_empty_when_no_payload(self): + assert run_worker._interrupt_summary({}) == "" + assert run_worker._interrupt_summary(None) == "" diff --git a/docker-compose.yml b/docker-compose.yml index 1619a303e..817b3a6cf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -345,8 +345,9 @@ services: dockerfile: docker/mineru.Dockerfile image: mineru-vllm:latest container_name: mineru-api + # GPU 服务:随 .env 的 COMPOSE_PROFILES=gpu 默认启动;无 GPU 机器置空即可跳过 profiles: - - all + - gpu env_file: - .env ports: @@ -384,8 +385,9 @@ services: dockerfile: docker/paddlex.Dockerfile image: paddlex:latest container_name: paddlex-ocr + # GPU 服务:随 .env 的 COMPOSE_PROFILES=gpu 默认启动;无 GPU 机器置空即可跳过 profiles: - - all + - gpu volumes: - ./docker/volumes/paddlex:/paddlex ports: diff --git a/docs/.vitepress/theme/components/YuxiHome.vue b/docs/.vitepress/theme/components/YuxiHome.vue index eb0d31912..92d71cf11 100644 --- a/docs/.vitepress/theme/components/YuxiHome.vue +++ b/docs/.vitepress/theme/components/YuxiHome.vue @@ -2,8 +2,8 @@ import { ref, computed } from 'vue' import { withBase } from 'vitepress' -const GITHUB = 'https://github.com/xerrors/Yuxi' -const DEMO = 'https://www.bilibili.com/video/BV1TZEx6NEit/' +// const GITHUB = 'https://github.com/xerrors/Yuxi' +// const DEMO = 'https://www.bilibili.com/video/BV1TZEx6NEit/' // 关键数字(占位,后续替换为真实数据) const stats = [ diff --git a/docs/develop-guides/changelog.md b/docs/develop-guides/changelog.md index 0a734e808..5da05305d 100644 --- a/docs/develop-guides/changelog.md +++ b/docs/develop-guides/changelog.md @@ -8,7 +8,7 @@ ### 开发记录 -- 新增 Yuxi Python CLI 首版底座:新增独立 `packages/yuxi-cli` 包,提供 `remote add/use/list/ping`、`login --browser`、`login --api-key`、`whoami`、`status`、`logout`;配置统一写入 `~/.yuxi/config.toml`,remote URL 只保留实例入口并派生 `/api` 请求路径。后端新增 `/api/auth/cli/sessions` device flow 授权接口与 `cli_auth_sessions` 持久表,浏览器确认后为当前用户创建一次性返回的 API Key;新增公开 `/api/system/discovery` 声明服务端版本、API 前缀、CLI 能力和关键端点,CLI 登录前校验服务端版本至少为 `0.7.1`(`0.7.1.dev*` 按 release tuple 兼容)及对应能力;前端新增 `/auth/cli/authorize` 授权确认页。补充 CLI 本地单测与后端服务/路由单测。 +- 修复 LangChain `HumanInTheLoopMiddleware` 工具审批 interrupt 在前后端多处被按 ask_user_question 误处理的问题。HIL 触发的 interrupt 载荷为 `action_requests`/`review_configs` 结构(HITLRequest),与 ask_user_question 的 `questions` 结构不同,原有逻辑在三处按 `questions` 假设导致 HIL 不可用:① `check_and_handle_interrupts` 无 `questions` 时塞默认空问题「请选择一个选项」,前端收到空弹窗;② `_compact_stream_chunk` 的 verbose=false 精简白名单漏掉 `action_requests`/`review_configs`,前端拿不到审批载荷;③ 前端 `hasPendingInterruptForRun` 只认 `questions`,HIL 中断被判为非 interrupt,弹窗一闪而过。现按 payload 是否含 `action_requests` 分流:后端 `check_and_handle_interrupts` 对 HIL 中断发出 `human_approval_required` 并携带 `action_requests`/`review_configs`,`_compact_stream_chunk` 白名单补入这两个字段,`run_worker` 摘要兼容两类载荷;前端 `useApproval`/`hasPendingInterruptForRun` 兼容 `actionRequests`,HIL 中断渲染新的 `HumanToolApprovalModal`,提供「确认执行/编辑参数/拒绝」三种决策,提交时发送 `{ decisions: [{ type: "approve" | "reject" | "edit", ... }] }`,与 LangGraph `Command(resume=...)` 恢复语义一致。ask_user_question 中断保持原有行为不变。补充后端单元测试(payload 分流、摘要生成、compact 字段保留)与前端 `extractPendingInterrupt` 分流测试。 - 安全与健壮性加固:token 兑换接口改为 `POST /api/auth/cli/sessions/token`,`device_code` 改走请求体,避免凭据出现在访问日志的 URL 路径中;兑换与批准会话时对会话行加 `with_for_update` 行锁,防止并发/重试导致重复签发 API Key;CLI 浏览器登录轮询区分瞬时错误(网络层错误、5xx)与终止错误,瞬时错误继续重试而非中断整个登录;`config.toml` 以 `0600` 原子创建并对名称等写入值做引号/反斜杠转义,避免明文凭据短暂可读及特殊字符破坏配置;API Key 认证在绑定用户失效时改为直接拒绝,不再 fallback 到部门管理员或 superadmin,创建 API Key 时校验部门与关联用户一致,用户软删除会同步禁用其 API Key;Dashboard 管理接口与前端入口改为仅 superadmin 可访问;用户软删除脱敏名改用用户主键生成,避免短哈希碰撞触发唯一索引冲突;前端授权页新增确认提示与对结构化错误 `detail` 的兼容渲染。 - 收敛 API Key 生成逻辑:移除独立 API Key 生成服务,统一通过 `AuthUtils.generate_api_key()` 生成 CLI 授权与用户管理中的 API Key。 - 收敛认证模块命名:CLI 浏览器授权路由合并到 `auth_router.py`,授权会话服务迁移到 `auth_service.py`。 diff --git a/web/index.html b/web/index.html index e23f29ede..ac1f72803 100644 --- a/web/index.html +++ b/web/index.html @@ -4,7 +4,7 @@ - 语析 - Knowledge Management + 楚天智航
diff --git a/web/public/1avatar.jpg b/web/public/1avatar.jpg new file mode 100644 index 000000000..080155b37 Binary files /dev/null and b/web/public/1avatar.jpg differ diff --git a/web/public/avatar.jpg b/web/public/avatar.jpg index 43e40a91b..6e15edaf2 100644 Binary files a/web/public/avatar.jpg and b/web/public/avatar.jpg differ diff --git a/web/public/favicon.svg b/web/public/favicon.svg index 4ab988793..e233cd3fa 100644 --- a/web/public/favicon.svg +++ b/web/public/favicon.svg @@ -1,10 +1,1537 @@ - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/public/login-bg.jpg b/web/public/login-bg.jpg index 724725464..ee87fc58e 100644 Binary files a/web/public/login-bg.jpg and b/web/public/login-bg.jpg differ diff --git a/web/src/components/AgentChatComponent.vue b/web/src/components/AgentChatComponent.vue index a501978ab..f0f0c9e5e 100644 --- a/web/src/components/AgentChatComponent.vue +++ b/web/src/components/AgentChatComponent.vue @@ -125,11 +125,21 @@
+
@@ -596,6 +606,7 @@ import { storeToRefs } from 'pinia' import { MessageProcessor } from '@/utils/messageProcessor' import { agentApi, threadApi } from '@/apis' import HumanApprovalModal from '@/components/HumanApprovalModal.vue' +import HumanToolApprovalModal from '@/components/HumanToolApprovalModal.vue' import { useApproval } from '@/composables/useApproval' import { useAgentThreadState } from '@/composables/useAgentThreadState' import { useAgentRunStream } from '@/composables/useAgentRunStream' @@ -1307,8 +1318,23 @@ const currentApprovalModalVisible = computed( Boolean(approvalState.threadId) && approvalState.threadId === currentChatId.value ) +const isAskUserApproval = computed( + () => approvalState.kind !== 'human_approval' +) const currentApprovalQuestions = computed(() => - currentApprovalModalVisible.value ? approvalState.questions : [] + currentApprovalModalVisible.value && isAskUserApproval.value + ? approvalState.questions + : [] +) +const currentApprovalActionRequests = computed(() => + currentApprovalModalVisible.value && !isAskUserApproval.value + ? approvalState.actionRequests + : [] +) +const currentApprovalReviewConfigs = computed(() => + currentApprovalModalVisible.value && !isAskUserApproval.value + ? approvalState.reviewConfigs + : [] ) const shouldSuppressRefsForApproval = () => @@ -2488,6 +2514,15 @@ const handleQuestionCancel = () => { handleApprovalWithStream('reject') } +// HIL 工具审批:approve/edit/reject 均发送 { decisions: [...] } +const handleToolApprovalSubmit = (payload) => { + handleApprovalWithStream(payload) +} + +const handleToolApprovalReject = (payload) => { + handleApprovalWithStream(payload) +} + const buildExportPayload = () => { const agentId = currentAgentId.value let agentDescription = '' diff --git a/web/src/components/HumanToolApprovalModal.vue b/web/src/components/HumanToolApprovalModal.vue new file mode 100644 index 000000000..87ed83ee1 --- /dev/null +++ b/web/src/components/HumanToolApprovalModal.vue @@ -0,0 +1,406 @@ +