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 @@ -
智能体发起检索 · 引擎融合向量与图谱 · 召回知识增强生成
@@ -211,14 +305,14 @@ import { useInfoStore } from '@/stores/info' import { healthApi } from '@/apis/system_api' import UserInfoComponent from '@/components/UserInfoComponent.vue' import { - BookText, - Star, - GitFork, - CircleDot, ArrowRight, Workflow, Library, - Sparkles + Sparkles, + User, + Brain, + Terminal, + Puzzle } from 'lucide-vue-next' const router = useRouter() @@ -232,20 +326,8 @@ const isLoading = ref(true) const error = ref(null) const typedBadge = ref('') const isBadgeTyping = ref(false) -const githubStats = ref(null) let badgeTimer = null let subtitleTimer = null -let starsFetchController = null - -const GITHUB_REPO_API = 'https://api.github.com/repos/xerrors/Yuxi' -const GITHUB_STARS_TIMEOUT = 3000 - -const formatStars = (count) => { - if (!Number.isFinite(count) || count <= 0) { - return '' - } - return `${count}` -} const subtitleIndex = ref(0) @@ -303,46 +385,8 @@ const startSubtitleCarousel = () => { }, 2800) } -const stopStarsFetch = () => { - if (starsFetchController) { - starsFetchController.abort() - starsFetchController = null - } -} - -const fetchGithubRepo = async () => { - stopStarsFetch() - const controller = new AbortController() - starsFetchController = controller - const timer = setTimeout(() => { - controller.abort() - }, GITHUB_STARS_TIMEOUT) - - try { - const response = await fetch(GITHUB_REPO_API, { signal: controller.signal }) - if (!response.ok) { - return null - } - - const data = await response.json() - return { - stars: Number(data?.stargazers_count) || 0, - forks: Number(data?.forks_count) || 0, - issues: Number(data?.open_issues_count) || 0 - } - } catch { - return null - } finally { - clearTimeout(timer) - if (starsFetchController === controller) { - starsFetchController = null - } - } -} - -const getHeroBadgeText = (starsCount = null) => { - const realtimeStars = formatStars(starsCount) - return realtimeStars ? `已获得 ${realtimeStars} GitHub Stars` : '' +const getHeroBadgeText = () => { + return '在“智”的道路上稳步前行' } const stopBadgeTyping = () => { @@ -353,9 +397,9 @@ const stopBadgeTyping = () => { isBadgeTyping.value = false } -const startBadgeTyping = (starsCount = null) => { +const startBadgeTyping = () => { stopBadgeTyping() - const text = getHeroBadgeText(starsCount) + const text = getHeroBadgeText() typedBadge.value = '' if (!text) { @@ -398,14 +442,11 @@ const loadData = async () => { // 健康检查通过后加载配置 await infoStore.loadInfoConfig() startSubtitleCarousel() - const repo = await fetchGithubRepo() - githubStats.value = repo - startBadgeTyping(repo?.stars ?? null) + startBadgeTyping() } catch (e) { console.error('加载失败:', e) stopBadgeTyping() stopSubtitleCarousel() - stopStarsFetch() typedBadge.value = '' } finally { isLoading.value = false @@ -434,24 +475,11 @@ onMounted(() => { onUnmounted(() => { stopBadgeTyping() stopSubtitleCarousel() - stopStarsFetch() }) -const formatCount = (count) => - Number.isFinite(count) && count >= 0 ? count.toLocaleString('en-US') : '' - -// 首页统计直接展示实时的 GitHub 仓库数据,不再依赖 branding 配置 +// 首页不再展示统计数据 const realtimeStats = computed(() => { - const stats = githubStats.value - if (!stats) { - return [] - } - - return [ - { key: 'stars', label: 'Stars', value: formatCount(stats.stars), icon: Star }, - { key: 'forks', label: 'Forks', value: formatCount(stats.forks), icon: GitFork }, - { key: 'issues', label: 'Open Issues', value: formatCount(stats.issues), icon: CircleDot } - ] + return [] }) @@ -748,7 +776,7 @@ const realtimeStats = computed(() => { flex-wrap: wrap; gap: 1.25rem; align-items: center; - margin-top: 0.5rem; + margin-top: 3.5rem; } .button-base { @@ -857,6 +885,84 @@ const realtimeStats = computed(() => { justify-content: space-between; } +.flow-row--second { + margin-top: 0; +} + +/* 竖向连线:复刻横向 flow-link 的轨道 + 流动圆点,方向改为上下 */ +.flow-vlink { + position: relative; + width: 54px; + height: 36px; + margin: 0 auto; +} + +.flow-vrail { + position: absolute; + top: 4px; + bottom: 4px; + left: 50%; + width: 2px; + transform: translateX(-50%); + border-radius: 2px; + background: linear-gradient( + 180deg, + var(--main-50), + var(--main-200) 25%, + var(--main-200) 75%, + var(--main-50) + ); +} + +.flow-dot--down { + left: calc(50% - 5px); + background: var(--main-500); + box-shadow: 0 0 0 4px var(--main-50); + animation: flowDown 2.4s linear infinite; + animation-delay: calc(var(--i) * 1.2s); +} + +.flow-dot--up { + left: calc(50% + 5px); + transform: translateX(-100%); + background: var(--main-300); + box-shadow: 0 0 0 4px var(--main-30); + animation: flowUp 2.4s linear infinite; + animation-delay: calc(var(--i) * 1.2s + 0.6s); +} + +/* 中间排:左竖线 / 中央LLM / 右竖线,与上下排三列对齐 */ +.flow-mid { + display: flex; + align-items: center; + justify-content: space-between; +} + +.flow-vcol { + width: 76px; + flex-shrink: 0; + display: flex; + justify-content: center; +} + +/* 中央 LLM 居中 */ +.flow-center { + flex: 1; + display: flex; + justify-content: center; +} + +.flow-icon--hub.flow-icon--llm { + width: 150px; + height: 60px; + border-radius: 26px; +} + +/* 中央 LLM 节点不受普通节点 76px 宽度限制 */ +.flow-center .flow-node { + width: auto; +} + .flow-node { display: flex; flex-direction: column; @@ -1105,6 +1211,40 @@ const realtimeStats = computed(() => { } } +@keyframes flowDown { + 0% { + top: -4px; + opacity: 0; + } + 15% { + opacity: 1; + } + 85% { + opacity: 1; + } + 100% { + top: calc(100% - 4px); + opacity: 0; + } +} + +@keyframes flowUp { + 0% { + top: calc(100% - 4px); + opacity: 0; + } + 15% { + opacity: 1; + } + 85% { + opacity: 1; + } + 100% { + top: -4px; + opacity: 0; + } +} + @keyframes hubPulse { 0% { opacity: 0.6; diff --git a/web/src/views/LoginView.vue b/web/src/views/LoginView.vue index 9fe31ea2e..30677757b 100644 --- a/web/src/views/LoginView.vue +++ b/web/src/views/LoginView.vue @@ -257,9 +257,9 @@