From 00508f69257929b39f54d287d4c1abea96733cac Mon Sep 17 00:00:00 2001 From: leoxyang Date: Thu, 27 Aug 2026 11:58:05 +0800 Subject: [PATCH 01/28] feat(channels): add DingTalk QR registration --- dashboard/src/api/modules/channel.ts | 23 +++ dashboard/src/locales/en.json | 13 ++ dashboard/src/locales/zh.json | 13 ++ .../pages/Agent/Channels/ChannelsPanel.tsx | 9 + .../Channels/components/ChannelDrawer.tsx | 164 +++++++++++++++ src/octop/api/routers/channels.py | 190 +++++++++++++++++- .../gateway/channels/dingtalk_registration.py | 57 ++++++ tests/unit/gateway/test_channels_qr.py | 159 ++++++++++++++- 8 files changed, 624 insertions(+), 4 deletions(-) create mode 100644 src/octop/infra/gateway/channels/dingtalk_registration.py diff --git a/dashboard/src/api/modules/channel.ts b/dashboard/src/api/modules/channel.ts index 1fe842c4..ffc5ef07 100644 --- a/dashboard/src/api/modules/channel.ts +++ b/dashboard/src/api/modules/channel.ts @@ -150,6 +150,29 @@ export const channelApi = { body: JSON.stringify({ qrcode_token }), }), + /** Start DingTalk one-click application registration. */ + dingtalkQrcodeGenerate: (agentId: string) => + request<{ + registration_id: string; + qrcode_url: string; + user_code: string; + expires_in: number; + interval: number; + }>(`/agents/${agentId}/channels/dingtalk/qrcode/generate`, { + method: "POST", + }), + + /** Poll DingTalk registration; credentials are persisted by the backend. */ + dingtalkQrcodePoll: (agentId: string, registration_id: string) => + request<{ + status: "waiting" | "success" | "failed" | "expired"; + channel_id?: string; + message?: string; + }>(`/agents/${agentId}/channels/dingtalk/qrcode/poll`, { + method: "POST", + body: JSON.stringify({ registration_id }), + }), + /** Start the Feishu bot auto-creation flow. */ feishuBotCreatorStart: ( agentId: string, diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index bce08fe8..c24998c5 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -1845,6 +1845,19 @@ "weixinQrStep2": "Scan the QR code below to authorize", "weixinQrStep3": "Credentials will be auto-filled after scanning", "weixinQrScanHint": "Scan with WeChat App", + "dingtalkQrLoading": "Initializing DingTalk application registration...", + "dingtalkQrIntro1": "Generate a DingTalk application authorization QR code", + "dingtalkQrIntro2": "Scan and confirm in DingTalk while signed in to the same organization", + "dingtalkQrIntro3": "Octop creates and enables the DingTalk channel automatically", + "dingtalkQrStep1": "Open DingTalk App", + "dingtalkQrStep2": "Scan the application authorization code", + "dingtalkQrStep3": "Confirm application creation", + "dingtalkQrScanHint": "Scan with DingTalk App to finish authorization", + "dingtalkUserCode": "User code", + "dingtalkGenerateQr": "Generate DingTalk QR Code", + "dingtalkBindSuccess": "DingTalk application created and channel enabled", + "dingtalkQrExpired": "DingTalk authorization expired; generate a new QR code", + "dingtalkQrFailed": "DingTalk application authorization failed", "qqQrIntro1": "Generate a QQ Bot binding QR code", "qqQrIntro2": "Scan and confirm with mobile QQ", "qqQrIntro3": "Save the returned App ID and secret automatically", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index d94c3228..a5d70b17 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -1844,6 +1844,19 @@ "weixinQrStep2": "扫描下方二维码完成授权", "weixinQrStep3": "扫码成功后自动获取凭据并接入", "weixinQrScanHint": "请使用微信扫描二维码", + "dingtalkQrLoading": "正在初始化钉钉应用创建流程...", + "dingtalkQrIntro1": "点击按钮生成钉钉应用授权二维码", + "dingtalkQrIntro2": "使用同一组织账号登录的钉钉 App 扫码并确认", + "dingtalkQrIntro3": "Octop 将自动创建并启用钉钉频道", + "dingtalkQrStep1": "打开钉钉 App", + "dingtalkQrStep2": "扫描应用授权二维码", + "dingtalkQrStep3": "确认创建应用", + "dingtalkQrScanHint": "请使用钉钉 App 扫码完成授权", + "dingtalkUserCode": "用户码", + "dingtalkGenerateQr": "生成钉钉扫码二维码", + "dingtalkBindSuccess": "钉钉应用已创建,频道已启用", + "dingtalkQrExpired": "钉钉授权已过期,请重新生成二维码", + "dingtalkQrFailed": "钉钉应用授权失败", "qqQrIntro1": "点击按钮生成 QQ 机器人绑定二维码", "qqQrIntro2": "使用手机 QQ 扫码并确认授权", "qqQrIntro3": "自动获取 App ID 和密钥并保存频道", diff --git a/dashboard/src/pages/Agent/Channels/ChannelsPanel.tsx b/dashboard/src/pages/Agent/Channels/ChannelsPanel.tsx index de4c6d09..3a233779 100644 --- a/dashboard/src/pages/Agent/Channels/ChannelsPanel.tsx +++ b/dashboard/src/pages/Agent/Channels/ChannelsPanel.tsx @@ -240,6 +240,14 @@ export default function ChannelsPanel({ agentId }: ChannelsPanelProps) { setDrawerInitialValues(undefined); }, []); + const handleProvisioned = useCallback(() => { + message.success(t("channels.dingtalkBindSuccess")); + setDrawerOpen(false); + setEditing(null); + setDrawerInitialValues(undefined); + void fetchChannels(); + }, [fetchChannels, t]); + const handleSubmit = useCallback( async ( kind: ChannelKey, @@ -435,6 +443,7 @@ export default function ChannelsPanel({ agentId }: ChannelsPanelProps) { onDelete={editing ? handleDeleteFromDrawer : undefined} onClose={handleDrawerClose} onSubmit={handleSubmit} + onProvisioned={handleProvisioned} onTest={handleTestFromDrawer} testing={ testState.loadingKey !== null && diff --git a/dashboard/src/pages/Agent/Channels/components/ChannelDrawer.tsx b/dashboard/src/pages/Agent/Channels/components/ChannelDrawer.tsx index 1aa24bd3..eafbf816 100644 --- a/dashboard/src/pages/Agent/Channels/components/ChannelDrawer.tsx +++ b/dashboard/src/pages/Agent/Channels/components/ChannelDrawer.tsx @@ -87,6 +87,7 @@ const QUICK_CONFIG_CHANNELS: ChannelKey[] = [ "qq", "wecom", "weixin", + "dingtalk", "feishu", "yuanbao", ]; @@ -114,6 +115,12 @@ type QrPhase = token: string; baseUrl: string; } + | { + phase: "dingtalk_ready"; + qrcodeUrl: string; + userCode: string; + } + | { phase: "dingtalk_success"; channelId: string } | { phase: "feishu_creating"; message: string } | { phase: "feishu_qr"; qrToken: string } | { phase: "feishu_progress"; message: string } @@ -146,6 +153,7 @@ interface ChannelDrawerProps { config: Record, enabled: boolean, ) => Promise; + onProvisioned: () => void; onTest?: () => void; testing?: boolean; agentId: string; @@ -456,6 +464,7 @@ export function ChannelDrawer({ deleting, onClose, onSubmit, + onProvisioned, onTest, testing, agentId, @@ -641,6 +650,61 @@ export function ChannelDrawer({ } }, [agentId, stopPolling]); + // ── DingTalk Flow ─────────────────────────────────────────────────────── + const startDingtalkQr = useCallback(async () => { + setQrState({ phase: "loading" }); + try { + const res = await channelApi.dingtalkQrcodeGenerate(agentId); + setQrState({ + phase: "dingtalk_ready", + qrcodeUrl: res.qrcode_url, + userCode: res.user_code, + }); + let polling = false; + const timer = setInterval( + async () => { + if (polling) return; + polling = true; + try { + const poll = await channelApi.dingtalkQrcodePoll( + agentId, + res.registration_id, + ); + if (poll.status === "success" && poll.channel_id) { + stopPolling(); + setQrState({ + phase: "dingtalk_success", + channelId: poll.channel_id, + }); + onProvisioned(); + } else if (poll.status === "failed" || poll.status === "expired") { + stopPolling(); + setQrState({ + phase: "error", + reason: + poll.message ?? + (poll.status === "expired" + ? t("channels.dingtalkQrExpired") + : t("channels.dingtalkQrFailed")), + }); + } + } catch { + // Transient network error: keep polling until DingTalk expires the flow. + } finally { + polling = false; + } + }, + Math.max(1000, res.interval * 1000), + ); + pollTimerRef.current = timer; + } catch (e: unknown) { + setQrState({ + phase: "error", + reason: e instanceof Error ? e.message : String(e), + }); + } + }, [agentId, onProvisioned, stopPolling, t]); + // ── Feishu Flow ───────────────────────────────────────────────────────── const startFeishuCreator = useCallback( async (platform: "feishu" | "lark" = "feishu") => { @@ -1354,6 +1418,105 @@ export function ChannelDrawer({ ); } + function renderDingtalkPanel() { + const s = qrState; + if (s.phase === "loading") { + return ( +
+ +

{t("channels.dingtalkQrLoading")}

+
+ ); + } + if (s.phase === "dingtalk_success") { + return ( +
+ +
+ ); + } + if (s.phase === "dingtalk_ready") { + return ( +
+
+ + 1 + {t("channels.dingtalkQrStep1")} + + + + 2 + {t("channels.dingtalkQrStep2")} + + + + 3 + {t("channels.dingtalkQrStep3")} + +
+
+
+ +
+
+

+ {t("channels.dingtalkQrScanHint")} +

+

+ {t("channels.dingtalkUserCode")}: {s.userCode} +

+ +
+ ); + } + if (s.phase === "error") { + return ( +
+ + +
+ ); + } + return ( +
+
+
+ 1 + {t("channels.dingtalkQrIntro1")} +
+
+ 2 + {t("channels.dingtalkQrIntro2")} +
+
+ 3 + {t("channels.dingtalkQrIntro3")} +
+
+ +
+ ); + } + function renderFeishuPanel() { const s = qrState; if (s.phase === "feishu_creating" || s.phase === "feishu_progress") { @@ -1681,6 +1844,7 @@ export function ChannelDrawer({ {selectedKind === "qq" && renderQqPanel()} {selectedKind === "wecom" && renderWecomPanel()} {selectedKind === "weixin" && renderWeixinPanel()} + {selectedKind === "dingtalk" && renderDingtalkPanel()} {selectedKind === "feishu" && renderFeishuPanel()} {selectedKind === "yuanbao" && renderYuanbaoPanel()} diff --git a/src/octop/api/routers/channels.py b/src/octop/api/routers/channels.py index 492932e2..5aa085f4 100644 --- a/src/octop/api/routers/channels.py +++ b/src/octop/api/routers/channels.py @@ -8,8 +8,11 @@ import os import platform as _platform import re +import secrets import subprocess import sys +import time +from dataclasses import dataclass, field from pathlib import Path as _FsPath from typing import Any, Literal, cast @@ -19,7 +22,7 @@ from octop.api.common.agent import require_agent_owner_row from octop.api.deps import get_server, require_permission from octop.infra.errors import ErrorCode, OctopError -from octop.infra.gateway.channels import qr_bind +from octop.infra.gateway.channels import dingtalk_registration, qr_bind from octop.infra.gateway.gateway import ChannelKind from octop.infra.utils.locale import DEFAULT_LOCALE, resolve_request_locale from octop.infra.utils.subprocess_io import parse_subprocess_json_lines @@ -58,6 +61,10 @@ class YuanbaoBotCreatorStartBody(BaseModel): ip: str = "" +class DingTalkRegistrationPollBody(BaseModel): + registration_id: str + + def _parse_bot_creator_body(model: type[BaseModel], raw: dict[str, Any] | None) -> BaseModel: try: return model.model_validate(raw or {}) @@ -406,7 +413,186 @@ def _get_plat_code() -> int: return 0 -# ─── WeCom QR code ────────────────────────────────────────────────────────── +# ─── DingTalk application registration ───────────────────────────────────── + + +_DINGTALK_MAX_EXPIRES_IN = 2 * 60 * 60 +_DINGTALK_MAX_SESSIONS = 256 + + +@dataclass +class _DingTalkRegistrationSession: + agent_id: str + actor_id: str + device_code: str + expires_at: float + interval: int + credentials: tuple[str, str] | None = None + channel_id: str | None = None + lock: asyncio.Lock = field(default_factory=asyncio.Lock) + + +_dingtalk_registration_sessions: dict[str, _DingTalkRegistrationSession] = {} + + +def _prune_dingtalk_registration_sessions(now: float) -> None: + expired = [ + key for key, session in _dingtalk_registration_sessions.items() if session.expires_at <= now + ] + for key in expired: + _dingtalk_registration_sessions.pop(key, None) + if len(_dingtalk_registration_sessions) < _DINGTALK_MAX_SESSIONS: + return + oldest = min( + _dingtalk_registration_sessions, + key=lambda key: _dingtalk_registration_sessions[key].expires_at, + ) + _dingtalk_registration_sessions.pop(oldest, None) + + +@router.post("/agents/{agent_id}/channels/dingtalk/qrcode/generate") +async def dingtalk_qrcode_generate( + agent_id: str, + as_user: int | None = None, + user: Any = Depends(require_permission("channels")), + server: Any = Depends(get_server), +) -> dict[str, Any]: + """Start DingTalk Device Flow while retaining the device code server-side.""" + _require_agent_access(agent_id, user=user, as_user=as_user, server=server) + try: + result = await dingtalk_registration.generate() + except Exception as exc: + logger.exception("Failed to start DingTalk application registration") + raise HTTPException( + status_code=502, + detail="Failed to start DingTalk application registration.", + ) from exc + + expires_in = max( + 1, + min(int(result.get("expires_in") or 600), _DINGTALK_MAX_EXPIRES_IN), + ) + interval = max(1, min(int(result.get("interval") or 5), 30)) + registration_id = secrets.token_urlsafe(32) + now = time.monotonic() + _prune_dingtalk_registration_sessions(now) + _dingtalk_registration_sessions[registration_id] = _DingTalkRegistrationSession( + agent_id=agent_id, + actor_id=str(as_user if as_user is not None else user.id), + device_code=str(result["device_code"]), + expires_at=now + expires_in, + interval=interval, + ) + return { + "registration_id": registration_id, + "qrcode_url": str(result["verification_uri_complete"]), + "user_code": str(result["user_code"]), + "expires_in": expires_in, + "interval": interval, + } + + +@router.post("/agents/{agent_id}/channels/dingtalk/qrcode/poll") +async def dingtalk_qrcode_poll( + agent_id: str, + body: DingTalkRegistrationPollBody, + request: Request, + as_user: int | None = None, + user: Any = Depends(require_permission("channels")), + server: Any = Depends(get_server), +) -> dict[str, Any]: + """Poll DingTalk registration and create the channel without exposing secrets.""" + _require_agent_access(agent_id, user=user, as_user=as_user, server=server) + registration_id = _sanitize_token(body.registration_id, "registration_id") + session = _dingtalk_registration_sessions.get(registration_id) + actor_id = str(as_user if as_user is not None else user.id) + if session is None or session.agent_id != agent_id or session.actor_id != actor_id: + raise HTTPException(status_code=404, detail="DingTalk registration session not found.") + + async with session.lock: + if session.channel_id is not None: + return {"status": "success", "channel_id": session.channel_id} + if time.monotonic() >= session.expires_at: + _dingtalk_registration_sessions.pop(registration_id, None) + return {"status": "expired"} + + if session.credentials is None: + try: + result = await dingtalk_registration.poll(session.device_code) + except Exception as exc: + logger.exception("Failed to poll DingTalk application registration") + raise HTTPException( + status_code=502, + detail="Failed to poll DingTalk application registration.", + ) from exc + + status = str(result.get("status") or "").upper() + if status == "WAITING": + return {"status": "waiting"} + if status == "EXPIRED": + _dingtalk_registration_sessions.pop(registration_id, None) + return {"status": "expired"} + if status == "FAIL": + _dingtalk_registration_sessions.pop(registration_id, None) + return { + "status": "failed", + "message": str(result.get("fail_reason") or "DingTalk authorization failed."), + } + if status != "SUCCESS": + raise HTTPException( + status_code=502, + detail="DingTalk returned an unknown registration status.", + ) + + client_id = result.get("client_id") + client_secret = result.get("client_secret") + if not isinstance(client_id, str) or not client_id: + raise HTTPException(status_code=502, detail="DingTalk did not return client_id.") + if not isinstance(client_secret, str) or not client_secret: + raise HTTPException( + status_code=502, detail="DingTalk did not return client_secret." + ) + session.credentials = (client_id, client_secret) + + client_id, client_secret = session.credentials + config = { + "client_id": client_id, + "client_secret": client_secret, + "response_mode": "invoke", + "show_thinking": False, + "show_tool_hints": False, + } + probe = await server.app_runtime.gateway.probe_config( + agent_id=agent_id, + kind="dingtalk", + config=config, + locale=resolve_request_locale(request), + ) + if not probe.get("ok"): + return { + "status": "failed", + "message": str(probe.get("error") or "DingTalk connection check failed."), + } + + from octop.infra.gateway.gateway import ChannelCreateSpec # noqa: PLC0415 + from octop.infra.utils.ulid import new_ulid as _new_ulid # noqa: PLC0415 + + spec = ChannelCreateSpec( + channel_id=_new_ulid(), + agent_id=agent_id, + user_id=_acting_user_id(user, as_user), + kind="dingtalk", + name="dingtalk", + config=config, + ) + row = await server.app_runtime.gateway.create_channel(spec) + session.channel_id = row.channel_id + session.credentials = None + session.device_code = "" + return {"status": "success", "channel_id": row.channel_id} + + +# ─── WeCom QR code ───────────────────────────────── @router.post("/agents/{agent_id}/channels/wecom/qrcode/generate") diff --git a/src/octop/infra/gateway/channels/dingtalk_registration.py b/src/octop/infra/gateway/channels/dingtalk_registration.py new file mode 100644 index 00000000..007e4eef --- /dev/null +++ b/src/octop/infra/gateway/channels/dingtalk_registration.py @@ -0,0 +1,57 @@ +"""DingTalk Device Flow helpers for one-click application registration.""" + +from __future__ import annotations + +from typing import Any + +import httpx + +_BASE_URL = "https://oapi.dingtalk.com" +_TIMEOUT_SECONDS = 15.0 + + +def _require_success(data: Any, operation: str) -> dict[str, Any]: + if not isinstance(data, dict): + raise RuntimeError(f"DingTalk {operation} returned an invalid response") + if data.get("errcode") != 0: + message = str(data.get("errmsg") or "unknown error") + raise RuntimeError(f"DingTalk {operation} failed: {message}") + return data + + +async def generate() -> dict[str, Any]: + """Initialize Device Flow and return the public QR-code metadata.""" + async with httpx.AsyncClient(base_url=_BASE_URL, timeout=_TIMEOUT_SECONDS) as client: + init_response = await client.post("/app/registration/init", json={}) + init_response.raise_for_status() + initialized = _require_success(init_response.json(), "registration init") + nonce = initialized.get("nonce") + if not isinstance(nonce, str) or not nonce: + raise RuntimeError("DingTalk registration init response is missing nonce") + + begin_response = await client.post( + "/app/registration/begin", + json={"nonce": nonce}, + ) + begin_response.raise_for_status() + started = _require_success(begin_response.json(), "registration begin") + + required = ( + "device_code", + "user_code", + "verification_uri_complete", + ) + if any(not isinstance(started.get(key), str) or not started[key] for key in required): + raise RuntimeError("DingTalk registration begin response is incomplete") + return started + + +async def poll(device_code: str) -> dict[str, Any]: + """Poll a DingTalk Device Flow registration without exposing its device code.""" + async with httpx.AsyncClient(base_url=_BASE_URL, timeout=_TIMEOUT_SECONDS) as client: + response = await client.post( + "/app/registration/poll", + json={"device_code": device_code}, + ) + response.raise_for_status() + return _require_success(response.json(), "registration poll") diff --git a/tests/unit/gateway/test_channels_qr.py b/tests/unit/gateway/test_channels_qr.py index 8ac5edef..386ec4a4 100644 --- a/tests/unit/gateway/test_channels_qr.py +++ b/tests/unit/gateway/test_channels_qr.py @@ -2,7 +2,8 @@ from __future__ import annotations -from unittest.mock import AsyncMock, MagicMock, patch +import asyncio +from unittest.mock import AsyncMock, MagicMock, call, patch import pytest from fastapi.testclient import TestClient @@ -12,7 +13,7 @@ def mock_server_and_user(): """Minimal server/user mock for channel QR endpoints.""" user = MagicMock() - user.id = "user1" + user.id = 1 user.is_admin = True registry = MagicMock() @@ -138,6 +139,160 @@ def test_qq_qrcode_poll_returns_credentials(mock_server_and_user): assert resp.json() == result +def test_dingtalk_qrcode_generate_keeps_device_code_server_side(mock_server_and_user): + """Only an opaque Octop registration ID and public QR metadata reach the browser.""" + server, user = mock_server_and_user + from octop.api.routers import channels as ch_module + + ch_module._dingtalk_registration_sessions.clear() + result = { + "device_code": "device-secret", + "user_code": "ABCD-EFGH-IJKL", + "verification_uri_complete": "https://open-dev.dingtalk.com/scan?code=public", + "expires_in": 7200, + "interval": 5, + } + with patch( + "octop.api.routers.channels.dingtalk_registration.generate", + new=AsyncMock(return_value=result), + ): + client = TestClient(_make_app(server, user)) + resp = client.post("/agents/agent1/channels/dingtalk/qrcode/generate") + + assert resp.status_code == 200 + data = resp.json() + assert data["qrcode_url"] == result["verification_uri_complete"] + assert data["user_code"] == result["user_code"] + assert data["interval"] == 5 + assert "device_code" not in data + stored = ch_module._dingtalk_registration_sessions[data["registration_id"]] + assert stored.device_code == "device-secret" + + +def test_dingtalk_qrcode_poll_creates_channel_without_returning_secret( + mock_server_and_user, +): + """Successful authorization probes and persists credentials only on the server.""" + server, user = mock_server_and_user + from octop.api.routers import channels as ch_module + + ch_module._dingtalk_registration_sessions.clear() + gateway = server.app_runtime.gateway + gateway.probe_config = AsyncMock(return_value={"ok": True}) + gateway.create_channel = AsyncMock(return_value=MagicMock(channel_id="channel-1")) + generated = { + "device_code": "device-secret", + "user_code": "ABCD-EFGH-IJKL", + "verification_uri_complete": "https://open-dev.dingtalk.com/scan?code=public", + "expires_in": 7200, + "interval": 5, + } + authorized = { + "status": "SUCCESS", + "client_id": "ding-client", + "client_secret": "ding-secret", + } + with ( + patch( + "octop.api.routers.channels.dingtalk_registration.generate", + new=AsyncMock(return_value=generated), + ), + patch( + "octop.api.routers.channels.dingtalk_registration.poll", + new=AsyncMock(return_value=authorized), + ), + ): + client = TestClient(_make_app(server, user)) + generated_resp = client.post("/agents/agent1/channels/dingtalk/qrcode/generate") + registration_id = generated_resp.json()["registration_id"] + resp = client.post( + "/agents/agent1/channels/dingtalk/qrcode/poll", + json={"registration_id": registration_id}, + ) + repeated_resp = client.post( + "/agents/agent1/channels/dingtalk/qrcode/poll", + json={"registration_id": registration_id}, + ) + + assert resp.status_code == 200 + assert resp.json() == {"status": "success", "channel_id": "channel-1"} + assert repeated_resp.json() == resp.json() + assert "ding-secret" not in resp.text + probe_config = gateway.probe_config.await_args.kwargs["config"] + assert probe_config["client_id"] == "ding-client" + assert probe_config["client_secret"] == "ding-secret" + spec = gateway.create_channel.await_args.args[0] + assert spec.kind == "dingtalk" + assert spec.config["client_secret"] == "ding-secret" + gateway.create_channel.assert_awaited_once() + session = ch_module._dingtalk_registration_sessions[registration_id] + assert session.credentials is None + assert session.device_code == "" + + +def test_dingtalk_qrcode_poll_rejects_different_user(mock_server_and_user): + server, user = mock_server_and_user + from octop.api.routers import channels as ch_module + + ch_module._dingtalk_registration_sessions.clear() + generated = { + "device_code": "device-secret", + "user_code": "ABCD-EFGH-IJKL", + "verification_uri_complete": "https://open-dev.dingtalk.com/scan?code=public", + "expires_in": 7200, + "interval": 5, + } + with patch( + "octop.api.routers.channels.dingtalk_registration.generate", + new=AsyncMock(return_value=generated), + ): + client = TestClient(_make_app(server, user)) + generated_resp = client.post("/agents/agent1/channels/dingtalk/qrcode/generate") + registration_id = generated_resp.json()["registration_id"] + + user.id = 2 + resp = client.post( + "/agents/agent1/channels/dingtalk/qrcode/poll", + json={"registration_id": registration_id}, + ) + assert resp.status_code == 404 + + +def test_dingtalk_registration_client_uses_official_device_flow_endpoints(): + from octop.infra.gateway.channels import dingtalk_registration + + init_response = MagicMock() + init_response.json.return_value = { + "errcode": 0, + "errmsg": "ok", + "nonce": "nonce-1", + "expires_in": 300, + } + begin_response = MagicMock() + begin_response.json.return_value = { + "errcode": 0, + "errmsg": "ok", + "device_code": "device-1", + "user_code": "ABCD-EFGH-IJKL", + "verification_uri_complete": "https://open-dev.dingtalk.com/scan", + "expires_in": 7200, + "interval": 5, + } + post = AsyncMock(side_effect=[init_response, begin_response]) + context = MagicMock() + context.__aenter__ = AsyncMock(return_value=MagicMock(post=post)) + context.__aexit__ = AsyncMock(return_value=False) + + with patch.object(dingtalk_registration.httpx, "AsyncClient", return_value=context): + result = asyncio.run(dingtalk_registration.generate()) + + assert result["device_code"] == "device-1" + assert post.await_args_list == [ + call("/app/registration/init", json={}), + call("/app/registration/begin", json={"nonce": "nonce-1"}), + ] + + def test_pkill_chrome_profile_accepts_resolved_path(monkeypatch: pytest.MonkeyPatch, tmp_path): """pkill pattern must not fail validation on the '=' separator.""" from octop.api.routers.channels import _pkill_chrome_profile, _safe_profile_directory From c38e5c028fc67bc36122a6d3bbf8d4ea1bc6c74e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=96=84=E7=94=9F?= <67680641+Bosheng0422@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:06:43 +0800 Subject: [PATCH 02/28] Feature/long session issue fix (#453) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 长session卡死优化 * chore: cut 0.9.29b0 and pin harness-agent 0.9.27 Long-session testers pull this branch from source; bump above 0.9.28 and lock vacuum-safe harness-agent / harness-memory so Codex installs pick them up. Co-authored-by: Cursor * chore: retag beta as 0.9.27b0 so PyPI 0.9.28 still looks newer Lets non-editable installs click Update to leave the long-session trial. Co-authored-by: Cursor * chore: cut 0.9.29 for merge (drop beta tag) Current main is 0.9.28; this branch ships as the next stable version. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- CHANGELOG.md | 10 + README.md | 2 +- dashboard/src/api/modules/octopThreads.ts | 27 ++ dashboard/src/locales/en.json | 14 + dashboard/src/locales/zh.json | 14 + .../Chat/components/ContextWindowRing.tsx | 35 +-- .../HistoryMigrationBanner.module.less | 74 ++++++ .../HistoryMigrationBanner.test.tsx | 52 ++++ .../components/HistoryMigrationBanner.tsx | 72 +++++ dashboard/src/pages/Chat/hooks/chatStore.ts | 11 + dashboard/src/pages/Chat/hooks/useChat.ts | 43 ++- .../Chat/hooks/useChatComposerResources.ts | 13 +- dashboard/src/pages/Chat/hooks/useChatSend.ts | 6 +- .../pages/Chat/hooks/useHistoryMigration.ts | 64 +++++ .../pages/Chat/hooks/useMemoryMaintenance.ts | 28 +- dashboard/src/pages/Chat/index.tsx | 16 +- pyproject.toml | 2 +- src/octop/__init__.py | 2 +- src/octop/api/routers/chat/history.py | 150 ++++++++++- src/octop/api/routers/chat/serialize.py | 139 ++++++++++ src/octop/infra/agents/context_breakdown.py | 101 +------ src/octop/infra/agents/manager.py | 82 +++++- src/octop/infra/agents/thread_fork.py | 15 ++ src/octop/infra/db/migrate.py | 13 +- .../010_thread_message_projection.pg.sql | 34 +++ .../010_thread_message_projection.sql | 33 +++ src/octop/infra/db/repos/thread_messages.py | 250 ++++++++++++++++++ src/octop/infra/db/repos/threads.py | 5 + src/octop/infra/db/services.py | 7 + src/octop/infra/gateway/gateway.py | 10 + src/octop/infra/gateway/history_backfill.py | 66 +++++ .../gateway/process/history_projection.py | 112 ++++++++ src/octop/infra/gateway/process/processor.py | 31 +++ .../infra/gateway/process/stream_project.py | 8 + tests/unit/agents/test_agent_manager.py | 69 +++++ tests/unit/agents/test_context_breakdown.py | 29 +- tests/unit/api/test_chat_history.py | 195 +++++++++++++- tests/unit/db/test_agent_profile_columns.py | 2 +- tests/unit/db/test_clip_thread_title.py | 2 +- tests/unit/db/test_db_pool.py | 11 +- tests/unit/db/test_published_experts_repo.py | 2 +- tests/unit/db/test_repo_knowledge.py | 2 +- tests/unit/db/test_skill_package_icons.py | 4 +- tests/unit/db/test_skill_packages_repo.py | 2 +- tests/unit/db/test_thread_messages_repo.py | 108 ++++++++ tests/unit/gateway/test_history_projection.py | 58 ++++ uv.lock | 2 +- 47 files changed, 1824 insertions(+), 203 deletions(-) create mode 100644 dashboard/src/pages/Chat/components/HistoryMigrationBanner.module.less create mode 100644 dashboard/src/pages/Chat/components/HistoryMigrationBanner.test.tsx create mode 100644 dashboard/src/pages/Chat/components/HistoryMigrationBanner.tsx create mode 100644 dashboard/src/pages/Chat/hooks/useHistoryMigration.ts create mode 100644 src/octop/infra/db/migrations/010_thread_message_projection.pg.sql create mode 100644 src/octop/infra/db/migrations/010_thread_message_projection.sql create mode 100644 src/octop/infra/db/repos/thread_messages.py create mode 100644 src/octop/infra/gateway/history_backfill.py create mode 100644 src/octop/infra/gateway/process/history_projection.py create mode 100644 tests/unit/db/test_thread_messages_repo.py create mode 100644 tests/unit/gateway/test_history_projection.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f75bddd5..bfc99a38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ ## [Unreleased] +## [0.9.29] - 2026-08-27 + +### 修复 + +- 长会话卡死:聊天历史改为独立投影分页加载,并支持后台迁移旧会话(不再同步扫 checkpoint) + +### 变更 + +- 依赖:`orcakit-harness-agent[all]>=0.9.27`、`harness-memory>=0.9.7`、`harness-browser>=0.7.6`(自动 full VACUUM 关闭,空闲维护只走 lifecycle GC + incremental `nudge_vacuum`) + ## [0.9.28] - 2026-08-26 ### 修复 diff --git a/README.md b/README.md index 78a3a558..65206075 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

Python 3.12+ License: MIT - Version + Version PyPI Code Style: Ruff GitHub stars diff --git a/dashboard/src/api/modules/octopThreads.ts b/dashboard/src/api/modules/octopThreads.ts index 8d031552..9791c0de 100644 --- a/dashboard/src/api/modules/octopThreads.ts +++ b/dashboard/src/api/modules/octopThreads.ts @@ -34,6 +34,10 @@ export interface OctopThreadHistory { has_more?: boolean; limit?: number; offset?: number; + /** Legacy checkpoint is being projected by the bounded background worker. */ + history_loading?: boolean; + history_status?: "pending" | "queued" | "running" | "ready" | "failed"; + history_retry_after_ms?: number; /** True while a turn is still streaming server-side for this thread. */ turn_active?: boolean; /** Pending tool approval for this thread (survives page reload). */ @@ -69,6 +73,18 @@ export interface ContextUsageBreakdown { segments: ContextUsageSegment[]; } +export interface HistoryMigrationStatus { + remaining: number; + pending: number; + queued: number; + running: number; + failed: number; + processing: boolean; + agent_busy: boolean; + can_start: boolean; + accepted?: number; +} + export const CHAT_HISTORY_PAGE_SIZE = 25; export const octopThreadsApi = { @@ -83,6 +99,17 @@ export const octopThreadsApi = { { method: "POST" }, ), + historyMigrationStatus: (agentId: string) => + request( + `/agents/${encodeURIComponent(agentId)}/history-migration/status`, + ), + + startHistoryMigration: (agentId: string) => + request( + `/agents/${encodeURIComponent(agentId)}/history-migration/start`, + { method: "POST" }, + ), + history: ( agentId: string, threadId: string, diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index c24998c5..e46b4a10 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -1018,6 +1018,20 @@ "hintBlocking": "Sending is paused for this agent until slimming finishes.", "elapsed": "{{seconds}}s elapsed" }, + "historyMigration": { + "title": "Upgrade complete: {{count}} old chats still need optimization", + "readyHint": "Run it in the background now so very long chats do not pause on first open. New chats remain available, and only one old chat is read at a time.", + "runningHint": "Processing old chats one at a time in the background. New chats remain available; avoid repeatedly refreshing the page.", + "agentBusyHint": "This expert is running a task. Wait until it is idle before starting old-chat optimization.", + "queueBusyHint": "The migration queue is handling other chats. You can start when capacity is available; this page refreshes automatically.", + "retryHint": "{{count}} chats failed during the previous attempt and can be retried safely.", + "startFailed": "Could not start background processing. Make sure the agent is running, then retry.", + "start": "Optimize in background", + "running": "Optimizing", + "queueRest": "Queue remaining chats", + "waiting": "Waiting for queue", + "retry": "Retry failed chats" + }, "thinking": "Thinking", "continuing": "Continuing", "generating": "Generating", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index a5d70b17..b7b40ad0 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -1018,6 +1018,20 @@ "hintBlocking": "整理期间此智能体暂时无法发送消息,完成后会自动恢复。", "elapsed": "已进行 {{seconds}}s" }, + "historyMigration": { + "title": "升级完成:还有 {{count}} 个旧会话需要优化", + "readyHint": "建议现在后台处理,之后首次打开超长会话就不必临时等待。处理时仍可使用新会话,系统每次只读取一个旧会话。", + "runningHint": "正在后台逐个处理;新会话可正常使用,请不要重复刷新页面。", + "agentBusyHint": "此专家正在执行任务,请等待专家空闲后再开始旧会话优化。", + "queueBusyHint": "后台迁移队列正在处理其他会话;空出位置后即可开始,本页会自动刷新状态。", + "retryHint": "有 {{count}} 个会话上次处理失败,可以安全重试。", + "startFailed": "未能启动后台处理,请确认 Agent 正在运行后重试。", + "start": "现在后台优化", + "running": "后台优化中", + "queueRest": "加入其余旧会话", + "waiting": "等待队列空闲", + "retry": "重试失败项" + }, "thinking": "正在思考", "continuing": "继续生成中", "generating": "生成中", diff --git a/dashboard/src/pages/Chat/components/ContextWindowRing.tsx b/dashboard/src/pages/Chat/components/ContextWindowRing.tsx index d093e023..b1b40c92 100644 --- a/dashboard/src/pages/Chat/components/ContextWindowRing.tsx +++ b/dashboard/src/pages/Chat/components/ContextWindowRing.tsx @@ -12,8 +12,6 @@ import styles from "../index.module.less"; const DEFAULT_MAX = 128_000; const BREAKDOWN_CACHE_TTL_MS = 30_000; -/** Debounce snapshot refresh after stream ``usedTokens`` changes. */ -const HINT_REFRESH_DEBOUNCE_MS = 800; const SEGMENT_COLORS: Record = { system_prompt: "#9ca3af", @@ -64,7 +62,6 @@ export default function ContextWindowRing({ // Stream hint — kept in a ref so prefetch does not re-fire on every token tick. const hintUsedRef = useRef(usedTokens ?? 0); hintUsedRef.current = usedTokens ?? 0; - const lastHintRefreshRef = useRef(null); const max = maxTokens > 0 ? maxTokens : DEFAULT_MAX; const hintUsed = usedTokens ?? 0; @@ -126,34 +123,15 @@ export default function ContextWindowRing({ [agentId, threadId, max, selectedConnectors, selectedSkills, cacheKey], ); - // Prefetch when the thread / cap / filters change — not when stream hint ticks. + // Reset detail cache on thread/filter changes. The ring itself keeps growing + // from the live/persisted token hint and never needs a checkpoint read. useEffect(() => { - if (!agentId || !threadId || isPendingThread(threadId)) { - setBreakdown(null); - cacheRef.current = null; - lastHintRefreshRef.current = null; - return; - } - lastHintRefreshRef.current = null; - void loadBreakdown({ silent: true }); - }, [agentId, threadId, cacheKey, loadBreakdown]); - - // After a turn finishes, ``usedTokens`` settles on last_input_tokens — - // soft-refresh once per distinct hint (debounced) for a fresh harness stamp. - useEffect(() => { - if (!agentId || !threadId || isPendingThread(threadId) || hintUsed <= 0) { - return; - } - if (lastHintRefreshRef.current === hintUsed) return; - const timer = window.setTimeout(() => { - lastHintRefreshRef.current = hintUsed; - cacheRef.current = null; - void loadBreakdown({ silent: true, force: true }); - }, HINT_REFRESH_DEBOUNCE_MS); - return () => window.clearTimeout(timer); - }, [hintUsed, agentId, threadId, loadBreakdown]); + setBreakdown(null); + cacheRef.current = null; + }, [cacheKey]); const ringUsed = useMemo(() => { + if (hintUsed > 0) return Math.min(hintUsed, max); if (breakdown && breakdown.used_tokens > 0) { const breakdownMax = breakdown.max_tokens > 0 ? breakdown.max_tokens : max; @@ -161,7 +139,6 @@ export default function ContextWindowRing({ } // Until prefetch returns, show last-call hint (capped so a stale // turn-sum cannot flash a full ring). - if (hintUsed > 0) return Math.min(hintUsed, max); return 0; }, [breakdown, hintUsed, max]); diff --git a/dashboard/src/pages/Chat/components/HistoryMigrationBanner.module.less b/dashboard/src/pages/Chat/components/HistoryMigrationBanner.module.less new file mode 100644 index 00000000..c0ce7166 --- /dev/null +++ b/dashboard/src/pages/Chat/components/HistoryMigrationBanner.module.less @@ -0,0 +1,74 @@ +.banner { + position: relative; + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 14px; + margin: 8px var(--chat-column-pad-x, 24px) 0; + padding: 10px 14px; + border: 1px solid var(--fn-border, #e8e8ed); + border-radius: 10px; + background: var(--fn-bg-secondary, #f5f5f7); + + @media (max-width: 767px) { + align-items: flex-start; + margin: 8px var(--chat-column-pad-x-narrow, 12px) 0; + } +} + +.content { + flex: 1; + min-width: 0; +} + +.title { + font-size: 13px; + font-weight: 600; + color: var(--fn-text-primary); +} + +.hint, +.errorHint { + margin-top: 3px; + font-size: 12px; + line-height: 1.45; + color: var(--fn-text-secondary); +} + +.errorHint { + color: var(--fn-color-error, #d14343); +} + +.progressTrack { + height: 3px; + margin-top: 8px; + overflow: hidden; + border-radius: 999px; + background: color-mix(in srgb, var(--fn-primary, #e8516d) 16%, transparent); +} + +.progressBar { + display: block; + width: 40%; + height: 100%; + border-radius: inherit; + background: var(--fn-primary, #e8516d); + animation: historyMigrationProgress 1.3s ease-in-out infinite; +} + +@keyframes historyMigrationProgress { + from { + transform: translateX(-100%); + } + + to { + transform: translateX(250%); + } +} + +@media (prefers-reduced-motion: reduce) { + .progressBar { + width: 100%; + animation: none; + } +} diff --git a/dashboard/src/pages/Chat/components/HistoryMigrationBanner.test.tsx b/dashboard/src/pages/Chat/components/HistoryMigrationBanner.test.tsx new file mode 100644 index 00000000..7f7404d5 --- /dev/null +++ b/dashboard/src/pages/Chat/components/HistoryMigrationBanner.test.tsx @@ -0,0 +1,52 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { HistoryMigrationStatus } from "../../../api/modules/octopThreads"; +import HistoryMigrationBanner from "./HistoryMigrationBanner"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +const idleStatus: HistoryMigrationStatus = { + remaining: 1, + pending: 1, + queued: 0, + running: 0, + failed: 0, + processing: false, + agent_busy: false, + can_start: true, +}; + +describe("HistoryMigrationBanner", () => { + it("shows an indeterminate progress bar only while migration is processing", () => { + const { rerender } = render( + , + ); + + expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); + + rerender( + , + ); + + const progress = screen.getByRole("progressbar"); + expect(progress).toHaveAttribute( + "aria-label", + "chat.historyMigration.running", + ); + expect(progress).not.toHaveAttribute("aria-valuenow"); + }); +}); diff --git a/dashboard/src/pages/Chat/components/HistoryMigrationBanner.tsx b/dashboard/src/pages/Chat/components/HistoryMigrationBanner.tsx new file mode 100644 index 00000000..23c3b3fa --- /dev/null +++ b/dashboard/src/pages/Chat/components/HistoryMigrationBanner.tsx @@ -0,0 +1,72 @@ +import { Button } from "antd"; +import { useTranslation } from "react-i18next"; +import type { HistoryMigrationStatus } from "../../../api/modules/octopThreads"; +import styles from "./HistoryMigrationBanner.module.less"; + +interface HistoryMigrationBannerProps { + status: HistoryMigrationStatus; + starting: boolean; + startFailed: boolean; + onStart: () => void; +} + +export default function HistoryMigrationBanner({ + status, + starting, + startFailed, + onStart, +}: HistoryMigrationBannerProps) { + const { t } = useTranslation(); + const active = status.processing; + const canQueueMore = status.can_start && status.pending + status.failed > 0; + const failedHint = startFailed || (status.failed > 0 && !active); + + return ( +

+
+
+ {t("chat.historyMigration.title", { count: status.remaining })} +
+
+ {startFailed + ? t("chat.historyMigration.startFailed") + : active + ? t("chat.historyMigration.runningHint") + : status.agent_busy + ? t("chat.historyMigration.agentBusyHint") + : !status.can_start + ? t("chat.historyMigration.queueBusyHint") + : status.failed > 0 + ? t("chat.historyMigration.retryHint", { count: status.failed }) + : t("chat.historyMigration.readyHint")} +
+ {active && ( +
+ +
+ )} +
+ +
+ ); +} diff --git a/dashboard/src/pages/Chat/hooks/chatStore.ts b/dashboard/src/pages/Chat/hooks/chatStore.ts index 022592a9..1e0bb6f1 100644 --- a/dashboard/src/pages/Chat/hooks/chatStore.ts +++ b/dashboard/src/pages/Chat/hooks/chatStore.ts @@ -2024,6 +2024,17 @@ export async function sendTurn( ): Promise { const state = getOrCreate(sessionId); + if (sessionId === "__pending__" || threadId === "__pending__") { + appendErrorBubble( + state, + "Thread is still being created. Please retry shortly.", + ); + clearStreamingFlags(state); + notify(state); + onStreamEnd?.(); + return; + } + if (!agentId) { appendErrorBubble(state, "No agent selected. Pick one from the top bar."); clearStreamingFlags(state); diff --git a/dashboard/src/pages/Chat/hooks/useChat.ts b/dashboard/src/pages/Chat/hooks/useChat.ts index 3db23e7a..8ed92e08 100644 --- a/dashboard/src/pages/Chat/hooks/useChat.ts +++ b/dashboard/src/pages/Chat/hooks/useChat.ts @@ -683,6 +683,8 @@ async function loadThreadHistory( nextOffset: number; turnActive: boolean; artifacts: string[]; + projectionLoading: boolean; + retryAfterMs: number; }> { try { const { octopThreadsApi, CHAT_HISTORY_PAGE_SIZE } = await import( @@ -722,6 +724,11 @@ async function loadThreadHistory( nextOffset: offset + limit, turnActive: Boolean(history.turn_active), artifacts, + projectionLoading: Boolean(history.history_loading), + retryAfterMs: + typeof history.history_retry_after_ms === "number" + ? history.history_retry_after_ms + : 1500, }; } catch (err) { console.error("loadThreadHistory failed", err); @@ -731,6 +738,8 @@ async function loadThreadHistory( nextOffset: 0, turnActive: false, artifacts: [], + projectionLoading: false, + retryAfterMs: 1500, }; } } @@ -879,18 +888,32 @@ export function useChat( setHistoryLoading(true); try { - const { - messages: converted, - hasMore, - nextOffset, - turnActive, - } = await loadThreadHistory(agentId, targetThreadId, { offset: 0 }); + let loaded = await loadThreadHistory(agentId, targetThreadId, { + offset: 0, + }); + while (loaded.projectionLoading && loadGenRef.current === gen) { + await new Promise((resolve) => + window.setTimeout( + resolve, + Math.max(500, Math.min(loaded.retryAfterMs, 5000)), + ), + ); + if (loadGenRef.current !== gen) return; + loaded = await loadThreadHistory(agentId, targetThreadId, { + offset: 0, + }); + } if (loadGenRef.current !== gen) return; - chatStore.setHistoryPage(key, converted, { - hasMore, - nextOffset, + chatStore.setHistoryPage(key, loaded.messages, { + hasMore: loaded.hasMore, + nextOffset: loaded.nextOffset, }); - if (shouldProbeActiveTurn({ isStreaming: false, turnActive })) { + if ( + shouldProbeActiveTurn({ + isStreaming: false, + turnActive: loaded.turnActive, + }) + ) { attachAfterHistory(key, targetThreadId); } } finally { diff --git a/dashboard/src/pages/Chat/hooks/useChatComposerResources.ts b/dashboard/src/pages/Chat/hooks/useChatComposerResources.ts index 4a7fec9b..7ba748c0 100644 --- a/dashboard/src/pages/Chat/hooks/useChatComposerResources.ts +++ b/dashboard/src/pages/Chat/hooks/useChatComposerResources.ts @@ -22,6 +22,7 @@ import { } from "../utils/chatStorage"; import { resolveInitialConnectors } from "../utils/resolveInitialConnectors"; import { withDefaultOpenKnowledgeBases } from "../utils/withDefaultOpenKnowledgeBases"; +import { isPendingThread } from "./useSessions"; export function useChatComposerResources( resolvedAgentId: string | null | undefined, @@ -312,7 +313,11 @@ export function useChatComposerResources( }, })); } - if (resolvedAgentId && activeThreadId) { + if ( + resolvedAgentId && + activeThreadId && + !isPendingThread(activeThreadId) + ) { void octopThreadsApi.patch(resolvedAgentId, activeThreadId, { model_ref: model, reasoning_mode: nextMode, @@ -337,7 +342,11 @@ export function useChatComposerResources( }, })); } - if (resolvedAgentId && activeThreadId) { + if ( + resolvedAgentId && + activeThreadId && + !isPendingThread(activeThreadId) + ) { void octopThreadsApi.patch(resolvedAgentId, activeThreadId, { reasoning_mode: mode, reasoning_effort: effort, diff --git a/dashboard/src/pages/Chat/hooks/useChatSend.ts b/dashboard/src/pages/Chat/hooks/useChatSend.ts index b16ed378..69acd868 100644 --- a/dashboard/src/pages/Chat/hooks/useChatSend.ts +++ b/dashboard/src/pages/Chat/hooks/useChatSend.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect } from "react"; import type { TFunction } from "i18next"; import { useNavigate } from "react-router-dom"; import type { ChatAttachment, UserComposerContext } from "./useChat"; -import type { Session } from "./useSessions"; +import { isPendingThread, type Session } from "./useSessions"; import * as chatStore from "./chatStore"; import { EMPTY_CHAT_SESSION_KEY, PENDING_THREAD_ID } from "../constants"; import { clipThreadTitle } from "../utils/threadTitle"; @@ -165,6 +165,10 @@ export function useChatSend({ overrides?.threadId !== undefined ? overrides.threadId : activeThreadId; if (targetThreadId) { + if (isPendingThread(targetThreadId)) { + message.info(t("chat.creatingSession", "正在创建会话,请稍候")); + return false; + } const hadMessages = targetThreadId === activeThreadId ? messagesLength > 0 diff --git a/dashboard/src/pages/Chat/hooks/useHistoryMigration.ts b/dashboard/src/pages/Chat/hooks/useHistoryMigration.ts new file mode 100644 index 00000000..5d34f032 --- /dev/null +++ b/dashboard/src/pages/Chat/hooks/useHistoryMigration.ts @@ -0,0 +1,64 @@ +import { useCallback, useEffect, useState } from "react"; +import { + octopThreadsApi, + type HistoryMigrationStatus, +} from "../../../api/modules/octopThreads"; + +export function useHistoryMigration( + agentId: string | null | undefined, + enabled: boolean, +) { + const [status, setStatus] = useState(null); + const [starting, setStarting] = useState(false); + const [startFailed, setStartFailed] = useState(false); + + useEffect(() => { + if (!agentId || !enabled) { + setStatus(null); + setStartFailed(false); + return; + } + let stopped = false; + let timer: number | null = null; + const pull = async () => { + let delay = 30_000; + try { + const next = await octopThreadsApi.historyMigrationStatus(agentId); + if (!stopped) setStatus(next); + if (next.remaining > 0) { + delay = next.processing || next.agent_busy ? 2000 : 10_000; + } + } catch { + // A later single-flight poll retries without hiding the last known status. + } finally { + if (!stopped) timer = window.setTimeout(pull, delay); + } + }; + void pull(); + return () => { + stopped = true; + if (timer != null) window.clearTimeout(timer); + }; + }, [agentId, enabled]); + + const start = useCallback(async () => { + if (!agentId || starting) return; + setStarting(true); + setStartFailed(false); + try { + setStatus(await octopThreadsApi.startHistoryMigration(agentId)); + } catch { + setStartFailed(true); + } finally { + setStarting(false); + } + }, [agentId, starting]); + + return { + status, + visible: Boolean(status && status.remaining > 0), + starting, + startFailed, + start, + }; +} diff --git a/dashboard/src/pages/Chat/hooks/useMemoryMaintenance.ts b/dashboard/src/pages/Chat/hooks/useMemoryMaintenance.ts index 8762999c..81c33d80 100644 --- a/dashboard/src/pages/Chat/hooks/useMemoryMaintenance.ts +++ b/dashboard/src/pages/Chat/hooks/useMemoryMaintenance.ts @@ -34,20 +34,26 @@ export function useMemoryMaintenance( return; } let stop = false; - const pull = () => { - request<{ memory_maintenance?: MemoryMaintenanceStatus | null }>( - `/agents/${agentId}/status`, - ) - .then((row) => { - if (!stop) setStatus(row.memory_maintenance ?? null); - }) - .catch(() => {}); + let timer: number | null = null; + const pull = async () => { + let nextDelay = 10_000; + try { + const row = await request<{ + memory_maintenance?: MemoryMaintenanceStatus | null; + }>(`/agents/${agentId}/status`); + const next = row.memory_maintenance ?? null; + if (!stop) setStatus(next); + if (next && VISIBLE.has(next.phase)) nextDelay = 2000; + } catch { + // Keep the last known state; a later single-flight poll can recover. + } finally { + if (!stop) timer = window.setTimeout(pull, nextDelay); + } }; - pull(); - const timer = setInterval(pull, 2000); + void pull(); return () => { stop = true; - clearInterval(timer); + if (timer != null) window.clearTimeout(timer); }; }, [agentId, enabled]); diff --git a/dashboard/src/pages/Chat/index.tsx b/dashboard/src/pages/Chat/index.tsx index ba16c0a3..4ae773a6 100644 --- a/dashboard/src/pages/Chat/index.tsx +++ b/dashboard/src/pages/Chat/index.tsx @@ -63,6 +63,8 @@ import ChatComposerChrome from "./components/ChatComposerChrome"; import { isAgentChatReady } from "../../utils/agentError"; import { useMemoryMaintenance } from "./hooks/useMemoryMaintenance"; import MemoryMaintenanceBanner from "./components/MemoryMaintenanceBanner"; +import { useHistoryMigration } from "./hooks/useHistoryMigration"; +import HistoryMigrationBanner from "./components/HistoryMigrationBanner"; import { apiErrorMessage } from "../../utils/apiError"; import PwaInstallPrompt from "../../components/PwaInstallPrompt"; import { promptNeedsUserInput } from "../../utils/quickInputPrefill"; @@ -171,6 +173,10 @@ function ChatPageInner() { visible: memoryMaintVisible, blocking: memoryMaintBlocking, } = useMemoryMaintenance(resolvedAgentId, agentChatReady && !noAgents); + const historyMigration = useHistoryMigration( + resolvedAgentId, + agentChatReady && !noAgents && !sharedExpertViewer, + ); useEffect(() => { void refreshAgents({ silent: true }); @@ -902,10 +908,12 @@ function ChatPageInner() { /> )} - {memoryMaintVisible && memoryMaint && ( - void historyMigration.start()} /> )} diff --git a/pyproject.toml b/pyproject.toml index 4b33a445..7614153b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "octop" -version = "0.9.28" +version = "0.9.29" description = "Smarter self-hosted AI assistant for multiple users and agents, built on harness-agent" readme = "README.md" license = { text = "MIT" } diff --git a/src/octop/__init__.py b/src/octop/__init__.py index f999b8b4..b7c3a716 100644 --- a/src/octop/__init__.py +++ b/src/octop/__init__.py @@ -2,4 +2,4 @@ from __future__ import annotations -__version__ = "0.9.28" +__version__ = "0.9.29" diff --git a/src/octop/api/routers/chat/history.py b/src/octop/api/routers/chat/history.py index e85f6557..35ed58fa 100644 --- a/src/octop/api/routers/chat/history.py +++ b/src/octop/api/routers/chat/history.py @@ -13,8 +13,9 @@ from octop.api.routers.chat.models import ForkThreadBody, RebindSessionBody, RenameThreadBody from octop.api.routers.chat.serialize import ( HISTORY_DEFAULT_LIMIT, + _backfill_thread_projection, _clamp_history_limit, - _load_thread_messages, + _load_projected_thread_messages, ) from octop.infra.agents.context_breakdown import SEGMENT_KEYS, compute_context_breakdown from octop.infra.agents.middleware.thread_artifacts import artifacts_for_response @@ -28,6 +29,35 @@ router = APIRouter() +def _agent_is_busy(server: Any, agent_id: str) -> bool: + checker = getattr(server.app_runtime.agent_registry, "is_agent_active", None) + return callable(checker) and checker(agent_id) is True + + +def _history_migration_payload(server: Any, *, agent_id: str, user_id: int) -> dict[str, Any]: + repo = server.services.thread_message_repo + summary = repo.migration_summary( + agent_id=agent_id, + user_id=user_id, + ) + queue = server.app_runtime.gateway.history_backfill + processing = any( + queue.contains(thread_id) + for thread_id in repo.migration_active_thread_ids(agent_id=agent_id, user_id=user_id) + ) + agent_busy = _agent_is_busy(server, agent_id) + return { + "remaining": summary.remaining, + "pending": summary.pending, + "queued": summary.queued, + "running": summary.running, + "failed": summary.failed, + "processing": processing, + "agent_busy": agent_busy, + "can_start": summary.remaining > 0 and queue.available_slots > 0 and not agent_busy, + } + + def _agent_facing_workspace_dir(server: Any, agent_id: str) -> Path: """Agent-visible workspace_dir for artifact path joins (not host root_dir map).""" registry = getattr(getattr(server, "app_runtime", None), "agent_registry", None) @@ -88,6 +118,74 @@ async def list_threads( ] +@router.get( + "/agents/{agent_id}/history-migration/status", + summary="Legacy history migration status", +) +async def get_history_migration_status( + agent_id: str, + as_user: int | None = None, + user: Any = Depends(current_user), + server: Any = Depends(get_server), +) -> dict[str, Any]: + """Report old conversations awaiting the v10 dashboard history projection.""" + require_agent_row(agent_id, user=user, as_user=as_user, server=server) + effective_uid = as_user if as_user is not None else user.id + return _history_migration_payload(server, agent_id=agent_id, user_id=effective_uid) + + +@router.post( + "/agents/{agent_id}/history-migration/start", + summary="Start legacy history migration", +) +async def start_history_migration( + agent_id: str, + as_user: int | None = None, + user: Any = Depends(current_user), + server: Any = Depends(get_server), +) -> dict[str, Any]: + """Queue a bounded batch; the gateway decodes exactly one checkpoint at a time.""" + require_agent_row(agent_id, user=user, as_user=as_user, server=server) + # Fail before changing persisted states when this agent is not actually available. + server.app_runtime.agent_registry.get_agent(agent_id) + effective_uid = as_user if as_user is not None else user.id + if _agent_is_busy(server, agent_id): + return { + **_history_migration_payload(server, agent_id=agent_id, user_id=effective_uid), + "accepted": 0, + } + queue = server.app_runtime.gateway.history_backfill + repo = server.services.thread_message_repo + candidate_limit = queue.available_slots + queue.active_jobs + candidates = repo.migration_candidates( + agent_id=agent_id, + user_id=effective_uid, + limit=candidate_limit, + ) + accepted = 0 + for candidate in candidates: + if queue.contains(candidate.thread_id): + continue + + async def _work(thread_id: str = candidate.thread_id) -> None: + await _backfill_thread_projection( + server, + agent_id, + thread_id, + user=user, + ) + + if not queue.enqueue(candidate.thread_id, _work): + break + if candidate.status != "running": + repo.mark_projection(candidate.thread_id, "queued") + accepted += 1 + return { + **_history_migration_payload(server, agent_id=agent_id, user_id=effective_uid), + "accepted": accepted, + } + + @router.post("/agents/{agent_id}/threads", status_code=201, summary="New thread") async def create_thread( agent_id: str, @@ -175,14 +273,44 @@ async def get_thread_history( row = _require_thread(server, agent_id, thread_id, user, as_user) page_limit = _clamp_history_limit(limit) page_offset = max(0, offset) - messages, has_more = await _load_thread_messages( - server, - agent_id, - thread_id, - page_limit, - offset=page_offset, - user=user, - ) + projection_repo = server.services.thread_message_repo + projection_status = projection_repo.projection_status(thread_id) + history_loading = projection_status != "ready" + if history_loading: + + async def _work() -> None: + await _backfill_thread_projection( + server, + agent_id, + thread_id, + user=user, + ) + + accepted = ( + False + if _agent_is_busy(server, agent_id) + else server.app_runtime.gateway.history_backfill.enqueue(thread_id, _work) + ) + if accepted and projection_status not in ("queued", "running"): + projection_repo.mark_projection(thread_id, "queued") + messages: list[dict[str, Any]] = [] + has_more = False + projection_status = ( + projection_status + if accepted and projection_status in ("queued", "running") + else "queued" + if accepted + else "pending" + ) + else: + messages, has_more = _load_projected_thread_messages( + server, + agent_id, + thread_id, + page_limit, + offset=page_offset, + user=user, + ) effective_uid = as_user if as_user is not None else user.id hitl_pending = pending_hitl_payload( server.app_runtime.gateway.processor.hitl_coordinator.store, @@ -197,6 +325,9 @@ async def get_thread_history( "has_more": has_more, "limit": page_limit, "offset": page_offset, + "history_loading": history_loading, + "history_status": projection_status, + "history_retry_after_ms": 1500 if history_loading else 0, "turn_active": server.app_runtime.gateway.ws_hub.is_turn_active(thread_id), "hitl_pending": hitl_pending, "artifacts": artifacts_for_response(row.artifacts, workspace_dir), @@ -256,6 +387,7 @@ async def fork_thread( content=body.content, assistant_turns_from_end=body.assistant_turns_from_end, locale=resolve_request_locale(request), + thread_message_repo=server.services.thread_message_repo, ) diff --git a/src/octop/api/routers/chat/serialize.py b/src/octop/api/routers/chat/serialize.py index 9d83623a..c13539cf 100644 --- a/src/octop/api/routers/chat/serialize.py +++ b/src/octop/api/routers/chat/serialize.py @@ -6,10 +6,14 @@ import json import logging import re +import sqlite3 from datetime import datetime from pathlib import Path from typing import Any +from langchain_core.messages import messages_from_dict +from langchain_core.runnables import RunnableConfig + from octop.api.common.agent_workspace import resolve_agent_workspace_dir from octop.i18n.domains.attachment import attachment_empty_image from octop.infra.agents.context_breakdown import usage_dict_from_message @@ -388,6 +392,141 @@ async def _load_thread_messages( ) +def _isolated_sqlite_checkpoint_messages( + harness: Any, + thread_id: str, +) -> list[Any] | None: + """Read the newest SQLite checkpoint through a separate read-only connection. + + ``None`` means the harness is not backed by a discoverable SQLite saver. + Once SQLite is detected, failures are raised instead of falling back to the + live saver connection: availability of new turns is more important than a + legacy UI backfill. + """ + checkpoint = getattr(harness, "_checkpointer_instance", None) + if checkpoint is None: + return None + + backend = getattr(checkpoint, "_backend", None) + raw_path = getattr(backend, "_db_path", None) + saver = getattr(checkpoint, "_checkpointer", checkpoint) + disk_saver = getattr(saver, "_disk", saver) + live_conn = getattr(disk_saver, "conn", None) + if raw_path is None and isinstance(live_conn, sqlite3.Connection): + rows = live_conn.execute("PRAGMA database_list").fetchall() + raw_path = next((row[2] for row in rows if row[1] == "main"), None) + if raw_path is None or str(raw_path) == ":memory:": + return None + + from langgraph.checkpoint.sqlite import SqliteSaver # noqa: PLC0415 + + uri = f"{Path(raw_path).resolve().as_uri()}?mode=ro" + conn = sqlite3.connect(uri, uri=True, check_same_thread=False) + try: + conn.execute("PRAGMA query_only = ON") + conn.execute("PRAGMA busy_timeout = 5000") + isolated = SqliteSaver(conn=conn, serde=getattr(disk_saver, "serde", None)) + config: RunnableConfig = {"configurable": {"thread_id": thread_id}} + for checkpoint_tuple in isolated.list(config, limit=1): + messages = checkpoint_tuple.checkpoint.get("channel_values", {}).get("messages", []) + return list(messages) if isinstance(messages, list) else [] + return [] + finally: + conn.close() + + +def _load_projected_thread_messages( + server: Any, + agent_id: str, + thread_id: str, + limit: int, + *, + user: Any, + offset: int = 0, +) -> tuple[list[dict[str, Any]], bool]: + """Read one page without touching the agent checkpoint database.""" + rows, has_more = server.services.thread_message_repo.page( + thread_id, + limit=limit, + offset=offset, + ) + out: list[dict[str, Any]] = [] + for row in rows: + try: + wire = json.loads(row.message_json) + message = messages_from_dict([wire])[0] + except Exception: + logger.warning( + "invalid projected history row thread=%s seq=%s", + thread_id, + row.seq, + exc_info=True, + ) + continue + entry = _serialize_history_message(message, user=user) + if entry is not None: + out.append(entry) + return _enrich_history_tool_media(out, agent_id=agent_id), has_more + + +async def _backfill_thread_projection( + server: Any, + agent_id: str, + thread_id: str, + *, + user: Any, +) -> None: + """Decode one legacy checkpoint off-request and atomically publish it.""" + repo = server.services.thread_message_repo + registry = server.app_runtime.agent_registry + reserve = getattr(registry, "try_begin_history_backfill", None) + release = getattr(registry, "end_history_backfill", None) + reserved = reserve(agent_id) if callable(reserve) else True + if reserved is False: + await asyncio.to_thread(repo.mark_projection, thread_id, "pending") + return + try: + await asyncio.to_thread(repo.mark_projection, thread_id, "running") + harness = registry.get_agent(agent_id) + raw = await asyncio.to_thread( + _isolated_sqlite_checkpoint_messages, + harness, + thread_id, + ) + if raw is None: + raw, _has_more = await _load_checkpoint_messages( + harness, + thread_id, + 1_000_000, + 0, + ) + if not raw: + session_rows, _ = await _load_thread_messages_from_sessions( + server, + agent_id, + thread_id, + 1_000_000, + offset=0, + user=user, + ) + raw = session_rows + from octop.infra.gateway.process.history_projection import message_inputs # noqa: PLC0415 + + projected = await asyncio.to_thread(message_inputs, list(raw)) + await asyncio.to_thread(repo.replace_all, thread_id, projected) + except Exception as exc: + logger.exception("history projection backfill failed for thread=%s", thread_id) + await asyncio.to_thread( + repo.mark_projection, + thread_id, + "failed", + error=str(exc)[:500], + ) + finally: + if callable(release): + release(agent_id) + + def _enrich_history_tool_media( messages: list[dict[str, Any]], *, diff --git a/src/octop/infra/agents/context_breakdown.py b/src/octop/infra/agents/context_breakdown.py index 8679c2ba..f37844e2 100644 --- a/src/octop/infra/agents/context_breakdown.py +++ b/src/octop/infra/agents/context_breakdown.py @@ -1,10 +1,4 @@ -"""Context window usage — thin adapter over harness-agent snapshots. - -Prefers ``HarnessAgent.aget_context_usage`` when present. Stock occupancy is -stored on ``AIMessage.additional_kwargs['context_usage']``; older stamps may -omit ``source``, which must not be treated as empty. If the live getter -drops those stamps, this module re-reads the same kwargs from history. -""" +"""Context window usage without checkpoint reads on dashboard requests.""" from __future__ import annotations @@ -126,35 +120,6 @@ def _additional_kwargs(msg: Any) -> dict[str, Any] | None: return _as_mapping(kwargs) -def _breakdown_from_context_stamp( - payload: Any, *, max_tokens: int -) -> ContextBreakdownResult | None: - """Rebuild the ring from ``additional_kwargs['context_usage']`` (stock snapshots).""" - mapped = _as_mapping(payload) - if not mapped: - return None - segs_raw = _as_mapping(mapped.get("segments")) or {} - segments = {key: _token_int(segs_raw.get(key)) for key in SEGMENT_KEYS} - used = ( - _token_int(mapped.get("used_tokens")) - or _token_int(mapped.get("input_tokens")) - or sum(segments.values()) - ) - if used <= 0: - return None - cap = _token_int(mapped.get("max_tokens")) - if cap == 0: - cap = max_tokens if max_tokens > 0 else 128_000 - if not any(segments.values()): - segments = dict.fromkeys(SEGMENT_KEYS, 0) - segments["conversation"] = min(used, cap) - return ContextBreakdownResult( - max_tokens=cap, - used_tokens=min(used, cap), - segments=segments, - ) - - def usage_dict_from_message(msg: Any) -> dict[str, Any] | None: """Normalize LangChain token counts for history + the context ring. @@ -224,39 +189,6 @@ def usage_dict_from_message(msg: Any) -> dict[str, Any] | None: return payload -def _message_input_tokens(msg: Any) -> int: - usage = usage_dict_from_message(msg) - return int(usage["input_tokens"]) if usage else 0 - - -async def _load_checkpoint_messages(harness: Any, thread_id: str) -> list[Any]: - getter = getattr(harness, "aget_history", None) - if getter is None: - return [] - try: - return list(await getter(thread_id, limit=40)) - except Exception: - logger.debug( - "aget_history failed while recovering context usage thread=%s", - thread_id, - exc_info=True, - ) - return [] - - -def _stamp_breakdown_from_messages( - messages: list[Any], *, max_tokens: int -) -> ContextBreakdownResult | None: - for msg in reversed(messages): - kwargs = _additional_kwargs(msg) - if not kwargs: - continue - bd = _breakdown_from_context_stamp(kwargs.get("context_usage"), max_tokens=max_tokens) - if bd is not None: - return bd - return None - - async def compute_context_breakdown( registry: Any, *, @@ -268,10 +200,12 @@ async def compute_context_breakdown( skills: list[str] | None = None, usage_repo: Any | None = None, ) -> ContextBreakdownResult: - """Return harness context usage, or a stream-token fallback. + """Return a non-blocking context snapshot or a token-ledger fallback. ``mcp_servers`` / ``skills`` are accepted for dashboard query compatibility - but unused — the harness snapshot already reflects the filtered request. + but unused — the live harness snapshot already reflects the filtered request. + This endpoint deliberately never falls back to checkpoint history: opening + the context popover must not decode a multi-gigabyte legacy checkpoint. """ del mcp_servers, skills row = registry.get_row(agent_id) @@ -285,15 +219,14 @@ async def compute_context_breakdown( logger.debug("no live harness for context usage agent=%s", agent_id, exc_info=True) usage: Any = None - getter = getattr(harness, "aget_context_usage", None) if harness is not None else None - if getter is not None: + middleware = getattr(harness, "_context_usage_mw", None) if harness is not None else None + snapshot_getter = getattr(middleware, "get_snapshot", None) + if callable(snapshot_getter): try: - # The harness snapshot owns the exact routed model capacity. Passing - # the dashboard fallback here would overwrite it after model switches. - usage = await getter(thread_id) + usage = snapshot_getter(thread_id) except Exception: logger.debug( - "harness aget_context_usage failed for thread=%s", + "live context snapshot failed for thread=%s", thread_id, exc_info=True, ) @@ -302,18 +235,6 @@ async def compute_context_breakdown( if _usage_has_segments(usage): return _usage_to_breakdown(usage, fallback_max_tokens=max_tokens) - messages: list[Any] = [] - if harness is not None: - messages = await _load_checkpoint_messages(harness, thread_id) - stamped = _stamp_breakdown_from_messages(messages, max_tokens=max_tokens) - if stamped is not None: - return stamped - - recovered = 0 - for msg in reversed(messages): - recovered = _message_input_tokens(msg) - if recovered: - break logged = 0 if usage_repo is not None: last_fn = getattr(usage_repo, "last_thread_input_tokens", None) @@ -326,7 +247,7 @@ async def compute_context_breakdown( thread_id, exc_info=True, ) - fallback = recovered or logged or _token_int(input_tokens) + fallback = logged or _token_int(input_tokens) if fallback: return _from_stream_input_tokens(fallback, max_tokens=max_tokens) diff --git a/src/octop/infra/agents/manager.py b/src/octop/infra/agents/manager.py index 449a3c71..08177db4 100644 --- a/src/octop/infra/agents/manager.py +++ b/src/octop/infra/agents/manager.py @@ -8,6 +8,7 @@ import re import shutil from collections.abc import AsyncIterator, Callable, Sequence +from contextlib import asynccontextmanager from dataclasses import dataclass, field, fields, replace from pathlib import Path from typing import TYPE_CHECKING, Any, cast @@ -333,6 +334,9 @@ def __init__( self._team_processor: Any | None = None self._harness_manager: HarnessAgentManager | None = None self._lock = asyncio.Lock() + self._active_invocations: dict[str, int] = {} + self._invocation_waiters: dict[str, int] = {} + self._history_backfills: dict[str, asyncio.Event] = {} self._reload_dirty: set[str] = set() self._reload_worker_running: dict[str, bool] = {} self._bootstrap_graph_refresh_pending: set[str] = set() @@ -872,25 +876,76 @@ async def delete_thread_checkpoint(self, agent_id: str, thread_id: str) -> bool: # Chat / invoke — stream, call, HITL, thread model overrides # ------------------------------------------------------------------ + def is_agent_active(self, agent_id: str) -> bool: + """Whether the agent is currently executing a user-visible invocation.""" + return self._active_invocations.get(agent_id, 0) > 0 + + def try_begin_history_backfill(self, agent_id: str) -> bool: + """Reserve an idle agent for one history backfill without racing a new turn.""" + if ( + self.is_agent_active(agent_id) + or self._invocation_waiters.get(agent_id, 0) > 0 + or agent_id in self._history_backfills + ): + return False + self._history_backfills[agent_id] = asyncio.Event() + return True + + def end_history_backfill(self, agent_id: str) -> None: + """Release one history-backfill reservation and wake waiting invocations.""" + event = self._history_backfills.pop(agent_id, None) + if event is not None: + event.set() + + async def _begin_invocation(self, agent_id: str) -> None: + self._invocation_waiters[agent_id] = self._invocation_waiters.get(agent_id, 0) + 1 + try: + while event := self._history_backfills.get(agent_id): + await event.wait() + finally: + waiting = self._invocation_waiters.get(agent_id, 1) - 1 + if waiting > 0: + self._invocation_waiters[agent_id] = waiting + else: + self._invocation_waiters.pop(agent_id, None) + self._active_invocations[agent_id] = self._active_invocations.get(agent_id, 0) + 1 + + def _end_invocation(self, agent_id: str) -> None: + active = self._active_invocations.get(agent_id, 1) - 1 + if active > 0: + self._active_invocations[agent_id] = active + else: + self._active_invocations.pop(agent_id, None) + + @asynccontextmanager + async def _track_invocation(self, agent_id: str) -> AsyncIterator[None]: + await self._begin_invocation(agent_id) + try: + yield + finally: + self._end_invocation(agent_id) + async def stream(self, agent_id: str, request: dict[str, Any]) -> AsyncIterator[Any]: """Stream harness chunks (Langfuse tracing handled inside harness-agent).""" if self._harness_manager is None: raise self._unavailable_error(agent_id) - self._apply_pending_bootstrap_graph_refresh(agent_id) - req = self._prepare_stream_request(agent_id, request) - async for chunk in self._harness_manager.stream(agent_id, cast(Any, req)): - yield chunk - self._apply_pending_bootstrap_graph_refresh(agent_id) + async with self._track_invocation(agent_id): + self._apply_pending_bootstrap_graph_refresh(agent_id) + req = self._prepare_stream_request(agent_id, request) + async for chunk in self._harness_manager.stream(agent_id, cast(Any, req)): + yield chunk + self._apply_pending_bootstrap_graph_refresh(agent_id) async def call(self, agent_id: str, request: dict[str, Any]) -> dict[str, Any]: """Non-streaming harness invocation (one-shot agent call).""" if self._harness_manager is None: raise self._unavailable_error(agent_id) - self._apply_pending_bootstrap_graph_refresh(agent_id) - req = self._prepare_stream_request(agent_id, request) - result = await self._harness_manager.call(agent_id, cast(Any, req)) - self._apply_pending_bootstrap_graph_refresh(agent_id) + async with self._track_invocation(agent_id): + self._apply_pending_bootstrap_graph_refresh(agent_id) + req = self._prepare_stream_request(agent_id, request) + result = await self._harness_manager.call(agent_id, cast(Any, req)) + self._apply_pending_bootstrap_graph_refresh(agent_id) if not isinstance(result, dict): return {"result": result} return result @@ -904,10 +959,11 @@ async def resume_hitl( """Resume a paused HITL interrupt for *thread_id*.""" if self._harness_manager is None: raise self._unavailable_error(agent_id) - self._apply_pending_bootstrap_graph_refresh(agent_id) - async for chunk in self._harness_manager.resume_hitl(agent_id, thread_id, decisions): - yield chunk - self._apply_pending_bootstrap_graph_refresh(agent_id) + async with self._track_invocation(agent_id): + self._apply_pending_bootstrap_graph_refresh(agent_id) + async for chunk in self._harness_manager.resume_hitl(agent_id, thread_id, decisions): + yield chunk + self._apply_pending_bootstrap_graph_refresh(agent_id) def cancel_stream(self, agent_id: str, thread_id: str) -> None: """Signal harness-agent to stop the active stream for *(agent_id, thread_id)*.""" diff --git a/src/octop/infra/agents/thread_fork.py b/src/octop/infra/agents/thread_fork.py index 3a5ec4f2..61a60f19 100644 --- a/src/octop/infra/agents/thread_fork.py +++ b/src/octop/infra/agents/thread_fork.py @@ -187,6 +187,7 @@ async def fork_dashboard_thread( content: str | None = None, assistant_turns_from_end: int | None = None, locale: str = "en", + thread_message_repo: Any | None = None, ) -> dict[str, Any]: """Create a dashboard thread seeded through the selected assistant reply.""" messages = await load_checkpoint_messages(harness, source.thread_id) @@ -198,6 +199,17 @@ async def fork_dashboard_thread( ) # Include the selected assistant message (and any tool traffic before it). prefix = messages[: idx + 1] + prefix_projection_inputs: list[Any] | None = None + if thread_message_repo is not None: + from octop.infra.gateway.process.history_projection import ( # noqa: PLC0415 + message_inputs, + ) + + projection_inputs = message_inputs(messages) + prefix_projection_inputs = message_inputs(prefix) + # A fork already paid the explicit full-history read cost. Reuse it to + # heal an empty read model instead of decoding the source again later. + thread_message_repo.append_if_ready(source.thread_id, projection_inputs) session_key = ThreadRegistry.dashboard_key(agent_id=source.agent_id, user_id=user_id) dest_id = thread_registry.create_thread( @@ -210,6 +222,9 @@ async def fork_dashboard_thread( ) try: await write_checkpoint_messages(harness, thread_id=dest_id, messages=prefix) + if thread_message_repo is not None: + assert prefix_projection_inputs is not None + thread_message_repo.append_if_ready(dest_id, prefix_projection_inputs) except Exception: thread_registry.delete_thread(dest_id) raise diff --git a/src/octop/infra/db/migrate.py b/src/octop/infra/db/migrate.py index 9928b828..470d4e8c 100644 --- a/src/octop/infra/db/migrate.py +++ b/src/octop/infra/db/migrate.py @@ -946,8 +946,10 @@ def _apply_sqlite_migration(db: DatabasePool, version: int, path: Path) -> None: (idempotent); PostgreSQL runs the ``.pg.sql`` file then the same helpers as a no-op safety net. ``config_json`` profile keys are backfilled in Python either way. - Version 8 adds cache-aware usage buckets and model call counts. - Version 9 adds one-time ``user_invites`` codes. + Version 8 adds cache-aware usage buckets and model call counts. + Version 9 adds one-time ``user_invites`` codes. + Version 10 adds the dashboard thread-message projection when the legacy + database actually contains conversation tables. """ if version == 2: if _table_exists(db, "cron_jobs"): @@ -1019,6 +1021,13 @@ def _apply_sqlite_migration(db: DatabasePool, version: int, path: Path) -> None: with db.connect() as conn: conn.execute("UPDATE _schema_version SET version = ?", (version,)) return + if version == 10 and not _table_exists(db, "threads"): + # Some very old/partial installs only contain ``users``. They still + # need the version watermark to advance, but there is no history to + # project and the migration's INSERT ... SELECT threads cannot run. + with db.connect() as conn: + conn.execute("UPDATE _schema_version SET version = ?", (version,)) + return sql = path.read_text(encoding="utf-8") with db.connect() as conn: conn.executescript(sql) diff --git a/src/octop/infra/db/migrations/010_thread_message_projection.pg.sql b/src/octop/infra/db/migrations/010_thread_message_projection.pg.sql new file mode 100644 index 00000000..0b9a80b0 --- /dev/null +++ b/src/octop/infra/db/migrations/010_thread_message_projection.pg.sql @@ -0,0 +1,34 @@ +-- Dashboard history read model. Existing message-bearing threads are backfilled lazily; +-- new/empty threads can use the projection immediately. + +CREATE TABLE IF NOT EXISTS thread_messages ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + thread_id TEXT NOT NULL REFERENCES threads(thread_id) ON DELETE CASCADE, + seq BIGINT NOT NULL, + message_id TEXT, + role TEXT NOT NULL, + message_json TEXT NOT NULL, + created_at BIGINT NOT NULL, + UNIQUE(thread_id, seq), + UNIQUE(thread_id, message_id) +); + +CREATE INDEX IF NOT EXISTS idx_thread_messages_thread_seq + ON thread_messages(thread_id, seq DESC); + +CREATE TABLE IF NOT EXISTS thread_history_projection ( + thread_id TEXT PRIMARY KEY REFERENCES threads(thread_id) ON DELETE CASCADE, + status TEXT NOT NULL, + updated_at BIGINT NOT NULL, + error TEXT +); + +INSERT INTO thread_history_projection(thread_id, status, updated_at, error) +SELECT thread_id, + CASE WHEN last_active > 0 OR title IS NOT NULL THEN 'pending' ELSE 'ready' END, + EXTRACT(EPOCH FROM NOW())::BIGINT, + NULL +FROM threads +ON CONFLICT(thread_id) DO NOTHING; + +UPDATE _schema_version SET version = 10; diff --git a/src/octop/infra/db/migrations/010_thread_message_projection.sql b/src/octop/infra/db/migrations/010_thread_message_projection.sql new file mode 100644 index 00000000..4e73b8da --- /dev/null +++ b/src/octop/infra/db/migrations/010_thread_message_projection.sql @@ -0,0 +1,33 @@ +-- Dashboard history read model. Existing message-bearing threads are backfilled lazily; +-- new/empty threads can use the projection immediately. + +CREATE TABLE IF NOT EXISTS thread_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + thread_id TEXT NOT NULL REFERENCES threads(thread_id) ON DELETE CASCADE, + seq INTEGER NOT NULL, + message_id TEXT, + role TEXT NOT NULL, + message_json TEXT NOT NULL, + created_at INTEGER NOT NULL, + UNIQUE(thread_id, seq), + UNIQUE(thread_id, message_id) +); + +CREATE INDEX IF NOT EXISTS idx_thread_messages_thread_seq + ON thread_messages(thread_id, seq DESC); + +CREATE TABLE IF NOT EXISTS thread_history_projection ( + thread_id TEXT PRIMARY KEY REFERENCES threads(thread_id) ON DELETE CASCADE, + status TEXT NOT NULL, + updated_at INTEGER NOT NULL, + error TEXT +); + +INSERT OR IGNORE INTO thread_history_projection(thread_id, status, updated_at, error) +SELECT thread_id, + CASE WHEN last_active > 0 OR title IS NOT NULL THEN 'pending' ELSE 'ready' END, + CAST(strftime('%s', 'now') AS INTEGER), + NULL +FROM threads; + +UPDATE _schema_version SET version = 10; diff --git a/src/octop/infra/db/repos/thread_messages.py b/src/octop/infra/db/repos/thread_messages.py new file mode 100644 index 00000000..09e6ce33 --- /dev/null +++ b/src/octop/infra/db/repos/thread_messages.py @@ -0,0 +1,250 @@ +"""Projection rows used by the dashboard history API.""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass + +from octop.infra.db.pool import DatabasePool +from octop.infra.db.repos._base import DbRow, now_ts + + +@dataclass(frozen=True) +class ThreadMessageInput: + message_id: str | None + role: str + message_json: str + created_at: int + + +@dataclass(frozen=True) +class ThreadMessageRow: + seq: int + message_id: str | None + role: str + message_json: str + created_at: int + + @classmethod + def from_row(cls, row: DbRow) -> ThreadMessageRow: + return cls( + seq=int(row["seq"]), + message_id=str(row["message_id"]) if row["message_id"] else None, + role=str(row["role"]), + message_json=str(row["message_json"]), + created_at=int(row["created_at"]), + ) + + +@dataclass(frozen=True) +class ThreadProjectionCandidate: + thread_id: str + status: str + + +@dataclass(frozen=True) +class ThreadProjectionSummary: + pending: int = 0 + queued: int = 0 + running: int = 0 + failed: int = 0 + + @property + def remaining(self) -> int: + return self.pending + self.queued + self.running + self.failed + + +class ThreadMessageRepo: + """Keep checkpoint-sized data out of normal dashboard history reads.""" + + def __init__(self, db: DatabasePool) -> None: + self._db = db + + def projection_status(self, thread_id: str) -> str: + with self._db.connect() as conn: + row = conn.execute( + "SELECT status FROM thread_history_projection WHERE thread_id = ?", + (thread_id,), + ).fetchone() + return str(row["status"]) if row else "pending" + + def mark_projection( + self, + thread_id: str, + status: str, + *, + error: str | None = None, + ) -> None: + with self._db.transaction() as conn: + conn.execute( + "INSERT INTO thread_history_projection(thread_id, status, updated_at, error) " + "VALUES (?, ?, ?, ?) " + "ON CONFLICT(thread_id) DO UPDATE SET " + "status = excluded.status, updated_at = excluded.updated_at, error = excluded.error", + (thread_id, status, now_ts(), error), + ) + + def migration_summary(self, *, agent_id: str, user_id: int) -> ThreadProjectionSummary: + """Count legacy threads that still need a history projection.""" + counts = {"pending": 0, "queued": 0, "running": 0, "failed": 0} + with self._db.connect() as conn: + rows = conn.execute( + "SELECT COALESCE(p.status, 'pending') AS status, COUNT(*) AS total " + "FROM threads t LEFT JOIN thread_history_projection p " + "ON p.thread_id = t.thread_id " + "WHERE t.agent_id = ? AND t.user_id = ? " + "AND COALESCE(p.status, 'pending') != 'ready' " + "GROUP BY COALESCE(p.status, 'pending')", + (agent_id, user_id), + ).fetchall() + for row in rows: + status = str(row["status"]) + if status in counts: + counts[status] = int(row["total"]) + else: + counts["pending"] += int(row["total"]) + return ThreadProjectionSummary(**counts) + + def migration_candidates( + self, + *, + agent_id: str, + user_id: int, + limit: int, + ) -> list[ThreadProjectionCandidate]: + """Return recent legacy threads first without reading checkpoint blobs.""" + if limit <= 0: + return [] + with self._db.connect() as conn: + rows = conn.execute( + "SELECT t.thread_id, COALESCE(p.status, 'pending') AS status " + "FROM threads t LEFT JOIN thread_history_projection p " + "ON p.thread_id = t.thread_id " + "WHERE t.agent_id = ? AND t.user_id = ? " + "AND COALESCE(p.status, 'pending') != 'ready' " + "ORDER BY t.last_active DESC, t.thread_id DESC LIMIT ?", + (agent_id, user_id, limit), + ).fetchall() + return [ + ThreadProjectionCandidate(thread_id=str(row["thread_id"]), status=str(row["status"])) + for row in rows + ] + + def migration_active_thread_ids(self, *, agent_id: str, user_id: int) -> list[str]: + """Persisted queued/running IDs, used to distinguish live work from restart residue.""" + with self._db.connect() as conn: + rows = conn.execute( + "SELECT t.thread_id FROM threads t " + "JOIN thread_history_projection p ON p.thread_id = t.thread_id " + "WHERE t.agent_id = ? AND t.user_id = ? " + "AND p.status IN ('queued', 'running')", + (agent_id, user_id), + ).fetchall() + return [str(row["thread_id"]) for row in rows] + + def page( + self, + thread_id: str, + *, + limit: int, + offset: int = 0, + ) -> tuple[list[ThreadMessageRow], bool]: + with self._db.connect() as conn: + rows = conn.execute( + "SELECT seq, message_id, role, message_json, created_at " + "FROM thread_messages WHERE thread_id = ? " + "ORDER BY seq DESC LIMIT ? OFFSET ?", + (thread_id, limit + 1, max(0, offset)), + ).fetchall() + has_more = len(rows) > limit + page = [ThreadMessageRow.from_row(row) for row in rows[:limit]] + page.reverse() + return page, has_more + + def append_if_ready( + self, + thread_id: str, + messages: Sequence[ThreadMessageInput], + ) -> int: + """Append one completed turn. Pending legacy threads wait for backfill.""" + if not messages: + return 0 + inserted = 0 + with self._db.transaction() as conn: + state = conn.execute( + "SELECT status FROM thread_history_projection WHERE thread_id = ?", + (thread_id,), + ).fetchone() + if state is None or str(state["status"]) != "ready": + return 0 + if self._db.dialect == "postgresql": + conn.execute( + "SELECT thread_id FROM threads WHERE thread_id = ? FOR UPDATE", + (thread_id,), + ).fetchone() + row = conn.execute( + "SELECT COALESCE(MAX(seq), 0) AS max_seq FROM thread_messages WHERE thread_id = ?", + (thread_id,), + ).fetchone() + seq = int(row["max_seq"]) if row else 0 + for message in messages: + if message.message_id: + duplicate = conn.execute( + "SELECT 1 FROM thread_messages WHERE thread_id = ? AND message_id = ?", + (thread_id, message.message_id), + ).fetchone() + if duplicate is not None: + continue + seq += 1 + conn.execute( + "INSERT INTO thread_messages(" + "thread_id, seq, message_id, role, message_json, created_at" + ") VALUES (?, ?, ?, ?, ?, ?)", + ( + thread_id, + seq, + message.message_id, + message.role, + message.message_json, + message.created_at, + ), + ) + inserted += 1 + return inserted + + def replace_all( + self, + thread_id: str, + messages: Sequence[ThreadMessageInput], + ) -> int: + """Publish a decoded legacy checkpoint using short write batches. + + Readers are gated by ``status != ready``, so the batches stay invisible + without holding SQLite's writer lock for the entire transcript. + """ + with self._db.transaction() as conn: + if self._db.dialect == "postgresql": + conn.execute( + "SELECT thread_id FROM threads WHERE thread_id = ? FOR UPDATE", + (thread_id,), + ).fetchone() + conn.execute("DELETE FROM thread_messages WHERE thread_id = ?", (thread_id,)) + batch_size = 200 + for start in range(0, len(messages), batch_size): + with self._db.transaction() as conn: + for index, message in enumerate(messages[start : start + batch_size], start=start): + conn.execute( + "INSERT INTO thread_messages(" + "thread_id, seq, message_id, role, message_json, created_at" + ") VALUES (?, ?, ?, ?, ?, ?)", + ( + thread_id, + index + 1, + message.message_id, + message.role, + message.message_json, + message.created_at, + ), + ) + self.mark_projection(thread_id, "ready") + return len(messages) diff --git a/src/octop/infra/db/repos/threads.py b/src/octop/infra/db/repos/threads.py index 50b54a89..a1edb3e7 100644 --- a/src/octop/infra/db/repos/threads.py +++ b/src/octop/infra/db/repos/threads.py @@ -184,6 +184,11 @@ def insert( ts, ), ) + conn.execute( + "INSERT INTO thread_history_projection(thread_id, status, updated_at, error) " + "VALUES (?, 'ready', ?, NULL) ON CONFLICT(thread_id) DO NOTHING", + (thread_id, ts), + ) def get(self, thread_id: str) -> ThreadRow | None: with self._db.connect() as conn: diff --git a/src/octop/infra/db/services.py b/src/octop/infra/db/services.py index 1898264c..11c587e8 100644 --- a/src/octop/infra/db/services.py +++ b/src/octop/infra/db/services.py @@ -23,6 +23,7 @@ from octop.infra.db.repos.settings import SettingsRepo from octop.infra.db.repos.skill_packages import SkillPackageRepo from octop.infra.db.repos.sso import SsoRepo +from octop.infra.db.repos.thread_messages import ThreadMessageRepo from octop.infra.db.repos.threads import ThreadRepo from octop.infra.db.repos.usage import UsageRepo from octop.infra.db.repos.users import UserRepo @@ -42,6 +43,7 @@ class RepoBundle: cron_repo: CronJobRepo session_repo: SessionRepo thread_repo: ThreadRepo + thread_message_repo: ThreadMessageRepo secret_repo: SecretRepo audit_repo: AuditRepo usage_repo: UsageRepo @@ -68,6 +70,7 @@ def from_pool(cls, db: DatabasePool) -> RepoBundle: cron_repo=CronJobRepo(db), session_repo=SessionRepo(db), thread_repo=ThreadRepo(db), + thread_message_repo=ThreadMessageRepo(db), secret_repo=SecretRepo(db), audit_repo=AuditRepo(db), usage_repo=UsageRepo(db), @@ -126,6 +129,10 @@ def session_repo(self) -> SessionRepo: def thread_repo(self) -> ThreadRepo: return self.repos.thread_repo + @property + def thread_message_repo(self) -> ThreadMessageRepo: + return self.repos.thread_message_repo + @property def secret_repo(self) -> SecretRepo: return self.repos.secret_repo diff --git a/src/octop/infra/gateway/gateway.py b/src/octop/infra/gateway/gateway.py index 37ff0fd4..00bc6ac9 100644 --- a/src/octop/infra/gateway/gateway.py +++ b/src/octop/infra/gateway/gateway.py @@ -20,6 +20,7 @@ from octop.infra.db.repos.sessions import SessionRow from octop.infra.errors import ErrorCode, OctopError from octop.infra.gateway.cli import CLI_CHANNEL_ID, CliChannel, CliHub +from octop.infra.gateway.history_backfill import HistoryBackfillQueue from octop.infra.gateway.process import build_harness_request, media_backend_for_agent from octop.infra.gateway.process.processor import GlobalProcessor from octop.infra.gateway.process.response_mode import ( @@ -116,6 +117,7 @@ def __init__( self._ws_channel: WebSocketChannel | None = None self._cli_channel: CliChannel | None = None self._runtime_status: dict[str, ChannelRuntimeStatus] = {} + self._history_backfill = HistoryBackfillQueue() def replace_repos(self, repos: RepoBundle) -> None: """Point channel/thread persistence at a rebound control-plane pool.""" @@ -124,6 +126,8 @@ def replace_repos(self, repos: RepoBundle) -> None: session_repo=repos.session_repo, thread_repo=repos.thread_repo, ) + if self._processor is not None: + self._processor.replace_thread_message_repo(repos.thread_message_repo) @property def ws_hub(self) -> WebSocketHub: @@ -155,6 +159,10 @@ def processor(self) -> GlobalProcessor: raise RuntimeError("gateway not booted") return self._processor + @property + def history_backfill(self) -> HistoryBackfillQueue: + return self._history_backfill + @property def slash_meta(self) -> SlashRuntimeMeta | None: return self._slash_meta @@ -199,6 +207,7 @@ async def boot(self) -> None: provider_repo=self._repos.provider_repo, dispatcher=self._dispatcher, usage_repo=self._repos.usage_repo, + thread_message_repo=self._repos.thread_message_repo, gateway=self, ) @@ -270,6 +279,7 @@ async def reload_channels_from_db(self) -> None: logger.info("Gateway channels reloaded from DB (%d enabled)", len(rows)) async def shutdown(self) -> None: + await self._history_backfill.close() if self._channel_manager: await self._channel_manager.stop() self._channel_manager = None diff --git a/src/octop/infra/gateway/history_backfill.py b/src/octop/infra/gateway/history_backfill.py new file mode 100644 index 00000000..e3611ea4 --- /dev/null +++ b/src/octop/infra/gateway/history_backfill.py @@ -0,0 +1,66 @@ +"""Bounded single-worker queue for legacy checkpoint history projection.""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +from collections.abc import Awaitable, Callable + +BackfillWork = Callable[[], Awaitable[None]] +logger = logging.getLogger(__name__) + + +class HistoryBackfillQueue: + """Deduplicate jobs and ensure only one huge checkpoint is decoded at once.""" + + def __init__(self, *, max_pending: int = 100) -> None: + self._max_pending = max_pending + self._queue: asyncio.Queue[tuple[str, BackfillWork]] = asyncio.Queue(maxsize=max_pending) + self._known: set[str] = set() + self._worker: asyncio.Task[None] | None = None + + def enqueue(self, key: str, work: BackfillWork) -> bool: + if key in self._known: + return True + if self._queue.full(): + return False + self._known.add(key) + self._queue.put_nowait((key, work)) + if self._worker is None or self._worker.done(): + self._worker = asyncio.create_task(self._run(), name="history-backfill") + return True + + @property + def available_slots(self) -> int: + """Number of bounded waiting slots available to bulk migration.""" + return max(0, self._max_pending - self._queue.qsize()) + + @property + def active_jobs(self) -> int: + """Queued plus currently running deduplicated jobs.""" + return len(self._known) + + def contains(self, key: str) -> bool: + """Whether this process is actively responsible for a persisted job.""" + return key in self._known + + async def _run(self) -> None: + while True: + key, work = await self._queue.get() + try: + await work() + except Exception: + logger.exception("legacy history backfill job failed: %s", key) + finally: + self._known.discard(key) + self._queue.task_done() + + async def close(self) -> None: + worker = self._worker + self._worker = None + if worker is None: + return + worker.cancel() + with contextlib.suppress(asyncio.CancelledError): + await worker diff --git a/src/octop/infra/gateway/process/history_projection.py b/src/octop/infra/gateway/process/history_projection.py new file mode 100644 index 00000000..70979ceb --- /dev/null +++ b/src/octop/infra/gateway/process/history_projection.py @@ -0,0 +1,112 @@ +"""Capture the current turn from harness stream state for cheap UI history.""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass, field +from typing import Any + +from langchain_core.messages import convert_to_messages, message_to_dict + +from octop.infra.db.repos._base import now_ts +from octop.infra.db.repos.thread_messages import ThreadMessageInput + +_USER_ROLES = frozenset({"human", "user"}) + + +def _role(message: Any) -> str: + if isinstance(message, dict): + return str(message.get("role") or message.get("type") or "").lower() + return str(getattr(message, "type", None) or getattr(message, "role", "")).lower() + + +def _chunk_messages(chunk: dict[str, Any]) -> list[Any]: + data = chunk.get("data") + if isinstance(data, dict) and isinstance(data.get("messages"), list): + return list(data["messages"]) + if isinstance(data, list): + return list(data) + return [] + + +def message_input(message: Any) -> ThreadMessageInput | None: + """Serialize one LangChain/dict message before entering a DB transaction.""" + try: + converted = convert_to_messages([message])[0] if isinstance(message, dict) else message + wire = message_to_dict(converted) + role = _role(converted) + if role in ("", "system"): + return None + return ThreadMessageInput( + message_id=str(getattr(converted, "id", "") or "") or None, + role=role, + message_json=json.dumps(wire, ensure_ascii=False, default=str), + created_at=now_ts(), + ) + except Exception: + return None + + +def message_inputs( + messages: list[Any], + *, + dedupe_missing_ids: bool = False, +) -> list[ThreadMessageInput]: + """Serialize and de-duplicate replays within one stream/backfill batch.""" + out: list[ThreadMessageInput] = [] + seen: set[str] = set() + for message in messages: + item = message_input(message) + if item is None: + continue + key = f"id:{item.message_id}" if item.message_id else "" + if not key and dedupe_missing_ids: + key = "wire:" + hashlib.sha256(item.message_json.encode()).hexdigest() + if not key: + out.append(item) + continue + if key in seen: + continue + seen.add(key) + out.append(item) + return out + + +@dataclass +class TurnHistoryTracker: + """Keep only the current user turn from replay-prone state chunks.""" + + seed_messages: list[Any] = field(default_factory=list) + _state_messages: list[Any] = field(default_factory=list, init=False) + + @classmethod + def from_request(cls, request: dict[str, Any]) -> TurnHistoryTracker: + raw = request.get("messages") + return cls(seed_messages=list(raw) if isinstance(raw, list) else []) + + def observe(self, chunk: dict[str, Any]) -> None: + if chunk.get("type") not in ("state_snapshot", "state_update"): + return + messages = _chunk_messages(chunk) + if not messages: + return + last_user = max( + (index for index, msg in enumerate(messages) if _role(msg) in _USER_ROLES), + default=-1, + ) + if last_user >= 0: + self._state_messages = messages[last_user:] + else: + self._state_messages.extend(messages) + + @property + def inputs(self) -> list[ThreadMessageInput]: + state_has_user = any(_role(msg) in _USER_ROLES for msg in self._state_messages) + source = ( + self._state_messages if state_has_user else [*self.seed_messages, *self._state_messages] + ) + return message_inputs( + source, + dedupe_missing_ids=True, + ) diff --git a/src/octop/infra/gateway/process/processor.py b/src/octop/infra/gateway/process/processor.py index 443bf137..eb3d876c 100644 --- a/src/octop/infra/gateway/process/processor.py +++ b/src/octop/infra/gateway/process/processor.py @@ -38,6 +38,7 @@ build_content_from_message, build_harness_request, ) +from octop.infra.gateway.process.history_projection import TurnHistoryTracker from octop.infra.gateway.process.message_keys import ( resolve_user_id_for_message, sanitize_im_metadata, @@ -102,6 +103,7 @@ def __init__( provider_repo: Any | None = None, dispatcher: SlashDispatcher, usage_repo: Any | None = None, + thread_message_repo: Any | None = None, gateway: Any | None = None, hitl: HitlChannelCoordinator | None = None, ) -> None: @@ -122,6 +124,7 @@ def __init__( ) self._dispatcher = dispatcher self._usage_repo = usage_repo + self._thread_message_repo = thread_message_repo self._gateway = gateway self._hitl = hitl or HitlChannelCoordinator() @@ -129,6 +132,10 @@ def __init__( def hitl_coordinator(self) -> HitlChannelCoordinator: return self._hitl + def replace_thread_message_repo(self, repo: Any) -> None: + """Rebind projection writes after a control-plane restore.""" + self._thread_message_repo = repo + # -- TeamProcessor (harness inbox async peer collaboration) ---------------- def compose_followup( @@ -474,6 +481,7 @@ async def __call__(self, msg: InboundMessage) -> AsyncIterator[MessageEvent]: stream_ok = False hitl_paused = False usage_tracker = UsageTracker() + history_tracker = TurnHistoryTracker.from_request(request) projection_state = StreamProjectionState() try: async for ev in project_stream( @@ -482,6 +490,7 @@ async def __call__(self, msg: InboundMessage) -> AsyncIterator[MessageEvent]: request, media_backend=media_backend, usage_tracker=usage_tracker, + history_tracker=history_tracker, locale=locale, projection_state=projection_state, hitl_coordinator=self._hitl, @@ -508,6 +517,7 @@ async def __call__(self, msg: InboundMessage) -> AsyncIterator[MessageEvent]: thread_id=thread_id, usage=usage_tracker.usage, ) + self._record_turn_history(thread_id, history_tracker) yield MessageEvent.completed() # -- Raw harness-chunk stream (Dashboard WS, etc.) ------------------------- @@ -585,6 +595,7 @@ async def iter_turn_chunks(self, msg: InboundMessage) -> AsyncIterator[dict[str, stream_ok = False harness_workspace = harness_workspace_for_agent(self._agent_manager, agent_id) usage_tracker = UsageTracker() + history_tracker = TurnHistoryTracker.from_request(request) locale = resolve_user_locale( user_repo=self._user_repo, user_id=user_id, @@ -595,6 +606,7 @@ async def iter_turn_chunks(self, msg: InboundMessage) -> AsyncIterator[dict[str, try: async for chunk in self._agent_manager.stream(agent_id, request): usage_tracker.observe(chunk) + history_tracker.observe(chunk) if chunk.get("type") == "hitl_required": request_payload = chunk.get("request") if isinstance(request_payload, dict): @@ -648,6 +660,7 @@ async def iter_turn_chunks(self, msg: InboundMessage) -> AsyncIterator[dict[str, thread_id=thread_id, usage=usage_tracker.usage, ) + self._record_turn_history(thread_id, history_tracker) yield {"type": "done"} async def _build_dashboard_request( @@ -883,3 +896,21 @@ def _record_turn_usage( thread_id=thread_id, usage=usage, ) + + def _record_turn_history( + self, + thread_id: str, + tracker: TurnHistoryTracker, + ) -> None: + if self._thread_message_repo is None: + return + try: + self._thread_message_repo.append_if_ready(thread_id, tracker.inputs) + except Exception: + # History projection is a read optimization and must never turn a + # successful model response into a failed chat turn. + logger.warning( + "failed to append thread history projection for thread=%s", + thread_id, + exc_info=True, + ) diff --git a/src/octop/infra/gateway/process/stream_project.py b/src/octop/infra/gateway/process/stream_project.py index bcf8f24f..63360632 100644 --- a/src/octop/infra/gateway/process/stream_project.py +++ b/src/octop/infra/gateway/process/stream_project.py @@ -13,6 +13,7 @@ from octop.infra.gateway.hitl.format import format_hitl_card from octop.infra.gateway.media.tool_media import media_events_from_tool_result from octop.infra.gateway.process.agent_resolve import harness_workspace_for_agent +from octop.infra.gateway.process.history_projection import TurnHistoryTracker from octop.infra.gateway.process.usage_record import UsageTracker from octop.infra.utils.locale import DEFAULT_LOCALE, Locale, normalize_locale @@ -113,6 +114,7 @@ async def _project_chunks( agent_id: str, locale: str | Locale, usage_tracker: UsageTracker | None, + history_tracker: TurnHistoryTracker | None, projection_state: StreamProjectionState | None, hitl_coordinator: HitlChannelCoordinator | None, hitl_ctx: HitlStreamContext | None, @@ -141,6 +143,8 @@ def _tool_end(raw: str) -> MessageEvent: async for chunk in chunks: if usage_tracker is not None: usage_tracker.observe(chunk) + if history_tracker is not None: + history_tracker.observe(chunk) ctype: str = chunk.get("type", "") node: str | None = chunk.get("node") @@ -231,6 +235,7 @@ async def project_stream( *, media_backend: MediaBackend | None = None, usage_tracker: UsageTracker | None = None, + history_tracker: TurnHistoryTracker | None = None, locale: str | Locale = DEFAULT_LOCALE, projection_state: StreamProjectionState | None = None, hitl_coordinator: HitlChannelCoordinator | None = None, @@ -243,6 +248,7 @@ async def project_stream( agent_id=agent_id, locale=locale, usage_tracker=usage_tracker, + history_tracker=history_tracker, projection_state=projection_state, hitl_coordinator=hitl_coordinator, hitl_ctx=hitl_ctx, @@ -257,6 +263,7 @@ async def project_resume_stream( decisions: list[dict[str, Any]], *, usage_tracker: UsageTracker | None = None, + history_tracker: TurnHistoryTracker | None = None, locale: str | Locale = DEFAULT_LOCALE, projection_state: StreamProjectionState | None = None, hitl_coordinator: HitlChannelCoordinator | None = None, @@ -268,6 +275,7 @@ async def project_resume_stream( agent_id=agent_id, locale=locale, usage_tracker=usage_tracker, + history_tracker=history_tracker, projection_state=projection_state, hitl_coordinator=hitl_coordinator, hitl_ctx=hitl_ctx, diff --git a/tests/unit/agents/test_agent_manager.py b/tests/unit/agents/test_agent_manager.py index 014a2764..05b65f53 100644 --- a/tests/unit/agents/test_agent_manager.py +++ b/tests/unit/agents/test_agent_manager.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import json import os from collections.abc import AsyncIterator @@ -28,6 +29,10 @@ posix_only = pytest.mark.skipif(os.name != "posix", reason="POSIX rootfs workspace paths") +async def _collect_async(iterator: AsyncIterator[Any]) -> list[Any]: + return [item async for item in iterator] + + def _expected_default_backend(manager: AgentManager, agent_id: str) -> dict[str, Any]: from octop.infra.agents.execute_env import inject_agent_execute_env @@ -430,6 +435,70 @@ async def fake_stream(*_args: Any, **_kwargs: Any) -> AsyncIterator[dict[str, st assert agent_id not in manager._bootstrap_graph_refresh_pending +@pytest.mark.asyncio +async def test_history_backfill_refuses_while_agent_stream_is_active( + manager: AgentManager, +) -> None: + agent_id = "AGT_BUSY" + started = asyncio.Event() + release = asyncio.Event() + harness_manager = MagicMock() + + async def fake_stream(*_args: Any, **_kwargs: Any) -> AsyncIterator[dict[str, str]]: + started.set() + await release.wait() + yield {"type": "done"} + + harness_manager.stream = fake_stream + manager._harness_manager = harness_manager + + task = asyncio.create_task(_collect_async(manager.stream(agent_id, {"thread_id": "thr-busy"}))) + await started.wait() + + assert manager.is_agent_active(agent_id) is True + assert manager.try_begin_history_backfill(agent_id) is False + + release.set() + await task + assert manager.is_agent_active(agent_id) is False + + +@pytest.mark.asyncio +async def test_waiting_agent_stream_prevents_next_history_backfill( + manager: AgentManager, +) -> None: + agent_id = "AGT_PRIORITY" + stream_started = asyncio.Event() + stream_release = asyncio.Event() + harness_manager = MagicMock() + + async def fake_stream(*_args: Any, **_kwargs: Any) -> AsyncIterator[dict[str, str]]: + stream_started.set() + await stream_release.wait() + yield {"type": "done"} + + harness_manager.stream = fake_stream + manager._harness_manager = harness_manager + assert manager.try_begin_history_backfill(agent_id) is True + + task = asyncio.create_task( + _collect_async(manager.stream(agent_id, {"thread_id": "thr-priority"})) + ) + await asyncio.sleep(0) + assert stream_started.is_set() is False + assert manager.try_begin_history_backfill(agent_id) is False + + manager.end_history_backfill(agent_id) + await stream_started.wait() + assert manager.is_agent_active(agent_id) is True + assert manager.try_begin_history_backfill(agent_id) is False + + stream_release.set() + await task + assert manager.try_begin_history_backfill(agent_id) is True + manager.end_history_backfill(agent_id) + + @pytest.mark.asyncio async def test_reload_agent_clears_bootstrap_refresh_pending(manager: AgentManager) -> None: agent_id = "AGT_RELOAD" diff --git a/tests/unit/agents/test_context_breakdown.py b/tests/unit/agents/test_context_breakdown.py index 5aa1ae04..d4422b12 100644 --- a/tests/unit/agents/test_context_breakdown.py +++ b/tests/unit/agents/test_context_breakdown.py @@ -61,8 +61,10 @@ def _registry(usage: object | None, *, history: list[object] | None = None) -> M registry = MagicMock() registry.get_row.return_value = MagicMock() harness = MagicMock() - # None → older harness without aget_context_usage - harness.aget_context_usage = None if usage is None else AsyncMock(return_value=usage) + harness._context_usage_mw = ( + None if usage is None else SimpleNamespace(get_snapshot=MagicMock(return_value=usage)) + ) + harness.aget_context_usage = AsyncMock(side_effect=AssertionError("must not read checkpoint")) harness.aget_history = AsyncMock(return_value=list(history or [])) registry.get_agent.return_value = harness return registry @@ -149,7 +151,7 @@ async def test_empty_without_stream_tokens() -> None: @pytest.mark.asyncio -async def test_recovers_response_metadata_token_usage() -> None: +async def test_does_not_recover_response_metadata_from_checkpoint() -> None: empty = _usage(used=0, segments={}, source="empty") history = [ { @@ -164,13 +166,11 @@ async def test_recovers_response_metadata_token_usage() -> None: thread_id="t1", max_tokens=128_000, ) - assert result.used_tokens == 8800 - assert result.segments["conversation"] == 8800 + assert result.used_tokens == 0 @pytest.mark.asyncio -async def test_reads_additional_kwargs_context_usage_without_source() -> None: - """Stock snapshots live on additional_kwargs; older stamps omit source.""" +async def test_does_not_read_checkpoint_context_stamp() -> None: empty = _usage(used=0, segments={}, source="empty") history = [ { @@ -196,14 +196,12 @@ async def test_reads_additional_kwargs_context_usage_without_source() -> None: thread_id="t1", max_tokens=1_000_000, ) - assert result.max_tokens == 128_000 - assert result.used_tokens == 15_400 - assert result.segments["system_prompt"] == 2_000 - assert result.segments["conversation"] == 13_400 + assert result.max_tokens == 1_000_000 + assert result.used_tokens == 0 @pytest.mark.asyncio -async def test_recovers_usage_metadata_when_snapshot_empty() -> None: +async def test_empty_snapshot_uses_usage_ledger_without_checkpoint() -> None: empty = _usage(used=0, segments={}, source="empty") history = [ {"role": "user", "content": "hi"}, @@ -213,14 +211,19 @@ async def test_recovers_usage_metadata_when_snapshot_empty() -> None: "usage_metadata": {"input_tokens": 4200, "output_tokens": 12}, }, ] + registry = _registry(empty, history=history) + usage_repo = MagicMock() + usage_repo.last_thread_input_tokens.return_value = 4200 result = await compute_context_breakdown( - _registry(empty, history=history), + registry, agent_id="agt", thread_id="t1", max_tokens=128_000, + usage_repo=usage_repo, ) assert result.used_tokens == 4200 assert result.segments["conversation"] == 4200 + registry.get_agent.return_value.aget_history.assert_not_awaited() @pytest.mark.asyncio diff --git a/tests/unit/api/test_chat_history.py b/tests/unit/api/test_chat_history.py index b9fd2a89..3dd6ca01 100644 --- a/tests/unit/api/test_chat_history.py +++ b/tests/unit/api/test_chat_history.py @@ -2,8 +2,10 @@ from __future__ import annotations +from pathlib import Path +from types import SimpleNamespace from typing import Any -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import MagicMock, call import pytest from langchain_core.messages import HumanMessage @@ -13,9 +15,14 @@ HISTORY_DEFAULT_LIMIT, HISTORY_MAX_LIMIT, _clamp_history_limit, + _isolated_sqlite_checkpoint_messages, _load_checkpoint_messages, _slice_message_page, ) +from octop.infra.db.repos.thread_messages import ( + ThreadProjectionCandidate, + ThreadProjectionSummary, +) from octop.infra.db.repos.threads import ThreadRow from octop.infra.gateway.threads import thread_row_has_messages @@ -63,6 +70,41 @@ def test_slice_message_page_long_thread_no_gaps_or_overlaps() -> None: assert collected == raw +def test_isolated_sqlite_history_supports_pypi_memory_0_9_7(tmp_path: Path) -> None: + from harness_memory import Memory + from langchain_core.messages import AIMessage + from langgraph.checkpoint.base import empty_checkpoint + + db_path = tmp_path / "checkpoint.db" + memory = Memory( + "agent_test", + backend="sqlite", + backend_config={"db_path": str(db_path)}, + ) + checkpoint = empty_checkpoint() + checkpoint["channel_values"] = { + "messages": [HumanMessage(content="hi", id="u1"), AIMessage(content="ok", id="a1")] + } + checkpoint["channel_versions"] = {"messages": 1} + memory.put( + {"configurable": {"thread_id": "thr", "checkpoint_ns": ""}}, + checkpoint, + {}, + {"messages": 1}, + ) + # Prove the migration opens its own read-only connection rather than using + # harness-memory 0.9.7's live synchronous saver connection. + memory._checkpointer.conn.close() + try: + harness = SimpleNamespace(_checkpointer_instance=memory) + messages = _isolated_sqlite_checkpoint_messages(harness, "thr") + finally: + memory._backend.close() + + assert messages is not None + assert [message.id for message in messages] == ["u1", "a1"] + + def test_thread_row_has_messages_uses_title_or_last_active() -> None: empty = ThreadRow( id=1, @@ -157,10 +199,11 @@ async def test_get_thread_history_returns_has_more(monkeypatch: pytest.MonkeyPat row = MagicMock(agent_id="agt_1", user_id=1, artifacts=()) server.app_runtime.gateway.thread_registry.get_thread.return_value = row + server.services.thread_message_repo.projection_status.return_value = "ready" monkeypatch.setattr( history_mod, - "_load_thread_messages", - AsyncMock(return_value=([{"role": "user", "content": "hi"}], True)), + "_load_projected_thread_messages", + MagicMock(return_value=([{"role": "user", "content": "hi"}], True)), ) out = await history_mod.get_thread_history( "agt_1", @@ -175,6 +218,152 @@ async def test_get_thread_history_returns_has_more(monkeypatch: pytest.MonkeyPat assert out["limit"] == HISTORY_DEFAULT_LIMIT assert out["offset"] == 0 assert out["messages"][0]["role"] == "user" + assert out["history_loading"] is False + + +@pytest.mark.asyncio +async def test_get_legacy_history_enqueues_without_reading_checkpoint( + monkeypatch: pytest.MonkeyPatch, +) -> None: + server = MagicMock() + row = MagicMock(agent_id="agt_1", user_id=1, artifacts=()) + server.app_runtime.gateway.thread_registry.get_thread.return_value = row + server.services.thread_message_repo.projection_status.return_value = "pending" + server.app_runtime.gateway.history_backfill.enqueue.return_value = True + load_projection = MagicMock(side_effect=AssertionError("must not read projection yet")) + monkeypatch.setattr(history_mod, "_load_projected_thread_messages", load_projection) + + out = await history_mod.get_thread_history( + "agt_1", + "thr_legacy", + user=MagicMock(id=1), + server=server, + ) + + assert out["messages"] == [] + assert out["history_loading"] is True + assert out["history_status"] == "queued" + server.app_runtime.gateway.history_backfill.enqueue.assert_called_once() + load_projection.assert_not_called() + + +@pytest.mark.asyncio +async def test_history_migration_status_is_scoped_to_effective_user() -> None: + server = MagicMock() + server.services.thread_message_repo.migration_summary.return_value = ThreadProjectionSummary( + pending=2, queued=1, running=1, failed=3 + ) + server.services.thread_message_repo.migration_active_thread_ids.return_value = ["thr_live"] + queue = server.app_runtime.gateway.history_backfill + queue.contains.return_value = True + queue.available_slots = 10 + user = MagicMock(id=7, is_admin=False) + + out = await history_mod.get_history_migration_status( + "agt_1", + user=user, + server=server, + ) + + assert out == { + "remaining": 7, + "pending": 2, + "queued": 1, + "running": 1, + "failed": 3, + "processing": True, + "agent_busy": False, + "can_start": True, + } + server.services.thread_message_repo.migration_summary.assert_called_once_with( + agent_id="agt_1", + user_id=7, + ) + + +@pytest.mark.asyncio +async def test_start_history_migration_queues_bounded_candidates() -> None: + server = MagicMock() + queue = server.app_runtime.gateway.history_backfill + queue.available_slots = 2 + queue.active_jobs = 0 + queue.enqueue.return_value = True + repo = server.services.thread_message_repo + repo.migration_candidates.return_value = [ + ThreadProjectionCandidate(thread_id="thr_a", status="pending"), + ThreadProjectionCandidate(thread_id="thr_b", status="failed"), + ] + repo.migration_summary.return_value = ThreadProjectionSummary(queued=2) + repo.migration_active_thread_ids.return_value = ["thr_a", "thr_b"] + queue.contains.side_effect = [False, False, True, True] + user = MagicMock(id=7, is_admin=False) + + out = await history_mod.start_history_migration( + "agt_1", + user=user, + server=server, + ) + + assert out["accepted"] == 2 + assert out["remaining"] == 2 + repo.migration_candidates.assert_called_once_with(agent_id="agt_1", user_id=7, limit=2) + assert repo.mark_projection.call_args_list == [ + call("thr_a", "queued"), + call("thr_b", "queued"), + ] + assert queue.enqueue.call_count == 2 + + +@pytest.mark.asyncio +async def test_history_migration_waits_while_agent_is_active() -> None: + server = MagicMock() + server.app_runtime.agent_registry.is_agent_active.return_value = True + server.services.thread_message_repo.migration_summary.return_value = ThreadProjectionSummary( + pending=1 + ) + server.services.thread_message_repo.migration_active_thread_ids.return_value = [] + server.app_runtime.gateway.history_backfill.available_slots = 10 + server.app_runtime.gateway.history_backfill.contains.return_value = False + row = MagicMock(agent_id="agt_1", user_id=7, artifacts=()) + server.app_runtime.gateway.thread_registry.get_thread.return_value = row + server.services.thread_message_repo.projection_status.return_value = "pending" + user = MagicMock(id=7, is_admin=False) + + status = await history_mod.get_history_migration_status( + "agt_1", + user=user, + server=server, + ) + history = await history_mod.get_thread_history( + "agt_1", + "thr_legacy", + user=user, + server=server, + ) + + assert status["agent_busy"] is True + assert status["can_start"] is False + assert history["history_status"] == "pending" + server.app_runtime.gateway.history_backfill.enqueue.assert_not_called() + + +@pytest.mark.asyncio +async def test_backfill_requeues_thread_when_agent_became_busy() -> None: + server = MagicMock() + registry = server.app_runtime.agent_registry + registry.try_begin_history_backfill.return_value = False + + await history_mod._backfill_thread_projection( + server, + "agt_1", + "thr_legacy", + user=MagicMock(id=7), + ) + + server.services.thread_message_repo.mark_projection.assert_called_once_with( + "thr_legacy", "pending" + ) + registry.get_agent.assert_not_called() @pytest.mark.asyncio diff --git a/tests/unit/db/test_agent_profile_columns.py b/tests/unit/db/test_agent_profile_columns.py index be5f95cd..1b373b61 100644 --- a/tests/unit/db/test_agent_profile_columns.py +++ b/tests/unit/db/test_agent_profile_columns.py @@ -110,7 +110,7 @@ def test_migration_007_backfills_profile_columns(tmp_path: Path) -> None: with pool.connect() as conn: version = conn.execute("SELECT version FROM _schema_version").fetchone()[0] row = conn.execute("SELECT * FROM agents WHERE agent_id = ?", ("ag1",)).fetchone() - assert version == 9 + assert version == 10 assert row["template_name"] == "general-assistant" assert row["icon_name"] == "zap" assert row["icon_url"] == "https://cdn.example.com/a.png" diff --git a/tests/unit/db/test_clip_thread_title.py b/tests/unit/db/test_clip_thread_title.py index 8afe0c67..d916cb1b 100644 --- a/tests/unit/db/test_clip_thread_title.py +++ b/tests/unit/db/test_clip_thread_title.py @@ -82,7 +82,7 @@ def test_migration_003_repairs_stored_hard_cuts(tmp_path: Path) -> None: with pool.connect() as conn: v = conn.execute("SELECT version FROM _schema_version").fetchone()[0] title = conn.execute("SELECT title FROM threads WHERE thread_id = ?", ("t1",)).fetchone()[0] - assert v == 9 + assert v == 10 assert title == "x" * 39 + "…" # Idempotent repair assert repair_all_legacy_thread_titles(pool) == 0 diff --git a/tests/unit/db/test_db_pool.py b/tests/unit/db/test_db_pool.py index 7d7a1789..c060b5c3 100644 --- a/tests/unit/db/test_db_pool.py +++ b/tests/unit/db/test_db_pool.py @@ -85,13 +85,14 @@ def test_run_migrations_idempotent(db: SqlitePool): doc_cols = { r["name"] for r in conn.execute("PRAGMA table_info(knowledge_documents)").fetchall() } - assert v == 9 + assert v == 10 assert "login_failed_count" in cols assert "login_locked_until" in cols assert "preferences_json" in cols assert "permissions" in cols assert {"email", "sso_provider_id", "sso_subject"}.issubset(cols) assert "user_invites" in table_names + assert {"thread_messages", "thread_history_projection"}.issubset(table_names) assert "task_type" in cron_cols assert "mcp_servers" in cron_cols assert {"model_ref", "reasoning_mode", "reasoning_effort", "artifacts"}.issubset(thread_cols) @@ -143,7 +144,7 @@ def test_migration_002_idempotent_when_column_already_present(tmp_path: Path) -> with pool.connect() as conn: v = conn.execute("SELECT version FROM _schema_version").fetchone()[0] cron_cols = {r["name"] for r in conn.execute("PRAGMA table_info(cron_jobs)").fetchall()} - assert v == 9 + assert v == 10 assert "mcp_servers" in cron_cols assert "skill_packages" in { r["name"] @@ -280,7 +281,7 @@ def test_stuck_version_6_without_permissions_column_is_repaired(tmp_path: Path) with pool.connect() as conn: cols = {r["name"] for r in conn.execute("PRAGMA table_info(users)").fetchall()} version = conn.execute("SELECT version FROM _schema_version").fetchone()[0] - assert version == 9 + assert version == 10 assert "permissions" in cols @@ -311,7 +312,7 @@ def test_ahead_of_max_schema_version_clamps_to_max(tmp_path: Path) -> None: r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall() } - assert version == 9 + assert version == 10 assert "skill_package_id" in pkg_cols assert "published_expert_id" in pub_cols assert "user_invites" in invite_tables @@ -350,7 +351,7 @@ def test_pre_squash_schema_version_clamped_and_knowledge_tables_filled( for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall() } user_cols = {r["name"] for r in conn.execute("PRAGMA table_info(users)").fetchall()} - assert version == 9 + assert version == 10 assert "permissions" in user_cols assert { "published_experts", diff --git a/tests/unit/db/test_published_experts_repo.py b/tests/unit/db/test_published_experts_repo.py index 5ac92957..4705d7f2 100644 --- a/tests/unit/db/test_published_experts_repo.py +++ b/tests/unit/db/test_published_experts_repo.py @@ -27,7 +27,7 @@ def test_published_experts_table_exists(db: SqlitePool) -> None: v = conn.execute("SELECT version FROM _schema_version").fetchone()[0] cols = {r["name"] for r in conn.execute("PRAGMA table_info(published_experts)").fetchall()} assert "published_experts" in names - assert v == 9 + assert v == 10 assert "published_expert_id" in cols diff --git a/tests/unit/db/test_repo_knowledge.py b/tests/unit/db/test_repo_knowledge.py index 7b81ee73..8572edb8 100644 --- a/tests/unit/db/test_repo_knowledge.py +++ b/tests/unit/db/test_repo_knowledge.py @@ -50,7 +50,7 @@ def test_knowledge_tables_migrated(db: SqlitePool) -> None: "knowledge_bases", "knowledge_documents", }.issubset(names) - assert v == 9 + assert v == 10 assert "knowledge_base_members" not in names assert "knowledge_base_id" in { r["name"] for r in conn.execute("PRAGMA table_info(knowledge_bases)").fetchall() diff --git a/tests/unit/db/test_skill_package_icons.py b/tests/unit/db/test_skill_package_icons.py index 9eb8d1fe..27ac3646 100644 --- a/tests/unit/db/test_skill_package_icons.py +++ b/tests/unit/db/test_skill_package_icons.py @@ -91,7 +91,7 @@ def test_migration_002_idempotent_when_icon_columns_already_present(tmp_path: Pa "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='skill_packages'" ) } - assert v == 9 + assert v == 10 assert "icon_name" in cols assert "icon_url" in cols assert "skill_package_id" in cols @@ -110,7 +110,7 @@ def test_repair_legacy_schema_adds_icon_columns_at_version_2(tmp_path: Path) -> with pool.connect() as conn: v = conn.execute("SELECT version FROM _schema_version").fetchone()[0] cols = {r["name"] for r in conn.execute("PRAGMA table_info(skill_packages)").fetchall()} - assert v == 9 + assert v == 10 assert "icon_name" in cols assert "icon_url" in cols assert "skill_package_id" in cols diff --git a/tests/unit/db/test_skill_packages_repo.py b/tests/unit/db/test_skill_packages_repo.py index 04259d7d..67d7cc58 100644 --- a/tests/unit/db/test_skill_packages_repo.py +++ b/tests/unit/db/test_skill_packages_repo.py @@ -28,7 +28,7 @@ def test_skill_packages_table_exists(db: SqlitePool) -> None: v = conn.execute("SELECT version FROM _schema_version").fetchone()[0] cols = {r["name"] for r in conn.execute("PRAGMA table_info(skill_packages)").fetchall()} assert "skill_packages" in names - assert v == 9 + assert v == 10 assert "skill_package_id" in cols diff --git a/tests/unit/db/test_thread_messages_repo.py b/tests/unit/db/test_thread_messages_repo.py new file mode 100644 index 00000000..8a7ff748 --- /dev/null +++ b/tests/unit/db/test_thread_messages_repo.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from pathlib import Path + +from octop.infra.db.migrate import run_migrations +from octop.infra.db.pool import SqlitePool +from octop.infra.db.repos.agents import AgentRepo +from octop.infra.db.repos.thread_messages import ThreadMessageInput, ThreadMessageRepo +from octop.infra.db.repos.threads import ThreadRepo +from octop.infra.db.repos.users import UserRepo + + +def _repos(tmp_path: Path) -> tuple[SqlitePool, ThreadRepo, ThreadMessageRepo]: + db = SqlitePool(tmp_path / "octop.db") + run_migrations(db) + user_id = UserRepo(db).create(username="u", password_hash="hash", role="user") + AgentRepo(db).create(agent_id="agt", user_id=user_id, name="Agent") + return db, ThreadRepo(db), ThreadMessageRepo(db) + + +def _message(mid: str | None, text: str) -> ThreadMessageInput: + return ThreadMessageInput( + message_id=mid, + role="human", + message_json=f'{{"text":"{text}"}}', + created_at=1, + ) + + +def test_new_thread_projection_appends_and_pages(tmp_path: Path) -> None: + _db, threads, messages = _repos(tmp_path) + threads.insert( + thread_id="thr", + agent_id="agt", + user_id=1, + channel_type="dashboard", + session_key="sk", + last_active=0, + ) + + assert messages.projection_status("thr") == "ready" + assert ( + messages.append_if_ready( + "thr", + [_message("m1", "one"), _message("m2", "two"), _message("m3", "three")], + ) + == 3 + ) + assert messages.append_if_ready("thr", [_message("m3", "replay")]) == 0 + + recent, more = messages.page("thr", limit=2) + older, older_more = messages.page("thr", limit=2, offset=2) + assert [row.message_id for row in recent] == ["m2", "m3"] + assert more is True + assert [row.message_id for row in older] == ["m1"] + assert older_more is False + + +def test_pending_legacy_thread_only_publishes_after_replace(tmp_path: Path) -> None: + _db, threads, messages = _repos(tmp_path) + threads.insert( + thread_id="legacy", + agent_id="agt", + user_id=1, + channel_type="dashboard", + session_key="legacy-sk", + last_active=10, + ) + messages.mark_projection("legacy", "pending") + + assert messages.append_if_ready("legacy", [_message("new", "new")]) == 0 + assert ( + messages.replace_all( + "legacy", + [_message("old", "old"), _message("new", "new")], + ) + == 2 + ) + page, more = messages.page("legacy", limit=25) + assert messages.projection_status("legacy") == "ready" + assert [row.message_id for row in page] == ["old", "new"] + assert more is False + + +def test_migration_summary_and_candidates_are_scoped(tmp_path: Path) -> None: + _db, threads, messages = _repos(tmp_path) + for thread_id, last_active in (("old-1", 10), ("old-2", 20), ("empty", 0)): + threads.insert( + thread_id=thread_id, + agent_id="agt", + user_id=1, + channel_type="dashboard", + session_key=thread_id, + last_active=last_active, + ) + messages.mark_projection("old-1", "pending") + messages.mark_projection("old-2", "failed", error="boom") + + summary = messages.migration_summary(agent_id="agt", user_id=1) + candidates = messages.migration_candidates(agent_id="agt", user_id=1, limit=10) + + assert summary.remaining == 2 + assert summary.pending == 1 + assert summary.failed == 1 + assert [(row.thread_id, row.status) for row in candidates] == [ + ("old-2", "failed"), + ("old-1", "pending"), + ] diff --git a/tests/unit/gateway/test_history_projection.py b/tests/unit/gateway/test_history_projection.py new file mode 100644 index 00000000..ab4d4831 --- /dev/null +++ b/tests/unit/gateway/test_history_projection.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import asyncio + +import pytest + +from octop.infra.gateway.history_backfill import HistoryBackfillQueue +from octop.infra.gateway.process.history_projection import TurnHistoryTracker + + +def test_turn_tracker_keeps_only_latest_user_turn_and_dedupes_replay() -> None: + tracker = TurnHistoryTracker.from_request( + {"messages": [{"role": "user", "content": "latest", "id": "u2"}]} + ) + state = [ + {"role": "user", "content": "old", "id": "u1"}, + {"role": "assistant", "content": "old answer", "id": "a1"}, + {"role": "user", "content": "latest", "id": "u2"}, + {"role": "assistant", "content": "new answer", "id": "a2"}, + ] + tracker.observe({"type": "state_snapshot", "data": {"messages": state}}) + tracker.observe({"type": "state_update", "data": {"messages": state}}) + + assert [item.message_id for item in tracker.inputs] == ["u2", "a2"] + + +@pytest.mark.asyncio +async def test_backfill_queue_runs_one_job_at_a_time_and_dedupes() -> None: + queue = HistoryBackfillQueue(max_pending=2) + first_started = asyncio.Event() + release_first = asyncio.Event() + order: list[str] = [] + + async def first() -> None: + order.append("first:start") + first_started.set() + await release_first.wait() + order.append("first:end") + + async def second() -> None: + order.append("second") + + assert queue.enqueue("thr-1", first) is True + assert queue.enqueue("thr-1", first) is True + assert queue.enqueue("thr-2", second) is True + assert queue.available_slots == 0 + assert queue.active_jobs == 2 + assert queue.contains("thr-1") is True + await first_started.wait() + assert order == ["first:start"] + assert queue.available_slots == 1 + release_first.set() + await asyncio.wait_for(queue._queue.join(), timeout=1) # noqa: SLF001 + assert order == ["first:start", "first:end", "second"] + assert queue.active_jobs == 0 + assert queue.available_slots == 2 + assert queue.contains("thr-1") is False + await queue.close() diff --git a/uv.lock b/uv.lock index ec62161b..b0cf647a 100644 --- a/uv.lock +++ b/uv.lock @@ -2474,7 +2474,7 @@ wheels = [ [[package]] name = "octop" -version = "0.9.28" +version = "0.9.29" source = { editable = "." } dependencies = [ { name = "acme" }, From fef9b2b0d0c7b6dcc7eee7ed3556b016393cf44d Mon Sep 17 00:00:00 2001 From: liukewia Date: Thu, 27 Aug 2026 15:09:29 +0800 Subject: [PATCH 03/28] fix(dashboard): theme confirmation dialogs via App.useApp() (#442) * fix(experts): restore clinical 037330c routing * feat(dashboard): integrate modal utility for consistent confirmation dialogs - Added `modal` utility from `antdModal` to replace direct `Modal` calls across various components, ensuring a unified approach to confirmation dialogs. - Updated components to utilize the new `modal.confirm` method, enhancing maintainability and consistency in user interactions. - Removed direct imports of `Modal` from `antd` in favor of the new utility, streamlining the codebase. This change improves the user experience by standardizing modal behavior and appearance throughout the application. * refactor(dashboard): unify App.useApp() integration across components - Replaced direct imports of `message` and `modal` from utility files with `App.useApp()` in various components, promoting a consistent approach to accessing application-wide functionalities. - Updated components to utilize the new structure, enhancing maintainability and reducing redundancy in imports. This change streamlines the codebase and improves the overall architecture of the dashboard components. --------- Co-authored-by: leoxyang --- dashboard/src/components/AntdAppProvider.tsx | 9 ++++-- dashboard/src/locales/en.json | 1 - dashboard/src/locales/zh.json | 1 - .../Admin/Storage/StorageBackendCard.tsx | 6 ++-- dashboard/src/pages/Agent/ACP/index.tsx | 6 ++-- .../Connectors/ConnectorInstanceCard.tsx | 6 ++-- .../Agent/Connectors/CustomMcpServerCard.tsx | 5 ++-- .../pages/Agent/Memory/VectorSearchConfig.tsx | 8 +++--- .../Agent/Memory/shared/deprecateAtom.tsx | 5 ++-- dashboard/src/pages/Agent/Skills/useSkills.ts | 6 ++-- .../Workspace/components/WorkspaceDrawer.tsx | 5 ++-- .../src/pages/Chat/components/ChatInput.tsx | 6 ++-- .../src/pages/Control/RemoteBrowser/index.tsx | 5 ++-- .../Control/RemoteDesktop/DesktopPanel.tsx | 7 +++-- .../Experts/components/EditAgentDrawer.tsx | 11 ++++---- .../components/PublishTemplateButton.tsx | 6 ++-- .../components/PublishedExpertCard.tsx | 6 ++-- .../Experts/components/SubagentManager.tsx | 6 ++-- dashboard/src/pages/KnowledgeBases/index.tsx | 9 +++--- .../pages/Settings/BackupRestore/index.tsx | 5 ++-- .../src/pages/Settings/Embedding/index.tsx | 6 ++-- .../src/pages/Settings/Environments/index.tsx | 8 +++--- .../Models/components/cards/ProviderCard.tsx | 6 ++-- .../components/modals/ModelListEditor.tsx | 6 ++-- .../components/modals/ProviderConfigModal.tsx | 21 ++++++-------- .../src/pages/Settings/SearchConfig/index.tsx | 6 ++-- dashboard/src/utils/antdModal.ts | 28 +++++++++++++++++++ dashboard/src/utils/confirmModal.ts | 6 ++-- 28 files changed, 120 insertions(+), 86 deletions(-) create mode 100644 dashboard/src/utils/antdModal.ts diff --git a/dashboard/src/components/AntdAppProvider.tsx b/dashboard/src/components/AntdAppProvider.tsx index 339c139f..5683f05d 100644 --- a/dashboard/src/components/AntdAppProvider.tsx +++ b/dashboard/src/components/AntdAppProvider.tsx @@ -1,16 +1,19 @@ import { App } from "antd"; import { useEffect, type ReactNode } from "react"; import { bindAntdMessage, unbindAntdMessage } from "../utils/antdMessage"; +import { bindAntdModal, unbindAntdModal } from "../utils/antdModal"; -/** Captures App.useApp() APIs for non-hook call sites (utils, hooks, callbacks). */ +/** Captures App.useApp() APIs for non-hook call sites (utils). */ function AntdAppApiBinder() { - const { message } = App.useApp(); + const { message, modal } = App.useApp(); useEffect(() => { bindAntdMessage(message); + bindAntdModal(modal); return () => { unbindAntdMessage(); + unbindAntdModal(); }; - }, [message]); + }, [message, modal]); return null; } diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index e46b4a10..bc690e98 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -2467,7 +2467,6 @@ "localDownloadContinueBackground": "Continue in background", "localDownloadBackground": "Download moved to the background. You will be notified when it finishes.", "localDownloadBackgroundHint": "You can close this window. The download continues on the server and you will get a notification when it finishes.", - "onnxDownloadProgress": "Downloading {{model}} ({{percent}}%)", "onnxDownloadLoading": "Loading {{model}}…", "defaultModelDownloadedOnly": "Only downloaded models can be selected", "defaultModelNeedDownload": "Download a model below first", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index b7b40ad0..9d90ed25 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -2465,7 +2465,6 @@ "localDownloadContinueBackground": "后台继续", "localDownloadBackground": "下载已转到后台,完成后会通知你", "localDownloadBackgroundHint": "可关闭此窗口,下载在服务端继续,完成后会弹出通知。", - "onnxDownloadProgress": "正在下载 {{model}}({{percent}}%)", "onnxDownloadLoading": "正在加载 {{model}}…", "defaultModelDownloadedOnly": "仅可选择已下载的模型", "defaultModelNeedDownload": "请先在下方管理模型中下载", diff --git a/dashboard/src/pages/Admin/Storage/StorageBackendCard.tsx b/dashboard/src/pages/Admin/Storage/StorageBackendCard.tsx index 639f9562..6cb79b40 100644 --- a/dashboard/src/pages/Admin/Storage/StorageBackendCard.tsx +++ b/dashboard/src/pages/Admin/Storage/StorageBackendCard.tsx @@ -5,8 +5,7 @@ * enabled toggle, edit and delete actions. */ import { useState } from "react"; -import { Button, Modal, Switch, Tooltip } from "antd"; -import { message } from "@/utils/antdMessage"; +import { App, Button, Switch, Tooltip } from "antd"; import { Pencil, @@ -39,6 +38,7 @@ export function StorageBackendCard({ isNew, }: StorageBackendCardProps) { const { t } = useTranslation(); + const { modal, message } = App.useApp(); const [editOpen, setEditOpen] = useState(false); const [browseOpen, setBrowseOpen] = useState(false); const [toggling, setToggling] = useState(false); @@ -96,7 +96,7 @@ export function StorageBackendCard({ }; const handleDelete = () => { - Modal.confirm({ + modal.confirm({ title: t("storage.deleteTitle"), content: t("storage.deleteConfirm", { name: backend.name }), okText: t("common.delete"), diff --git a/dashboard/src/pages/Agent/ACP/index.tsx b/dashboard/src/pages/Agent/ACP/index.tsx index 15dca3fb..5a47beee 100644 --- a/dashboard/src/pages/Agent/ACP/index.tsx +++ b/dashboard/src/pages/Agent/ACP/index.tsx @@ -1,6 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Button, Empty, Form, Modal, Switch } from "antd"; -import { message } from "@/utils/antdMessage"; +import { App, Button, Empty, Form, Switch } from "antd"; import { useTranslation } from "react-i18next"; import PageShell from "../../../layouts/PageShell"; @@ -25,6 +24,7 @@ const EMPTY_RUNNERS: Record = {}; export default function ACPPage() { const { t } = useTranslation(); + const { modal, message } = App.useApp(); const { activeAgentId } = useAgent(); const [runners, setRunners] = useState>(EMPTY_RUNNERS); @@ -232,7 +232,7 @@ export default function ACPPage() { const handleDelete = () => { if (!activeKey || isBuiltinRunner(activeKey)) return; - Modal.confirm({ + modal.confirm({ title: t("acp.deleteTitle", { name: activeKey }), content: t("acp.deleteConfirm"), okText: t("common.delete"), diff --git a/dashboard/src/pages/Agent/Connectors/ConnectorInstanceCard.tsx b/dashboard/src/pages/Agent/Connectors/ConnectorInstanceCard.tsx index c86b3475..9ed6cba7 100644 --- a/dashboard/src/pages/Agent/Connectors/ConnectorInstanceCard.tsx +++ b/dashboard/src/pages/Agent/Connectors/ConnectorInstanceCard.tsx @@ -1,5 +1,4 @@ -import { Button, Modal } from "antd"; -import { message } from "@/utils/antdMessage"; +import { App, Button } from "antd"; import { Activity, Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -24,6 +23,7 @@ export function ConnectorInstanceCard({ onDeleted, }: ConnectorInstanceCardProps) { const { t } = useTranslation(); + const { modal, message } = App.useApp(); const accent = catalogEntry ? connectorAccent(catalogEntry) : "#8c8c8c"; const handleTest = async () => { @@ -38,7 +38,7 @@ export function ConnectorInstanceCard({ }; const handleDelete = () => { - Modal.confirm({ + modal.confirm({ title: t("connectors.deleteConfirm", { name: instance.display_name }), okText: t("common.delete"), okButtonProps: { danger: true }, diff --git a/dashboard/src/pages/Agent/Connectors/CustomMcpServerCard.tsx b/dashboard/src/pages/Agent/Connectors/CustomMcpServerCard.tsx index 0be341b7..489a269b 100644 --- a/dashboard/src/pages/Agent/Connectors/CustomMcpServerCard.tsx +++ b/dashboard/src/pages/Agent/Connectors/CustomMcpServerCard.tsx @@ -1,5 +1,5 @@ import type { CSSProperties } from "react"; -import { Alert, Button, Input, Modal, Select, Switch } from "antd"; +import { Alert, App, Button, Input, Select, Switch } from "antd"; import { Activity, Cable, @@ -40,6 +40,7 @@ export function CustomMcpServerCard({ onProbe, }: CustomMcpServerCardProps) { const { t } = useTranslation(); + const { modal } = App.useApp(); const isHttp = card.transport === "streamable_http"; const label = friendlyServerLabel(card); const accent = accentForServerName(card.name.trim() || label); @@ -56,7 +57,7 @@ export function CustomMcpServerCard({ .join(" ") || "npx / uvx / python"; const handleRemove = () => { - Modal.confirm({ + modal.confirm({ title: t("connectors.customMcp.deleteConfirm", { name: label, defaultValue: `确定删除 MCP 服务器「${label}」?`, diff --git a/dashboard/src/pages/Agent/Memory/VectorSearchConfig.tsx b/dashboard/src/pages/Agent/Memory/VectorSearchConfig.tsx index 8993f410..31437d61 100644 --- a/dashboard/src/pages/Agent/Memory/VectorSearchConfig.tsx +++ b/dashboard/src/pages/Agent/Memory/VectorSearchConfig.tsx @@ -1,7 +1,6 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { useTranslation } from "react-i18next"; -import { Select, Input, Button, Spin, Progress, Modal, Alert } from "antd"; -import { message } from "@/utils/antdMessage"; +import { App, Select, Input, Button, Spin, Progress, Alert } from "antd"; import { Download, CheckCircle, AlertCircle } from "lucide-react"; import api from "../../../api"; @@ -16,6 +15,7 @@ import styles from "./VectorSearchConfig.module.less"; export default function VectorSearchConfig() { const { t } = useTranslation(); + const { modal, message } = App.useApp(); const [config, setConfig] = useState(null); const [loading, setLoading] = useState(true); @@ -102,7 +102,7 @@ export default function VectorSearchConfig() { /** Disable vector search in one action by applying provider=none immediately. */ const handleDisableVectorSearch = useCallback(() => { if (!config || config.provider === "none") return; - Modal.confirm({ + modal.confirm({ title: t("memory.vs.disableVectorConfirmTitle"), content: t("memory.vs.disableVectorConfirmDesc"), okText: t("common.disable"), @@ -181,7 +181,7 @@ export default function VectorSearchConfig() { const currentModel = config?.localModel || ""; if (!currentModel) return; - Modal.confirm({ + modal.confirm({ title: t("memory.vs.deleteModelCacheTitle"), content: t("memory.vs.deleteModelCacheDesc", { model: currentModel }), okText: t("common.delete"), diff --git a/dashboard/src/pages/Agent/Memory/shared/deprecateAtom.tsx b/dashboard/src/pages/Agent/Memory/shared/deprecateAtom.tsx index 87543a99..6fecc9ab 100644 --- a/dashboard/src/pages/Agent/Memory/shared/deprecateAtom.tsx +++ b/dashboard/src/pages/Agent/Memory/shared/deprecateAtom.tsx @@ -4,8 +4,9 @@ * Reuses the same confirm dialog, optional reason input, deprecateAtom call, * and toast behavior across tree and list drawers. */ -import { Input, Modal, Typography } from "antd"; +import { Input, Typography } from "antd"; import { message } from "@/utils/antdMessage"; +import { modal } from "@/utils/antdModal"; import i18n from "@/i18n"; import { @@ -24,7 +25,7 @@ export function confirmDeprecateAtom({ onSuccess?: () => void; }) { let reason = ""; - Modal.confirm({ + modal.confirm({ title: i18n.t("memory.deprecate.title"), content: (
diff --git a/dashboard/src/pages/Agent/Skills/useSkills.ts b/dashboard/src/pages/Agent/Skills/useSkills.ts index 56ed41be..0f6cc7f1 100644 --- a/dashboard/src/pages/Agent/Skills/useSkills.ts +++ b/dashboard/src/pages/Agent/Skills/useSkills.ts @@ -1,6 +1,5 @@ import { useCallback, useState } from "react"; -import { Modal } from "antd"; -import { message } from "@/utils/antdMessage"; +import { App } from "antd"; import { useTranslation } from "react-i18next"; import { request } from "../../../api/request"; @@ -94,6 +93,7 @@ export function useSkills( options?: { enabled?: boolean }, ) { const { t } = useTranslation(); + const { modal, message } = App.useApp(); const enabled = options?.enabled !== false && !!agentId; const { @@ -209,7 +209,7 @@ export function useSkills( async (skill: SkillSpec): Promise => { if (!agentId) return false; const confirmed = await new Promise((resolve) => { - Modal.confirm({ + modal.confirm({ title: t("common.delete"), content: t("skills.deleteConfirmContent", { slug: skill.slug }), okText: t("common.delete"), diff --git a/dashboard/src/pages/Agent/Workspace/components/WorkspaceDrawer.tsx b/dashboard/src/pages/Agent/Workspace/components/WorkspaceDrawer.tsx index c59f1b75..07e83300 100644 --- a/dashboard/src/pages/Agent/Workspace/components/WorkspaceDrawer.tsx +++ b/dashboard/src/pages/Agent/Workspace/components/WorkspaceDrawer.tsx @@ -6,6 +6,7 @@ import { useCallback, useEffect, useState } from "react"; import { + App, Tree, Empty, Spin, @@ -21,7 +22,6 @@ import { Popconfirm, Input, } from "antd"; -import { message } from "@/utils/antdMessage"; import type { TreeDataNode, TreeProps } from "antd"; import { @@ -259,6 +259,7 @@ export default function WorkspaceDrawer({ onClose, }: WorkspaceDrawerProps) { const { t } = useTranslation(); + const { modal, message } = App.useApp(); const isMobile = useIsMobile(); const timeZone = useServerTimezone(); const { agents } = useAgent(); @@ -707,7 +708,7 @@ export default function WorkspaceDrawer({ label: t("common.delete"), danger: true, onClick: () => { - Modal.confirm({ + modal.confirm({ title: target.is_dir ? t("workspace.deleteDirConfirm") : t("workspace.deleteConfirm"), diff --git a/dashboard/src/pages/Chat/components/ChatInput.tsx b/dashboard/src/pages/Chat/components/ChatInput.tsx index 89bab4ba..b13b8319 100644 --- a/dashboard/src/pages/Chat/components/ChatInput.tsx +++ b/dashboard/src/pages/Chat/components/ChatInput.tsx @@ -7,8 +7,7 @@ import { useImperativeHandle, } from "react"; import { useTranslation } from "react-i18next"; -import { Modal } from "antd"; -import { message as antMessage } from "@/utils/antdMessage"; +import { App } from "antd"; import { useIsMobile } from "../../../hooks/useIsMobile"; import { useSlashCommands } from "../../../hooks/useSlashCommands"; @@ -151,6 +150,7 @@ const ChatInput = forwardRef( ref, ) { const { t, i18n } = useTranslation(); + const { modal, message: antMessage } = App.useApp(); const { commands: slashCommands, labelFor } = useSlashCommands("ui"); const isMobile = useIsMobile(); useKeyboardOffset(); @@ -488,7 +488,7 @@ const ChatInput = forwardRef( }; if (text.trim() || attachments.length > 0) { - Modal.confirm({ + modal.confirm({ title: t("chat.queue.reclaimOverwriteTitle"), content: t("chat.queue.reclaimOverwrite"), okText: t("common.confirm", "确认"), diff --git a/dashboard/src/pages/Control/RemoteBrowser/index.tsx b/dashboard/src/pages/Control/RemoteBrowser/index.tsx index ee815c39..7385ff84 100644 --- a/dashboard/src/pages/Control/RemoteBrowser/index.tsx +++ b/dashboard/src/pages/Control/RemoteBrowser/index.tsx @@ -10,6 +10,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { Alert, + App, Button, Drawer, Modal, @@ -20,7 +21,6 @@ import { Tooltip, Typography, } from "antd"; -import { message as antMessage } from "@/utils/antdMessage"; import { Bot, @@ -310,6 +310,7 @@ export default function RemoteBrowserPage({ isVisible = true, }: RemoteBrowserPageProps) { const { t } = useTranslation(); + const { modal, message: antMessage } = App.useApp(); const isMobile = useIsMobile(); const { activeAgent, activeAgentId, agents } = useAgent(); const [searchParams] = useSearchParams(); @@ -610,7 +611,7 @@ export default function RemoteBrowserPage({ const handleUninstall = useCallback(() => { if (!envStatus?.playwright_chromium || uninstalling) return; - Modal.confirm({ + modal.confirm({ title: t("remoteBrowser.uninstallTitle", "卸载远程浏览器"), content: t( "remoteBrowser.uninstallConfirm", diff --git a/dashboard/src/pages/Control/RemoteDesktop/DesktopPanel.tsx b/dashboard/src/pages/Control/RemoteDesktop/DesktopPanel.tsx index ee50b71c..664271f1 100644 --- a/dashboard/src/pages/Control/RemoteDesktop/DesktopPanel.tsx +++ b/dashboard/src/pages/Control/RemoteDesktop/DesktopPanel.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert, + App, Button, Drawer, Modal, @@ -11,7 +12,6 @@ import { Tag, Tooltip, } from "antd"; -import { message } from "@/utils/antdMessage"; import { CheckCircle2, @@ -90,6 +90,7 @@ export default function DesktopPanel({ isVisible = true, }: DesktopPanelProps) { const { t } = useTranslation(); + const { modal, message } = App.useApp(); const user = useCurrentUser(); const canDesktop = userCan(user, "desktop"); const isMobile = useIsMobile(); @@ -253,7 +254,7 @@ export default function DesktopPanel({ "remoteDesktop.uninstallConfirmLinux", "将停止虚拟桌面服务并移除已安装组件,是否继续?", ); - Modal.confirm({ + modal.confirm({ title: t("remoteDesktop.uninstallTitle", "卸载远程桌面"), content: confirmText, okText: t("remoteDesktop.uninstall", "卸载"), @@ -394,7 +395,7 @@ export default function DesktopPanel({ const handleResolutionChange = useCallback( (value: DesktopResolution) => { if (envStatus?.platform === "linux" && isStreaming) { - Modal.confirm({ + modal.confirm({ title: t("remoteDesktop.geometryRestartTitle", "切换分辨率"), content: t( "remoteDesktop.geometryRestartWarning", diff --git a/dashboard/src/pages/Experts/components/EditAgentDrawer.tsx b/dashboard/src/pages/Experts/components/EditAgentDrawer.tsx index 2310c421..1b5fad39 100644 --- a/dashboard/src/pages/Experts/components/EditAgentDrawer.tsx +++ b/dashboard/src/pages/Experts/components/EditAgentDrawer.tsx @@ -2,6 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { + Alert, + App, Button, Collapse, Drawer, @@ -12,9 +14,7 @@ import { Select, Spin, Switch, - Alert, } from "antd"; -import { message } from "@/utils/antdMessage"; import { MoreHorizontal } from "lucide-react"; import { request } from "../../../api/request"; @@ -208,6 +208,7 @@ function EditAgentDrawerBody({ onSavingChange, }: EditAgentDrawerBodyProps) { const { t } = useTranslation(); + const { modal, message } = App.useApp(); const { refresh } = useAgent(); const skillDisplayName = useSkillDisplayName(); const [workspaceDrawerOpen, setWorkspaceDrawerOpen] = useState(false); @@ -548,7 +549,7 @@ function EditAgentDrawerBody({ }; const confirmDeleteConfigFile = (path: string) => { - Modal.confirm({ + modal.confirm({ title: t("workspace.deleteConfirm"), okText: t("common.delete"), cancelText: t("common.cancel"), @@ -563,7 +564,7 @@ function EditAgentDrawerBody({ const confirmDeleteSkill = (skill: SkillSummary) => { const slug = skill.slug ?? skill.name; - Modal.confirm({ + modal.confirm({ title: t("skills.deleteConfirmContent", { slug }), okText: t("common.delete"), cancelText: t("common.cancel"), @@ -579,7 +580,7 @@ function EditAgentDrawerBody({ }; const confirmDeleteSubagent = (subagent: SubagentSummary) => { - Modal.confirm({ + modal.confirm({ title: t("workspace.deleteConfirm"), okText: t("common.delete"), cancelText: t("common.cancel"), diff --git a/dashboard/src/pages/Experts/components/PublishTemplateButton.tsx b/dashboard/src/pages/Experts/components/PublishTemplateButton.tsx index 1fd7e8bf..811ee5f8 100644 --- a/dashboard/src/pages/Experts/components/PublishTemplateButton.tsx +++ b/dashboard/src/pages/Experts/components/PublishTemplateButton.tsx @@ -1,7 +1,6 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { Dropdown, Modal, Tooltip } from "antd"; -import { message } from "@/utils/antdMessage"; +import { App, Dropdown, Tooltip } from "antd"; import { Upload } from "lucide-react"; import { publishedExpertsApi, @@ -27,6 +26,7 @@ export default function PublishTemplateButton({ buttonClassName = styles.agentCard2NameActionBtn, }: PublishTemplateButtonProps) { const { t } = useTranslation(); + const { modal, message } = App.useApp(); const [loading, setLoading] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false); const [drawerMode, setDrawerMode] = useState<"publish" | "refresh">( @@ -45,7 +45,7 @@ export default function PublishTemplateButton({ const confirmUnpublish = () => { if (!published) return; - Modal.confirm({ + modal.confirm({ title: t("experts.published.unpublishConfirm"), okText: t("experts.published.unpublish"), cancelText: t("common.cancel"), diff --git a/dashboard/src/pages/Experts/components/PublishedExpertCard.tsx b/dashboard/src/pages/Experts/components/PublishedExpertCard.tsx index c5f300b4..527d9e37 100644 --- a/dashboard/src/pages/Experts/components/PublishedExpertCard.tsx +++ b/dashboard/src/pages/Experts/components/PublishedExpertCard.tsx @@ -1,7 +1,6 @@ import { memo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Dropdown, Modal } from "antd"; -import { message } from "@/utils/antdMessage"; +import { App, Dropdown } from "antd"; import { MoreHorizontal } from "lucide-react"; import { iconForName } from "./iconForName"; import { @@ -25,11 +24,12 @@ export const PublishedExpertCard = memo(function PublishedExpertCard({ onChanged, }: PublishedExpertCardProps) { const { t } = useTranslation(); + const { modal, message } = App.useApp(); const [loading, setLoading] = useState(false); const accent = expert.color || "var(--fn-color-brand)"; const confirmUnpublish = () => { - Modal.confirm({ + modal.confirm({ title: t("experts.published.unpublishConfirm"), okText: t("experts.published.unpublish"), cancelText: t("common.cancel"), diff --git a/dashboard/src/pages/Experts/components/SubagentManager.tsx b/dashboard/src/pages/Experts/components/SubagentManager.tsx index f7d29c1f..9bc882ea 100644 --- a/dashboard/src/pages/Experts/components/SubagentManager.tsx +++ b/dashboard/src/pages/Experts/components/SubagentManager.tsx @@ -9,8 +9,7 @@ import { useState, type CSSProperties, } from "react"; -import { Alert, Empty, Form, Input, Modal, Spin, Tabs } from "antd"; -import { message } from "@/utils/antdMessage"; +import { Alert, App, Empty, Form, Input, Spin, Tabs } from "antd"; import { CircleCheck, Download, Plus, RefreshCw, Search } from "lucide-react"; import { Eye, Pencil, Trash2 } from "lucide-react"; @@ -70,6 +69,7 @@ export default function SubagentManager({ fillHeight = false, }: SubagentManagerProps) { const { t, i18n } = useTranslation(); + const { modal, message } = App.useApp(); const lang = normalizeUiLocale(i18n.language); const [divisions, setDivisions] = useState([]); const [allItems, setAllItems] = useState([]); @@ -339,7 +339,7 @@ export default function SubagentManager({ }; const confirmDeleteSubagent = (subagent: AgentSubagentSummary) => { - Modal.confirm({ + modal.confirm({ title: t("workspace.deleteConfirm"), okText: t("common.delete"), cancelText: t("common.cancel"), diff --git a/dashboard/src/pages/KnowledgeBases/index.tsx b/dashboard/src/pages/KnowledgeBases/index.tsx index 714cb295..811be13e 100644 --- a/dashboard/src/pages/KnowledgeBases/index.tsx +++ b/dashboard/src/pages/KnowledgeBases/index.tsx @@ -7,6 +7,7 @@ import { } from "react"; import { Alert, + App, Button, Drawer, Empty, @@ -25,7 +26,6 @@ import { Tooltip, Typography, } from "antd"; -import { message } from "@/utils/antdMessage"; import { ResizableTable } from "@/components/ResizableTable"; import { Check, @@ -221,6 +221,7 @@ function ReadinessRow({ export default function KnowledgeBasesPage() { const { t } = useTranslation(); + const { modal, message } = App.useApp(); const navigate = useNavigate(); const isMobile = useIsMobile(); const timeZone = useServerTimezone(); @@ -417,7 +418,7 @@ export default function KnowledgeBasesPage() { ); } else if (status === "downloading") { setDownloadProgressLabel( - t("models.onnxDownloadProgress", { model: modelId, percent: pct }), + t("models.onnxDownloading", { model: modelId }), ); } else { setDownloadProgressLabel(t("models.localDownloadPreparing")); @@ -717,7 +718,7 @@ export default function KnowledgeBasesPage() { capability.provider_id !== (featureBackend === "remote" ? featureProviderId : "")) ) { - Modal.confirm({ + modal.confirm({ title: t("knowledgeBases.rebuildConfirmTitle"), content: t("knowledgeBases.rebuildConfirmDescription"), okText: t("common.confirm"), @@ -778,7 +779,7 @@ export default function KnowledgeBasesPage() { const startOnnxDownload = async (modelId: string) => { const selected = catalog.find((model) => model.id === modelId); const size = formatSizeGb(selected?.size_gb); - Modal.confirm({ + modal.confirm({ title: t("models.localDownloadConfirmTitle"), content: t("models.localDownloadConfirmOnnx", { name: modelId, diff --git a/dashboard/src/pages/Settings/BackupRestore/index.tsx b/dashboard/src/pages/Settings/BackupRestore/index.tsx index 69258f17..1f458bb2 100644 --- a/dashboard/src/pages/Settings/BackupRestore/index.tsx +++ b/dashboard/src/pages/Settings/BackupRestore/index.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from "react"; import { + App, Button, Checkbox, Divider, @@ -14,7 +15,6 @@ import { Table, Upload, } from "antd"; -import { message } from "@/utils/antdMessage"; import type { ColumnsType } from "antd/es/table"; import { @@ -143,6 +143,7 @@ function BackupFileCard({ export default function BackupRestorePanel() { const { t } = useTranslation(); + const { modal, message } = App.useApp(); const isMobile = useIsMobile(); const serverTimezone = useServerTimezone(); const { isRestarting } = useServiceRestartContext(); @@ -304,7 +305,7 @@ export default function BackupRestorePanel() { const onDelete = (row: BackupFileItem) => { if (busy) return; - Modal.confirm({ + modal.confirm({ title: t("backup.deleteConfirmTitle"), content: t("backup.deleteConfirmBody", { name: row.name }), okText: t("common.delete"), diff --git a/dashboard/src/pages/Settings/Embedding/index.tsx b/dashboard/src/pages/Settings/Embedding/index.tsx index ed71a38b..291d1111 100644 --- a/dashboard/src/pages/Settings/Embedding/index.tsx +++ b/dashboard/src/pages/Settings/Embedding/index.tsx @@ -1,7 +1,6 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { useTranslation } from "react-i18next"; -import { Select, Input, Button, Spin, Progress, Alert, Modal } from "antd"; -import { message } from "@/utils/antdMessage"; +import { App, Select, Input, Button, Spin, Progress, Alert } from "antd"; import { Download, CheckCircle, AlertCircle } from "lucide-react"; import api from "../../../api"; @@ -16,6 +15,7 @@ import styles from "./index.module.less"; export default function EmbeddingPage() { const { t } = useTranslation(); + const { modal, message } = App.useApp(); // State management. const [config, setConfig] = useState(null); @@ -120,7 +120,7 @@ export default function EmbeddingPage() { const handleDisableVectorSearch = useCallback(() => { if (!config || config.provider === "none") return; - Modal.confirm({ + modal.confirm({ title: t("embedding.disableVectorConfirmTitle"), content: t("embedding.disableVectorConfirmDesc"), okText: t("common.disable"), diff --git a/dashboard/src/pages/Settings/Environments/index.tsx b/dashboard/src/pages/Settings/Environments/index.tsx index 54038974..6a5899c7 100644 --- a/dashboard/src/pages/Settings/Environments/index.tsx +++ b/dashboard/src/pages/Settings/Environments/index.tsx @@ -1,6 +1,5 @@ import { useState, useCallback, useMemo } from "react"; -import { Button, Modal } from "antd"; -import { message } from "@/utils/antdMessage"; +import { App, Button } from "antd"; import { useTranslation } from "react-i18next"; @@ -39,6 +38,7 @@ function shiftIndices(prev: Set, removedIdx: number): Set { function EnvironmentsPage() { const { t } = useTranslation(); + const { modal, message } = App.useApp(); const isMobile = useIsMobile(); const { envVars, loading, error, fetchAll } = useEnvVars(); const [rows, setRows] = useState(null); @@ -137,7 +137,7 @@ function EnvironmentsPage() { return; } - Modal.confirm({ + modal.confirm({ title: t("environments.deleteVariable"), content: t("environments.deleteConfirm", { name: row.key }), okText: t("common.delete"), @@ -171,7 +171,7 @@ function EnvironmentsPage() { ? names.map((n) => `"${n}"`).join(", ") : `${names.length} variables`; - Modal.confirm({ + modal.confirm({ title: t("environments.deleteSelected"), content: t("environments.deleteSelectedConfirm", { label }), okText: t("common.delete"), diff --git a/dashboard/src/pages/Settings/Models/components/cards/ProviderCard.tsx b/dashboard/src/pages/Settings/Models/components/cards/ProviderCard.tsx index 3ca8c664..e40dc761 100644 --- a/dashboard/src/pages/Settings/Models/components/cards/ProviderCard.tsx +++ b/dashboard/src/pages/Settings/Models/components/cards/ProviderCard.tsx @@ -4,8 +4,7 @@ * All providers are admin-managed. Local runtimes cannot be deleted. */ import { useEffect, useState } from "react"; -import { Button, Card, Modal, Switch, Tooltip } from "antd"; -import { message } from "@/utils/antdMessage"; +import { App, Button, Card, Switch, Tooltip } from "antd"; import { Pencil, Trash2, Zap } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -40,6 +39,7 @@ export function ProviderCard({ apiPrefix = "/admin/providers", }: ProviderCardProps) { const { t } = useTranslation(); + const { modal, message } = App.useApp(); const [modalOpen, setModalOpen] = useState(false); const [testing, setTesting] = useState(false); const [toggling, setToggling] = useState(false); @@ -96,7 +96,7 @@ export function ProviderCard({ const handleDelete = (e: React.MouseEvent) => { e.stopPropagation(); - Modal.confirm({ + modal.confirm({ title: t("models.deleteProviderTitle"), content: t("models.deleteProviderConfirmSimple", { name: provider.name }), okText: t("common.delete"), diff --git a/dashboard/src/pages/Settings/Models/components/modals/ModelListEditor.tsx b/dashboard/src/pages/Settings/Models/components/modals/ModelListEditor.tsx index 98669963..0e2718af 100644 --- a/dashboard/src/pages/Settings/Models/components/modals/ModelListEditor.tsx +++ b/dashboard/src/pages/Settings/Models/components/modals/ModelListEditor.tsx @@ -6,16 +6,15 @@ */ import { useState } from "react"; import { + App, Button, Form, Input, InputNumber, - Modal, Select, Switch, Tooltip, } from "antd"; -import { message } from "@/utils/antdMessage"; import { Check, @@ -76,6 +75,7 @@ export function ModelListEditor({ onTestModel, }: ModelListEditorProps) { const { t } = useTranslation(); + const { modal, message } = App.useApp(); const downloadedSet = (() => { if (!localDownload) return null; const raw = localDownload.downloadedIds; @@ -120,7 +120,7 @@ export function ModelListEditor({ }; const handleRemoveModel = (modelId: string, modelName: string) => { - Modal.confirm({ + modal.confirm({ title: t("models.removeModel"), content: t("models.removeModelConfirm", { name: modelName, diff --git a/dashboard/src/pages/Settings/Models/components/modals/ProviderConfigModal.tsx b/dashboard/src/pages/Settings/Models/components/modals/ProviderConfigModal.tsx index 9cde7395..d61d1974 100644 --- a/dashboard/src/pages/Settings/Models/components/modals/ProviderConfigModal.tsx +++ b/dashboard/src/pages/Settings/Models/components/modals/ProviderConfigModal.tsx @@ -7,8 +7,7 @@ * with local model list, download, and delete UI */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Button, Divider, Form, Input, Modal, Progress, Select } from "antd"; -import { message } from "@/utils/antdMessage"; +import { App, Button, Divider, Form, Input, Modal, Progress, Select } from "antd"; import { Download, Key, Loader2, Trash2, X, Zap } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -76,6 +75,7 @@ export function ProviderConfigModal({ apiPrefix = "/providers", }: ProviderConfigModalProps) { const { t } = useTranslation(); + const { modal, message } = App.useApp(); const [saving, setSaving] = useState(false); const [formDirty, setFormDirty] = useState(false); const [testing, setTesting] = useState(false); @@ -296,7 +296,7 @@ export function ProviderConfigModal({ }; const handleOllamaDelete = (model: OllamaModelResponse) => { - Modal.confirm({ + modal.confirm({ title: t("models.localDeleteModel"), content: t("models.localDeleteConfirm", { name: model.name }), okText: t("common.delete"), @@ -320,7 +320,7 @@ export function ProviderConfigModal({ }; const handleCancelOllamaDownload = (task: OllamaDownloadTaskResponse) => { - Modal.confirm({ + modal.confirm({ title: t("models.localCancelDownload"), content: t("models.localCancelDownloadConfirm", { repo: task.name }), okText: t("models.localCancelDownload"), @@ -432,10 +432,7 @@ export function ProviderConfigModal({ ); } else if (d.status === "downloading") { setDownloadProgressLabel( - t("models.onnxDownloadProgress", { - model: modelId, - percent: pct, - }), + t("models.onnxDownloading", { model: modelId }), ); } }, @@ -469,7 +466,7 @@ export function ProviderConfigModal({ ); } else if (d.status === "downloading") { setDownloadProgressLabel( - t("models.onnxDownloadProgress", { model: modelId, percent: pct }), + t("models.onnxDownloading", { model: modelId }), ); } }); @@ -488,7 +485,7 @@ export function ProviderConfigModal({ const handleLocalModelDownload = useCallback( async (modelId: string) => { if (isOllama) { - Modal.confirm({ + modal.confirm({ title: t("models.localDownloadConfirmTitle"), content: t("models.localDownloadConfirmOllama", { name: modelId }), okText: t("models.localDownloadModel"), @@ -531,7 +528,7 @@ export function ProviderConfigModal({ /* use cached / unknown */ } - Modal.confirm({ + modal.confirm({ title: t("models.localDownloadConfirmTitle"), content: t("models.localDownloadConfirmOnnx", { name: modelId, @@ -691,7 +688,7 @@ export function ProviderConfigModal({ }; const handleRevoke = () => { - Modal.confirm({ + modal.confirm({ title: t("models.revokeConfirmTitle"), content: t("models.revokeConfirmContentSimple", { name: provider.name }), okText: t("models.revoke"), diff --git a/dashboard/src/pages/Settings/SearchConfig/index.tsx b/dashboard/src/pages/Settings/SearchConfig/index.tsx index cb744aa9..07a425a3 100644 --- a/dashboard/src/pages/Settings/SearchConfig/index.tsx +++ b/dashboard/src/pages/Settings/SearchConfig/index.tsx @@ -1,6 +1,5 @@ import { useEffect, useState, useCallback } from "react"; -import { Button, Form, Input, Typography, Spin, Modal } from "antd"; -import { message } from "@/utils/antdMessage"; +import { App, Button, Form, Input, Typography, Spin, Modal } from "antd"; import { Check, @@ -84,6 +83,7 @@ function ConfigureModal({ onSaved, }: ConfigureModalProps) { const { t } = useTranslation(); + const { modal, message } = App.useApp(); const [form] = Form.useForm(); const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(false); @@ -155,7 +155,7 @@ function ConfigureModal({ }; const handleRevoke = () => { - Modal.confirm({ + modal.confirm({ title: t("setupWizard.search.revokeTitle", { name: provider.name }), content: t("setupWizard.search.revokeConfirm", { name: provider.name }), okText: t("setupWizard.search.revoke"), diff --git a/dashboard/src/utils/antdModal.ts b/dashboard/src/utils/antdModal.ts new file mode 100644 index 00000000..9a731132 --- /dev/null +++ b/dashboard/src/utils/antdModal.ts @@ -0,0 +1,28 @@ +import { Modal } from "antd"; +import type { HookAPI } from "antd/es/modal/useModal"; + +/** + * Theme-aware modal API bound from antd `` via App.useApp(). + * Prefer this over `Modal.confirm` (static methods ignore ConfigProvider theme). + */ +let bound: HookAPI | null = null; + +export function bindAntdModal(api: HookAPI): void { + bound = api; +} + +export function unbindAntdModal(): void { + bound = null; +} + +function resolve(): HookAPI { + return bound ?? (Modal as unknown as HookAPI); +} + +export const modal: HookAPI = new Proxy({} as HookAPI, { + get(_target, prop, _receiver) { + const api = resolve(); + const value = Reflect.get(api, prop, api); + return typeof value === "function" ? value.bind(api) : value; + }, +}); diff --git a/dashboard/src/utils/confirmModal.ts b/dashboard/src/utils/confirmModal.ts index 917c1f86..81db2a62 100644 --- a/dashboard/src/utils/confirmModal.ts +++ b/dashboard/src/utils/confirmModal.ts @@ -1,5 +1,5 @@ -import { Modal } from "antd"; import type { ModalFuncProps } from "antd"; +import { modal } from "./antdModal"; const MOBILE_BREAKPOINT = 768; @@ -7,14 +7,14 @@ function detectMobile(): boolean { return typeof window !== "undefined" && window.innerWidth < MOBILE_BREAKPOINT; } -/** Mobile-friendly wrapper around antd `Modal.confirm` (stacked full-width buttons). */ +/** Mobile-friendly wrapper around themed `modal.confirm` (stacked full-width buttons). */ export function showConfirmModal( props: ModalFuncProps, options?: { isMobile?: boolean }, ): void { const isMobile = options?.isMobile ?? detectMobile(); - Modal.confirm({ + modal.confirm({ centered: true, ...(isMobile ? { From 91fbcc8c975753afe25cd0651d87c6a0e48ec811 Mon Sep 17 00:00:00 2001 From: jubaoliang Date: Thu, 27 Aug 2026 06:43:43 +0000 Subject: [PATCH 04/28] fix(dashboard): hide bubblewrap skipped toast on non-Linux macOS/Windows cannot use bubblewrap; treat ensure-bwrap skipped as silent so saving a scoped root_dir no longer shows a useless warning. Co-authored-by: Cursor --- .../Experts/components/agentBackendForm.ensureBwrap.test.ts | 2 +- dashboard/src/pages/Experts/components/agentBackendForm.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dashboard/src/pages/Experts/components/agentBackendForm.ensureBwrap.test.ts b/dashboard/src/pages/Experts/components/agentBackendForm.ensureBwrap.test.ts index 142e912b..14f51e98 100644 --- a/dashboard/src/pages/Experts/components/agentBackendForm.ensureBwrap.test.ts +++ b/dashboard/src/pages/Experts/components/agentBackendForm.ensureBwrap.test.ts @@ -5,7 +5,7 @@ describe("ensureBwrap helpers", () => { it("maps status to toast kind", () => { expect(ensureBwrapToastKind("ready")).toBe("none"); expect(ensureBwrapToastKind("installed")).toBe("success"); - expect(ensureBwrapToastKind("skipped")).toBe("warning"); + expect(ensureBwrapToastKind("skipped")).toBe("none"); expect(ensureBwrapToastKind("degraded")).toBe("warning"); }); diff --git a/dashboard/src/pages/Experts/components/agentBackendForm.ts b/dashboard/src/pages/Experts/components/agentBackendForm.ts index 1f1a2576..ddf05b96 100644 --- a/dashboard/src/pages/Experts/components/agentBackendForm.ts +++ b/dashboard/src/pages/Experts/components/agentBackendForm.ts @@ -168,7 +168,7 @@ export async function ensureBubblewrapAfterProbe(): Promise { export function ensureBwrapToastKind( status: EnsureBwrapStatus, ): "none" | "success" | "warning" { - if (status === "ready") return "none"; + if (status === "ready" || status === "skipped") return "none"; if (status === "installed") return "success"; return "warning"; } From a64c6403bda82d61559e7dc1a0232c5eca8ab704 Mon Sep 17 00:00:00 2001 From: veenyi <60565189+veenyi@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:18:50 +0800 Subject: [PATCH 05/28] =?UTF-8?q?fix(fnos):=20FPK=20=E9=83=A8=E7=BD=B2?= =?UTF-8?q?=E7=A6=81=E6=AD=A2=E6=97=A0=E6=95=88=E5=86=85=E7=BD=AE=E5=9C=A8?= =?UTF-8?q?=E7=BA=BF=E5=8D=87=E7=BA=A7=EF=BC=8C=E4=BF=AE=E5=A4=8D=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=E7=89=88=E5=90=AF=E5=8A=A8=E5=8A=A0=E8=BD=BD=20common?= =?UTF-8?q?.sh=20(#446)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(fnos): 镜像命名空间改为 ghcr.io/jubaoliang/octop - fnos-build-fpk.yml / fnos/README.md / fnos/docker/Dockerfile / docker-compose.yaml / manifest 的镜像引用从 ghcr.io/TencentCloud/octop 改为 ghcr.io/jubaoliang/octop(Docker 仓库名必须小写,原大写 T 的 tag 无法通过 buildx) - workflow 镜像 tags 增加 ghcr.io/jubaoliang/octop:latest(Docker 版 FPK 的 docker-compose 按 :latest 拉取,此前只推 :vanilla 会导致飞牛拉不到镜像) * fix(fnos): 镜像发布目标改为 Docker Hub jubaoliang/octop(非 ghcr.io) 根据反馈,镜像仓库位于 hub.docker.com: - 镜像名从 ghcr.io/jubaoliang/octop 改为 jubaoliang/octop(docker pull jubaoliang/octop 无前缀即 Docker Hub) - workflow 登录从 GHCR/GITHUB_TOKEN 改为 Docker Hub 的 DOCKERHUB_USERNAME/DOCKERHUB_TOKEN(与 docker-publish.yml 同一套凭据) * fix(fnos): FPK 部署禁止无效内置在线升级,修复原生版启动加载 common.sh - self_update.py:run_upgrade() 检测到 OCTOP_INSTALL_MODE=fpk-* 时直接返回失败并提示 经飞牛应用中心安装新版 FPK 完成升级。FPK 部署下在线安装到系统 Python 的版本 永远不会被 launcher 加载(PYTHONPATH 指向应用中心托管的 site-packages), 重启后仍是旧版且界面提示成功,升级形同虚设。 - fnos/native launcher(octop/octop-cli):设置 OCTOP_INSTALL_MODE=fpk-native 触发守卫; 并补充 common.sh 回退路径 /var/apps/octop-native/cmd/common.sh——FPK 解包后 cmd/ 落在 管理目录,$0/../cmd 指向载荷内不存在的位置,原 launcher 会因 find_python312 未定义 而启动失败(0.9.27 原生版复现)。 - fnos/docker compose:注入 OCTOP_INSTALL_MODE=fpk-docker,容器内同样禁止无效在线升级, 应用中心更新 FPK 时会重新拉取镜像。 * fix(fnos): FPK 在线升级安装到 launcher 实际加载的 site-packages,升级真正生效 上一版把 FPK 部署下的内置在线升级直接禁止,体验不可接受(发现新版本却无法 一键升级)。重新实现: - self_update.py:OCTOP_FPK_SITE_PACKAGES 非空时走 _run_fpk_upgrade(),用 pip install --target 把新版安装到 launcher 通过 PYTHONPATH 实际加载的打包 site-packages,重启服务后即加载新版;此前安装到系统 Python 的版本永远不会 被加载,导致「升级成功但重启无效」。 - fnos/native launcher(octop/octop-cli):导出 OCTOP_FPK_SITE_PACKAGES 指向打包 目录;octop-cli 在 sudo -u octop-native 时透传该变量与 OCTOP_INSTALL_MODE。 - compose:移除上一版的 OCTOP_INSTALL_MODE=fpk-docker(Docker 版在线升级本就 安装到容器内环境、重启容器生效,无需 FPK 分支,不应被禁止)。 * fix(fnos): FPK 在线升级安装到 launcher 实际加载的 site-packages,升级真正生效 上一版把 FPK 部署下的内置在线升级直接禁止,体验不可接受(发现新版本却无法 一键升级)。重新实现: - self_update.py:OCTOP_FPK_SITE_PACKAGES 非空时走 _run_fpk_upgrade(),用 pip install --target 把新版安装到 launcher 通过 PYTHONPATH 实际加载的打包 site-packages,重启服务后即加载新版;此前安装到系统 Python 的版本永远不会 被加载,导致「升级成功但重启无效」。 - fnos/native launcher(octop/octop-cli):导出 OCTOP_FPK_SITE_PACKAGES 指向打包 目录;octop-cli 在 sudo -u octop-native 时透传该变量与 OCTOP_INSTALL_MODE。 - compose:移除上一版的 OCTOP_INSTALL_MODE=fpk-docker(Docker 版在线升级本就 安装到容器内环境、重启容器生效,无需 FPK 分支,不应被禁止)。 * fix(fnos): FPK 在线升级安装到 launcher 实际加载的 site-packages,升级真正生效 上一版把 FPK 部署下的内置在线升级直接禁止,体验不可接受(发现新版本却无法 一键升级)。重新实现: - self_update.py:OCTOP_FPK_SITE_PACKAGES 非空时走 _run_fpk_upgrade(),用 pip install --target 把新版安装到 launcher 通过 PYTHONPATH 实际加载的打包 site-packages,重启服务后即加载新版;此前安装到系统 Python 的版本永远不会 被加载,导致「升级成功但重启无效」。 - fnos/native launcher(octop/octop-cli):导出 OCTOP_FPK_SITE_PACKAGES 指向打包 目录;octop-cli 在 sudo -u octop-native 时透传该变量与 OCTOP_INSTALL_MODE。 - compose:移除上一版的 OCTOP_INSTALL_MODE=fpk-docker(Docker 版在线升级本就 安装到容器内环境、重启容器生效,无需 FPK 分支,不应被禁止)。 --- fnos/native/app/bin/octop | 21 +++- fnos/native/app/bin/octop-cli | 23 +++- src/octop/infra/setup/self_update.py | 163 +++++++++++++++++++++++++++ 3 files changed, 205 insertions(+), 2 deletions(-) mode change 100644 => 100755 fnos/native/app/bin/octop mode change 100644 => 100755 fnos/native/app/bin/octop-cli diff --git a/fnos/native/app/bin/octop b/fnos/native/app/bin/octop old mode 100644 new mode 100755 index e282f6b4..de1d0f3d --- a/fnos/native/app/bin/octop +++ b/fnos/native/app/bin/octop @@ -9,7 +9,11 @@ set -euo pipefail # 共享函数库(find_python312 / fix_ownership_and_perms / free_octop_ports) # 打包时由 scripts/build-fpk.sh 注入;仓库内运行无此文件时跳过(函数缺失时再报错)。 -_OCTOP_COMMON=""$(dirname "$0")/../cmd/common.sh"" +# 注意:FPK 解包后 cmd/ 落在 /var/apps/octop-native/cmd(管理目录),而本文件位于 +# 负载目录 $TRIM_APPDEST/bin,`$0/../cmd` 指向载荷内不存在的位置,故需回退到 +# 外层 cmd 的实际落点。 +_OCTOP_COMMON="$(dirname "$0")/../cmd/common.sh" +[ -f "$_OCTOP_COMMON" ] || _OCTOP_COMMON="/var/apps/octop-native/cmd/common.sh" [ -f "$_OCTOP_COMMON" ] && source "$_OCTOP_COMMON" APPDEST="${TRIM_APPDEST:-/var/apps/octop-native}" @@ -47,6 +51,21 @@ PY="$(find_python312)" || { SITE_PACKAGES="$APPDEST/site-packages" export PYTHONPATH="${SITE_PACKAGES}${PYTHONPATH:+:${PYTHONPATH}}" +# 内置在线升级优先使用 uv(pip 对 orcakit-harness-agent[all] 依赖树解析会卡死)。 +# 系统无 uv 且当前为 root 时自动安装一次(装到系统 Python bin,软链到 /usr/local/bin)。 +if [ "$(id -u)" = "0" ] && ! command -v uv >/dev/null 2>&1; then + "$PY" -m pip install -q --disable-pip-version-check uv 2>/dev/null || true + if [ -x "$(dirname "$PY")/uv" ]; then + ln -sf "$(dirname "$PY")/uv" /usr/local/bin/uv 2>/dev/null || true + fi +fi + +# 飞牛 FPK 部署标识 + 打包 site-packages 路径:内置在线升级安装到此目录 +# (launcher 实际加载的位置),重启服务后即加载新版,升级真正生效 +# (见 src/octop/infra/setup/self_update.py 的 _run_fpk_upgrade)。 +export OCTOP_INSTALL_MODE="fpk-native" +export OCTOP_FPK_SITE_PACKAGES="$SITE_PACKAGES" + # 兜底:若包内 site-packages 中未检测到 octop(升级异常 / 损坏等), # 用内置核心 whl 就地离线安装,避免用户联网下载。 if ! "$PY" -c "import octop" >/dev/null 2>&1; then diff --git a/fnos/native/app/bin/octop-cli b/fnos/native/app/bin/octop-cli old mode 100644 new mode 100755 index ffa781b5..a3b50e29 --- a/fnos/native/app/bin/octop-cli +++ b/fnos/native/app/bin/octop-cli @@ -21,7 +21,11 @@ set -euo pipefail # 共享函数库(find_python312 / fix_ownership_and_perms / free_octop_ports) # 打包时由 scripts/build-fpk.sh 注入;仓库内运行无此文件时跳过(函数缺失时再报错)。 -_OCTOP_COMMON=""$(dirname "$0")/../cmd/common.sh"" +# 注意:FPK 解包后 cmd/ 落在 /var/apps/octop-native/cmd(管理目录),而本文件位于 +# 负载目录 $TRIM_APPDEST/bin,`$0/../cmd` 指向载荷内不存在的位置,故需回退到 +# 外层 cmd 的实际落点。 +_OCTOP_COMMON="$(dirname "$0")/../cmd/common.sh" +[ -f "$_OCTOP_COMMON" ] || _OCTOP_COMMON="/var/apps/octop-native/cmd/common.sh" [ -f "$_OCTOP_COMMON" ] && source "$_OCTOP_COMMON" APPDEST="${TRIM_APPDEST:-/var/apps/octop-native}" @@ -56,6 +60,21 @@ export HOME="$DATA_DIR" export OCTOP_HOME="$DATA_DIR/.octop" export OCTOP_DATA_DIR="$DATA_DIR" +# 内置在线升级优先使用 uv(pip 对 orcakit-harness-agent[all] 依赖树解析会卡死)。 +# 系统无 uv 且当前为 root 时自动安装一次(装到系统 Python bin,软链到 /usr/local/bin)。 +if [ "$(id -u)" = "0" ] && ! command -v uv >/dev/null 2>&1; then + "$PY" -m pip install -q --disable-pip-version-check uv 2>/dev/null || true + if [ -x "$(dirname "$PY")/uv" ]; then + ln -sf "$(dirname "$PY")/uv" /usr/local/bin/uv 2>/dev/null || true + fi +fi + +# 飞牛 FPK 部署标识 + 打包 site-packages 路径:内置在线升级安装到此目录 +# (launcher 实际加载的位置),重启服务后即加载新版,升级真正生效 +# (见 src/octop/infra/setup/self_update.py 的 _run_fpk_upgrade)。 +export OCTOP_INSTALL_MODE="fpk-native" +export OCTOP_FPK_SITE_PACKAGES="$SITE_PACKAGES" + # 兜底:若包内 octop 未就绪,用内置核心 whl 离线安装(避免联网) if ! "$PY" -c "import octop" >/dev/null 2>&1; then echo "[octop] 未检测到已安装的 Octop,正在用内置核心 whl 离线安装 ..." @@ -72,6 +91,8 @@ if id octop-native >/dev/null 2>&1 && command -v sudo >/dev/null 2>&1; then HOME="$HOME" \ OCTOP_HOME="$OCTOP_HOME" \ OCTOP_DATA_DIR="$OCTOP_DATA_DIR" \ + OCTOP_INSTALL_MODE="$OCTOP_INSTALL_MODE" \ + OCTOP_FPK_SITE_PACKAGES="$OCTOP_FPK_SITE_PACKAGES" \ "$PY" -m octop.cli.main "$@" else exec "$PY" -m octop.cli.main "$@" diff --git a/src/octop/infra/setup/self_update.py b/src/octop/infra/setup/self_update.py index 2b014fc0..06b06205 100644 --- a/src/octop/infra/setup/self_update.py +++ b/src/octop/infra/setup/self_update.py @@ -258,7 +258,170 @@ def get_installed_version(python_exe: str) -> str | None: return None +def get_version_in_dir(python_exe: str, target: str) -> str | None: + """Return the octop version installed in *target* (a ``pip --target`` dir).""" + try: + code = ( + "import sys; sys.path.insert(0, sys.argv[1]); " + "from importlib.metadata import version; print(version('octop'))" + ) + result = subprocess.run( + [python_exe, "-c", code, target], + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0: + return result.stdout.strip() or None + except Exception: + pass + return None + + +def _verify_fpk_upgrade( + local_ver: str, + site_packages: str, + python_exe: str, + mirror_errors: list[str], +) -> UpgradeResult: + actual_ver: str | None = None + for attempt in range(3): + actual_ver = get_version_in_dir(python_exe, site_packages) + if actual_ver and actual_ver != local_ver: + break + if attempt < 2: + time.sleep(0.5) + + if actual_ver and is_newer(actual_ver, local_ver): + return UpgradeResult( + success=True, + message=f"已升级到 {actual_ver},请重启服务生效(应用中心托管的服务重启后加载新版)。", + installed_version=actual_ver, + mirror_errors=mirror_errors, + ) + if actual_ver == local_ver: + return UpgradeResult( + success=False, + error=( + f"安装完成但版本仍为 {actual_ver};" + "请确认新版已发布,或改用飞牛应用中心安装新版 FPK。" + ), + installed_version=actual_ver, + mirror_errors=mirror_errors, + ) + return UpgradeResult( + success=True, + message="upgrade completed", + installed_version=actual_ver, + mirror_errors=mirror_errors, + ) + + +def _run_fpk_upgrade(site_packages: str, *, verbose: bool = False) -> UpgradeResult: + """FnOS FPK 部署下的在线升级:把新版安装到 launcher 实际加载的打包目录。 + + launcher 通过 PYTHONPATH 从应用中心托管的打包 site-packages 加载 octop, + 在线安装到系统 Python 永远不会被加载(重启后仍是旧版)。本函数把新版 + 安装到该打包目录本身,重启服务后即加载新版,升级真正生效。 + + 与普通部署不同,FPK 首次在线升级需要从零解析并下载完整依赖树 + (octop 依赖 orcakit-harness-agent 等大包),故超时显著放宽;且某镜像 + 可能滞后(装到同版本旧版),此时继续尝试下一个镜像,最后以 pypi.org + 兜底,避免「镜像有货但版本不新」导致升级假成功。 + """ + if not os.path.isdir(site_packages): + return UpgradeResult( + success=False, + error=f"FPK site-packages 目录不存在:{site_packages}", + ) + local_ver = get_local_version() + mirror_errors: list[str] = [] + per_mirror_timeout = 900 # FPK 首次升级需下载完整依赖树,180s 不够 + + def _run_install(cmd: list[str], label: str) -> tuple[int | None, str]: + logger.debug("running %s: %s", label, " ".join(cmd)) + try: + # NOCA:DangerousSubprocessUseAudit(argv list with shell=False; installer paths and mirrors are trusted) + result = subprocess.run( + cmd, + check=False, + capture_output=not verbose, + text=True, + timeout=per_mirror_timeout, + ) + except subprocess.TimeoutExpired: + return None, f"timed out after {per_mirror_timeout}s" + if result.returncode == 0: + return 0, "" + snippet = (result.stderr or result.stdout or "")[:300] + return result.returncode, snippet + + python_exe = sys.executable + installer = detect_installer() # uv 优先:pip 对 orcakit-harness-agent[all] 依赖树解析会卡死 + + def _build_cmd(index_url: str = "") -> list[str]: + if installer == "uv": + cmd = [ + find_uv_executable(), + "pip", + "install", + "--python", + python_exe, + "--target", + site_packages, + "--upgrade-package", + _PACKAGE_NAME, + ] + if index_url: + cmd.extend(["--index-url", index_url]) + cmd.append(_PACKAGE_NAME) + return cmd + cmd = [ + python_exe, + "-m", + "pip", + "install", + "--upgrade", + "--upgrade-strategy", + "only-if-needed", + "--target", + site_packages, + ] + if index_url: + cmd.extend(["-i", index_url]) + cmd.append(_PACKAGE_NAME) + return cmd + + for mirror in _MIRRORS: + rc, err_snippet = _run_install(_build_cmd(mirror), mirror) + if rc != 0: + mirror_errors.append(f"{mirror}: {err_snippet}") + continue + res = _verify_fpk_upgrade(local_ver, site_packages, python_exe, mirror_errors) + if res.success: + return res + # 镜像装到了同版本/旧版(同步滞后):继续尝试下一个镜像 + mirror_errors.append(f"{mirror}: {res.error or 'version unchanged'}") + + rc, err_snippet = _run_install(_build_cmd(), "pypi.org") + if rc != 0: + mirror_errors.append(f"pypi.org: {err_snippet or 'unknown error'}") + return UpgradeResult( + success=False, + error="upgrade failed on all mirrors", + mirror_errors=mirror_errors, + ) + return _verify_fpk_upgrade(local_ver, site_packages, python_exe, mirror_errors) + + def run_upgrade(*, verbose: bool = False) -> UpgradeResult: + # [FPK] FnOS FPK 部署:launcher 通过 PYTHONPATH 从应用中心托管的打包 + # site-packages 加载 octop,在线安装到系统 Python 永远不会被加载(重启 + # 无效)。launcher 导出 OCTOP_FPK_SITE_PACKAGES 指向该打包目录,升级即 + # 安装到此目录并提示重启服务生效——升级真正可用,而非禁止升级。 + _fpk_site = os.environ.get("OCTOP_FPK_SITE_PACKAGES", "").strip() + if _fpk_site: + return _run_fpk_upgrade(_fpk_site, verbose=verbose) installer = detect_installer() venv_python = resolve_venv_python() local_ver = get_local_version() From 90b8ca256070d49fbb422d44a729d1f0647f5090 Mon Sep 17 00:00:00 2001 From: chujieHong <31946519+chujieHong@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:38:05 +0800 Subject: [PATCH 06/28] =?UTF-8?q?feat(knowledge):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=8C=89=E7=9F=A5=E8=AF=86=E5=BA=93=E9=85=8D=E7=BD=AE=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E6=95=B0=E9=87=8F=E4=B8=8A=E9=99=90=20(#427)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - schema v10: knowledge_bases 新增 max_documents 列(默认 100,0=不限制,上限 10000) - 上传时按 KB 自身上限在 SQL 事务内原子拦截,第 N+1 个文档被拒绝 - 前端编辑模态框在"描述"与"图标"之间新增"文档数量上限"输入项,保存即同步 - 详情面板"X/Y 文档"文本、上限 Alert、上传按钮均改用该 KB 自身上限 - SQLite 通过启动兜底加列,PostgreSQL 使用 ADD COLUMN IF NOT EXISTS 幂等迁移 --- dashboard/src/api/modules/knowledgeBases.ts | 3 + dashboard/src/locales/en.json | 2 + dashboard/src/locales/zh.json | 2 + dashboard/src/pages/KnowledgeBases/index.tsx | 29 ++++++- src/octop/api/routers/knowledge_bases.py | 14 ++++ src/octop/infra/db/migrate.py | 2 + .../db/migrations/010_kb_max_documents.pg.sql | 7 ++ .../db/migrations/010_kb_max_documents.sql | 6 ++ src/octop/infra/db/repos/knowledge.py | 79 ++++++++++++++----- src/octop/infra/knowledge/service.py | 17 +++- tests/unit/api/test_knowledge_bases.py | 41 ++++++++++ tests/unit/db/test_repo_knowledge.py | 56 ++++++++++++- tests/unit/knowledge/test_service_acl.py | 21 +++++ 13 files changed, 249 insertions(+), 30 deletions(-) create mode 100644 src/octop/infra/db/migrations/010_kb_max_documents.pg.sql create mode 100644 src/octop/infra/db/migrations/010_kb_max_documents.sql diff --git a/dashboard/src/api/modules/knowledgeBases.ts b/dashboard/src/api/modules/knowledgeBases.ts index 6a66d2d7..d8cfb256 100644 --- a/dashboard/src/api/modules/knowledgeBases.ts +++ b/dashboard/src/api/modules/knowledgeBases.ts @@ -37,6 +37,7 @@ export interface KnowledgeBase { embedding_model: string; embedding_dim: number; doc_count: number; + max_documents: number; created_at: number; updated_at: number; } @@ -151,6 +152,7 @@ export const knowledgeBasesApi = { default_open?: boolean; shared?: boolean; icon_name?: string; + max_documents?: number; }) => request("/knowledge-bases", { method: "POST", @@ -165,6 +167,7 @@ export const knowledgeBasesApi = { default_open?: boolean; shared?: boolean; icon_name?: string; + max_documents?: number; }, ) => request(`/knowledge-bases/${id}`, { diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index bc690e98..e7d448ea 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -322,6 +322,8 @@ "documents": "Documents", "documentLimit": "{{count}} / {{max}} documents", "documentLimitReached": "This knowledge base already contains the maximum of {{count}} documents.", + "maxDocuments": "Document limit", + "maxDocumentsHint": "Maximum number of files in this knowledge base. 0 = unlimited, default 100.", "documentTooLarge": "Each document must be at most {{sizeMb}} MB.", "baseLimitReached": "You can create at most {{count}} knowledge bases.", "uploadHint": "Supports md / txt / pdf / docx / pptx. Max {{sizeMb}} MB per file.", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 9d90ed25..748f30ac 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -322,6 +322,8 @@ "documents": "文档", "documentLimit": "{{count}} / {{max}} 个文档", "documentLimitReached": "此知识库已达到 {{count}} 个文档的上限。", + "maxDocuments": "文档数量上限", + "maxDocumentsHint": "本知识库可容纳的最大文档数,0 表示不限制,默认为 100。", "documentTooLarge": "单个文档不能超过 {{sizeMb}} MB。", "baseLimitReached": "每个用户最多可创建 {{count}} 个知识库。", "uploadHint": "支持 md / txt / pdf / docx / pptx,单文件不超过 {{sizeMb}} MB。", diff --git a/dashboard/src/pages/KnowledgeBases/index.tsx b/dashboard/src/pages/KnowledgeBases/index.tsx index 811be13e..9cfad61c 100644 --- a/dashboard/src/pages/KnowledgeBases/index.tsx +++ b/dashboard/src/pages/KnowledgeBases/index.tsx @@ -13,6 +13,7 @@ import { Empty, Form, Input, + InputNumber, List, Modal, Popconfirm, @@ -94,6 +95,7 @@ type BaseFormValues = { name: string; description?: string; icon_name?: string; + max_documents?: number; }; type DocsViewMode = "card" | "table"; @@ -328,7 +330,8 @@ export default function KnowledgeBasesPage() { : 0; const atBaseLimit = ownedBaseCount >= limits.max_bases_per_owner; const fileCount = documents.filter((document) => !document.is_dir).length; - const isAtDocumentLimit = fileCount >= limits.max_docs_per_kb; + const isAtDocumentLimit = + fileCount >= (selected?.max_documents ?? limits.max_docs_per_kb); const folderEntries = documents .filter((document) => isDirectKnowledgeChild(document.path || document.filename, currentFolder), @@ -599,6 +602,7 @@ export default function KnowledgeBasesPage() { name: "", description: "", icon_name: "book-open", + max_documents: 100, }); setDefaultOpenChecked(false); setSharedChecked(false); @@ -612,6 +616,7 @@ export default function KnowledgeBasesPage() { name: selected.name, description: selected.description, icon_name: selected.icon_name || undefined, + max_documents: selected.max_documents, }); setDefaultOpenChecked(selected.default_open); setSharedChecked(selected.shared); @@ -814,7 +819,10 @@ export default function KnowledgeBasesPage() { const uploadDocuments = async (files: FileList | null) => { if (!selected || !files || !usable || isAtDocumentLimit) return; - const remaining = Math.max(0, limits.max_docs_per_kb - fileCount); + const remaining = Math.max( + 0, + (selected?.max_documents ?? limits.max_docs_per_kb) - fileCount, + ); const chosen = Array.from(files).slice(0, remaining); const oversized = chosen.filter( (file) => file.size > limits.max_document_bytes, @@ -1477,7 +1485,7 @@ export default function KnowledgeBasesPage() { {t("knowledgeBases.documentLimit", { count: fileCount, - max: limits.max_docs_per_kb, + max: selected?.max_documents ?? limits.max_docs_per_kb, })}
@@ -1608,7 +1616,7 @@ export default function KnowledgeBasesPage() { type="info" showIcon message={t("knowledgeBases.documentLimitReached", { - count: limits.max_docs_per_kb, + count: selected?.max_documents ?? limits.max_docs_per_kb, })} /> ) : null} @@ -1938,6 +1946,19 @@ export default function KnowledgeBasesPage() { showCount /> + + + diff --git a/src/octop/api/routers/knowledge_bases.py b/src/octop/api/routers/knowledge_bases.py index 8e8bf167..dc24ec00 100644 --- a/src/octop/api/routers/knowledge_bases.py +++ b/src/octop/api/routers/knowledge_bases.py @@ -69,6 +69,12 @@ class CreateBaseBody(BaseModel): default_open: bool = False shared: bool = False icon_name: str = Field(default="", max_length=64) + max_documents: int | None = Field( + default=None, + ge=0, + le=10_000, + description="Per-base document limit. 0 = unlimited, default 100.", + ) class CreateFolderBody(BaseModel): @@ -98,6 +104,12 @@ class UpdateBaseBody(BaseModel): default_open: bool | None = None shared: bool | None = None icon_name: str | None = Field(default=None, max_length=64) + max_documents: int | None = Field( + default=None, + ge=0, + le=10_000, + description="Per-base document limit. 0 = unlimited.", + ) class RenameDocumentBody(BaseModel): @@ -418,6 +430,7 @@ async def create_base( default_open=body.default_open, shared=body.shared, icon_name=body.icon_name.strip(), + max_documents=body.max_documents if body.max_documents is not None else MAX_DOCS_PER_KB, ) return _base_payload(server, base) except Exception as exc: @@ -480,6 +493,7 @@ async def update_base( default_open=body.default_open, shared=body.shared, icon_name=body.icon_name.strip() if body.icon_name is not None else None, + max_documents=body.max_documents, is_admin=_is_admin(user), ), ) diff --git a/src/octop/infra/db/migrate.py b/src/octop/infra/db/migrate.py index 470d4e8c..458300c8 100644 --- a/src/octop/infra/db/migrate.py +++ b/src/octop/infra/db/migrate.py @@ -637,6 +637,8 @@ def _ensure_knowledge_bases_schema(db: DatabasePool) -> None: """Create or rebuild knowledge tables to the integer-PK identity schema.""" _rebuild_knowledge_identity_schema(db) _drop_knowledge_base_members(db) + # Schema v10: per-knowledge-base configurable document limit. + _ensure_column(db, "knowledge_bases", "max_documents", "INTEGER NOT NULL DEFAULT 100") def _ensure_sso_oidc_schema(db: DatabasePool) -> None: diff --git a/src/octop/infra/db/migrations/010_kb_max_documents.pg.sql b/src/octop/infra/db/migrations/010_kb_max_documents.pg.sql new file mode 100644 index 00000000..c0a0598f --- /dev/null +++ b/src/octop/infra/db/migrations/010_kb_max_documents.pg.sql @@ -0,0 +1,7 @@ +-- Schema v10: per-knowledge-base configurable document limit. +-- PostgreSQL: ADD COLUMN IF NOT EXISTS is safe (skips if already present). + +ALTER TABLE knowledge_bases + ADD COLUMN IF NOT EXISTS max_documents INTEGER NOT NULL DEFAULT 100; + +UPDATE _schema_version SET version = 10; diff --git a/src/octop/infra/db/migrations/010_kb_max_documents.sql b/src/octop/infra/db/migrations/010_kb_max_documents.sql new file mode 100644 index 00000000..523e4bed --- /dev/null +++ b/src/octop/infra/db/migrations/010_kb_max_documents.sql @@ -0,0 +1,6 @@ +-- Schema v10: per-knowledge-base configurable document limit. +-- The column is added by migrate.py::_ensure_knowledge_bases_schema so that +-- boot-time repair covers pre-v10 databases. This file only bumps _schema_version. +-- 100 is the previous system-wide default; 0 means unlimited. + +UPDATE _schema_version SET version = 10; diff --git a/src/octop/infra/db/repos/knowledge.py b/src/octop/infra/db/repos/knowledge.py index 2455c4cf..c5b28fa5 100644 --- a/src/octop/infra/db/repos/knowledge.py +++ b/src/octop/infra/db/repos/knowledge.py @@ -31,11 +31,16 @@ class KnowledgeBaseRow: embedding_model: str embedding_dim: int doc_count: int + max_documents: int created_at: int updated_at: int @classmethod def from_row(cls, r: DbRow) -> KnowledgeBaseRow: + # Schema v10 adds max_documents. Fall back to 100 for pre-v10 DBs. + # sqlite3.Row has no __contains__; use keys() (like users.py). + keys = frozenset(r.keys()) if hasattr(r, "keys") else frozenset() + max_doc = int(r["max_documents"]) if "max_documents" in keys else 100 return cls( id=str(r["knowledge_base_id"]), pk=int(r["id"]), @@ -48,6 +53,7 @@ def from_row(cls, r: DbRow) -> KnowledgeBaseRow: embedding_model=r["embedding_model"], embedding_dim=r["embedding_dim"], doc_count=r["doc_count"], + max_documents=max_doc, created_at=r["created_at"], updated_at=r["updated_at"], ) @@ -112,29 +118,54 @@ def create_base( icon_name: str = "", embedding_model: str = "", embedding_dim: int = 0, + max_documents: int | None = None, ) -> KnowledgeBaseRow: kb_id = self._allocate_base_id() ts = now_ts() with self._db.transaction() as conn: - conn.execute( - "INSERT INTO knowledge_bases(" - "knowledge_base_id, owner_user_id, name, description, default_open, shared, " - "icon_name, embedding_model, embedding_dim, doc_count, created_at, updated_at" - ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)", - ( - kb_id, - owner_user_id, - name, - description, - bool_int(default_open), - bool_int(shared), - icon_name, - embedding_model, - embedding_dim, - ts, - ts, - ), - ) + if max_documents is None: + # Rely on the column DEFAULT (100) for max_documents. + conn.execute( + "INSERT INTO knowledge_bases(" + "knowledge_base_id, owner_user_id, name, description, default_open, shared, " + "icon_name, embedding_model, embedding_dim, doc_count, created_at, updated_at" + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)", + ( + kb_id, + owner_user_id, + name, + description, + bool_int(default_open), + bool_int(shared), + icon_name, + embedding_model, + embedding_dim, + ts, + ts, + ), + ) + else: + conn.execute( + "INSERT INTO knowledge_bases(" + "knowledge_base_id, owner_user_id, name, description, default_open, shared, " + "icon_name, embedding_model, embedding_dim, doc_count, max_documents, " + "created_at, updated_at" + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)", + ( + kb_id, + owner_user_id, + name, + description, + bool_int(default_open), + bool_int(shared), + icon_name, + embedding_model, + embedding_dim, + max_documents, + ts, + ts, + ), + ) row = self.get_base(kb_id) if row is None: raise RuntimeError(f"knowledge base insert failed: {kb_id}") @@ -183,6 +214,7 @@ def update_base( embedding_model: str | None = None, embedding_dim: int | None = None, doc_count: int | None = None, + max_documents: int | None = None, ) -> None: fields, params = partial_updates( [ @@ -194,6 +226,7 @@ def update_base( ("embedding_model", embedding_model), ("embedding_dim", embedding_dim), ("doc_count", doc_count), + ("max_documents", max_documents), ] ) if not fields: @@ -264,8 +297,12 @@ def create_document( self.ensure_folder(kb_id, folder) doc_id = new_ulid() ts = now_ts() + # Treat both None (caller did not specify) and 0 (per-base "unlimited" + # sentinel) as unbounded. Otherwise 0 would be enforced literally as + # "at most 0 documents" and reject every create. + enforce_limit = max_documents is not None and max_documents > 0 with self._db.transaction() as conn: - if max_documents is not None: + if enforce_limit: cursor = conn.execute( "UPDATE knowledge_bases SET doc_count = doc_count + 1, updated_at = ? " "WHERE knowledge_base_id = ? AND doc_count < ?", @@ -280,7 +317,7 @@ def create_document( ") VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?, '', 0, ?, ?)", (doc_id, kb_id, rel, name, content_type, byte_size, content_hash, status, ts, ts), ) - if max_documents is None: + if not enforce_limit: conn.execute( "UPDATE knowledge_bases SET doc_count = doc_count + 1, updated_at = ? " "WHERE knowledge_base_id = ?", diff --git a/src/octop/infra/knowledge/service.py b/src/octop/infra/knowledge/service.py index a6a5ab5e..d6e9d56e 100644 --- a/src/octop/infra/knowledge/service.py +++ b/src/octop/infra/knowledge/service.py @@ -24,6 +24,8 @@ MAX_DOCS_PER_KB = 100 MAX_BASES_PER_OWNER = 20 MAX_DOCUMENT_BYTES = upload_mb_to_bytes(DEFAULT_MAX_UPLOAD_MB) +# Upper bound for the per-base max_documents field. Mirrors Field(le=10000). +MAX_KB_MAX_DOCUMENTS = 10_000 _MAX_PREVIEW_CHARS = 200_000 _EXT_TO_CONTENT_TYPE = { ".txt": "text/plain", @@ -73,10 +75,13 @@ def create_base( default_open: bool = False, shared: bool = False, icon_name: str = "", + max_documents: int = MAX_DOCS_PER_KB, ) -> KnowledgeBaseRow: assert_knowledge_usable( self._services.settings_repo.get, getattr(self._services, "provider_repo", None) ) + if max_documents < 0 or max_documents > MAX_KB_MAX_DOCUMENTS: + raise ValueError(f"max_documents must be between 0 and {MAX_KB_MAX_DOCUMENTS}") owned = self._repo.count_bases_for_owner(owner_user_id) if owned >= MAX_BASES_PER_OWNER: raise ValueError(f"a user can own at most {MAX_BASES_PER_OWNER} knowledge bases") @@ -91,6 +96,7 @@ def create_base( shared=shared, icon_name=icon_name, embedding_model=model, + max_documents=max_documents, ), ) @@ -111,9 +117,14 @@ def update_base( default_open: bool | None = None, shared: bool | None = None, icon_name: str | None = None, + max_documents: int | None = None, is_admin: bool = False, ) -> KnowledgeBaseRow: self.require_owner(kb_id, actor_user_id=actor_user_id, is_admin=is_admin) + if max_documents is not None and ( + max_documents < 0 or max_documents > MAX_KB_MAX_DOCUMENTS + ): + raise ValueError(f"max_documents must be between 0 and {MAX_KB_MAX_DOCUMENTS}") self._repo.update_base( kb_id, name=name, @@ -121,6 +132,7 @@ def update_base( default_open=default_open, shared=shared, icon_name=icon_name, + max_documents=max_documents, ) return self._require_base(kb_id) @@ -294,7 +306,7 @@ def upload_document( assert_knowledge_usable( self._services.settings_repo.get, getattr(self._services, "provider_repo", None) ) - self.get_writable_base(kb_id, actor_user_id=actor_user_id, is_admin=is_admin) + base = self.get_writable_base(kb_id, actor_user_id=actor_user_id, is_admin=is_admin) limit = self._max_document_bytes() if len(content) > limit: raise ValueError(f"knowledge document size exceeds maximum of {limit} bytes") @@ -305,13 +317,14 @@ def upload_document( resolved_type = _resolve_content_type(name, content_type) if resolved_type not in _ALLOWED_CONTENT_TYPES: raise ValueError(f"unsupported knowledge document content type: {content_type}") + # The per-base limit lives on the KB row (schema v10). 0 = unlimited. document = self._repo.create_document( kb_id=kb_id, filename=name, path=rel, content_type=resolved_type, byte_size=len(content), - max_documents=MAX_DOCS_PER_KB, + max_documents=base.max_documents, ) try: write_document(kb_id, document.id, name, content) diff --git a/tests/unit/api/test_knowledge_bases.py b/tests/unit/api/test_knowledge_bases.py index 839e4a5f..d0a50968 100644 --- a/tests/unit/api/test_knowledge_bases.py +++ b/tests/unit/api/test_knowledge_bases.py @@ -39,6 +39,7 @@ class _Base: embedding_model: str = "model" embedding_dim: int = 0 doc_count: int = 0 + max_documents: int = 100 created_at: int = 1 updated_at: int = 1 @@ -199,6 +200,7 @@ async def test_create_base_uses_selected_model_when_usable( "default_open": False, "shared": False, "icon_name": "", + "max_documents": 100, } @@ -550,3 +552,42 @@ def fail(*_args: object, **_kwargs: object) -> None: ) assert raised.value.code == ErrorCode.KNOWLEDGE_NAME_INVALID + + +@pytest.mark.asyncio +async def test_update_base_accepts_max_documents(monkeypatch: pytest.MonkeyPatch) -> None: + from octop.api.routers import knowledge_bases + + received: dict[str, object] = {} + + class _StubSvc: + def update_base(self, *_: object, **kw: object) -> object: + received.update(kw) + return _Base(max_documents=42) + + monkeypatch.setattr(knowledge_bases, "_knowledge_service", lambda _s: _StubSvc()) + + response = await knowledge_bases.update_base( + kb_id="kb-1", + body=knowledge_bases.UpdateBaseBody(max_documents=42), + request=_request(), + server=SimpleNamespace(services=_services()), + user=SimpleNamespace(id=1, is_admin=False), + ) + assert received["max_documents"] == 42 + assert response["max_documents"] == 42 + + +@pytest.mark.asyncio +async def test_update_base_rejects_max_documents_out_of_range() -> None: + from pydantic import ValidationError + + from octop.api.routers import knowledge_bases + + with pytest.raises(ValidationError): + knowledge_bases.UpdateBaseBody(max_documents=-1) + with pytest.raises(ValidationError): + knowledge_bases.UpdateBaseBody(max_documents=10_001) + assert knowledge_bases.UpdateBaseBody(max_documents=0).max_documents == 0 + assert knowledge_bases.UpdateBaseBody(max_documents=10_000).max_documents == 10_000 + assert knowledge_bases.UpdateBaseBody().max_documents is None diff --git a/tests/unit/db/test_repo_knowledge.py b/tests/unit/db/test_repo_knowledge.py index 8572edb8..00d38a4f 100644 --- a/tests/unit/db/test_repo_knowledge.py +++ b/tests/unit/db/test_repo_knowledge.py @@ -52,9 +52,9 @@ def test_knowledge_tables_migrated(db: SqlitePool) -> None: }.issubset(names) assert v == 10 assert "knowledge_base_members" not in names - assert "knowledge_base_id" in { - r["name"] for r in conn.execute("PRAGMA table_info(knowledge_bases)").fetchall() - } + cols = {r["name"] for r in conn.execute("PRAGMA table_info(knowledge_bases)").fetchall()} + assert "knowledge_base_id" in cols + assert "max_documents" in cols def test_path_layout_knowledge_dir(tmp_path: Path) -> None: @@ -296,3 +296,53 @@ def test_rename_document_missing_or_wrong_kb_returns_none( assert repo.rename_document("missing-kb", folder.id, "x") is None assert repo.rename_document(other.id, folder.id, "x") is None assert repo.rename_document(kb.id, "missing-doc", "x") is None + + +def test_update_base_persists_max_documents(repo: KnowledgeRepo, owner_id: int) -> None: + kb = repo.create_base(owner_user_id=owner_id, name="Docs") + assert kb.max_documents == 100 + repo.update_base(kb.id, max_documents=42) + refreshed = repo.get_base(kb.id) + assert refreshed is not None + assert refreshed.max_documents == 42 + repo.update_base(kb.id, description="unchanged") + assert repo.get_base(kb.id).max_documents == 42 # type: ignore[union-attr] + + +def test_create_document_uses_kb_max_documents_for_limit( + repo: KnowledgeRepo, owner_id: int +) -> None: + # The repo enforces whatever max_documents the caller passes (the service + # layer forwards kb.max_documents). A cap of 2 must reject the 3rd. + kb = repo.create_base(owner_user_id=owner_id, name="Tiny", max_documents=2) + assert kb.max_documents == 2 + for i in range(2): + repo.create_document( + kb_id=kb.id, + filename=f"d{i}.md", + content_type="text/markdown", + byte_size=2, + max_documents=kb.max_documents, + ) + with pytest.raises(ValueError, match="at most 2 documents"): + repo.create_document( + kb_id=kb.id, + filename="d2.md", + content_type="text/markdown", + byte_size=2, + max_documents=kb.max_documents, + ) + + +def test_create_document_zero_max_means_unlimited(repo: KnowledgeRepo, owner_id: int) -> None: + kb = repo.create_base(owner_user_id=owner_id, name="Unbounded", max_documents=0) + assert kb.max_documents == 0 + for i in range(150): + repo.create_document( + kb_id=kb.id, + filename=f"d{i:03}.md", + content_type="text/markdown", + byte_size=2, + max_documents=kb.max_documents, + ) + assert repo.get_base(kb.id).doc_count == 150 # type: ignore[union-attr] diff --git a/tests/unit/knowledge/test_service_acl.py b/tests/unit/knowledge/test_service_acl.py index 35c1676d..7c6bdcc0 100644 --- a/tests/unit/knowledge/test_service_acl.py +++ b/tests/unit/knowledge/test_service_acl.py @@ -256,3 +256,24 @@ def test_shared_reader_cannot_rename(service: KnowledgeService) -> None: with pytest.raises(PermissionError, match="write"): service.rename_document(kb.id, folder.id, actor_user_id=viewer, new_name="b") + + +def test_update_base_validates_max_documents_range(service: KnowledgeService) -> None: + users = service._services.user_repo + owner = users.create(username="ow", password_hash="h", role="user") + kb = service.create_base(owner_user_id=owner, name="Docs") + # In range + service.update_base(kb.id, actor_user_id=owner, max_documents=0) + assert service.get_readable_base(kb.id, actor_user_id=owner).max_documents == 0 + service.update_base(kb.id, actor_user_id=owner, max_documents=500) + assert service.get_readable_base(kb.id, actor_user_id=owner).max_documents == 500 + service.update_base(kb.id, actor_user_id=owner, max_documents=10_000) + assert service.get_readable_base(kb.id, actor_user_id=owner).max_documents == 10_000 + # Out of range + with pytest.raises(ValueError, match="max_documents must be between"): + service.update_base(kb.id, actor_user_id=owner, max_documents=-1) + with pytest.raises(ValueError, match="max_documents must be between"): + service.update_base(kb.id, actor_user_id=owner, max_documents=10_001) + # Omit keeps value + service.update_base(kb.id, actor_user_id=owner, description="keep") + assert service.get_readable_base(kb.id, actor_user_id=owner).max_documents == 10_000 From 10e862406ea2df241adc8e5fac2f483dd9d814c7 Mon Sep 17 00:00:00 2001 From: jubaoliang Date: Thu, 27 Aug 2026 17:27:04 +0800 Subject: [PATCH 07/28] fix(db): duplicate v10 migration skipped thread projection tables (#461) * fix(db): resolve duplicate schema v10 migrations skipping projection tables Two 010_*.sql files raced: kb_max_documents bumped the watermark first and skipped thread_message_projection. Keep a single 010 and repair missing tables. Co-authored-by: Cursor * test(agents): disable memory in harness create/close unit tests on Windows Avoid Windows access-violation when memory GC races HarnessAgentManager.close under pytest-xdist; unrelated to the schema v10 fix but was failing PR CI. Co-authored-by: Cursor --------- Co-authored-by: jubaoliang Co-authored-by: Cursor --- CHANGELOG.md | 5 + src/octop/infra/db/migrate.py | 105 ++++++++++++++++++ .../db/migrations/010_kb_max_documents.pg.sql | 7 -- .../db/migrations/010_kb_max_documents.sql | 6 - .../010_thread_message_projection.pg.sql | 7 +- .../010_thread_message_projection.sql | 7 +- tests/unit/agents/test_agent_manager.py | 46 ++++++-- tests/unit/db/test_db_pool.py | 25 +++++ 8 files changed, 184 insertions(+), 24 deletions(-) delete mode 100644 src/octop/infra/db/migrations/010_kb_max_documents.pg.sql delete mode 100644 src/octop/infra/db/migrations/010_kb_max_documents.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index bfc99a38..13f16369 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ ## [Unreleased] +### 修复 + +- 合并冲突的双份 schema v10 迁移:投影表不再被 `010_kb_max_documents` 抢先抬版本而跳过;启动时幂等补建 `thread_messages` / `thread_history_projection` +- Windows CI:`test_create_keeps_user_workspace_dir` 等真实 Harness 单测关闭 memory,避免 GC 线程与 `close()` 争用 SQLite 触发 access violation + ## [0.9.29] - 2026-08-27 ### 修复 diff --git a/src/octop/infra/db/migrate.py b/src/octop/infra/db/migrate.py index 458300c8..0343dc42 100644 --- a/src/octop/infra/db/migrate.py +++ b/src/octop/infra/db/migrate.py @@ -48,6 +48,15 @@ def _discover(dialect: str = "sqlite") -> list[tuple[int, Path]]: m = re.match(r"^(\d{3})_.*\.sql$", name) if m: out.append((int(m.group(1)), entry)) + seen: dict[int, Path] = {} + for version, path in out: + prior = seen.get(version) + if prior is not None: + raise RuntimeError( + f"Duplicate migration version {version:03d}: {prior.name} and {path.name}. " + "Fold unreleased schema into a single NNN_*.sql / NNN_*.pg.sql pair." + ) + seen[version] = path return out @@ -813,6 +822,97 @@ def _ensure_user_invites_schema(db: DatabasePool) -> None: ) +def _ensure_thread_message_projection_schema(db: DatabasePool) -> None: + """Create dashboard history projection tables if missing (schema v10). + + Idempotent repair for installs that bumped ``_schema_version`` to 10 via a + colliding parallel ``010_*.sql`` without applying the projection DDL. + """ + if not _table_exists(db, "threads"): + return + if _table_exists(db, "thread_messages") and _table_exists(db, "thread_history_projection"): + return + if db.dialect == "postgresql": + with db.connect() as conn, conn.transaction(): + conn.execute( + """ + CREATE TABLE IF NOT EXISTS thread_messages ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + thread_id TEXT NOT NULL REFERENCES threads(thread_id) ON DELETE CASCADE, + seq BIGINT NOT NULL, + message_id TEXT, + role TEXT NOT NULL, + message_json TEXT NOT NULL, + created_at BIGINT NOT NULL, + UNIQUE(thread_id, seq), + UNIQUE(thread_id, message_id) + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_thread_messages_thread_seq + ON thread_messages(thread_id, seq DESC) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS thread_history_projection ( + thread_id TEXT PRIMARY KEY REFERENCES threads(thread_id) ON DELETE CASCADE, + status TEXT NOT NULL, + updated_at BIGINT NOT NULL, + error TEXT + ) + """ + ) + conn.execute( + """ + INSERT INTO thread_history_projection(thread_id, status, updated_at, error) + SELECT thread_id, + CASE WHEN last_active > 0 OR title IS NOT NULL + THEN 'pending' ELSE 'ready' END, + EXTRACT(EPOCH FROM NOW())::BIGINT, + NULL + FROM threads + ON CONFLICT(thread_id) DO NOTHING + """ + ) + return + with db.connect() as conn: + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS thread_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + thread_id TEXT NOT NULL REFERENCES threads(thread_id) ON DELETE CASCADE, + seq INTEGER NOT NULL, + message_id TEXT, + role TEXT NOT NULL, + message_json TEXT NOT NULL, + created_at INTEGER NOT NULL, + UNIQUE(thread_id, seq), + UNIQUE(thread_id, message_id) + ); + + CREATE INDEX IF NOT EXISTS idx_thread_messages_thread_seq + ON thread_messages(thread_id, seq DESC); + + CREATE TABLE IF NOT EXISTS thread_history_projection ( + thread_id TEXT PRIMARY KEY REFERENCES threads(thread_id) ON DELETE CASCADE, + status TEXT NOT NULL, + updated_at INTEGER NOT NULL, + error TEXT + ); + + INSERT OR IGNORE INTO thread_history_projection(thread_id, status, updated_at, error) + SELECT thread_id, + CASE WHEN last_active > 0 OR title IS NOT NULL THEN 'pending' ELSE 'ready' END, + CAST(strftime('%s', 'now') AS INTEGER), + NULL + FROM threads; + """ + ) + + def _repair_legacy_schema(db: DatabasePool) -> None: """Idempotent compatibility repairs for local databases from old builds.""" if _table_exists(db, "users"): @@ -894,6 +994,8 @@ def _reconcile_pre_squash_schema_version(db: DatabasePool) -> None: _ensure_usage_cache_schema(db) if max_version >= 9: _ensure_user_invites_schema(db) + if max_version >= 10: + _ensure_thread_message_projection_schema(db) with db.connect() as conn: conn.execute("UPDATE _schema_version SET version = %s", (max_version,)) return @@ -921,6 +1023,8 @@ def _reconcile_pre_squash_schema_version(db: DatabasePool) -> None: _ensure_usage_cache_schema(db) if max_version >= 9: _ensure_user_invites_schema(db) + if max_version >= 10: + _ensure_thread_message_projection_schema(db) with db.connect() as conn: conn.execute("UPDATE _schema_version SET version = ?", (max_version,)) @@ -1063,3 +1167,4 @@ def run_migrations(db: DatabasePool) -> None: _ensure_published_experts_schema(db) _ensure_usage_cache_schema(db) _ensure_user_invites_schema(db) + _ensure_thread_message_projection_schema(db) diff --git a/src/octop/infra/db/migrations/010_kb_max_documents.pg.sql b/src/octop/infra/db/migrations/010_kb_max_documents.pg.sql deleted file mode 100644 index c0a0598f..00000000 --- a/src/octop/infra/db/migrations/010_kb_max_documents.pg.sql +++ /dev/null @@ -1,7 +0,0 @@ --- Schema v10: per-knowledge-base configurable document limit. --- PostgreSQL: ADD COLUMN IF NOT EXISTS is safe (skips if already present). - -ALTER TABLE knowledge_bases - ADD COLUMN IF NOT EXISTS max_documents INTEGER NOT NULL DEFAULT 100; - -UPDATE _schema_version SET version = 10; diff --git a/src/octop/infra/db/migrations/010_kb_max_documents.sql b/src/octop/infra/db/migrations/010_kb_max_documents.sql deleted file mode 100644 index 523e4bed..00000000 --- a/src/octop/infra/db/migrations/010_kb_max_documents.sql +++ /dev/null @@ -1,6 +0,0 @@ --- Schema v10: per-knowledge-base configurable document limit. --- The column is added by migrate.py::_ensure_knowledge_bases_schema so that --- boot-time repair covers pre-v10 databases. This file only bumps _schema_version. --- 100 is the previous system-wide default; 0 means unlimited. - -UPDATE _schema_version SET version = 10; diff --git a/src/octop/infra/db/migrations/010_thread_message_projection.pg.sql b/src/octop/infra/db/migrations/010_thread_message_projection.pg.sql index 0b9a80b0..825a0aa5 100644 --- a/src/octop/infra/db/migrations/010_thread_message_projection.pg.sql +++ b/src/octop/infra/db/migrations/010_thread_message_projection.pg.sql @@ -1,5 +1,8 @@ --- Dashboard history read model. Existing message-bearing threads are backfilled lazily; --- new/empty threads can use the projection immediately. +-- Schema v10: dashboard history read model. +-- knowledge_bases.max_documents is applied by migrate.py::_ensure_knowledge_bases_schema +-- (not a separate 010_*.sql — duplicate version numbers skip later files). +-- Existing message-bearing threads are backfilled lazily; new/empty threads can use +-- the projection immediately. CREATE TABLE IF NOT EXISTS thread_messages ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, diff --git a/src/octop/infra/db/migrations/010_thread_message_projection.sql b/src/octop/infra/db/migrations/010_thread_message_projection.sql index 4e73b8da..922a1f38 100644 --- a/src/octop/infra/db/migrations/010_thread_message_projection.sql +++ b/src/octop/infra/db/migrations/010_thread_message_projection.sql @@ -1,5 +1,8 @@ --- Dashboard history read model. Existing message-bearing threads are backfilled lazily; --- new/empty threads can use the projection immediately. +-- Schema v10: dashboard history read model. +-- knowledge_bases.max_documents is applied by migrate.py::_ensure_knowledge_bases_schema +-- (not a separate 010_*.sql — duplicate version numbers skip later files). +-- Existing message-bearing threads are backfilled lazily; new/empty threads can use +-- the projection immediately. CREATE TABLE IF NOT EXISTS thread_messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/tests/unit/agents/test_agent_manager.py b/tests/unit/agents/test_agent_manager.py index 05b65f53..470573fc 100644 --- a/tests/unit/agents/test_agent_manager.py +++ b/tests/unit/agents/test_agent_manager.py @@ -28,6 +28,12 @@ # Linux/Docker sandbox concept; on Windows they are not absolute paths. posix_only = pytest.mark.skipif(os.name != "posix", reason="POSIX rootfs workspace paths") +# Real HarnessAgentManager starts memory maintenance (GC/vacuum) on a daemon +# thread. Closing the agent while that tick holds the SQLite handle races on +# Windows (access violation under pytest-xdist). Unit tests here only need +# workspace/config behavior — keep memory off. +_MEMORY_OFF: dict[str, Any] = {"memory": {"memory_enabled": False}} + async def _collect_async(iterator: AsyncIterator[Any]) -> list[Any]: return [item async for item in iterator] @@ -775,7 +781,12 @@ async def test_start_agent_real_harness_seeds_agents_md(manager: AgentManager) - manager._harness_manager = HarnessAgentManager( providers=manager.providers.build_harness_configs(), ) - row = manager._repos.agent_repo.create(agent_id="REAL01", user_id=None, name="real") + row = manager._repos.agent_repo.create( + agent_id="REAL01", + user_id=None, + name="real", + config_json=json.dumps(_MEMORY_OFF), + ) row = manager._repos.agent_repo.get("REAL01") assert row is not None @@ -800,7 +811,12 @@ async def test_stop_and_start_round_trip(manager: AgentManager) -> None: manager._harness_manager = HarnessAgentManager( providers=manager.providers.build_harness_configs(), ) - manager._repos.agent_repo.create(agent_id="STOP01", user_id=None, name="stop-me") + manager._repos.agent_repo.create( + agent_id="STOP01", + user_id=None, + name="stop-me", + config_json=json.dumps(_MEMORY_OFF), + ) row = manager.get_row("STOP01") assert row is not None await manager._start_agent(row) @@ -830,7 +846,12 @@ async def test_save_security_rebuilds_running_harness_agent(manager: AgentManage manager._harness_manager = HarnessAgentManager( providers=manager.providers.build_harness_configs(), ) - manager._repos.agent_repo.create(agent_id="SEC01", user_id=None, name="sec") + manager._repos.agent_repo.create( + agent_id="SEC01", + user_id=None, + name="sec", + config_json=json.dumps(_MEMORY_OFF), + ) row = manager.get_row("SEC01") assert row is not None await manager._start_agent(row) @@ -853,7 +874,12 @@ async def test_reload_skips_stopped_agent(manager: AgentManager) -> None: manager._harness_manager = HarnessAgentManager( providers=manager.providers.build_harness_configs(), ) - manager._repos.agent_repo.create(agent_id="SKIP01", user_id=None, name="skip") + manager._repos.agent_repo.create( + agent_id="SKIP01", + user_id=None, + name="skip", + config_json=json.dumps(_MEMORY_OFF), + ) row = manager.get_row("SKIP01") assert row is not None await manager._start_agent(row) @@ -880,7 +906,7 @@ async def test_create_seeds_bootstrap_files(manager: AgentManager) -> None: manager._harness_manager = HarnessAgentManager( providers=manager.providers.build_harness_configs(), ) - row = await manager.create(AgentCreateSpec(name="seeded")) + row = await manager.create(AgentCreateSpec(name="seeded", config=dict(_MEMORY_OFF))) agent = manager.get_agent(row.agent_id) assert agent.workspace.exists("BOOTSTRAP.md") assert agent.workspace.exists("AGENTS.md") @@ -919,6 +945,7 @@ async def test_create_keeps_user_workspace_dir( AgentCreateSpec( name="user-ws", config={ + **_MEMORY_OFF, "backend": { "type": "local_shell", "root_dir": str(tmp_path), @@ -958,11 +985,12 @@ async def test_create_persists_rootfs_workspace_under_scoped_root( AgentCreateSpec( name="scoped-ws", config={ + **_MEMORY_OFF, "backend": { "type": "local_shell", "root_dir": str(tmp_path), "virtual_mode": True, - } + }, }, ) ) @@ -1023,7 +1051,11 @@ async def test_templated_agent_keeps_expert_soul_on_reload(manager: AgentManager providers=manager.providers.build_harness_configs(), ) row = await manager.create( - AgentCreateSpec(name="tpl-bot", template_name="general-assistant"), + AgentCreateSpec( + name="tpl-bot", + template_name="general-assistant", + config=dict(_MEMORY_OFF), + ), ) agent = manager.get_agent(row.agent_id) expected_soul = (default_library_root() / "general-assistant" / "SOUL.md").read_text( diff --git a/tests/unit/db/test_db_pool.py b/tests/unit/db/test_db_pool.py index c060b5c3..1fa6f505 100644 --- a/tests/unit/db/test_db_pool.py +++ b/tests/unit/db/test_db_pool.py @@ -285,6 +285,31 @@ def test_stuck_version_6_without_permissions_column_is_repaired(tmp_path: Path) assert "permissions" in cols +def test_schema_v10_without_projection_tables_is_repaired(tmp_path: Path) -> None: + """DBs that bumped to v10 via a colliding 010 file still get projection tables.""" + db_path = tmp_path / "octop.db" + pool = SqlitePool(db_path) + with pool.connect() as conn: + conn.executescript( + ( + Path(__file__).resolve().parents[3] + / "src/octop/infra/db/migrations/001_initial.sql" + ).read_text() + ) + conn.execute("UPDATE _schema_version SET version = 10") + run_migrations(pool) + with pool.connect() as conn: + version = conn.execute("SELECT version FROM _schema_version").fetchone()[0] + table_names = { + r["name"] + for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall() + } + kb_cols = {r["name"] for r in conn.execute("PRAGMA table_info(knowledge_bases)").fetchall()} + assert version == 10 + assert {"thread_messages", "thread_history_projection"}.issubset(table_names) + assert "max_documents" in kb_cols + + def test_ahead_of_max_schema_version_clamps_to_max(tmp_path: Path) -> None: """A DB whose watermark is ahead of discovered migrations clamps on boot. From 803c859bd97e15b7a38ec33589f001cb3e66df40 Mon Sep 17 00:00:00 2001 From: jubaoliang Date: Thu, 27 Aug 2026 18:22:27 +0800 Subject: [PATCH 08/28] ci: GHCR+Hub Docker, auto FnOS, cascade desktop after release (#459) * ci: publish Docker to GHCR+Hub and auto-build FnOS after Release Cascade FnOS FPK from Release (reuse GHCR image and release wheel), keep Docker Hub sync, and fix workflow_dispatch version tags for Auto Tag. Co-authored-by: Cursor * ci: cascade desktop package with release and tighten triggers Stop running the six-platform desktop matrix on every branch push. Auto Tag dispatches Octop Desktop alongside Release and Docker so artifacts attach to the same v* GitHub Release. Co-authored-by: Cursor --------- Co-authored-by: jubaoliang Co-authored-by: Cursor --- .cursor/skills/publish/SKILL.md | 2 +- .github/workflows/auto-tag-on-release.yml | 10 +- .github/workflows/docker-publish.yml | 69 ++++- .github/workflows/fnos-build-fpk.yml | 172 ++++++------ .github/workflows/octop-desktop.yml | 304 +++++++++++++++++++++ .github/workflows/release.yml | 25 ++ CHANGELOG.md | 7 + fnos/README.md | 15 +- fnos/docker/Dockerfile | 2 +- fnos/docker/app/docker/docker-compose.yaml | 2 +- fnos/docker/manifest | 2 +- 11 files changed, 490 insertions(+), 120 deletions(-) create mode 100644 .github/workflows/octop-desktop.yml diff --git a/.cursor/skills/publish/SKILL.md b/.cursor/skills/publish/SKILL.md index 684d7967..9b0039bc 100644 --- a/.cursor/skills/publish/SKILL.md +++ b/.cursor/skills/publish/SKILL.md @@ -3,7 +3,7 @@ name: publish description: >- Publish the Octop Python package: cut a release branch from develop, bump version, update CHANGELOG, open a PR to main; after merge, Actions tag on - main (PyPI / Docker Hub) and sync main into develop. Use when the user asks + main (PyPI / Docker Hub + GHCR) and sync main into develop. Use when the user asks to publish, release, bump version, cut a release, or run /publish. disable-model-invocation: true --- diff --git a/.github/workflows/auto-tag-on-release.yml b/.github/workflows/auto-tag-on-release.yml index 08e2f254..280d23b0 100644 --- a/.github/workflows/auto-tag-on-release.yml +++ b/.github/workflows/auto-tag-on-release.yml @@ -64,8 +64,11 @@ jobs: git push origin "$tag" # GITHUB_TOKEN tag pushes do not cascade to other workflows. Explicitly - # dispatch Release / Docker Publish (workflow_dispatch is exempt). - - name: Trigger Release and Docker Publish + # dispatch Release / Docker Publish / Desktop Package (workflow_dispatch + # is exempt). FnOS FPK is cascaded from Release after the GitHub Release + # exists (reuses Release wheel + waits for GHCR image from Docker Publish). + # Desktop builds in parallel; its release job upserts zips onto the same v*. + - name: Trigger Release, Docker Publish, and Desktop Package if: steps.check_tag.outputs.should_publish == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -73,3 +76,6 @@ jobs: tag="${{ steps.version.outputs.tag }}" gh workflow run release.yml --ref "$tag" gh workflow run docker-publish.yml --ref "$tag" + gh workflow run octop-desktop.yml --ref "$tag" \ + -f platforms=all \ + -f attach_release=true diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 5e37cad7..dd787522 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,26 +1,59 @@ name: Docker Publish -# 仅在推送版本标签(v*)时构建并发布镜像到 Docker Hub。 -# 与 .github/workflows/release.yml(发 PyPI)相互独立、可并行。 -# 普通 push / PR 不会触发,CI 开销保持在最低。 +# 仅在版本标签(v*)上构建并同步发布镜像到 Docker Hub 与 GHCR。 +# 文档与对外引用统一使用:ghcr.io/tencentcloud/octop +# (Hub 仍用 DOCKERHUB_* secrets 同步推送。) +# +# 触发路径: +# 1. 人工推送 v* tag → on.push.tags +# 2. Auto Tag 用 GITHUB_TOKEN 推 tag 后显式 workflow_dispatch(token push 不会触发 push 工作流) +# +# 注意:workflow_dispatch 下 metadata-action 的 type=semver 不可靠, +# 必须从 GITHUB_REF_NAME 显式解析版本号。 +# +# FnOS FPK 由 Release 成功后自动 dispatch,并等待本工作流推送的 :{version} 就绪。 +# +# 拉取示例: +# docker pull ghcr.io/tencentcloud/octop:latest +# docker pull ghcr.io/tencentcloud/octop:0.9.28 on: push: tags: - "v*" - # Allow Auto Tag On Release Merge to cascade after a GITHUB_TOKEN tag push - # (token-authored pushes do not retrigger push workflows). workflow_dispatch: +permissions: + contents: read + packages: write + jobs: docker: name: Build and push image runs-on: ubuntu-latest + # 拒绝在非 v* ref 上误跑(例如 UI 里对 main 点 Run workflow) + if: startsWith(github.ref, 'refs/tags/v') steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 + - name: Resolve version from tag ref + id: ver + run: | + set -euo pipefail + ref="${GITHUB_REF_NAME}" + case "$ref" in + v*) + echo "version=${ref#v}" >> "$GITHUB_OUTPUT" + echo "tag=$ref" >> "$GITHUB_OUTPUT" + ;; + *) + echo "::error::Expected refs/tags/v*, got ref_name=$ref" + exit 1 + ;; + esac + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -30,16 +63,29 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Image names + id: image + run: | + echo "ghcr=ghcr.io/$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" + echo "hub=${{ secrets.DOCKERHUB_USERNAME }}/octop" >> "$GITHUB_OUTPUT" + - name: Docker metadata id: meta uses: docker/metadata-action@v5 with: - # 镜像名 = /octop(个人或组织命名空间均可)。 - images: ${{ secrets.DOCKERHUB_USERNAME }}/octop + images: | + ${{ steps.image.outputs.hub }} + ${{ steps.image.outputs.ghcr }} + # 显式 raw tag:兼容 push.tags 与 Auto Tag 的 workflow_dispatch tags: | - # v1.2.3 -> 1.2.3 - type=semver,pattern={{version}} - # 每个 tag 同时打 latest + type=raw,value=${{ steps.ver.outputs.version }} type=raw,value=latest - name: Build and push @@ -48,11 +94,8 @@ jobs: context: . file: ./docker/Dockerfile push: true - # 默认仅 amd64,构建最快最稳。如需 ARM64(Apple Silicon / Graviton), - # 改为 "linux/amd64,linux/arm64" 并加上 docker/setup-qemu-action@v3 步骤。 platforms: linux/amd64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - # 复用 GitHub Actions 缓存加速后续构建 cache-from: type=gha cache-to: type=gha,mode=max diff --git a/.github/workflows/fnos-build-fpk.yml b/.github/workflows/fnos-build-fpk.yml index 05c6a338..9ce4060a 100644 --- a/.github/workflows/fnos-build-fpk.yml +++ b/.github/workflows/fnos-build-fpk.yml @@ -1,35 +1,30 @@ -name: Build Octop FPK (template) +name: Build Octop FPK -# TEMPLATE — FnOS (飞牛 NAS) app packaging pipeline for fork maintainers. -# # This workflow builds the Octop Docker image, the Docker .fpk and the native .fpk, -# # then publishes GitHub releases (fixed version + rolling latest). -# # -# # To enable on your fork: -# # 1. Replace jubaoliang/octop with your own image namespace (or fork a copy). -# # 2. Optionally add a push trigger below (e.g. branches: [main]) so a rebuild happens -# # automatically on every upstream sync. It is intentionally workflow_dispatch-only -# # here so it never runs on the upstream repository. -# # -# image job:从仓库源码构建 Octop Docker 镜像,推送到 Docker Hub(jubaoliang/octop:vanilla) -# fpk job:构建「Docker 版」飞牛安装包 Fnos-octop-vanilla--.fpk(依赖 image,确保镜像已就绪) -# native job:构建「本地版(非 Docker)」飞牛安装包 Fnos-octop-vanilla-native--.fpk。 -# 不内置 Python 运行时(依赖飞牛应用商店 Python 3.12), -# 仅内置 Octop 核心 whl + 核心依赖 site-packages,不含浏览器二进制与预装技能。 -# release job: -# - 创建固定版本 release(如 fnos-0.9.16-01),保留历史、带更新日志; -# - 同步更新滚动 release fnos-vanilla-latest,方便用户始终从同一 URL 下载最新版。 +# FnOS (飞牛 NAS) 安装包流水线。 # -# 镜像包随公开仓库默认即为 public,无需额外设置可见性。 +# 触发: +# - Release 工作流在 GitHub Release 创建成功后自动 dispatch(推荐) +# - 亦可手动 workflow_dispatch(需对应版本镜像已在 GHCR) +# +# 制品复用: +# - Docker 镜像:不再重建,等待 docker-publish 推送的 +# ghcr.io/tencentcloud/octop:{version|latest} +# - Native wheel:优先从同版本 GitHub Release(v*)下载; +# 缺失时再回退到本仓源码构建 +# +# Jobs: +# version → ensure-image → fpk(Docker 版 .fpk,仅打包 compose) +# ↘ native(本地版 .fpk)→ release(固定版 + 滚动 latest) on: workflow_dispatch: permissions: contents: write - packages: write + packages: read concurrency: - group: build-octop-vanilla + group: build-octop-fnos cancel-in-progress: false jobs: @@ -40,17 +35,20 @@ jobs: iter: ${{ steps.iter.outputs.iter }} tag: ${{ steps.iter.outputs.tag }} pkg_ver: ${{ steps.iter.outputs.pkg_ver }} + image: ${{ steps.ver.outputs.image }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 fetch-tags: true - - name: Read version + - name: Read version and image name id: ver run: | VER=$(grep -m1 '^version' pyproject.toml | sed -E 's/.*"([0-9][0-9.]*[0-9])".*/\1/') + IMAGE="ghcr.io/$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" echo "version=$VER" >> "$GITHUB_OUTPUT" + echo "image=$IMAGE" >> "$GITHUB_OUTPUT" - name: Compute iteration id: iter @@ -58,10 +56,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | VER=${{ steps.ver.outputs.version }} - # 查询已存在的 fnos--NN releases,取最大序号 +1 作为本次迭代号。 - # 之前用 GitHub API / git ls-remote / git tag -l 都在 CI 中因认证、 - # 限流或本地 tag 未拉取而回退成 0,导致每次都覆盖 fnos--01。 - # GitHub CLI 在 Actions runner 中已预装且自动使用 GITHUB_TOKEN,最稳。 + # 查询已存在的 fnos-vanilla--NN releases,取最大序号 +1。 echo "[version] existing releases matching fnos-vanilla-${VER}-NN:" gh release list --repo "${{ github.repository }}" --limit 100 --json tagName | python3 -c 'import sys,json; [print(" "+r.get("tagName","")) for r in json.load(sys.stdin)]' || true EXISTING=$(gh release list --repo "${{ github.repository }}" --limit 100 --json tagName | python3 -c 'import sys,json,re; d=json.load(sys.stdin); pat=re.compile(r"fnos-vanilla-[0-9]+\.[0-9]+\.[0-9]+-([0-9]+)$"); nums=[int(pat.match(r.get("tagName","")).group(1)) for r in d if pat.match(r.get("tagName",""))]; print(max(nums) if nums else 0)') @@ -71,42 +66,45 @@ jobs: echo "iter=$ITER" >> "$GITHUB_OUTPUT" echo "tag=fnos-vanilla-${VER}-${ITER}" >> "$GITHUB_OUTPUT" echo "pkg_ver=${VER}-${ITER}" >> "$GITHUB_OUTPUT" - echo "[version] VER=$VER, EXISTING_MAX=$MAX, ITER=$ITER, TAG=fnos-${VER}-${ITER}" + echo "[version] VER=$VER, EXISTING_MAX=$MAX, ITER=$ITER, TAG=fnos-vanilla-${VER}-${ITER}" - image: + # 复用 docker-publish 已推送的镜像,不在此重新 build/push。 + ensure-image: needs: version runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - fetch-tags: true - - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Login to Docker Hub + - name: Log in to GHCR uses: docker/login-action@v3 with: - # Docker Hub(与 docker-publish.yml 同一凭据,DOCKERHUB_USERNAME 为 jubaoliang 时推送 jubaoliang/octop) - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push image - uses: docker/build-push-action@v6 - with: - context: . - file: fnos/docker/Dockerfile - push: true - tags: | - jubaoliang/octop:latest - jubaoliang/octop:vanilla - jubaoliang/octop:vanilla-v${{ needs.version.outputs.version }} - cache-from: type=gha - cache-to: type=gha,mode=max + - name: Wait for GHCR image from docker-publish + env: + IMAGE: ${{ needs.version.outputs.image }} + VER: ${{ needs.version.outputs.version }} + run: | + set -euo pipefail + TAG="${IMAGE}:${VER}" + echo "Waiting for ${TAG} (published by docker-publish)..." + for i in $(seq 1 90); do + if docker buildx imagetools inspect "$TAG" >/dev/null 2>&1; then + echo "Image ready: $TAG" + docker buildx imagetools inspect "$TAG" | head -20 + exit 0 + fi + echo "[$i/90] not ready yet..." + sleep 20 + done + echo "::error::Timed out waiting for ${TAG}. Ensure Docker Publish finished for v${VER}." + exit 1 fpk: - needs: [image, version] + needs: [ensure-image, version] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -128,8 +126,7 @@ jobs: if-no-files-found: error native: - # 即使原生版构建失败也不阻塞整体发布:Docker 版(fpk)始终会发布; - # 原生版成功则一并附带。continue-on-error 让 needs 视为通过。 + # 即使原生版构建失败也不阻塞整体发布:Docker 版(fpk)始终会发布。 continue-on-error: true needs: version runs-on: ubuntu-latest @@ -141,22 +138,40 @@ jobs: fetch-depth: 0 fetch-tags: true - - name: Set up Python 3.12 (build-only) + - name: Try download wheel from GitHub Release + id: wheel + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VER: ${{ needs.version.outputs.version }} + run: | + set -euo pipefail + mkdir -p dist + REL_TAG="v${VER}" + if gh release download "$REL_TAG" --repo "${{ github.repository }}" -p 'octop-*.whl' -D dist 2>/tmp/wheel-dl.err; then + ls -lh dist/octop-*.whl + echo "source=release" >> "$GITHUB_OUTPUT" + echo "Reusing wheel from GitHub Release ${REL_TAG}" + else + echo "Release wheel not available yet:" + cat /tmp/wheel-dl.err || true + echo "source=build" >> "$GITHUB_OUTPUT" + fi + + - name: Set up Python 3.12 uses: actions/setup-python@v5 with: python-version: "3.12" - name: Set up Node.js 20 (frontend build) + if: steps.wheel.outputs.source == 'build' uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" cache-dependency-path: dashboard/package-lock.json - # native 包必须包含当前 fork 的源码修改(如 /admin/providers/fetch-models)。 - # 上游 TencentCloud/Octop 的 release wheel 不包含这些修改,因此改为从当前 - # 仓库源码构建 wheel:先构建前端产物到 src/octop/dashboard,再打包 wheel。 - name: Build Octop wheel from current source + if: steps.wheel.outputs.source == 'build' run: | set -e echo "=== build frontend ===" @@ -184,25 +199,11 @@ jobs: mkdir -p "${{ github.workspace }}/fnos/native/app/site-packages" SP="${{ github.workspace }}/fnos/native/app/site-packages" { - # 用 uv 安装核心 whl 及其依赖到包内 site-packages。 - # pip 对 orcakit-harness-agent[all] 的复杂依赖树会报 resolution-too-deep, - # uv 的回溯能力强得多(旧方案已验证)。 python3 -m pip install --quiet uv - # 第一步:让 uv 自由解析并安装核心 whl(含其完整依赖树)。 - # 自由解析会拉到最新的 mcp==2.0.0 + langchain-mcp-adapters==0.3.1,这两者在结构上 - # 不兼容(见第二步),但本步只负责把依赖装齐,不要求版本正确。 - # 注意:不能把钉版与 octop.whl 放在同一条 uv 命令里——会和 whl 的传递依赖约束 - # 发生 resolution 冲突,导致整个 native 构建失败(fdca4c6 的教训)。 python3 -m uv pip install --python python3.12 \ --target "$SP" --no-cache "$(ls dist/octop-*.whl | head -1)" - # 第二步:强制覆盖为仓库 uv.lock 锁定的兼容组合 mcp==1.28.1 + langchain-mcp-adapters==0.3.0。 - # mcp 2.0 移除了 mcp.server.fastmcp / mcp.shared.context.RequestContext 等符号, - # 而 langchain-mcp-adapters 0.3.x 仍硬导入它们,导致 octop 启动即 ImportError。 - # 先清掉旧版目录,防止多版本共存导致 Python 仍加载坏的 mcp 2.0。 rm -rf "$SP/mcp" "$SP"/mcp-*.dist-info \ "$SP/langchain_mcp_adapters" "$SP"/langchain_mcp_adapters-*.dist-info - # 用 pip download 拿到钉版 whl,再 unzip 进 site-packages。 - # 这比 pip install --target --force-reinstall 更可控,也不会触发 resolution。 mkdir -p /tmp/octop-pinned python3.12 -m pip download --no-deps --no-cache-dir -d /tmp/octop-pinned \ "mcp==1.28.1" "langchain-mcp-adapters==0.3.0" @@ -210,13 +211,9 @@ jobs: echo "Extracting pinned wheel: $whl" python3.12 -m zipfile -e "$whl" "$SP" done - # 把核心 whl 也内置进去,供飞牛主机离线重装/回滚使用。 cp dist/octop-*.whl "$SP/octop.whl" - # 校验:必须能走通真正的启动导入链(octop.launch → octop.infra.server → - # harness_agent.mcp → langchain_mcp_adapters)。仅 import octop 不会触发该链路, - # 故这里显式导入启动入口,确保 mcp/langchain-mcp-adapters 组合确实可用。 PYTHONPATH="$SP" python3.12 \ - -c "from octop.launch import run_foreground_blocking; import mcp, langchain_mcp_adapters; print('octop', octop.__version__, '| mcp', mcp.__version__, '| lcma', langchain_mcp_adapters.__version__)" + -c "import octop, mcp, langchain_mcp_adapters; from octop.launch import run_foreground_blocking; print('octop', octop.__version__, '| mcp', mcp.__version__, '| lcma', langchain_mcp_adapters.__version__)" } 2>&1 | tee -a "$LOG" - name: Build native FPK @@ -246,8 +243,7 @@ jobs: if-no-files-found: ignore release: - # 即使原生版失败(continue-on-error)也要发布 Docker 版; - # 原生版成功时 artifacts/native-fpk 存在,会被一并打包进发布。 + # 即使原生版失败(continue-on-error)也要发布 Docker 版。 if: always() needs: [fpk, native, version] runs-on: ubuntu-latest @@ -257,8 +253,6 @@ jobs: fetch-depth: 0 fetch-tags: true - # 分别按名称下载,单个大产物(native-fpk ~560MB)单独下载比「下载全部」更稳定, - # 避免 GitHub artifact 服务对“全部”模式偶发 5 次重试失败。 - name: Download docker fpk artifact (required) uses: actions/download-artifact@v4 with: @@ -267,7 +261,6 @@ jobs: if-no-files-found: error - name: Download native fpk artifact (best-effort) - # 原生版即使下载/解压偶发失败也不阻塞发布:Docker 版始终会发布。 continue-on-error: true uses: actions/download-artifact@v4 with: @@ -287,10 +280,7 @@ jobs: run: | VER="${{ needs.version.outputs.version }}" ITER="${{ needs.version.outputs.iter }}" - TAG="fnos-vanilla-${VER}-${ITER}" - # 找本分支历史中可达的最近固定版本 tag(排除滚动 tag)。 - # 不能直接 git describe:若历史 tag 误指向其它分支(如 main)的 commit, - # describe 会失败并回退到仓库根,导致更新说明把整个历史都列出来。 + IMAGE="${{ needs.version.outputs.image }}" PREV_TAG="" for t in $(git tag --list "fnos-vanilla-*" --sort=-creatordate); do [ "$t" = "fnos-vanilla-latest" ] && continue @@ -301,11 +291,10 @@ jobs: done echo "prev_tag=$PREV_TAG" >> "$GITHUB_OUTPUT" { - # 安装说明置顶,用户一眼看到下载文件 echo "## 安装说明" echo "" echo "1. 飞牛应用中心 → 设置 → 手动安装应用,选择对应 .fpk。" - echo "2. Docker 版 **Fnos-octop-vanilla-${VER}-${ITER}.fpk**(约 80KB):飞牛自动从 Docker Hub 拉取镜像运行,镜像为 \\"jubaoliang/octop:vanilla\\"。" + echo "2. Docker 版 **Fnos-octop-vanilla-${VER}-${ITER}.fpk**(约 80KB):飞牛自动从 GHCR 拉取 \`${IMAGE}:latest\`。" echo "3. 本地版 **Fnos-octop-vanilla-native-${VER}-${ITER}.fpk**(约 200MB):非 Docker,原生运行在飞牛主机;安装时自动关联系统 Python 3.12 开发工具;浏览器、远程桌面等附加组件在 Octop 应用内按需安装。" echo "" echo "---" @@ -313,7 +302,6 @@ jobs: echo "" echo "## 本次更新(v${VER}-${ITER})" echo "" - # 只列相对上一版的非 merge commit,最多 30 条,避免说明冗长 if [ -n "$PREV_TAG" ]; then git log --oneline --no-merges "$PREV_TAG"..HEAD | head -30 else @@ -322,15 +310,12 @@ jobs: } > /tmp/release-body.md cat /tmp/release-body.md - # --- 固定版本 release:保留历史 --- - name: Publish fixed release uses: softprops/action-gh-release@v2 with: tag_name: ${{ needs.version.outputs.tag }} - # 显式指向构建所用 commit,避免 GitHub 默认指向 main 分支 HEAD - # (此前 fnos-vanilla-* tag 全部错误指向 main 的 commit) target_commitish: ${{ github.sha }} - name: Octop (FnOS Vanilla) v${{ needs.version.outputs.pkg_ver }} + name: Octop (FnOS) v${{ needs.version.outputs.pkg_ver }} body_path: /tmp/release-body.md files: dist/*.fpk draft: false @@ -338,7 +323,6 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # --- 滚动 release fnos-vanilla-latest:始终指向最新版,只保留最新 assets --- - name: Delete rolling release if exists env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -359,7 +343,7 @@ jobs: with: tag_name: fnos-vanilla-latest target_commitish: ${{ github.sha }} - name: Octop (FnOS Vanilla) latest + name: Octop (FnOS) latest body_path: /tmp/release-body.md files: dist/*.fpk draft: false diff --git a/.github/workflows/octop-desktop.yml b/.github/workflows/octop-desktop.yml new file mode 100644 index 00000000..4a453911 --- /dev/null +++ b/.github/workflows/octop-desktop.yml @@ -0,0 +1,304 @@ +name: Octop Desktop Package + +# 正式发版:Auto Tag 在打 v* 后与 Release / Docker Publish 一并 dispatch(GITHUB_TOKEN +# 打的 tag 不会触发 push workflow)。产物由本 workflow 的 release job upsert 到同一 +# GitHub Release。workflow_dispatch 保留给手工补包 / 按平台重跑。 +# 不要对任意分支 push 跑六平台矩阵。 + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + platforms: + description: "Comma-separated plats, or 'all'" + required: true + default: "all" + type: string + attach_release: + description: "If running on a v* tag, also upload zips to that GitHub Release" + required: false + default: true + type: boolean + release_tag: + description: "GitHub Release tag to attach to (e.g. v0.9.27). Defaults to the current v* tag ref." + required: false + default: "" + type: string + attach_from_run: + description: "Existing workflow run ID — attach its Octop-* artifacts and skip the 6-platform rebuild" + required: false + default: "" + type: string + +permissions: + contents: read + +concurrency: + group: green-portable-${{ github.ref }} + cancel-in-progress: true + +env: + # Prefer GitHub upstream on Actions runners (npmmirror is for CN local builds). + PBS_BASE_URL: https://github.com/astral-sh/python-build-standalone/releases/download/20251209 + PBS_TAG: "20251209" + PBS_PY: "3.12.12" + +jobs: + frontend: + name: Build dashboard + if: github.event_name != 'workflow_dispatch' || github.event.inputs.attach_from_run == '' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-node@v5 + with: + node-version: "24" + cache: npm + cache-dependency-path: dashboard/package-lock.json + + - name: Build frontend → src/octop/dashboard + run: make build-frontend + + - uses: actions/upload-artifact@v5 + with: + name: dashboard-dist + path: src/octop/dashboard/ + if-no-files-found: error + retention-days: 7 + + package: + name: "${{ matrix.plat }}" + needs: frontend + runs-on: ${{ matrix.os }} + # Matrix runners are native for each plat; pin host detection so x64 Git Bash + # on windows-11-arm does not mis-classify the job as windows-amd64 cross-build. + env: + GREEN_HOST_PLAT: ${{ matrix.plat }} + strategy: + fail-fast: false + matrix: + include: + - plat: linux-amd64 + arch: amd64 + os: ubuntu-latest + - plat: linux-arm64 + arch: arm64 + os: ubuntu-24.04-arm + - plat: darwin-arm64 + arch: arm64 + os: macos-14 + - plat: darwin-amd64 + arch: amd64 + os: macos-15-intel + - plat: windows-amd64 + arch: amd64 + os: windows-latest + - plat: windows-arm64 + arch: arm64 + os: windows-11-arm + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v5 + + - name: Decide whether to build this platform + id: want + run: | + set -euo pipefail + sel="${{ github.event.inputs.platforms || 'all' }}" + sel="$(echo "$sel" | tr '[:upper:]' '[:lower:]' | tr -d ' ')" + plat="${{ matrix.plat }}" + if [[ "$sel" == "all" || ",$sel," == *",$plat,"* ]]; then + echo "build=true" >> "$GITHUB_OUTPUT" + else + echo "build=false" >> "$GITHUB_OUTPUT" + echo "Skipping ${plat} (selection=${sel})" + fi + + - uses: actions/download-artifact@v5 + if: steps.want.outputs.build == 'true' + with: + name: dashboard-dist + path: src/octop/dashboard + + - uses: astral-sh/setup-uv@v6 + if: steps.want.outputs.build == 'true' + with: + enable-cache: true + python-version: "3.12" + + # GitHub Cache outages must not fail the build — PBS download is cheap enough. + - name: Cache python-build-standalone downloads + if: steps.want.outputs.build == 'true' + continue-on-error: true + uses: actions/cache@v5 + with: + path: green/.cache + key: pbs-${{ env.PBS_TAG }}-${{ env.PBS_PY }}-${{ matrix.plat }} + + - name: Bootstrap portable CPython + if: steps.want.outputs.build == 'true' + env: + GREEN_HOST_PLAT: ${{ matrix.plat }} + run: bash desktop/portable/bootstrap-runtime.sh "${{ matrix.plat }}" + + - name: Assemble green zip + if: steps.want.outputs.build == 'true' + env: + GREEN_HOST_PLAT: ${{ matrix.plat }} + run: | + set -euo pipefail + echo "GREEN_HOST_PLAT=${GREEN_HOST_PLAT} RUNNER_ARCH=${RUNNER_ARCH:-} uname=$(uname -ms)" + bash desktop/portable/package.sh "${{ matrix.plat }}" + + - name: Smoke import (native host only) + if: steps.want.outputs.build == 'true' + run: | + set -euo pipefail + staging="desktop/portable/release/Octop-${{ matrix.plat }}" + if [[ -x "${staging}/runtime/bin/python3" ]]; then + py="${staging}/runtime/bin/python3" + elif [[ -f "${staging}/runtime/python.exe" ]]; then + py="${staging}/runtime/python.exe" + else + echo "python missing under ${staging}/runtime" >&2 + exit 1 + fi + req_file="desktop/portable/requirements-${{ matrix.plat }}.txt" + if [[ ! -f "$req_file" ]]; then + echo "frozen requirements missing: ${req_file}" >&2 + exit 1 + fi + verify_args=( + --packages "${staging}/packages" + --requirements "$req_file" + ) + override_file="desktop/portable/overrides-${{ matrix.plat }}.txt" + if [[ -f "$override_file" ]]; then + verify_args+=(--overrides "$override_file") + fi + PYTHONNOUSERSITE=1 \ + "$py" desktop/portable/verify_imports.py \ + "${verify_args[@]}" + # macOS: fail if any native extension linked Homebrew/MacPorts paths. + if [[ "${{ matrix.plat }}" == darwin-* ]]; then + REPO_ROOT="$PWD" # shellcheck source=desktop/portable/_common.sh + source desktop/portable/_common.sh + verify_no_homebrew_dylibs "${staging}/packages" "${{ matrix.plat }}" + fi + + - uses: actions/setup-go@v6 + if: steps.want.outputs.build == 'true' + with: + go-version: "1.25.x" + cache-dependency-path: desktop/src/go.sum + + - name: Install Linux desktop build dependencies + if: steps.want.outputs.build == 'true' && runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libgtk-4-dev libwebkitgtk-6.0-dev + + - name: Install Wails v3 CLI + if: steps.want.outputs.build == 'true' + run: go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-beta.13 + + - name: Package desktop app with bundled portable runtime + if: steps.want.outputs.build == 'true' + working-directory: desktop/src + run: >- + wails3 task package + ARCH=${{ matrix.arch }} + PORTABLE_ZIP=../portable/release/Octop-${{ matrix.plat }}.zip + + # archive: false — upload the prebuilt zip as-is. Default archive=true would + # wrap it again, so Actions UI / "Download artifact" becomes zip-in-zip + # (Octop-.zip containing another Octop-.zip). Affects every plat. + # With archive:false, artifact name is the filename (name: is ignored). + - uses: actions/upload-artifact@v7 + if: steps.want.outputs.build == 'true' + with: + path: desktop/portable/release/Octop-${{ matrix.plat }}.zip + archive: false + if-no-files-found: error + retention-days: 14 + + - name: Upload bundled desktop package + if: steps.want.outputs.build == 'true' + uses: actions/upload-artifact@v7 + with: + path: desktop/src/bin/Octop-Desktop-${{ matrix.plat }}.* + archive: false + if-no-files-found: error + retention-days: 14 + + release: + name: Attach zips to GitHub Release + needs: package + # always(): still run when package was skipped (attach_from_run rebuild skip). + if: >- + always() && + !cancelled() && + (needs.package.result == 'success' || needs.package.result == 'skipped') && + ( + (github.event_name == 'workflow_dispatch' && + github.event.inputs.attach_from_run != '') || + (startsWith(github.ref, 'refs/tags/v') && + (github.event_name == 'push' || + (github.event_name == 'workflow_dispatch' && + github.event.inputs.attach_release == 'true'))) + ) + runs-on: ubuntu-latest + permissions: + contents: write + actions: read + steps: + - name: Resolve release tag + id: rel + run: | + set -euo pipefail + tag="${{ github.event.inputs.release_tag }}" + if [[ -z "$tag" && "${GITHUB_REF}" == refs/tags/v* ]]; then + tag="${GITHUB_REF_NAME}" + fi + if [[ "$tag" != v* ]]; then + echo "Need a v* tag (run on a version tag, or pass release_tag)." >&2 + exit 1 + fi + echo "tag=${tag}" >> "$GITHUB_OUTPUT" + + # v8 required for archive:false artifacts. skip-decompress keeps the + # uploaded .zip / .tar / .dmg / .exe intact — default unzip turns portable + # zips into directories, so files: release-assets/* would skip them. + - uses: actions/download-artifact@v8 + with: + pattern: Octop-* + path: release-assets + merge-multiple: true + skip-decompress: true + repository: ${{ github.repository }} + run-id: ${{ github.event.inputs.attach_from_run || github.run_id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: List release assets + run: | + set -euo pipefail + ls -lh release-assets/ + if find release-assets -mindepth 1 -maxdepth 1 -type d | grep -q .; then + echo "Download extracted archives into directories; refuse incomplete attach." >&2 + find release-assets -mindepth 1 -maxdepth 1 -print + exit 1 + fi + + - name: Upload to GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.rel.outputs.tag }} + files: release-assets/* + fail_on_unmatched_files: true + # Upsert: works whether Release workflow already created the release. + generate_release_notes: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index de83ed74..48fbe6b1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,12 @@ name: Release +# 版本标签 v* → 测包、发 PyPI、建 GitHub Release,再级联: +# - Sync Main Into Develop +# - Build Octop FPK(复用本 Release 的 wheel;等待 docker-publish 的 GHCR 镜像) +# +# 与 docker-publish.yml / octop-desktop.yml 并行(由 Auto Tag 同时 dispatch, +# 或人工 push v* tag 同时触发)。桌面产物由 Desktop Package 挂到同一 GitHub Release。 + on: push: tags: @@ -20,6 +27,7 @@ jobs: build: name: Build distributions runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') steps: - uses: actions/checkout@v4 with: @@ -141,3 +149,20 @@ jobs: gh workflow run sync-main-to-develop.yml \ --repo "${{ github.repository }}" \ --ref main + + # FnOS FPK:复用本 Release 的 wheel + docker-publish 的 GHCR 镜像。 + # 与 sync-develop 并行;失败不阻断发版。 + trigger-fnos: + name: Trigger FnOS FPK build + needs: github-release + runs-on: ubuntu-latest + continue-on-error: true + steps: + - name: Dispatch Build Octop FPK + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # --ref 用版本 tag,保证 FPK 打在与 PyPI / 镜像相同的 commit 上 + gh workflow run fnos-build-fpk.yml \ + --repo "${{ github.repository }}" \ + --ref "${{ github.ref_name }}" diff --git a/CHANGELOG.md b/CHANGELOG.md index 13f16369..d02a7d66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ ## [Unreleased] +### 变更 + +- Docker / FnOS 镜像对外统一为 `ghcr.io/tencentcloud/octop`(仍同步推送 Docker Hub;移除 vanilla 镜像标签) +- FnOS FPK 在 Release 成功后自动构建:复用 GHCR 镜像与 Release wheel,不再重复编镜像 +- 正式发版时 Auto Tag 同步触发桌面客户端打包,产物挂到同一 `v*` GitHub Release(不再对任意分支 push 跑六平台矩阵) +- 修复 Auto Tag → `workflow_dispatch` 时 Docker 镜像可能只有 `:latest`、缺少 `:{version}` 的问题(显式从 tag 解析版本) + ### 修复 - 合并冲突的双份 schema v10 迁移:投影表不再被 `010_kb_max_documents` 抢先抬版本而跳过;启动时幂等补建 `thread_messages` / `thread_history_projection` diff --git a/fnos/README.md b/fnos/README.md index 2dfde019..90f534e5 100644 --- a/fnos/README.md +++ b/fnos/README.md @@ -19,10 +19,10 @@ After install, open the app (Docker: `http://:8088`, native: `http:// | 版本 | 包名 | 体积 | 运行方式 | 依赖 | |------|------|------|----------|------| -| **Docker 版** | `octop-.fpk` | ~8 KB | 飞牛自动从 Docker Hub 拉取 `jubaoliang/octop:latest` 镜像运行 | 宿主需有 Docker 运行时 | +| **Docker 版** | `octop-.fpk` | ~8 KB | 飞牛自动从 GHCR 拉取 `ghcr.io/tencentcloud/octop:latest` 镜像运行 | 宿主需有 Docker 运行时 | | **本地版(非 Docker)** | `octop-native-.fpk` | ~560 MB | 自带 Python 3.12 运行时 + 前端 + 全部附加组件 + Chromium,原生运行在飞牛主机 | 无需 Docker | -- **Docker 版**实现为 FnOS `docker-project`:包体只含 `docker-compose.yaml` 与向导配置,运行时由飞牛从 Docker Hub 拉取镜像。镜像已内置全部附加组件(`browser` 浏览器自动化 + `desktop` 桌面控制)与前端。 +- **Docker 版**实现为 FnOS `docker-project`:包体只含 `docker-compose.yaml` 与向导配置,运行时由飞牛从 GHCR 拉取镜像。镜像已内置全部附加组件(`browser` 浏览器自动化 + `desktop` 桌面控制)与前端。 - **本地版**实现为 FnOS 原生 `app`:包内自带独立 Python 3.12 运行时、Octop 全部依赖、前端构建产物、Playwright Chromium,以及 `data-share` 共享数据目录,直接以进程方式运行,不依赖 Docker。 > 两款包都在滚动发布 **`fnos-latest`**: @@ -44,7 +44,7 @@ fnos/ │ │ └── install # 安装向导(可配置管理员账号/密码、日志级别、LLM 密钥) │ ├── app/ │ │ ├── docker/ -│ │ │ └── docker-compose.yaml # 引用 jubaoliang/octop:latest +│ │ │ └── docker-compose.yaml # 引用 ghcr.io/tencentcloud/octop:latest │ │ └── ui/ │ │ ├── config # 桌面图标入口 │ │ └── images/icon-{64,256}.png @@ -63,9 +63,10 @@ fnos/ ## 工作机制 -1. **源码同步**:`.github/workflows/zz-sync-upstream.yml` 每 6 小时把上游 `TencentCloud/Octop` 的更新合并进本仓 `main` 分支(使用仓库自带 `GITHUB_TOKEN`,无需 PAT)。 -2. **镜像构建**:`.github/workflows/fnos-build-fpk.yml` 的 `image` job 在 `main` 更新时,用 `fnos/docker/Dockerfile` 从仓库源码构建 Octop 镜像并推送到 Docker Hub `jubaoliang/octop:latest`(含全部附加组件)。 -3. **安装包构建**:同一 workflow 的 `fpk` job 用 `scripts/build-fpk.sh` 把 `fnos/docker/` 打成 Docker 版 `.fpk`;`native` job 用 python-build-standalone 构建 Python 3.12 运行时、安装全部依赖与 Playwright Chromium,把 `fnos/native/` 打成本地版 `.fpk`(`continue-on-error`,失败不阻塞 Docker 版)。两者均以滚动发布 `fnos-latest` 提供下载。 +1. **发版链路**:`v*` tag → `release.yml`(PyPI + GitHub Release)与 `docker-publish.yml`(GHCR / Hub)并行;Release 成功后自动 `workflow_dispatch` 本工作流。 +2. **镜像复用**:不再重新 build 镜像;`ensure-image` 轮询等待 `ghcr.io/tencentcloud/octop:{version}`(由 docker-publish 推送)。Docker 版 `.fpk` 仅打包 compose,运行时拉取该镜像。 +3. **Wheel 复用**:Native 版优先从同版本 GitHub Release(`v*`)下载 `octop-*.whl`;若缺失再回退源码构建前端 + wheel。 +4. **安装包构建**:`fpk` / `native` job 用 `scripts/build-fpk.sh` 打包,并以滚动发布 `fnos-vanilla-latest` 提供下载。 ## 本地构建 .fpk(无需 Docker) @@ -84,6 +85,6 @@ bash scripts/build-fpk.sh native # 仅本地版 → dist/octop-native-< - 不想依赖 Docker、希望自带运行时原生运行 → 选 `octop-native-.fpk` 2. 安装向导中设置管理员账号/密码、日志级别、LLM 密钥(可选)。 3. 安装完成后桌面出现「Octop AI 助手」图标,浏览器打开 `http://<设备IP>:8088`。 -4. Docker 版镜像首次会从 Docker Hub `jubaoliang/octop:latest` 拉取;请确保该镜像为公开(workflow 已自动设为 public)。本地版无需联网拉镜像,首次启动会按需要补装 Chromium 系统库(需 root 权限,已尽力处理)。 +4. Docker 版镜像首次会从 GHCR `ghcr.io/tencentcloud/octop:latest` 拉取;请确保该包为 Public(首次推送后可在 GitHub Packages 设置)。本地版无需联网拉镜像,首次启动会按需要补装 Chromium 系统库(需 root 权限,已尽力处理)。 > 服务端口固定为 `8088`(飞牛端口映射与桌面图标均据此)。附加组件(browser 浏览器自动化 + desktop 桌面控制)已在镜像中默认安装。 diff --git a/fnos/docker/Dockerfile b/fnos/docker/Dockerfile index fabbfc4d..4de37b74 100644 --- a/fnos/docker/Dockerfile +++ b/fnos/docker/Dockerfile @@ -7,7 +7,7 @@ # - desktop (mss / pynput / pillow,桌面控制附加组件) # # 构建上下文必须为仓库根目录(fnpack / CI 中以仓库根为 context): -# docker build -f fnos/docker/Dockerfile -t jubaoliang/octop:latest . +# docker build -f fnos/docker/Dockerfile -t ghcr.io/tencentcloud/octop:latest . # # 国内加速(可选 build-arg): # PIP_INDEX_URL / PIP_TRUSTED_HOST / NPM_REGISTRY / APT_MIRROR diff --git a/fnos/docker/app/docker/docker-compose.yaml b/fnos/docker/app/docker/docker-compose.yaml index c3ef0082..4c4510d1 100644 --- a/fnos/docker/app/docker/docker-compose.yaml +++ b/fnos/docker/app/docker/docker-compose.yaml @@ -1,6 +1,6 @@ services: octop: - image: jubaoliang/octop:latest + image: ghcr.io/tencentcloud/octop:latest container_name: octop restart: unless-stopped pull_policy: always diff --git a/fnos/docker/manifest b/fnos/docker/manifest index 7db0b030..7f6e0e8b 100644 --- a/fnos/docker/manifest +++ b/fnos/docker/manifest @@ -1,7 +1,7 @@ appname=octop version=0.9.15 display_name=OCTOP -desc=腾讯开源的多用户多智能体 AI 助手平台,支持 Web 控制台、CLI、IM 渠道与定时任务,数据全部本地存储。

安装后使用:
在应用中心点击「打开」按钮即可进入 Web 控制台,默认地址 http://设备IP:8088。
初始管理员账号:admin,初始密码:Octop123(首次登录后请在头像菜单修改密码)。

首次登录后:
请在控制台「设置」中配置 LLM 模型、IM 渠道、远程桌面、浏览器等组件。

注意:Docker 版通过容器部署,会自动从 jubaoliang/octop:latest 拉取镜像运行,已预装 browser / desktop 附加组件。 +desc=腾讯开源的多用户多智能体 AI 助手平台,支持 Web 控制台、CLI、IM 渠道与定时任务,数据全部本地存储。

安装后使用:
在应用中心点击「打开」按钮即可进入 Web 控制台,默认地址 http://设备IP:8088。
初始管理员账号:admin,初始密码:Octop123(首次登录后请在头像菜单修改密码)。

首次登录后:
请在控制台「设置」中配置 LLM 模型、IM 渠道、远程桌面、浏览器等组件。

注意:Docker 版通过容器部署,会自动从 ghcr.io/tencentcloud/octop:latest 拉取镜像运行,已预装 browser / desktop 附加组件。 source=thirdparty platform=all maintainer=TencentCloud OrcaKit From 94375d12f7648b254af0de6071a10c4a5caaf96e Mon Sep 17 00:00:00 2001 From: jubaoliang Date: Thu, 27 Aug 2026 19:50:13 +0800 Subject: [PATCH 09/28] chore: sync develop onto main after accidental main merges (#464) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(desktop): Octop 桌面客户端 (#462) * feat: add multi-platform green portable packaging Ship relocatable Octop-.zip via scripts/green (portable CPython, frozen uv.lock deps, Windows pywin32 DLL fix) and octop-portable.yml. No OOB overlay or product trimming; first run uses upstream setup. Co-authored-by: Harry * feat(desktop): Wails v3 shell with green portable under ~/.octop Move green packaging to desktop/portable, add a Wails v3 client that downloads Octop-.zip, spawns launch.py with OCTOP_HOME=~/.octop, and exposes locale/theme/autostart/tray/macOS caffeinate settings. Green upgrades install into packages via OCTOP_GREEN_PACKAGES. Co-authored-by: Harry * fix(desktop): add Wails v3 Taskfile and build/config.yml wails3 build/dev look for Taskfile.yml and build/config.yml in desktop/src. Un-ignore that tree from the repo-wide build/ gitignore. Co-authored-by: Harry * 初始化桌面客户端 * add drag and toggle * add prevent sleep * update * change dock icon * clean package/useless files * add Attach zips to GitHub Release * pack file into windows bin-file * Packages a production app * chanage workflow name * chanage workflow name * linux package in single ifle * linux package in single ifle * 在浏览器中打开_blank 的链接 * upgrade python to 3.12 --------- Co-authored-by: Cursor Agent Co-authored-by: Harry Co-authored-by: 杨树海 * ci: cascade desktop package on release and stop all-branch builds (#463) Auto Tag dispatches Octop Desktop with Release/Docker. Restrict octop-desktop.yml to v* tags + workflow_dispatch only. Co-authored-by: jubaoliang --------- Co-authored-by: Harry <56063441+forcemeter@users.noreply.github.com> Co-authored-by: Cursor Agent Co-authored-by: Harry Co-authored-by: 杨树海 Co-authored-by: jubaoliang --- .gitignore | 2 + dashboard/src/App.tsx | 3 + dashboard/src/utils/desktopExternalLinks.ts | 113 +++++ desktop/README.md | 82 ++++ desktop/package-release.sh | 116 +++++ desktop/portable/.gitignore | 6 + .../portable/AGENT_ELECTRON_INTEGRATION.md | 75 +++ desktop/portable/Makefile | 55 +++ desktop/portable/README.md | 116 +++++ desktop/portable/_common.sh | 286 +++++++++++ desktop/portable/bootstrap-runtime.sh | 89 ++++ desktop/portable/package-linux-docker.sh | 49 ++ desktop/portable/package.sh | 291 ++++++++++++ desktop/portable/rebuild.sh | 20 + desktop/portable/templates/README.txt | 37 ++ desktop/portable/templates/launch.py | 61 +++ desktop/portable/templates/start.bat | 88 ++++ desktop/portable/templates/start.sh | 79 ++++ desktop/portable/vendor-wheels.sh | 63 +++ desktop/portable/verify_imports.py | 140 ++++++ desktop/src/.gitignore | 8 + desktop/src/Taskfile.yml | 33 ++ desktop/src/assets/index.html | 447 ++++++++++++++++++ desktop/src/assets/octop-mascot-peek.webp | Bin 0 -> 249002 bytes desktop/src/assets/octop-mascot-type.webp | Bin 0 -> 378064 bytes desktop/src/assets/tray-icon.png | Bin 0 -> 12269 bytes desktop/src/build/Taskfile.yml | 50 ++ desktop/src/build/appicon.png | Bin 0 -> 131241 bytes desktop/src/build/config.yml | 45 ++ desktop/src/build/darwin/Info.dev.plist | 35 ++ desktop/src/build/darwin/Info.plist | 33 ++ desktop/src/build/darwin/Taskfile.yml | 65 +++ desktop/src/build/linux/Taskfile.yml | 50 ++ desktop/src/build/windows/Taskfile.yml | 74 +++ desktop/src/build/windows/info.json | 15 + desktop/src/build/windows/wails.exe.manifest | 22 + desktop/src/bundled/.gitkeep | 0 desktop/src/cmd/devserver/main.go | 21 + desktop/src/download.go | 195 ++++++++ desktop/src/download_test.go | 128 +++++ desktop/src/embed_portable.go | 7 + desktop/src/embed_portable_embedded.go | 11 + desktop/src/go.mod | 18 + desktop/src/go.sum | 35 ++ desktop/src/main.go | 418 ++++++++++++++++ desktop/src/openurl.go | 157 ++++++ desktop/src/preventsleep.go | 34 ++ desktop/src/preventsleep_darwin.go | 21 + desktop/src/preventsleep_linux.go | 47 ++ desktop/src/preventsleep_other.go | 9 + desktop/src/preventsleep_windows.go | 27 ++ desktop/src/process.go | 56 +++ desktop/src/process_unix.go | 17 + desktop/src/process_windows.go | 31 ++ desktop/src/settings.go | 128 +++++ desktop/src/settings_test.go | 23 + src/octop/infra/setup/self_update.py | 25 +- tests/unit/cli/test_update_cmd.py | 30 ++ tests/unit/test_green_launch.py | 56 +++ 59 files changed, 4139 insertions(+), 3 deletions(-) create mode 100644 dashboard/src/utils/desktopExternalLinks.ts create mode 100644 desktop/README.md create mode 100755 desktop/package-release.sh create mode 100644 desktop/portable/.gitignore create mode 100644 desktop/portable/AGENT_ELECTRON_INTEGRATION.md create mode 100644 desktop/portable/Makefile create mode 100644 desktop/portable/README.md create mode 100644 desktop/portable/_common.sh create mode 100755 desktop/portable/bootstrap-runtime.sh create mode 100755 desktop/portable/package-linux-docker.sh create mode 100755 desktop/portable/package.sh create mode 100755 desktop/portable/rebuild.sh create mode 100644 desktop/portable/templates/README.txt create mode 100644 desktop/portable/templates/launch.py create mode 100644 desktop/portable/templates/start.bat create mode 100755 desktop/portable/templates/start.sh create mode 100755 desktop/portable/vendor-wheels.sh create mode 100755 desktop/portable/verify_imports.py create mode 100644 desktop/src/.gitignore create mode 100644 desktop/src/Taskfile.yml create mode 100644 desktop/src/assets/index.html create mode 100644 desktop/src/assets/octop-mascot-peek.webp create mode 100644 desktop/src/assets/octop-mascot-type.webp create mode 100644 desktop/src/assets/tray-icon.png create mode 100644 desktop/src/build/Taskfile.yml create mode 100644 desktop/src/build/appicon.png create mode 100644 desktop/src/build/config.yml create mode 100644 desktop/src/build/darwin/Info.dev.plist create mode 100644 desktop/src/build/darwin/Info.plist create mode 100644 desktop/src/build/darwin/Taskfile.yml create mode 100644 desktop/src/build/linux/Taskfile.yml create mode 100644 desktop/src/build/windows/Taskfile.yml create mode 100644 desktop/src/build/windows/info.json create mode 100644 desktop/src/build/windows/wails.exe.manifest create mode 100644 desktop/src/bundled/.gitkeep create mode 100644 desktop/src/cmd/devserver/main.go create mode 100644 desktop/src/download.go create mode 100644 desktop/src/download_test.go create mode 100644 desktop/src/embed_portable.go create mode 100644 desktop/src/embed_portable_embedded.go create mode 100644 desktop/src/go.mod create mode 100644 desktop/src/go.sum create mode 100644 desktop/src/main.go create mode 100644 desktop/src/openurl.go create mode 100644 desktop/src/preventsleep.go create mode 100644 desktop/src/preventsleep_darwin.go create mode 100644 desktop/src/preventsleep_linux.go create mode 100644 desktop/src/preventsleep_other.go create mode 100644 desktop/src/preventsleep_windows.go create mode 100644 desktop/src/process.go create mode 100644 desktop/src/process_unix.go create mode 100644 desktop/src/process_windows.go create mode 100644 desktop/src/settings.go create mode 100644 desktop/src/settings_test.go create mode 100644 tests/unit/test_green_launch.py diff --git a/.gitignore b/.gitignore index 3a4491df..779e5c26 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,8 @@ __pycache__/ *.so .Python build/ +!desktop/src/build/ +!desktop/src/build/** dist/ *.egg-info/ *.egg diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index c368d618..5c686478 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -19,6 +19,7 @@ import { LayoutModeProvider } from "./context/LayoutModeContext"; import { VoiceOutputProvider } from "./context/VoiceOutputContext"; import { useIsMobile } from "./hooks/useIsMobile"; import { useUnauthorizedRedirect } from "./hooks/useUnauthorizedRedirect"; +import { installDesktopExternalLinks } from "./utils/desktopExternalLinks"; import { brandTokensFor } from "./styles/themePalettes"; import "./styles/theme-vars.css"; import "./styles/layout.css"; @@ -44,6 +45,8 @@ function ThemedApp() { useUnauthorizedRedirect(); + useEffect(() => installDesktopExternalLinks(), []); + // Set document title based on current language useEffect(() => { document.title = t("app.pageTitle"); diff --git a/dashboard/src/utils/desktopExternalLinks.ts b/dashboard/src/utils/desktopExternalLinks.ts new file mode 100644 index 00000000..88bdd469 --- /dev/null +++ b/dashboard/src/utils/desktopExternalLinks.ts @@ -0,0 +1,113 @@ +const INSTALL_FLAG = "__OCTOP_EXTERNAL_LINKS_INSTALLED__"; +const OPEN_URL_EVENT_PREFIX = "desktop:open-url:"; + +type DesktopWindow = Window & { + __OCTOP_EXTERNAL_LINKS_INSTALLED__?: boolean; + _wails?: { invoke?: (message: string) => void }; +}; + +export function isDesktopExternalURL( + raw: string, + base = window.location.href, +): boolean { + try { + const parsed = new URL(raw, base); + const scheme = parsed.protocol.replace(":", "").toLowerCase(); + if (scheme === "mailto") { + return Boolean(parsed.pathname || parsed.href.slice("mailto:".length)); + } + return scheme === "http" || scheme === "https"; + } catch { + return false; + } +} + +function wailsInvoke(): ((message: string) => void) | undefined { + const invoke = (window as DesktopWindow)._wails?.invoke; + return typeof invoke === "function" ? invoke : undefined; +} + +let lastUrl = ""; +let lastAt = 0; + +function openExternal(url: string): boolean { + const invoke = wailsInvoke(); + if (!invoke || !isDesktopExternalURL(url)) return false; + const now = Date.now(); + if (url === lastUrl && now - lastAt < 800) return true; + lastUrl = url; + lastAt = now; + invoke("wails:event:emit:" + OPEN_URL_EVENT_PREFIX + encodeURIComponent(url)); + return true; +} + +function linkFromEvent(event: Event): HTMLAnchorElement | null { + const raw = event.target; + const node = + raw instanceof Element + ? raw + : raw instanceof Node + ? raw.parentElement + : null; + if (!node) return null; + const link = node.closest("a[href][target]"); + if (!(link instanceof HTMLAnchorElement)) return null; + if (link.target.toLowerCase() !== "_blank") return null; + if (link.hasAttribute("download")) return null; + return link; +} + +function onActivate(event: Event): void { + if (event instanceof MouseEvent && event.button !== 0) return; + const link = linkFromEvent(event); + if (!link) return; + if (!openExternal(link.href)) return; + event.preventDefault(); +} + +function patchWindowOpen(): () => void { + const original = window.open.bind(window); + window.open = ((url?: string | URL, target?: string, features?: string) => { + const href = url == null ? "" : String(url); + const name = target == null ? "_blank" : String(target); + if (href && name.toLowerCase() === "_blank" && openExternal(href)) { + return null; + } + return original(url, target, features); + }) as typeof window.open; + return () => { + window.open = original; + }; +} + +export function tryInstallDesktopExternalLinks(): (() => void) | undefined { + const w = window as DesktopWindow; + if (!wailsInvoke() || w[INSTALL_FLAG]) return undefined; + w[INSTALL_FLAG] = true; + document.addEventListener("click", onActivate, true); + document.addEventListener("pointerdown", onActivate, true); + const restoreOpen = patchWindowOpen(); + return () => { + document.removeEventListener("click", onActivate, true); + document.removeEventListener("pointerdown", onActivate, true); + restoreOpen(); + w[INSTALL_FLAG] = false; + }; +} + +export function installDesktopExternalLinks(): () => void { + let cancelled = false; + let uninstall: (() => void) | undefined; + const tryInstall = () => { + if (cancelled || uninstall) return; + uninstall = tryInstallDesktopExternalLinks(); + }; + tryInstall(); + const timer = window.setInterval(tryInstall, 250); + window.setTimeout(() => window.clearInterval(timer), 12_000); + return () => { + cancelled = true; + window.clearInterval(timer); + uninstall?.(); + }; +} diff --git a/desktop/README.md b/desktop/README.md new file mode 100644 index 00000000..b8f2873f --- /dev/null +++ b/desktop/README.md @@ -0,0 +1,82 @@ +# Octop desktop (Wails v3 + green portable) + +All desktop-client code lives here. This is **not** `src/octop/infra/desktop` +(remote desktop streaming). + +| Path | Role | +|------|------| +| [`portable/`](portable/) | Green zip packaging (was `scripts/green/`) | +| [`src/`](src/) | Wails v3 shell: load bundled zip, spawn Octop, tray/settings | +| [`package-release.sh`](package-release.sh) | Native end-to-end portable + Wails release build | + +## Data directory + +Same as the Octop CLI/server default: + +- `OCTOP_HOME` → `~/.octop` (or the existing `OCTOP_HOME` env) +- Green runtime extract → `~/.octop/portable/` +- Shell prefs → `~/.octop/desktop-settings.json` + +## Build green zip + +From repo root: + +```bash +make -f desktop/portable/Makefile green +``` + +CI: `.github/workflows/octop-portable.yml` builds all six native platform/arch +variants. Each job first creates the green zip and then packages the matching +Wails application. + +## Build a complete desktop release + +Run the end-to-end script on the matching native host. It builds the Dashboard, +creates and verifies the portable runtime, embeds it into Wails, and produces +the final native package: + +```bash +desktop/package-release.sh +# Reuse an existing desktop/portable/release/Octop-.zip: +desktop/package-release.sh darwin-arm64 --reuse-portable +``` + +Wails requires native packaging, so all six variants are produced by the CI +matrix on macOS, Linux, and Windows runners rather than cross-compiled locally. + +## Build the Wails shell + +Run these from **`desktop/src`** (that directory contains `Taskfile.yml` and +`build/config.yml`). Requires **Go 1.25+**, [Wails v3](https://v3.wails.io/) +`v3.0.0-beta.13`. + +```bash +go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-beta.13 +cd desktop/src +go mod tidy +wails3 build # development binary under desktop/src/bin/ +wails3 task package ARCH=arm64 \ + PORTABLE_ZIP=../portable/release/Octop-darwin-arm64.zip +``` + +Dev against an already-running Octop (skips the bundled green zip): + +```bash +cd desktop/src +OCTOP_DESKTOP_URL=http://127.0.0.1:8088 wails3 dev +``` + +Without `OCTOP_DESKTOP_URL`, first launch uses `~/.octop/portable/` if valid, +otherwise extracts the matching zip shipped with the desktop package (embedded +in the Windows and Linux binaries, under `Contents/Resources` on macOS). The +Wails shell never downloads Octop. For local runtime debugging, set +`OCTOP_DESKTOP_PORTABLE_ZIP=/absolute/path/Octop-.zip`. + +Desktop outputs are native GUI packages: `Octop-Desktop-.dmg` (macOS), +`Octop-Desktop-.exe` (Windows), and `Octop-Desktop-.tar` (Linux). +The Linux tar contains only the GUI binary; it has no separate portable zip or +server terminal process. Runtime upgrades remain owned by Octop: +the shell sets `OCTOP_GREEN_PACKAGES`, so `octop update` upgrades the extracted +`packages/` directory through Octop's existing `--target` logic. + +Linux also needs GTK4 + WebKitGTK 6 to link. macOS 12+. diff --git a/desktop/package-release.sh b/desktop/package-release.sh new file mode 100755 index 00000000..7d3e99ea --- /dev/null +++ b/desktop/package-release.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# Build one native Octop desktop release: Dashboard → portable runtime → Wails package. +# Run once per native platform; the GitHub Actions matrix runs all six variants. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=desktop/portable/_common.sh +source "${REPO_ROOT}/desktop/portable/_common.sh" + +if ! command -v npm >/dev/null 2>&1 && [[ -s "${HOME}/.nvm/nvm.sh" ]]; then + # shellcheck disable=SC1091 + source "${HOME}/.nvm/nvm.sh" + nvm use 24 >/dev/null +fi + +usage() { + cat <<'EOF' +Usage: desktop/package-release.sh [platform] [--reuse-portable] + +Platforms: + darwin-arm64 darwin-amd64 linux-arm64 linux-amd64 + windows-arm64 windows-amd64 + +The platform defaults to the current native host. --reuse-portable skips +rebuilding desktop/portable/release/Octop-.zip when it already exists. +EOF +} + +plat="" +reuse_portable=0 +for arg in "$@"; do + case "$arg" in + --reuse-portable) reuse_portable=1 ;; + -h|--help) usage; exit 0 ;; + -*) echo "unknown option: $arg" >&2; usage >&2; exit 2 ;; + *) + if [[ -n "$plat" ]]; then + echo "only one platform may be specified" >&2 + exit 2 + fi + plat="$arg" + ;; + esac +done + +if [[ -z "$plat" ]]; then + plat="$(host_plat)" +fi +if ! is_known_plat "$plat"; then + echo "unknown platform: $plat" >&2 + exit 2 +fi +host="$(host_plat)" +if [[ "$plat" != "$host" ]]; then + echo "Wails release packages require a native runner: requested=${plat}, host=${host}" >&2 + exit 2 +fi +if ! command -v wails3 >/dev/null 2>&1; then + echo "wails3 is required: go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-beta.13" >&2 + exit 1 +fi + +arch="${plat##*-}" +portable_zip="${REPO_ROOT}/desktop/portable/release/Octop-${plat}.zip" + +echo "[desktop-release] platform=${plat}" +echo "[desktop-release] building Dashboard" +make -C "$REPO_ROOT" build-frontend + +if [[ "$reuse_portable" == 1 && -f "$portable_zip" ]]; then + echo "[desktop-release] reusing ${portable_zip}" +else + echo "[desktop-release] building portable runtime" + bash "${REPO_ROOT}/desktop/portable/bootstrap-runtime.sh" "$plat" + bash "${REPO_ROOT}/desktop/portable/package.sh" "$plat" +fi + +staging="${REPO_ROOT}/desktop/portable/release/Octop-${plat}" +requirements="${REPO_ROOT}/desktop/portable/requirements-${plat}.txt" +if [[ "$plat" == windows-* ]]; then + portable_python="${staging}/runtime/python.exe" +else + portable_python="${staging}/runtime/bin/python3" +fi +if [[ ! -f "$portable_python" || ! -f "$requirements" ]]; then + echo "portable staging is incomplete for ${plat}" >&2 + exit 1 +fi + +verify_args=(--packages "${staging}/packages" --requirements "$requirements") +override_file="${REPO_ROOT}/desktop/portable/overrides-${plat}.txt" +if [[ -f "$override_file" ]]; then + verify_args+=(--overrides "$override_file") +fi +echo "[desktop-release] verifying portable imports" +PYTHONNOUSERSITE=1 "$portable_python" \ + "${REPO_ROOT}/desktop/portable/verify_imports.py" "${verify_args[@]}" + +echo "[desktop-release] packaging Wails application" +( + cd "${REPO_ROOT}/desktop/src" + wails3 task package "ARCH=${arch}" "PORTABLE_ZIP=${portable_zip}" +) + +case "$plat" in + darwin-*) output="${REPO_ROOT}/desktop/src/bin/Octop-Desktop-${plat}.dmg" ;; + windows-*) output="${REPO_ROOT}/desktop/src/bin/Octop-Desktop-${plat}.exe" ;; + linux-*) output="${REPO_ROOT}/desktop/src/bin/Octop-Desktop-${plat}.tar" ;; +esac +if [[ ! -s "$output" ]]; then + echo "desktop release was not created: ${output}" >&2 + exit 1 +fi + +echo "[desktop-release] complete" +echo "$output" diff --git a/desktop/portable/.gitignore b/desktop/portable/.gitignore new file mode 100644 index 00000000..9829673b --- /dev/null +++ b/desktop/portable/.gitignore @@ -0,0 +1,6 @@ +/.cache/ +/release/ +/runtimes/ +/wheels/ +/requirements-*.txt +/overrides-*.txt diff --git a/desktop/portable/AGENT_ELECTRON_INTEGRATION.md b/desktop/portable/AGENT_ELECTRON_INTEGRATION.md new file mode 100644 index 00000000..103f2ceb --- /dev/null +++ b/desktop/portable/AGENT_ELECTRON_INTEGRATION.md @@ -0,0 +1,75 @@ +# Octop Electron green-package integration + +This document is the contract for embedding Octop's **green portable zip** +in an Electron (or other) desktop shell. Do not invent a parallel install +path (no system Python, no `PYTHONPATH=packages`). + +## Artifact + +CI / `make -f desktop/portable/Makefile green` produces: + +``` +desktop/portable/release/Octop-.zip +``` + +Platforms: `darwin-arm64` `darwin-amd64` `linux-amd64` `linux-arm64` +`windows-amd64` `windows-arm64`. + +Layout after extract (outside asar): + +``` +Octop-/ + runtime/ portable CPython + packages/ site-packages + launch.py + start.sh / start.bat +``` + +## Spawn + +1. Ship the matching zip with the desktop application. The Wails packages embed + it in the Windows `.exe`, place it under `Contents/Resources` (macOS), or + beside the executable (Linux); the desktop shell does not download it at runtime. +2. Extract to a writable user directory **outside** `app.asar`. +3. On macOS, after checksum: `xattr -dr com.apple.quarantine `. +4. Set `OCTOP_HOME` to Octop's default data dir (`~/.octop`, or `$OCTOP_HOME` + if already set). Do **not** use the zip's `./data` folder when launching + from the Wails desktop shell (`desktop/src`). + Extract the zip under `~/.octop/portable/` so runtime files stay next to + user data without overwriting `octop.db`. +5. Set `PYTHONNOUSERSITE=1` and `OCTOP_GREEN_PACKAGES=/packages`. + **Do not set `PYTHONPATH`.** + +6. Spawn: + + - macOS / Linux: `/runtime/bin/python3 /launch.py run --host 127.0.0.1 --port ` + - Windows: `/runtime/python.exe /launch.py run --host 127.0.0.1 --port ` + +7. Poll `http://127.0.0.1:/api/health` until ready, then load the + Dashboard (`http://127.0.0.1:/`). +8. First run uses the **normal Octop setup wizard** (create admin). The + green zip does not skip setup or mint loopback sessions. +9. On quit, kill the process **tree** (Windows: taskkill `/T`; POSIX: process group). + +## Windows / pywintypes + +`No module named pywintypes` means: + +1. The process did not start via `launch.py` (plain `PYTHONPATH=packages` + skips `.pth` processing). +2. `packages\pywin32_system32\pywintypes*.dll` is missing; a current + package copies those DLLs into `runtime\`. +3. Workaround for an old zip only: `pip install --target packages pywin32` + then copy DLLs next to `python.exe`. Do not use `%CD%` in PowerShell. + +## Health + +`GET /api/health` is the ready signal. Do not scrape stdout for “ready”. + +## What not to do + +- Do not bundle the zip inside asar (native libs and the interpreter must + be real files). +- Do not use the system Python or `uv run`. +- Do not enable desktop OOB / auto-login overlays; this package tracks + upstream auth and setup. diff --git a/desktop/portable/Makefile b/desktop/portable/Makefile new file mode 100644 index 00000000..c7e9d537 --- /dev/null +++ b/desktop/portable/Makefile @@ -0,0 +1,55 @@ +# Standalone Makefile for green portable packaging. +# Does not modify the repo-root Makefile — merge-friendly with upstream. +# +# Usage (from repo root): +# make -f desktop/portable/Makefile green +# make -f desktop/portable/Makefile green GREEN_PLAT=linux-amd64 +# make -f desktop/portable/Makefile green-linux +# make -f desktop/portable/Makefile clean + +SHELL := /bin/bash +REPO_ROOT := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))/../..) +GREEN_PLAT ?= + +.DEFAULT_GOAL := help + +.PHONY: help +help: + @echo "Green portable (offline zip)" + @echo " make -f desktop/portable/Makefile green # host plat zip" + @echo " make -f desktop/portable/Makefile green GREEN_PLAT=…" + @echo " make -f desktop/portable/Makefile bootstrap|wheels|package|green-linux|clean" + +.PHONY: bootstrap +bootstrap: + bash $(REPO_ROOT)/desktop/portable/bootstrap-runtime.sh $(GREEN_PLAT) + +.PHONY: wheels +wheels: + bash $(REPO_ROOT)/desktop/portable/vendor-wheels.sh $(GREEN_PLAT) + +.PHONY: package +package: + bash $(REPO_ROOT)/desktop/portable/package.sh $(GREEN_PLAT) + +.PHONY: green +green: + $(MAKE) -C $(REPO_ROOT) build-frontend + bash $(REPO_ROOT)/desktop/portable/bootstrap-runtime.sh $(GREEN_PLAT) + bash $(REPO_ROOT)/desktop/portable/package.sh $(GREEN_PLAT) + @echo "[green] Done. Zips under $(REPO_ROOT)/desktop/portable/release/" + +.PHONY: green-linux +green-linux: + $(MAKE) -C $(REPO_ROOT) build-frontend + bash $(REPO_ROOT)/desktop/portable/package-linux-docker.sh $(if $(GREEN_PLAT),$(GREEN_PLAT),linux-amd64) + +.PHONY: clean +clean: + rm -rf $(REPO_ROOT)/desktop/portable/runtimes \ + $(REPO_ROOT)/desktop/portable/wheels \ + $(REPO_ROOT)/desktop/portable/.cache \ + $(REPO_ROOT)/desktop/portable/release + rm -f $(REPO_ROOT)/desktop/portable/requirements-*.txt \ + $(REPO_ROOT)/desktop/portable/overrides-*.txt + @echo "[green] removed generated files under $(REPO_ROOT)/desktop/portable" diff --git a/desktop/portable/README.md b/desktop/portable/README.md new file mode 100644 index 00000000..b5027136 --- /dev/null +++ b/desktop/portable/README.md @@ -0,0 +1,116 @@ +# Octop 绿色便携包(多平台) + +解压即用:内置便携 CPython + Octop 及依赖,通过 `start.sh` / `start.bat` 启动。 +**不依赖**系统 Python,也**不包含** Wails / 桌面壳——用浏览器打开 Dashboard。 +首启走上游正常 setup wizard(本目录**不含** OOB / UI 裁剪)。 + +## 与上游解耦 + +本能力全部落在: + +- `desktop/portable/**`(脚本 / 模板 / 本目录 Makefile) +- `.github/workflows/octop-portable.yml`(多平台 CI) +- 根 `.gitignore` 一行 `/green`(忽略构建产物) +- `tests/unit/test_green_launch.py`(launch.py PATH / addsitedir) + +**不修改** `src/`、`dashboard/`、`pyproject.toml`、`uv.lock`、根 `Makefile`。 +合并上游时只需留意上述路径;日常用: + +```bash +make -f desktop/portable/Makefile green +``` + +依赖版本必须以仓库根目录 `uv.lock` 为准:`package.sh` 使用 +`uv export --frozen`,出包后跑 `desktop/portable/verify_imports.py` +校验关键包 pin 与 import(含 `langchain-openai` / `langchain-core` 配对), +避免「同安装、不同环境」因版本错配或原生扩展加载失败而偶发报错。 +平台 overrides(`darwin-amd64` / `windows-arm64` 的 `cryptography==46.x`) +会一并传入校验,避免与 lock 中的 49.x 误报不一致。 + +## 产物布局 + +``` +Octop-/ + runtime/ # python-build-standalone + packages/ # Octop + 依赖(site-packages,可搬迁) + launch.py # 启动引导(site.addsitedir / Windows pywin32) + start.sh # macOS / Linux + start.bat # Windows + README.txt + data/ # 首次运行自动创建(用户数据) +``` + +支持平台:`darwin-arm64` `darwin-amd64` `linux-amd64` `linux-arm64` `windows-amd64` `windows-arm64`。 + +## 构建(在仓库根目录) + +需已安装:`uv`、`curl`、`zip`(可选)、Node(编前端)。 + +```bash +# 一键:当前主机平台(前端 + 便携 CPython + zip) +make -f desktop/portable/Makefile green + +# 或分步: +make build-frontend # 上游已有目标 +bash desktop/portable/bootstrap-runtime.sh +bash desktop/portable/package.sh +``` + +本地一键重建(nvm 24): + +```bash +bash desktop/portable/rebuild.sh +``` + +交叉组装其它平台时,**带 C 扩展的包**必须在目标 ABI 上构建: + +| 目标 | 推荐方式 | +|------|----------| +| 当前主机 | `make -f desktop/portable/Makefile green` | +| Linux(从 macOS/Windows) | `make -f desktop/portable/Makefile green-linux` | +| Windows | 在 Windows / CI 上执行同上 `green` | + +### 离线包 + +```bash +bash desktop/portable/vendor-wheels.sh # 按当前 uv.lock 预取 wheel +OCTOP_GREEN_OFFLINE=1 bash desktop/portable/package.sh +``` + +离线缓存必须来自**当前分支**的 `uv.lock`,不要复用旧分叉的 wheel 目录。 + +### macOS Intel(`darwin-amd64`)注意 + +锁定的 `cryptography` 49.x **不再发布** macOS x86_64 / universal2 wheel。若允许从 sdist 编译,会链到构建机 Homebrew 的 `/usr/local/opt/openssl@3`,用户机缺库即启动失败。 + +绿包脚本已做: + +1. `darwin-amd64` 覆盖钉死 `cryptography==46.0.3`(仍有 `macosx_*_universal2` wheel) +2. 全平台 `--only-binary cryptography`,禁止源码编译 +3. 打包后 `otool` 检查,拒绝 Homebrew/MacPorts 绝对路径 +4. smoke import 覆盖 `cryptography.fernet` + +### Windows / pywin32 + +`mcp` / `docker` 在 win32 上传递依赖 `pywin32`。打包脚本会: + +1. 若 `packages/pywin32_system32` 缺失则显式 `uv pip install pywin32` +2. 把 `pywintypes*.dll` / `pythoncom*.dll` 拷到 `runtime/` +3. `launch.py` 用 `site.addsitedir` 处理 `.pth`,并 `os.add_dll_directory` + +**不要**设置 `PYTHONPATH=packages`(会跳过 `.pth`,导致 `No module named pywintypes`)。 + +`windows-arm64` 另排除无 wheel 的 `psycopg-binary` / `sqlite-vec`,并把 `cryptography` 钉到 `46.0.0`(仅该版本提供 `win_arm64` wheel)。 + +## CI + +[`.github/workflows/octop-portable.yml`](../../.github/workflows/octop-portable.yml) 在 6 个 runner 上出 zip: + +`linux-amd64` `linux-arm64` `darwin-arm64` `darwin-amd64` `windows-amd64` `windows-arm64` + +Actions 使用 GitHub 上游 PBS(`PBS_BASE_URL`),本机构建默认 npmmirror。产物以 `archive: false` 上传,避免 zip-in-zip。 + +## Electron 壳 + +壳只消费 `Octop-.zip`,不要把绿包编进 asar。步骤见 +[`AGENT_ELECTRON_INTEGRATION.md`](AGENT_ELECTRON_INTEGRATION.md)。 diff --git a/desktop/portable/_common.sh b/desktop/portable/_common.sh new file mode 100644 index 00000000..8106052f --- /dev/null +++ b/desktop/portable/_common.sh @@ -0,0 +1,286 @@ +# Shared helpers for green portable packaging scripts. +# shellcheck shell=bash +# Sourced by bootstrap-runtime.sh / vendor-wheels.sh / package.sh + +GREEN_ROOT="${GREEN_ROOT:-${REPO_ROOT}/desktop/portable}" +GREEN_RUNTIMES="${GREEN_ROOT}/runtimes" +GREEN_WHEELS="${GREEN_ROOT}/wheels" +GREEN_CACHE="${GREEN_ROOT}/.cache" +GREEN_RELEASE="${GREEN_ROOT}/release" + +# Pin a known-good python-build-standalone release (override with PBS_TAG / PBS_PY). +PBS_TAG="${PBS_TAG:-20251209}" +PBS_PY="${PBS_PY:-3.12.12}" +# Prefer npmmirror mirror; override with PBS_BASE_URL for GitHub upstream. +PBS_BASE_URL="${PBS_BASE_URL:-https://registry.npmmirror.com/-/binary/python-build-standalone/${PBS_TAG}}" + +ALL_PLATS=( + darwin-arm64 + darwin-amd64 + linux-amd64 + linux-arm64 + windows-amd64 + windows-arm64 +) + +# Map GitHub Actions RUNNER_ARCH (X64/ARM64/...) → green arch suffix. +runner_arch_to_green() { + case "$(printf '%s' "${1:-}" | tr '[:lower:]' '[:upper:]')" in + ARM64|ARM) echo arm64 ;; + X64|AMD64) echo amd64 ;; + *) return 1 ;; + esac +} + +# Probe python machine() — setup-uv on windows-11-arm provides arm64 CPython +# even when Git Bash itself is an x64 process. +python_machine_arch() { + local py m + for py in python python3; do + if command -v "$py" >/dev/null 2>&1; then + m="$("$py" -c 'import platform; print(platform.machine())' 2>/dev/null | tr -d '\r\n')" + case "$(printf '%s' "$m" | tr '[:lower:]' '[:upper:]')" in + ARM64|AARCH64) echo arm64; return 0 ;; + AMD64|X86_64|X64) echo amd64; return 0 ;; + esac + fi + done + return 1 +} + +host_plat() { + # CI / operators can pin this when shell arch detection is wrong + # (common on windows-11-arm + x64 Git Bash). + if [[ -n "${GREEN_HOST_PLAT:-}" ]]; then + if ! is_known_plat "$GREEN_HOST_PLAT"; then + echo "GREEN_HOST_PLAT=${GREEN_HOST_PLAT} is not a known plat" >&2 + return 1 + fi + echo "$GREEN_HOST_PLAT" + return 0 + fi + + local os arch + case "$(uname -s)" in + Darwin) os=darwin ;; + Linux) os=linux ;; + MINGW*|MSYS*|CYGWIN*|Windows_NT) os=windows ;; + *) + echo "unsupported host OS: $(uname -s)" >&2 + return 1 + ;; + esac + + # GitHub Actions: RUNNER_ARCH is the VM architecture (not the shell binary). + if arch="$(runner_arch_to_green "${RUNNER_ARCH:-}")"; then + echo "${os}-${arch}" + return 0 + fi + + # Windows: detect OS / interpreter arch, not the current process arch. + # Git Bash / MSYS on windows-11-arm often is an x64 binary, so both + # `uname -m` and PROCESSOR_ARCHITECTURE can look like amd64 while the + # machine is ARM64. That falsely enables cross-assemble + --only-binary + # and breaks sdist-only pure-Python deps (e.g. aliyun-python-sdk-core). + if [[ "$os" == windows ]]; then + if arch="$(python_machine_arch)"; then + echo "${os}-${arch}" + return 0 + fi + + local os_arch="" pa="${PROCESSOR_ARCHITECTURE:-}" pa32="${PROCESSOR_ARCHITEW6432:-}" + if command -v powershell.exe >/dev/null 2>&1; then + os_arch="$( + powershell.exe -NoProfile -Command \ + '[System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString()' \ + 2>/dev/null | tr -d '\r\n' + )" + fi + # Fallback: CIM OS architecture (more reliable on some runner images). + if [[ -z "$os_arch" ]] && command -v powershell.exe >/dev/null 2>&1; then + os_arch="$( + powershell.exe -NoProfile -Command \ + '(Get-CimInstance -ClassName Win32_OperatingSystem).OSArchitecture' \ + 2>/dev/null | tr -d '\r\n' + )" + fi + case "$(printf '%s' "$os_arch" | tr '[:lower:]' '[:upper:]')" in + ARM64|*ARM64*|ARM\ 64*) arch=arm64 ;; + X64|AMD64|*X64*) arch=amd64 ;; + *) + case "$(printf '%s' "${pa32:-$pa}" | tr '[:lower:]' '[:upper:]')" in + ARM64) arch=arm64 ;; + AMD64|X86_64) arch=amd64 ;; + *) + case "$(uname -m)" in + arm64|aarch64) arch=arm64 ;; + x86_64|amd64) arch=amd64 ;; + *) + echo "unsupported arch: OSArchitecture=${os_arch} uname=$(uname -m) PROCESSOR_ARCHITECTURE=${pa} PROCESSOR_ARCHITEW6432=${pa32} RUNNER_ARCH=${RUNNER_ARCH:-}" >&2 + return 1 + ;; + esac + ;; + esac + ;; + esac + else + case "$(uname -m)" in + arm64|aarch64) arch=arm64 ;; + x86_64|amd64) arch=amd64 ;; + *) + echo "unsupported arch: $(uname -m)" >&2 + return 1 + ;; + esac + fi + echo "${os}-${arch}" +} + +# python-build-standalone triple +plat_triple() { + case "$1" in + darwin-arm64) echo "aarch64-apple-darwin" ;; + darwin-amd64) echo "x86_64-apple-darwin" ;; + linux-amd64) echo "x86_64-unknown-linux-gnu" ;; + linux-arm64) echo "aarch64-unknown-linux-gnu" ;; + windows-amd64) echo "x86_64-pc-windows-msvc" ;; + windows-arm64) echo "aarch64-pc-windows-msvc" ;; + *) return 1 ;; + esac +} + +# uv --python-platform values +uv_platform() { + case "$1" in + darwin-arm64) echo "aarch64-apple-darwin" ;; + darwin-amd64) echo "x86_64-apple-darwin" ;; + linux-amd64) echo "x86_64-unknown-linux-gnu" ;; + linux-arm64) echo "aarch64-unknown-linux-gnu" ;; + windows-amd64) echo "x86_64-pc-windows-msvc" ;; + windows-arm64) echo "aarch64-pc-windows-msvc" ;; + *) return 1 ;; + esac +} + +# pip --platform tags (for optional offline wheel prefetch) +pip_platform() { + case "$1" in + darwin-arm64) echo "macosx_11_0_arm64" ;; + darwin-amd64) echo "macosx_11_0_x86_64" ;; + linux-amd64) echo "manylinux2014_x86_64" ;; + linux-arm64) echo "manylinux2014_aarch64" ;; + windows-amd64) echo "win_amd64" ;; + windows-arm64) echo "win_arm64" ;; + *) return 1 ;; + esac +} + +is_known_plat() { + local p + for p in "${ALL_PLATS[@]}"; do + [[ "$p" == "$1" ]] && return 0 + done + return 1 +} + +runtime_python() { + # Print path to python inside a runtime root (may not exist yet). + local runtime_dir="$1" + if [[ -x "${runtime_dir}/bin/python3" ]]; then + echo "${runtime_dir}/bin/python3" + elif [[ -f "${runtime_dir}/python.exe" ]]; then + echo "${runtime_dir}/python.exe" + elif [[ -x "${runtime_dir}/install/bin/python3" ]]; then + echo "${runtime_dir}/install/bin/python3" + else + echo "${runtime_dir}/bin/python3" + fi +} + +require_uv() { + if ! command -v uv >/dev/null 2>&1; then + echo "uv is required (https://docs.astral.sh/uv/)" >&2 + exit 1 + fi +} + +pbs_archive_name() { + local plat="$1" + local triple + triple="$(plat_triple "$plat")" || return 1 + echo "cpython-${PBS_PY}+${PBS_TAG}-${triple}-install_only_stripped.tar.gz" +} + +# Write platform-specific uv/pip overrides (empty file = none). +# Prints the override file path when overrides exist; otherwise prints nothing. +# +# cryptography: locked 49.x dropped macOS Intel / universal2 wheels. Native +# darwin-amd64 builds then compile from sdist and link Homebrew OpenSSL +# (/usr/local/opt/openssl@3), which is missing on end-user machines. +# Pin to the last release that still ships macosx_*_universal2 wheels. +write_green_overrides() { + local plat="$1" + local out="${2:-${GREEN_ROOT}/overrides-${plat}.txt}" + mkdir -p "$(dirname "$out")" + case "$plat" in + windows-arm64) + { + echo "psycopg-binary ; sys_platform == 'octop-unsupported'" + echo "sqlite-vec ; sys_platform == 'octop-unsupported'" + # 46.0.0 is the only release shipping a win_arm64 wheel; newer ones would + # fall back to a Rust + OpenSSL source build that fails on the runner. + echo "cryptography==46.0.0" + } > "$out" + echo "$out" + ;; + darwin-amd64) + { + # 46.0.3 still publishes cp*-abi3-macosx_10_9_universal2 (covers x86_64). + # cryptography>=47 only ships macos arm64 wheels → sdist + brew openssl. + echo "cryptography==46.0.3" + } > "$out" + echo "$out" + ;; + *) + rm -f "$out" + ;; + esac +} + +# Fail if any Mach-O under packages/ links Homebrew / MacPorts absolute paths. +# Catches relocatable-package regressions (e.g. cryptography built against brew openssl). +verify_no_homebrew_dylibs() { + local staging_packages="$1" + local plat="$2" + + case "$plat" in + darwin-*) ;; + *) return 0 ;; + esac + if ! command -v otool >/dev/null 2>&1; then + echo "[green] WARNING: otool missing — skip Homebrew dylib check" >&2 + return 0 + fi + + local bad=0 + local f deps + # Limit to native extension shared objects (skip .dylibs already vendored beside wheels). + while IFS= read -r -d '' f; do + deps="$(otool -L "$f" 2>/dev/null || true)" + if printf '%s\n' "$deps" | grep -E -q '/usr/local/opt/|/opt/homebrew/|/opt/local/'; then + echo "[green] ERROR: non-portable dylib link in ${f}" >&2 + printf '%s\n' "$deps" | grep -E '/usr/local/opt/|/opt/homebrew/|/opt/local/' >&2 || true + bad=1 + fi + done < <(find "$staging_packages" \( -name '*.so' -o -name '*.dylib' \) -print0 2>/dev/null) + + if [[ "$bad" -ne 0 ]]; then + echo "[green] ERROR: green zip must not depend on Homebrew/MacPorts libraries." >&2 + echo " Tip: pin cryptography to a release with macos universal2 wheels (see write_green_overrides)," >&2 + echo " and keep --only-binary cryptography so sdist builds cannot slip through." >&2 + return 1 + fi + echo "[green] dylib check OK (no Homebrew/MacPorts absolute paths)" + return 0 +} diff --git a/desktop/portable/bootstrap-runtime.sh b/desktop/portable/bootstrap-runtime.sh new file mode 100755 index 00000000..4c03e6ed --- /dev/null +++ b/desktop/portable/bootstrap-runtime.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# Download python-build-standalone into desktop/portable/runtimes/. +# +# Usage: +# bash desktop/portable/bootstrap-runtime.sh # host platform +# bash desktop/portable/bootstrap-runtime.sh darwin-arm64 +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# shellcheck source=desktop/portable/_common.sh +source "${REPO_ROOT}/desktop/portable/_common.sh" + +download() { + local url="$1" + local dest="$2" + mkdir -p "$(dirname "$dest")" + if [[ -f "$dest" && -s "$dest" ]]; then + echo "[bootstrap] cache hit ${dest}" >&2 + return 0 + fi + echo "[bootstrap] downloading ${url}" >&2 + curl -fL --retry 3 --retry-delay 2 -o "${dest}.partial" "$url" + mv "${dest}.partial" "$dest" +} + +flatten_pbs_install() { + local extract_dir="$1" + local dest="$2" + rm -rf "$dest" + mkdir -p "$(dirname "$dest")" + if [[ -d "${extract_dir}/python" ]]; then + mv "${extract_dir}/python" "$dest" + elif [[ -d "${extract_dir}/install" ]]; then + mv "${extract_dir}/install" "$dest" + else + echo "[bootstrap] unexpected PBS layout under ${extract_dir}" >&2 + find "$extract_dir" -maxdepth 2 -print >&2 || true + exit 1 + fi +} + +bootstrap_one() { + local plat="$1" + local triple archive url cache dest + triple="$(plat_triple "$plat")" || { + echo "unknown platform: $plat" >&2 + exit 1 + } + archive="$(pbs_archive_name "$plat")" + url="${PBS_BASE_URL}/${archive}" + cache="${GREEN_CACHE}/${archive}" + dest="${GREEN_RUNTIMES}/${plat}" + + mkdir -p "$GREEN_RUNTIMES" "$GREEN_CACHE" + if [[ -x "$(runtime_python "$dest")" || -f "${dest}/python.exe" ]]; then + echo "[bootstrap] ${plat}: runtime already present → ${dest}" >&2 + return 0 + fi + + download "$url" "$cache" + local tmp + tmp="$(mktemp -d "${GREEN_CACHE}/extract.XXXXXX")" + tar -xzf "$cache" -C "$tmp" + flatten_pbs_install "$tmp" "$dest" + rm -rf "$tmp" + + local py + py="$(runtime_python "$dest")" + if [[ ! -x "$py" && ! -f "$py" ]]; then + echo "[bootstrap] python missing after extract: ${py}" >&2 + exit 1 + fi + echo "[bootstrap] ${plat}: ${py}" >&2 + "$py" -c "import sys; print(sys.version)" >&2 || true +} + +main() { + local plat="${1:-}" + if [[ -z "$plat" ]]; then + plat="$(host_plat)" + fi + if ! is_known_plat "$plat"; then + echo "unknown platform: $plat (known: ${ALL_PLATS[*]})" >&2 + exit 1 + fi + bootstrap_one "$plat" +} + +main "${1:-}" diff --git a/desktop/portable/package-linux-docker.sh b/desktop/portable/package-linux-docker.sh new file mode 100755 index 00000000..834b395a --- /dev/null +++ b/desktop/portable/package-linux-docker.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Cross-build a Linux green zip inside Docker (native ABI for C extensions). +# +# Usage: +# bash desktop/portable/package-linux-docker.sh +# bash desktop/portable/package-linux-docker.sh linux-arm64 +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# shellcheck source=desktop/portable/_common.sh +source "${REPO_ROOT}/desktop/portable/_common.sh" + +PLAT="${1:-linux-amd64}" +case "$PLAT" in + linux-amd64|linux-arm64) ;; + *) + echo "package-linux-docker.sh: expected linux-amd64 or linux-arm64, got ${PLAT}" >&2 + exit 1 + ;; +esac + +if ! command -v docker >/dev/null 2>&1; then + echo "docker is required for Linux cross-builds" >&2 + exit 1 +fi + +IMAGE="${GREEN_LINUX_IMAGE:-python:3.12-bookworm}" +echo "[docker] image=${IMAGE} plat=${PLAT}" >&2 + +docker run --rm \ + -e GREEN_HOST_PLAT="$PLAT" \ + -e PBS_TAG="$PBS_TAG" \ + -e PBS_PY="$PBS_PY" \ + -e PBS_BASE_URL="${PBS_BASE_URL}" \ + -e OCTOP_GREEN_OFFLINE="${OCTOP_GREEN_OFFLINE:-0}" \ + -v "${REPO_ROOT}:/src" \ + -w /src \ + "$IMAGE" \ + bash -lc " + set -euo pipefail + apt-get update -qq + apt-get install -y -qq curl zip ca-certificates >/dev/null + if ! command -v uv >/dev/null 2>&1; then + curl -LsSf https://astral.sh/uv/install.sh | sh + export PATH=\"\$HOME/.local/bin:\$PATH\" + fi + bash desktop/portable/bootstrap-runtime.sh '${PLAT}' + bash desktop/portable/package.sh '${PLAT}' + " diff --git a/desktop/portable/package.sh b/desktop/portable/package.sh new file mode 100755 index 00000000..190a6136 --- /dev/null +++ b/desktop/portable/package.sh @@ -0,0 +1,291 @@ +#!/usr/bin/env bash +# Assemble a relocatable green portable zip for one platform. +# +# Prerequisites: +# bash desktop/portable/bootstrap-runtime.sh +# make build-frontend # recommended (dashboard inside wheel) +# +# Usage: +# bash desktop/portable/package.sh # host platform (online install) +# bash desktop/portable/package.sh darwin-arm64 +# OCTOP_GREEN_OFFLINE=1 bash desktop/portable/package.sh # require local wheels +# +# Layout of each zip: +# Octop-/ +# runtime/ portable CPython +# packages/ site-packages (uv --target, relocatable) +# start.sh / start.bat +# README.txt +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# shellcheck source=desktop/portable/_common.sh +source "${REPO_ROOT}/desktop/portable/_common.sh" + +TEMPLATES="${REPO_ROOT}/desktop/portable/templates" +require_uv + +build_octop_wheel() { + local wheel_dir="$1" + mkdir -p "$wheel_dir" + # Drop previous local octop wheels so we pick the fresh build. + find "$wheel_dir" -maxdepth 1 -type f -name 'octop-*.whl' -delete 2>/dev/null || true + if [[ ! -f "${REPO_ROOT}/src/octop/dashboard/index.html" ]]; then + echo "[package] WARNING: dashboard not built (src/octop/dashboard/index.html missing)." >&2 + echo " Run: make build-frontend" >&2 + fi + echo "[package] building octop wheel → ${wheel_dir}" >&2 + uv build --wheel --out-dir "$wheel_dir" "$REPO_ROOT" >&2 + local whl + whl="$(ls -1 "${wheel_dir}"/octop-*.whl 2>/dev/null | sort | tail -1 || true)" + if [[ -z "$whl" ]]; then + echo "[package] failed to build octop-*.whl" >&2 + exit 1 + fi + # stdout: wheel path only (captured by caller) + echo "$whl" +} + +wheel_cache_usable() { + local wheel_dir="$1" + local count + count="$(find "$wheel_dir" -maxdepth 1 -type f -name '*.whl' 2>/dev/null | wc -l | tr -d ' ')" + # Need octop + a reasonable set of deps. + [[ "${count:-0}" -ge 10 ]] +} + +# Copy pywin32 DLLs beside portable python.exe so ``import pywintypes`` works. +fix_windows_pywin32() { + local staging="$1" + local dll_src="${staging}/packages/pywin32_system32" + local runtime="${staging}/runtime" + if [[ ! -d "$dll_src" ]]; then + echo "[package] pywin32_system32 missing under packages/ — was pywin32 installed?" >&2 + return 1 + fi + local f + local copied=0 + for f in "$dll_src"/pywintypes*.dll "$dll_src"/pythoncom*.dll; do + [[ -f "$f" ]] || continue + cp -f "$f" "$runtime/" + copied=$((copied + 1)) + done + if [[ "$copied" -eq 0 ]]; then + echo "[package] no pywintypes/pythoncom DLLs found in ${dll_src}" >&2 + return 1 + fi + echo "[package] copied ${copied} pywin32 DLL(s) → runtime/" + return 0 +} + +assemble_one() { + local plat="$1" + local runtime="${GREEN_RUNTIMES}/${plat}" + local wheel_dir="${GREEN_WHEELS}/${plat}" + local staging="${GREEN_RELEASE}/Octop-${plat}" + local zip_path="${GREEN_RELEASE}/Octop-${plat}.zip" + local pyplat + + if ! pyplat="$(uv_platform "$plat")"; then + echo "unknown platform: $plat" >&2 + exit 1 + fi + + if [[ ! -d "$runtime" ]]; then + echo "[package] missing runtime ${runtime}" >&2 + echo " Run: bash desktop/portable/bootstrap-runtime.sh ${plat}" >&2 + exit 1 + fi + + mkdir -p "$wheel_dir" "$GREEN_RELEASE" "$GREEN_ROOT" + local octop_whl + octop_whl="$(build_octop_wheel "$wheel_dir")" + + local req_file="${GREEN_ROOT}/requirements-${plat}.txt" + echo "[package] ${plat}: exporting frozen deps → ${req_file}" >&2 + uv export --project "$REPO_ROOT" --frozen --no-dev --no-emit-project --no-hashes -o "$req_file" >/dev/null + + # Platform-specific pins / exclusions (see write_green_overrides in _common.sh). + local override_file="" + override_file="$(write_green_overrides "$plat" || true)" + if [[ -n "$override_file" ]]; then + echo "[package] ${plat}: using overrides → ${override_file}" >&2 + cat "$override_file" >&2 || true + fi + + echo "[package] ${plat}: staging → ${staging}" + rm -rf "$staging" + mkdir -p "${staging}/packages" + cp -a "$runtime" "${staging}/runtime" + + local common_args=( + --target "${staging}/packages" + --python-platform "$pyplat" + --python-version 3.12 + # Never compile cryptography from sdist: on Intel macOS that links + # Homebrew OpenSSL and breaks portable zips on machines without brew. + --only-binary cryptography + ) + if [[ -n "$override_file" ]]; then + common_args+=( --overrides "$override_file" ) + fi + local offline_args=() + local host + host="$(host_plat)" + echo "[package] ${plat}: host=${host} RUNNER_ARCH=${RUNNER_ARCH:-} GREEN_HOST_PLAT=${GREEN_HOST_PLAT:-}" >&2 + + # Last-resort correction: packaging windows-arm64 while shell looks amd64, but + # Actions/toolcache clearly provides ARM64 (matches the failing CI symptom). + if [[ "$plat" == "windows-arm64" && "$host" == "windows-amd64" ]]; then + local py_arch="" + py_arch="$(python_machine_arch 2>/dev/null || true)" + if [[ "${RUNNER_ARCH:-}" == "ARM64" || "$py_arch" == "arm64" ]]; then + echo "[package] ${plat}: overriding host windows-amd64 → windows-arm64 (RUNNER_ARCH=${RUNNER_ARCH:-} python=${py_arch:-unknown})" >&2 + host=windows-arm64 + fi + fi + + # uv pip resolves against the host interpreter. macOS /usr/bin/python3 is + # often Xcode 3.9, which cannot satisfy octop's requires-python >=3.12. + if [[ "$plat" == "$host" ]]; then + local install_python + install_python="$(runtime_python "${staging}/runtime")" + if [[ ! -x "$install_python" && ! -f "$install_python" ]]; then + echo "[package] portable python missing: ${install_python}" >&2 + exit 1 + fi + echo "[package] ${plat}: resolving with ${install_python}" >&2 + common_args+=( --python "$install_python" ) + else + uv python install 3.12 >/dev/null + echo "[package] ${plat}: resolving with uv-managed CPython 3.12" >&2 + common_args+=( --python 3.12 ) + fi + + # Cross-platform: refuse compiling sdists on the host (wrong ABI). Prefer + # binary wheels only; for Linux use desktop/portable/package-linux-docker.sh. + if [[ "$plat" != "$host" ]]; then + common_args+=( --only-binary ":all:" ) + echo "[package] ${plat}: cross-assemble from ${host} (binary wheels only)" >&2 + fi + + if [[ "${OCTOP_GREEN_OFFLINE:-0}" == "1" ]]; then + if ! wheel_cache_usable "$wheel_dir"; then + echo "[package] OCTOP_GREEN_OFFLINE=1 but wheels cache incomplete: ${wheel_dir}" >&2 + echo " Run: bash desktop/portable/vendor-wheels.sh ${plat}" >&2 + exit 1 + fi + echo "[package] ${plat}: offline install from ${wheel_dir}" + offline_args+=( --no-index --find-links "$wheel_dir" ) + elif wheel_cache_usable "$wheel_dir"; then + echo "[package] ${plat}: install preferring local wheels (+ PyPI fallback)" + offline_args+=( --find-links "$wheel_dir" ) + else + echo "[package] ${plat}: online install from locked requirements" + fi + + # Install locked deps first, then the local octop wheel without re-resolving deps. + # bash 3.2 + set -u: empty array expansion is unbound — branch explicitly. + set +e + if [[ ${#offline_args[@]} -gt 0 ]]; then + uv pip install "${common_args[@]}" "${offline_args[@]}" -r "$req_file" + status=$? + if [[ $status -eq 0 ]]; then + uv pip install "${common_args[@]}" "${offline_args[@]}" --no-deps "$octop_whl" + status=$? + fi + else + uv pip install "${common_args[@]}" -r "$req_file" + status=$? + if [[ $status -eq 0 ]]; then + uv pip install "${common_args[@]}" --no-deps "$octop_whl" + status=$? + fi + fi + # Windows markers: ensure pywin32 landed (mcp needs pywintypes). + if [[ $status -eq 0 && "$plat" == windows-* ]]; then + if [[ ! -d "${staging}/packages/pywin32_system32" ]]; then + echo "[package] ${plat}: pywin32 missing — installing explicitly" >&2 + if [[ ${#offline_args[@]} -gt 0 ]]; then + uv pip install "${common_args[@]}" "${offline_args[@]}" pywin32 + else + uv pip install "${common_args[@]}" pywin32 + fi + status=$? + fi + fi + set -e + if [[ $status -ne 0 ]]; then + echo "[package] install failed for ${plat} (exit ${status})" >&2 + if [[ "$plat" != "$host" && "$plat" == linux-* ]]; then + echo " Tip: build Linux zips in Docker:" >&2 + echo " bash desktop/portable/package-linux-docker.sh ${plat}" >&2 + elif [[ "$plat" != "$host" && "$plat" == windows-* ]]; then + echo " Tip: build Windows zips on a Windows host (or CI):" >&2 + echo " make -f desktop/portable/Makefile green GREEN_PLAT=${plat}" >&2 + fi + exit "$status" + fi + + cp "${TEMPLATES}/start.sh" "${staging}/start.sh" + cp "${TEMPLATES}/start.bat" "${staging}/start.bat" + cp "${TEMPLATES}/launch.py" "${staging}/launch.py" + cp "${TEMPLATES}/README.txt" "${staging}/README.txt" + chmod +x "${staging}/start.sh" + + # Windows: pywin32 DLLs must be findable next to python.exe (or on PATH). + # --target installs leave them under packages/pywin32_system32 only. + if [[ "$plat" == windows-* ]]; then + fix_windows_pywin32 "$staging" || { + echo "[package] WARNING: pywin32 DLL fix failed — Windows runtime may miss pywintypes" >&2 + } + fi + + # macOS: reject Homebrew-linked native extensions before zipping. + verify_no_homebrew_dylibs "${staging}/packages" "$plat" + + { + echo "platform=${plat}" + echo "python=${PBS_PY}" + echo "pbs_tag=${PBS_TAG}" + sed -n 's/^version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/octop_version=\1/p' \ + "${REPO_ROOT}/pyproject.toml" | head -1 + } > "${staging}/VERSION.txt" + + rm -f "$zip_path" + echo "[package] ${plat}: zipping → ${zip_path}" + ( + cd "$GREEN_RELEASE" + if command -v zip >/dev/null 2>&1; then + zip -qry "Octop-${plat}.zip" "Octop-${plat}" + else + # Windows runners often have `python` but not `python3` / `zip`. + py="" + if command -v python3 >/dev/null 2>&1; then + py=python3 + elif command -v python >/dev/null 2>&1; then + py=python + fi + if [[ -z "$py" ]]; then + echo "[package] need zip or python to create archive" >&2 + exit 1 + fi + "$py" -c "import shutil; shutil.make_archive('Octop-${plat}', 'zip', '.', 'Octop-${plat}')" + fi + ) + echo "[package] wrote ${zip_path}" +} + +main() { + local plat="${1:-}" + if [[ -z "$plat" ]]; then + plat="$(host_plat)" + fi + if ! is_known_plat "$plat"; then + echo "unknown platform: $plat (known: ${ALL_PLATS[*]})" >&2 + exit 1 + fi + assemble_one "$plat" +} + +main "${1:-}" diff --git a/desktop/portable/rebuild.sh b/desktop/portable/rebuild.sh new file mode 100755 index 00000000..bedaad54 --- /dev/null +++ b/desktop/portable/rebuild.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Local one-shot: rebuild dashboard and the host-platform green zip. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$REPO_ROOT" + +if [[ -f "${HOME}/.nvm/nvm.sh" ]]; then + # shellcheck disable=SC1091 + source "${HOME}/.nvm/nvm.sh" + nvm use 24 +fi + +rm -rf src/octop/dashboard +rm -rf desktop/portable/runtimes desktop/portable/wheels \ + desktop/portable/.cache desktop/portable/release +rm -f desktop/portable/requirements-*.txt desktop/portable/overrides-*.txt + +make build-frontend +make -f desktop/portable/Makefile green diff --git a/desktop/portable/templates/README.txt b/desktop/portable/templates/README.txt new file mode 100644 index 00000000..d3e86635 --- /dev/null +++ b/desktop/portable/templates/README.txt @@ -0,0 +1,37 @@ +Octop green portable package +============================ + +Extract this zip anywhere. It includes a portable CPython runtime and Octop +dependencies. No system Python install is required. + +Start +----- + macOS / Linux: ./start.sh + Windows: start.bat + +Defaults: http://127.0.0.1:8088 data dir = ./data (OCTOP_HOME) + + ./start.sh --home /path/to/data --host 127.0.0.1 --port 8088 + +First launch follows the normal Octop setup wizard (create admin password). + +Layout +------ + runtime/ portable CPython + packages/ Octop + locked dependencies (site-packages) + launch.py entry bootstrap (loads packages/ + Windows pywin32 DLLs) + start.sh / start.bat + README.txt + VERSION.txt + +Notes +----- + Do not set PYTHONPATH=packages. Always start via start.sh / start.bat / + launch.py so .pth files (pywin32) are processed. + + macOS: if Gatekeeper quarantines the unzipped folder: + + xattr -dr com.apple.quarantine . + +Windows: if import pywintypes fails, rebuild from a current + green package (launch.py + pywin32 DLL copy). Do not set PYTHONPATH manually. diff --git a/desktop/portable/templates/launch.py b/desktop/portable/templates/launch.py new file mode 100644 index 00000000..5e172cb8 --- /dev/null +++ b/desktop/portable/templates/launch.py @@ -0,0 +1,61 @@ +"""Green portable entry: wire packages/ onto sys.path then run ``octop``. + +Using plain ``PYTHONPATH=packages`` skips ``.pth`` processing (pywin32 etc.). +``site.addsitedir`` loads those hooks. On Windows we also expose +``pywin32_system32`` DLLs so ``import pywintypes`` works. +""" +from __future__ import annotations + +import os +import runpy +import site +import sys +from pathlib import Path + + +def _bootstrap() -> Path: + root = Path(__file__).resolve().parent + # The bundled interpreter is launched by absolute path, so its bin dir is + # rarely on PATH. Prepend it so inherited envs (agent shells, subprocesses) + # resolve ``python3`` to the same interpreter Octop runs with. + interpreter_dir = os.path.dirname(sys.executable) + if interpreter_dir: + _path = os.environ.get("PATH", "") + if interpreter_dir not in _path.split(os.pathsep): + os.environ["PATH"] = ( + f"{interpreter_dir}{os.pathsep}{_path}" if _path else interpreter_dir + ) + packages = root / "packages" + if not packages.is_dir(): + sys.stderr.write(f"launch.py: packages/ missing next to {root}\n") + raise SystemExit(1) + + # Process .pth files (required for pywin32 layout). + site.addsitedir(str(packages)) + + if sys.platform == "win32": + dll_dir = packages / "pywin32_system32" + if dll_dir.is_dir(): + dll_s = str(dll_dir) + path = os.environ.get("PATH", "") + if dll_s.lower() not in path.lower(): + os.environ["PATH"] = dll_s + os.pathsep + path + # Python 3.8+: prefer explicit DLL search path. + add_dll = getattr(os, "add_dll_directory", None) + if add_dll is not None: + try: + add_dll(dll_s) + except OSError: + pass + return root + + +def main() -> None: + _bootstrap() + # ``python launch.py …`` → same as ``python -m octop …`` + sys.argv = [sys.argv[0], *sys.argv[1:]] + runpy.run_module("octop", run_name="__main__", alter_sys=True) + + +if __name__ == "__main__": + main() diff --git a/desktop/portable/templates/start.bat b/desktop/portable/templates/start.bat new file mode 100644 index 00000000..bdf2d580 --- /dev/null +++ b/desktop/portable/templates/start.bat @@ -0,0 +1,88 @@ +@echo off +setlocal EnableExtensions EnableDelayedExpansion + +rem Octop green portable launcher (Windows) +rem Usage: +rem start.bat +rem start.bat --home D:\octop-data +rem start.bat --home .\data --host 0.0.0.0 --port 8088 + +set "ROOT=%~dp0" +if "%ROOT:~-1%"=="\" set "ROOT=%ROOT:~0,-1%" + +if not defined OCTOP_HOME set "OCTOP_HOME=%ROOT%\data" +set "HOST=127.0.0.1" +set "PORT=8088" +set "EXTRA=" + +:parse +if "%~1"=="" goto run +if /I "%~1"=="--home" ( + if "%~2"=="" ( + echo start.bat: --home requires a path + exit /b 1 + ) + set "OCTOP_HOME=%~2" + shift + shift + goto parse +) +if /I "%~1"=="--host" ( + if "%~2"=="" ( + echo start.bat: --host requires a value + exit /b 1 + ) + set "HOST=%~2" + shift + shift + goto parse +) +if /I "%~1"=="--port" ( + if "%~2"=="" ( + echo start.bat: --port requires a value + exit /b 1 + ) + set "PORT=%~2" + shift + shift + goto parse +) +if /I "%~1"=="-h" goto help +if /I "%~1"=="--help" goto help +set "EXTRA=!EXTRA! %~1" +shift +goto parse + +:help +echo Octop green portable launcher +echo. +echo Usage: start.bat [--home DIR] [--host HOST] [--port PORT] [octop run args...] +echo. +echo Defaults: +echo OCTOP_HOME / --home %%ROOT%%\data +echo --host 127.0.0.1 +echo --port 8088 +exit /b 0 + +:run +if not exist "%OCTOP_HOME%" mkdir "%OCTOP_HOME%" + +set "PY=%ROOT%\runtime\python.exe" +if not exist "%PY%" ( + echo start.bat: portable Python not found at %PY% + exit /b 1 +) + +if not exist "%ROOT%\launch.py" ( + echo start.bat: launch.py missing — rebuild the green package + exit /b 1 +) + +rem Prefer launch.py (site.addsitedir + pywin32 DLL path). Do not set PYTHONPATH. +set "PYTHONNOUSERSITE=1" +set "PYTHONPATH=" + +echo [octop] home=%OCTOP_HOME% +echo [octop] http://%HOST%:%PORT% +"%PY%" "%ROOT%\launch.py" run --host %HOST% --port %PORT% %EXTRA% +exit /b %ERRORLEVEL% diff --git a/desktop/portable/templates/start.sh b/desktop/portable/templates/start.sh new file mode 100755 index 00000000..8f4800d0 --- /dev/null +++ b/desktop/portable/templates/start.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Octop green portable launcher (macOS / Linux). +# Usage: +# ./start.sh +# ./start.sh --home /path/to/data +# ./start.sh --home ./data --host 0.0.0.0 --port 8088 +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export OCTOP_HOME="${OCTOP_HOME:-${ROOT}/data}" + +HOST="127.0.0.1" +PORT="8088" +EXTRA=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --home) + [[ $# -ge 2 ]] || { echo "start.sh: --home requires a path" >&2; exit 1; } + OCTOP_HOME="$2" + shift 2 + ;; + --host) + [[ $# -ge 2 ]] || { echo "start.sh: --host requires a value" >&2; exit 1; } + HOST="$2" + shift 2 + ;; + --port) + [[ $# -ge 2 ]] || { echo "start.sh: --port requires a value" >&2; exit 1; } + PORT="$2" + shift 2 + ;; + -h|--help) + cat <&2 + exit 1 +fi + +# launch.py adds packages/ via site.addsitedir (honours .pth / pywin32). +export PYTHONNOUSERSITE=1 +unset PYTHONPATH || true + +echo "[octop] home=${OCTOP_HOME}" +echo "[octop] http://${HOST}:${PORT}" +if [[ ${#EXTRA[@]} -gt 0 ]]; then + exec "$PY" "${ROOT}/launch.py" run --host "$HOST" --port "$PORT" "${EXTRA[@]}" +else + exec "$PY" "${ROOT}/launch.py" run --host "$HOST" --port "$PORT" +fi diff --git a/desktop/portable/vendor-wheels.sh b/desktop/portable/vendor-wheels.sh new file mode 100755 index 00000000..c068fb09 --- /dev/null +++ b/desktop/portable/vendor-wheels.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Prefetch wheels for OCTOP_GREEN_OFFLINE=1 packaging. +# +# Usage: +# bash desktop/portable/vendor-wheels.sh # host platform +# bash desktop/portable/vendor-wheels.sh windows-amd64 +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# shellcheck source=desktop/portable/_common.sh +source "${REPO_ROOT}/desktop/portable/_common.sh" + +require_uv + +vendor_one() { + local plat="$1" + local pyplat wheel_dir req_file override_file + pyplat="$(uv_platform "$plat")" || { + echo "unknown platform: $plat" >&2 + exit 1 + } + wheel_dir="${GREEN_WHEELS}/${plat}" + mkdir -p "$wheel_dir" "$GREEN_ROOT" + + req_file="${GREEN_ROOT}/requirements-${plat}.txt" + echo "[wheels] ${plat}: exporting frozen deps → ${req_file}" >&2 + uv export --frozen --no-dev --no-emit-project --no-hashes -o "$req_file" >/dev/null + + override_file="$(write_green_overrides "$plat" || true)" + local extra=() + if [[ -n "$override_file" ]]; then + extra+=( --overrides "$override_file" ) + fi + + # Host python3 may be <3.12 (e.g. Xcode 3.9). Download still needs a 3.12 + # interpreter so requires-python on octop/deps can resolve. + uv python install 3.12 >/dev/null + echo "[wheels] ${plat}: downloading → ${wheel_dir}" >&2 + uv pip download \ + --dest "$wheel_dir" \ + --python 3.12 \ + --python-platform "$pyplat" \ + --python-version 3.12 \ + --only-binary cryptography \ + "${extra[@]}" \ + -r "$req_file" + + echo "[wheels] ${plat}: done ($(find "$wheel_dir" -maxdepth 1 -type f -name '*.whl' | wc -l | tr -d ' ') wheels)" +} + +main() { + local plat="${1:-}" + if [[ -z "$plat" ]]; then + plat="$(host_plat)" + fi + if ! is_known_plat "$plat"; then + echo "unknown platform: $plat (known: ${ALL_PLATS[*]})" >&2 + exit 1 + fi + vendor_one "$plat" +} + +main "${1:-}" diff --git a/desktop/portable/verify_imports.py b/desktop/portable/verify_imports.py new file mode 100755 index 00000000..f2c46f51 --- /dev/null +++ b/desktop/portable/verify_imports.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Smoke-check a green packages/ tree against frozen requirements. + +Usage: + python desktop/portable/verify_imports.py \\ + --packages desktop/portable/release/Octop-/packages \\ + --requirements desktop/portable/requirements-.txt \\ + [--overrides desktop/portable/overrides-.txt] +""" +from __future__ import annotations + +import argparse +import ast +import importlib +import re +import site +import sys +from pathlib import Path + +_REQ_RE = re.compile( + r"^(?P[A-Za-z0-9][A-Za-z0-9._-]*)(?:\s*==\s*(?P[^\s;#]+))?" +) + +# Import names that have historically drifted across lock/install environments. +_SMOKE_IMPORTS = ( + "octop", + "fastapi", + "cryptography.fernet", + "langchain_core", + "langchain_openai", +) + + +def _parse_pins(path: Path) -> dict[str, str]: + pins: dict[str, str] = {} + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith(("#", "-")): + continue + match = _REQ_RE.match(line) + if match is None: + continue + name = match.group("name").replace("_", "-").lower() + ver = match.group("ver") + if ver: + pins[name] = ver + return pins + + +def _parse_override_pins(path: Path | None) -> dict[str, str]: + if path is None or not path.is_file(): + return {} + pins: dict[str, str] = {} + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or ";" in line: + continue + match = _REQ_RE.match(line) + if match is None or not match.group("ver"): + continue + pins[match.group("name").replace("_", "-").lower()] = match.group("ver") + return pins + + +def _installed_version(mod_name: str) -> str | None: + try: + mod = importlib.import_module(mod_name) + except Exception: + return None + ver = getattr(mod, "__version__", None) + if isinstance(ver, str): + return ver + return None + + +def _dist_version(dist_name: str) -> str | None: + try: + from importlib.metadata import version + except ImportError: + return None + try: + return version(dist_name) + except Exception: + return None + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--packages", required=True, type=Path) + parser.add_argument("--requirements", required=True, type=Path) + parser.add_argument("--overrides", type=Path, default=None) + args = parser.parse_args() + + packages = args.packages.resolve() + if not packages.is_dir(): + print(f"packages/ missing: {packages}", file=sys.stderr) + return 1 + site.addsitedir(str(packages)) + + req_pins = _parse_pins(args.requirements) + override_pins = _parse_override_pins(args.overrides) + pins = {**req_pins, **override_pins} + + errors: list[str] = [] + for dist in ("langchain-core", "langchain-openai", "cryptography"): + expected = pins.get(dist) + if expected is None: + continue + got = _dist_version(dist) + if got is None: + errors.append(f"{dist}: not installed (want {expected})") + elif got != expected: + errors.append(f"{dist}: installed {got}, want {expected}") + + for mod in _SMOKE_IMPORTS: + try: + importlib.import_module(mod) + except Exception as exc: + errors.append(f"import {mod}: {type(exc).__name__}: {exc}") + + # Sanity: launch.py must stay importable as a file (no compile errors). + try: + ast.parse(Path(__file__).read_text(encoding="utf-8")) + except SyntaxError as exc: + errors.append(f"self-parse: {exc}") + + if errors: + print("verify_imports FAILED:", file=sys.stderr) + for item in errors: + print(f" - {item}", file=sys.stderr) + return 1 + print("verify_imports OK") + langchain_core_ver = _installed_version("langchain_core") + if langchain_core_ver: + print(f"langchain_core={langchain_core_ver}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/desktop/src/.gitignore b/desktop/src/.gitignore new file mode 100644 index 00000000..734c8808 --- /dev/null +++ b/desktop/src/.gitignore @@ -0,0 +1,8 @@ +bin/ +.task/ +*.syso +bundled/portable.zip +build/windows/*.syso +build/darwin/icons.icns +build/windows/icon.ico +build/linux/icon.png diff --git a/desktop/src/Taskfile.yml b/desktop/src/Taskfile.yml new file mode 100644 index 00000000..7eae6780 --- /dev/null +++ b/desktop/src/Taskfile.yml @@ -0,0 +1,33 @@ +version: "3" + +includes: + common: ./build/Taskfile.yml + windows: ./build/windows/Taskfile.yml + darwin: ./build/darwin/Taskfile.yml + linux: ./build/linux/Taskfile.yml + +vars: + APP_NAME: "Octop" + BIN_DIR: "bin" + VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}' + +tasks: + build: + summary: Builds the Octop desktop shell + cmds: + - task: "{{OS}}:build" + + package: + summary: Packages a production build + cmds: + - task: "{{OS}}:package" + + run: + summary: Runs the built binary + cmds: + - task: "{{OS}}:run" + + dev: + summary: Dev mode (watches Go, rebuilds) + cmds: + - wails3 dev -config ./build/config.yml -port {{.VITE_PORT}} diff --git a/desktop/src/assets/index.html b/desktop/src/assets/index.html new file mode 100644 index 00000000..48945299 --- /dev/null +++ b/desktop/src/assets/index.html @@ -0,0 +1,447 @@ + + + + + + Octop + + + + +
+ Octop + +

正在启动,请稍候…

+ 正在检查运行环境,可能需要几分钟。 +
+ + + + + diff --git a/desktop/src/assets/octop-mascot-peek.webp b/desktop/src/assets/octop-mascot-peek.webp new file mode 100644 index 0000000000000000000000000000000000000000..a5461f967b5301e982029a68d47e6b19e829f9fd GIT binary patch literal 249002 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៘r zB0uj2fb!$X_wLa5Zi0d|E@Ec#+U4^)m4T1rjbY4KA<(G{0`QkdtBj^odqaFo&ir4-l=_R<}MSz!yea`};K%4u_#pjui*PC9X^x174 zhe`2Uk)U_iVl=4fs~Yr#R|N)Xxk&icCO-arCPdb0m7YPVuS?ZdY_L#@!}W$Zh!7UX z%jHGib!A||ffylWYlZ!l+>3!FZhwcPxvGA5S>XKDux{6%NOo3-_w3e!9gR*_o#iJN z6&{Cvd=!xDj0+d;E7p1JAVnf^Qxi$-1?vlMgVt{-{b|gPhIm^o?gGY!X&yPNR^k zubH_7v%Yb$x0(fHFERNcf#%OihZ;OrI9wEW0ClhKh?)gTxUrwFbQm6lTlp`^oHg;$ zbJXpci&E^tuKv6%F`z;Tpk5QJ4v&O#+Q zZ5wHYt8^xm&?YiNY`qK>Btj_Evxu5hjGU!#CY1cW+RTmoZv*}LkAad+u7fr)wn4@) zav|BnSSsJZI9{UFAe+wJj`G0TZ~ASMtRzuzRWr`(6+m8)fq8;+Wq-6J>gHf z-p|(k4q7vdvX)2-=yYY`_Q>B{u2)>EM@(qn!D3bmes|j+nFk~(3O&9!YFDmjP`_gp zmLTa>p#}!CK=O{`pu;9pNJ5NfeuM_RSK){g0kb0}m%)l^n9x@a5W52#zSuhS1)5rj zY@p={`unQ{GCAf##Vi1$p`$o45xU6OE%oIpJ2gRvs*f3(3JKV^iHIU5tjPk&;A&%k z6~!s;^#Gd2y*b!~Anh%y)WCgs3{5)=Ij6Rdk`*t?{v_U@rj@qjtcTa+yh8``V?FDZ zRO^O+wQQ`4Lv5b@bq(1G<9VAqI=t@=H#bx1eY|0ky*YTF-5xGlD(n$HwSGe$ zsGDm3uBcHX_+7EsC)oCST7jT`pboi(?cC0#n|`>ENb%(M$amVYFuX{NiFGG}3#7-T@`xvAJr=(<074+n!2l2XaCNH6w z;~1P{D=rC9x7vc#ic#>$f@awfV0rM!l1@pDi6Y+u`jW|;WI`xK2E9U}XujlxUZO{6 z?Tj8vcM-l-Uut98|87A8n>wyGX=Fx&WM&rau^JUZB<)OY&CMy<(R`;(o{}Z=XB?gh zZ>5cRdQ@uq+dvOVuU3Z{R%mVCg>s92>{_q-N+V~wfr^QCXp3Yt{Y4_NTAh?Ubj=Lm zs9;kMA$D%lN4`2ZKt}Fpn`DT5yJc3FxKwoPYoWUFFXg;SPZR06MF#r79K+;od`pdu z7>pMyK#Jtd|9boK7YF}m%KO5%fzJM428xog$nh@&-R6pY^+C#LQ|YYC;IvvX-C1um z%Xn9P54a2bn79T_VH+S|%z4otwywAIEHM2s@*f{@A&a+V%O#tB`uHP(yNrwk(-WC^_j#$>rKAxm14@9Z2}u86`+Dpj=0Cd_AX z?r$+b!eltqso5Vy)#h#NzQtq8iAu!4CwEa#q7a4fae^7+7#3KOlT+{ssxKX;}1M9=*W<*}oK!{f*;MS?J#N{BLubg!kGH%h#%MJ z8GA#6F$hOo)jiUt4X*#bGHg2}jT9S4YQ=2|{m?fh{HkE?HHO)iVKgf0<4eCHsl1SU z?@He~J#g;>qDk_sAyTAF=RjlZ{ifbN3{4hICUoa{{Y76u^O_@Ww#*zpZNqKX_CnXo zuSf}m4fEg~?g`O>l`We}CWF~c_H`Ee<`F;DXzi`3e86w0eAVUr1N?*08UR{)2I@h^ zlDRX=pvP#x`Rp}{^17R=m^qPxVDe0n?}@k0pj6Vo9yWK6{v(OJw%7D|SzAw5^})}* z`q4R_{Y?iL&zap>hqB-v%Y!(uzN?@G%;ferTvn%gq_(2w$&!VAPj4nzp84y;1jw^C z61=+_5Dqpn-}F<9A5(PCgq5nMrNV|7v{k3kVhNAMy2AldOch;=wZav@;0_TO7Bn~j^xWev(mx%_x||v`P|^&dtx9we1`|N2BRK- zZ@9yKIKf~?^K|)DFenF<%DaoxWfyAC^CAlZaUdnpDdz(Ljl}&-9Zi_YkB41lmY2ZA zG_6m~N6HE%i|ha(U<$}d=cT)H?=6u@_sWrQFgJbRg?2ee zq1+9RWl^M#vQp_~UEzU%fQw(DCXvi8OsSPcB6)MCs7DDE53|+SLhikY6~Mo%0`eVuDIj@)B1dCL6-28snuq1kvhp6CEbRoL zzo2mK&49M)6Z>zv+#PJNuggR=C7{222`k|>fh+?d-=OkDo_}CN!QLlP41;-}a8CYa zKoG^0r|ZUGruIT$!(BLbI}kviY{6!C8IWT`;)hpchJJ_pInm?*$Kz)i9Nz~`t|d}R zRzg3rG~wXaJ5q#>Rd@W>3|n54MPncCGKj~K{8|sJXZ^uUtIkO9aw~L!d-5|g7^5+% z4s1}_lwq-b;`$uJOh9J&y{EX#Ygm_2=NRRp0C4uMtpcig0GqLoFjNKfDguo)*>`vt zG{#eMHqk@Oldn7NxhAJmX3#ta5-B-Hzed0`VlxR=bl-{+?0cT)PbrvX3$p0EhOGut z&}EW|vfW$FnjBMI6GHdVH?JA@*IOHinFYkpJ}?fk=~Eii$hNt>>pD?pKVMINUGT>% z7$9RtO49tJ|LYTGg-m9e9z}KoPK7p}z7>*dLkSElVSbPVYDvcPyB@EKsT4f|ESgQH z`}&g^Sc3bshf-Z0Xn6Tq$iIRuyWN7k+z))uIz;ei^MV((KCD-?CZcG3uU`R1 zKVE@2nJPCQb0ChpNebb3Dw9+H|z)nARfu!EBE6R)G;k4R456*#QQ6DRbG z3|lmHAo=GhYvXdHhBjf@lHZTKRdaq6E%T@&(On8Hva@?tICD8$Fbvtw@hdKU6j$GoT$|&;n7iGWybOJmlnsZz z#P8<@zWlh&Ox8Fx8O%itpKTgr=T0)*qa@oEx}KjqMm$B@r2=F~o)nLjiawTpAwrK` zD!k($W6}aQO2h-Wzn88xzz5vLUEr#(5Tt&lcS9z!mYm#hB%syZ`KDrcW7vnu$zfGu z3O*v{oAYpG6ip~6rq2b12L7$^ATNgPpOvH&&7zhsFK?yE{Jt(hhHMX0GuE>GbO!J$G~oU{Oo-;7XI>;MdcV9@j6M+KeLj?x*MYe9M2WYv4O3Suaji6&ghe=H*0 zVbnMOmhziR{=UQvdqM8%&&&9PSpws4<^$P=mNxHk z#bYa?tH+r~sXBL5L+k|@G6AixL3JE0S|Oc!y*;3o)75&`L9c8dG?Ox-@lZ}Qe^R|2 zr5JqF)CgxCQ&dpl6NkP!A%~WqL?$+;p^Ef}LQ_wKAg;>RK&QXbSV7Cw33hB8?cOJm zH#t8BHa8NVra|#}aJo5*juQEQtHfYZI#muPE_FTk1w47;qClUiw3iU@>|UCI0Z5xQ zCAG|S2IwI4^o)C`T=bcKWs5-#%g44DZQSpT1lndX1f_CN6efX}#v$p!1&O0<5I!E}GUdzrZ!X_Z866V=|XV8$lYAi|tE z7~E{%0on{q+c$B!$>o(o_3ZLT+=E}`O1uqs2tS5+(qmkNOAt9^^shn;oho8l(@1ok z{55^%Gx=88nec5DPlVks4>wUs6xi}Vshl1V4bB;oFPW%cWRWe4FqOkc4LIbur|ume0{0MwE*lY) zB-v%e{ttdh&dLT0Z|?#%S6vXvUhkL95v@hg0$n|U!aQHUb~Mty-f;wBW@XGe(Kq4> zA`mEs6xRyQN+Rt<6>6C6NJ^>(OZ)^;3hp<;N+PqGhS1R9b&NT3@*QO5jfcwXK@Y;t z6xshG)a}l03}*m2%$x{~ zrStnO6DOEJsfzVu*eHqE+T=r-k7$%!DM4ET~k$LXr7MuqXjQ_0Vk4XHq zfD9Sj#yW$s-x|3KZly5dLfePW#~vh^#jBU^9}?vVFadOZj_PgmcD^5!QSMqdGbB_i z)+UJf!Pd}+O4oz$@~M+Ye$*p1*M_`Jk$TQ-lu7=6naxNIKe?=TH~FrUG+jvC>Gye; zYp~HsoMg3+s43ZtZ|cE2D83 z48QWdXH~sa1|`bw8fq}sxN7ZEC$l$P&8&A2Fzpe8ui<| z>gV~%(2(kGzNXd>+=QOVq#D<*_|_)lfF>{2>=qA17FZ@LF4BI0#_!u-Vn$hNQ@#mo>XnS9GYp5&homMf`?dh^zY)tO>xynPPn$jvY+7!1I)ZIEFL zl4$8youvRGEdGP0Qz)rA)m}}0VLM6;@)*kcaaL3V;xSb;+=c=^ z5-L~y@GVzotm?oNS_Ma?8)=aMyj~j%o1Zfjhx7PZpmSIMHUGPv8 zl4Y(~>MFTRCfQ9(1q{q#!Y9qDT)aN;>ysgGi>NMMz)A!lccVk4>q*vhoI$KK2T){| z@Dj+{DCXhbV~3q*upFP&1agxVDi18lIrZS)^!}Nk&ehOA3#pa@zfF=ovdXlQx?BO% zU*BNn^_#+8eZq_a{_t*yo0Fj>MJLLMY46t;AbC zvWA;*S+e=0qVY#H*>xNUiorinK?o|_R2}r|ZRfEEdsU^y&NW~DVqI(`q|-;8=JFxF zw7SD-_|?c}Ni-YSAk2y&+$<=8^lU#xXN^|{6Zn3II>p-#gsLu;usyT>-HJ`RNW46?EpU5$?p0rrl@4V8g;uXc9a|aA}*?B-u83RD5TBdm~;Y5@d1XR50JB zowIJG*_pesw2vehtHKne*C&_zj7sN^fyMqqxijwEiI$H_B%X-X#B^1#w`TM5=mRJY zcuRI}p^E01L!R{5bS2!iS^ZJXb8G!z(de|E19pMy9QO3>i{W280d7~3hoFTq)=e6v z8B;cUtCKU_7Ix)puomkuogshsnc1@4EX?g{lI!I0JkYXt?tsis4Q`vd;y_7+F@Dbu zsH58|R_@(o*JOjK06?Ldfqww>uq({9(KA~K3A^BDd`T+25|_dwfD(F&C+rt&xICkA zyI>z&WnHIg+pw6a`5_15Pn#yYZn#&*>{&m6f`#~3{>0Z|A}|ohd=-wCdBl8jX{~L8 zOvm^-ToV+5mw7d0FkTw9HYsl04X}#{6Zkba}x^+FZ%|?zG`872(Nef)d}GLY|@Uh z=mSz?V{@YiXA_mbuV?&amTnH*6^t%WJCItFc1J)sEo=*-i%&t91;*Z|6r{gnXB8@k z?Fubre#>cnsS6oP?%Yiul?Jg6WZGF-J4u+$EO9GlWtG_?2W8^e0Bt^b#OTClC4*Rh z?raMQNJQB2J*6lZh^N|~H_Hp(>t3BiOM!AcZm zdK?@X>*0g`U9-wsm^Ks(J@xoDCpm2h8|Ntw5jOjV<)1=P<(OFTDY86I|}r!+d)~L$u}ToCtOa~enH%|CrJ^v@0%&0|5$8d zJjwsGSW|}&>ef;m4gS*I-qjJxS}3SW*OY-`WkT1JcN8n<16bQ_G?${M#I5j2aN*NGjbHv8G~?p0{G2SnSm zGe?C|5O}>WxqqcmdvM{t+n5?#aiL%?PgB>F`2Z~NJ6`KrD=GFy+xHKxRXINfGch?n z#nzSlNjH(J&)BuNKC4{Zce=aR#}OPvIl$wVwXyF_D>S#?Y;lc0N#-ttN-hCwkc?5_ z%G+MN&1%4DUuv}(v2wN_XcR|1Xx1n`4S?}am=XdN-EWEQv^0G$`02;?S|vlqaW)81 z6)$%ZclghkXY)|C9&koA-=r7#pBr^TXCndFNO!*0VOX1u1%AUvvQ9&YqJes?daW`i6B6LN8dj9({Q+irDuj;?it*bpu`Qt12UW+x z;3rG|CCU?SjC^7&hnyC#Fk&B$Y!eExFO)I>gVe}u5)pw1&B_wP)1H$BP zwtP?J5^MT0T9&#p-G5#1<~=HX)IHs_-D?}{D!Ym>ylA0q9mc(Ut{O3%x2Fm(gQjC% zHHVHSsX>|`6QX(295$ZEeYPGpHiGew^-Ku7QHz(X+PO%Mg|6hjcAq4D!mOL?9&eS| zwv1)MU|yB)4UoO4N}fXYV~beXpXKA~1+L3_hEBU7Dv0l5F=!0QF&1mfVK%DF^<~)> zk$&bdOL?=9>x>OoGIM|bg>+Zu|83sg_J@Odi`kJcbItnINg+-0)kDOTd-31FlTIcQ zrj#47O_bnf-@tTK?dv0NX39MzA5}2WoKuki7{94!+y0hi0M^xU$O`=X8~)EdqO98S zojoR+J+diF82*wNYI)O`n37(Ri_Ry+ZojK&IC|`VJa!}WtETM%k?6Tt+8rq@bt9b^ z)%ZJ-nogt2k$V&Y4Rs+FnIMx@8oaBEQ4BQ!2xMkdf;{~`4F(`2*78Iw1xvTv5?VCD z10zZW*EGq@*`foS9$AGpomANl}8`X^DR((3KRkF$z)1?1|7#PNBaG zw?&fDDWxTgAG0wI(s_3+7|_U`ghwn*~Yq*X;&zlyTbIi$H~eo|e)4N_eo&gW&p?!r}>=vY`9i`~-O`ovBZ z$JW5GGzt31Hi(ltZd8s?+ApD4u1bi94M_f3DUcx`-=c!cE#$z=~CH59RW@*YblBoLchO&HWE_-~Pg`R?6T zNc!(oDruZ3Nj?!OOw)ge>;o_B8}}Up=Q^Zz1L$A{u0Mbu2-mD@>hqB;TJ!8a(Mp30*Q`&O z|Is^$G$?g_OD`@Imb5ZgsBFtyzBFr?W^fv;RiPxkY1D%@CN~&;C7%wDB#_q`4M_UW zuimR@ZMp9o)})0A{i_7+bEoBY?H(Weld^WRFy3q55+y$9>DTgzM7yPm~;BFQ5IPepH@z zLi)wBM)eE}YVg;|p`v8z!{z;iV`-W+$#8?gT;%xD7y-PJcL)x)*Z=D4oq}Wu6gJzo zZQHhO+s1C&wr#t2+qP}nwz1pYx6hgTPt3ebR7GS)K2$_KWkuFn-@+~II1UkQ^EmGp zDnttA!+6jXj@-RIgU{>FBZ4~4zOwsW%wJ?U??fFX!A)Si?<_UW98Z${hlKGIIie%u z5V9nw#5u+hA}@_6x(7ia(Sqsn>cq=q28=NO=*U`PO0J!^o~SA0eXHQ(V$&YZpBqty zCUqI~=c7gWn`_##HxHI6`9%bctJ>~J+&s1%-j~%~E_4(Qj=z|4b;Nni0^D@Hu!-BotU@9%lM}n8R68%b@>oUT!?-f^45Pk{$Iq%8}VZb<< z=gL8(xTk}3BXx$$h+{0LxiX@$@HVu6$ z_H9GZxQW12H_yYVyOpJCi>34HcN;GC8xeB41;Z;Yf9ET$Y92yfM}jcQ+t%bW^?~`h zEMroz#FRiEfvA6ZrcaCAvlziPlc8PF@{@WCx9S50mv67}BN@((L}}DzQlIb$7+cq% z{FRMcm=2#ia~<3MBL`GrjYr9g#P#)h+DTlsLI=q;z?MG*Y?OnVy4da2&iTh*LXs8u zL+L8*4eK=hLnxC3wLa$sWn8QB4dMu-TYraw^H8pqsq)WyC8moZeRf@D<;E8-mx7r) zBnC+>c-3Bk!;MBNRG5By<}62d*?+9o()|DVT@66T+*Jc7P4sdSIyo}JR!$10l1Id> z<(8TodGZhaY(DA=et1e#m#`vb0P`*I8utfP%)H(0qhd1s_}y=|d`+-pFT zd3})*lf~H^#{=iw+Q9h4U&?S-f8c>%=>gFT3L;K#KHcY#SV)VRe+SVU6cnhFD?f}3ri0u0bU23{e@oU4*($3 z`P{CmCa$ufOgx^H0It7VNi=|L^u=MSBE$9=c1VTpjcc~QIynK0l%j%#3g^C}h&ipL zm5uywYl*i}z!63j5=`#A;sIe6a78kk&v~Oy>zyYG5M*x^M?WX?u!?jpD8>r)cyd;z z7SiV~d-nqVK0skhpF|AMSY`)2ptWhm8QM5!`^)FyAKJ$-(!GT1zXxD4Vot~` zyv!~@*=IZwt}CMK&kw$q5H3guGJo1*2LK|_-Yp%nMUM`}r%ne+WU>RvgLGkY@tag! zH)ihnWn?MBL8<%F;JBw)1l9kXB2Xjvx;W7Ikeh!6vMx(geLQNFYHkE01X}9vt&17|hvGCFy?fVIVwJ2wUaSuj+bDNQykb+ilq7wxQ> z7SCG<+<~|%Wj|*6x7M6fcJdqFZk*!%3Xdd#v_%L~eNC}-KTw9%9QRZMt)dj6Ib{~C zgI74VY_;~sq^~w(Y;wj~h4=4pb$ZmiemUk+r0<|@`Ql8Yq6*|n zI8LsGW9)1mShoS~8>1;pR8b|~VOfam?gX=sp2uOT>tZW6s2~s8%qa(;ZW}|)>XkLgF@o>fb%)kDW!3I_Z7j^f zVJ6g1T6c3~5}5jx6b`m08-c>|&rKWysCrr1oKU6rXFZSdo-C5(HF#-S2BslEb+5_q zKx!2{vl=Z{GBKZCvp4Vzgr=qVh=o+<9S{`c^b-mOuxby+KE!tbRM*5GD-w;_oTY>~ zpv04~kt{bk@r1HaKG-48AqUrt2n*00FV)Y@&6=WLHHGpx2!<$wT35e>bGhuhq1G=OL=-m3M))cNF89Zey| z_Y8TRURJ58HV4K-r;zLMZgAx>0QKkf-WQV|S z#s*g^*IJ600RJ^mJTNp8b#(3+@+Wgwp(x#)-1-_32vXn*88?nq z5_4yMTXCJ*x4N=fs5!iwqmk%cqz*C;U$0U?x}xHWi3o6VBY*?I=rnpm#;PpXwUc7i zrWwFg=s-Zq1gJSKI*WA?M10mv@h>-s)qv5gd;0@`h*X%lT8>!i1MOb)8+0M{7MA@p z{RMZa8p@#r1B6*mkSAR$#26QTMXl?D(oJ>K>%<4^)L*3mMN3O4sd)Sr@xJ@S^y4OHSU5x zSlUB*>rBhlqzIiv1&NvVjDovR0G zP0g4{l3i$SrNA-%VvM=mu*AyJuvOLmZ^E+GnB#+{5b*;;Vcwq)WZT?Fz)EIy?q&oA zW`6NFvx8e3=85Du42pm`5Q3>s1r>90g`m$7?sp_9a9pEaL)tH%@0xYK+gzWYr$sv$ zAMWP|Jg+)g-X)5!ayR%P8HV7kPu~N#$6e~KO{Z2RDUoY-r3AXAkd3~8e-jyD!O3z3 z-S)VY9Or}IQ+Scs)y=9_E3L!<(-CTOlRmB7+8Cu|^10$GoT*IZ4@l{z&-O_1y?Y!5 z*a)B-^1Nz^3UdjrF~EXDlDQFNCQpqB+ZHK`xo>2rq#h)ahEo`+0v>H!c-xQdZ$vEs zPbodNGq`|~F=Q>!j~xn^-mOyxj)Pcrlx}fLvKjofOi+e0k=Fmdb+_3vHeh{_(wm&2 zzgDaiNVCQ>aOA7wlw|I&cK0T^D*j3W@6?OIy-Q8CreFCmqSM}S!2nJx%o%xubHYsc zlFH=?5DSh zi-R$xY6t?Njd|t*GOy3Dy6Z+XX6$dFYzBEiL_|P~K69NR9D6v1LgrYU!^5L;N7mcN zfi;73o#&OE*JCjRKa=vKC4G;O)>@5mF^*8|xtS(5F&f$|e7e9o7yf9E{d8>ze%xUP z^UH7#z#9k5H6A4KNXw*#ckKpJ~~Q`e!Iv z`magaXC9C+^EcI_5AhoVmhpdT<))~pf8afvJ6QXNO)+IC?MqR`jPyC)y*MlSD+c)? zV~=U<>@Xre}F%Hr1X#$ zloKE{>2e@lXQF07ZU~dot`ld`R~P50=*;;*E^2!>kIh={+#f_XcO$K)(dM^Ti=pfP zXbs4$G3;4%1Cc6&Z_FV9cE6OLLq!_K&9KJZFi&f6C3kDNGQNa&ERQk>7)L{-AF?&* z^m)jy*<*5BtAf@u_b^x;S6Guxk^hq)kqDz&#R9aPs9npkxh-Hy%OJsyFUN7$f*3D;nn-yuRki(IFe0JtKvW!B$^uoGwl;bI_RwZi7e=Gg$wfYz@E zIlz~i!MK!x!Nl>KBMk(UJ+8h0Rq=sW@Mi~--k&5!JKe=O0|ZfMQ@J;wcf2=-%Bl*$ z?HS;vs-CpF^^5Mbn>&NAL3V(`N_C7$%ZBe6Gbl`WXeKR0)g)D*-vk!lXX1$OGQa_DqR?fLW+a6Gq$PusKa|tViP_o@@G=`n}>m*pNwoV;}YH+8*fMn&;!FGfX6(Yr-0f zNI3#BJGRdhp%TL$gyG6i>Q2g-Z#B-YQ8y1&O+Vz50iK!rtY#z7|W2rOZL&) zFbo#ny94bqYr5q|oGPR@Z=09-twCuIAa4;p3!qap%*65pCo*RL3IOo+6I3e{joX!< zkG8b)LY}!g{7m@BN@azXQNXNXOa#ezVd@wp${1|z?W-$DW{d;xQf!Q+fIWK-T}6EkMGsnZ z;@dE3^~(ahFn?1(ehfp(;`if**Ndc}1t!ZkE2Ec_)0q7ToleSo#yM+_+3^F>DRnc9 z!gLc_s<3PMMCq}?1>E8M_)-Q#Y!1%WAw2j7LMPaxaV*1W4C;G-Ky`wxk>jz~hOL^; zo@HOFiy$>3f+>z6lMNuj%k6V5YUN zxnHegBfL^WI`4d+i=7na@@8$6R3pBLDDl!;3rwuU`8PL~U`Benc`YF#HE>~1b9ue^ z9s0(>q@!oiUVep9cWMnjS0dSttL_m+0=%i*qI@W|SOMj`CX9f$A`A>8RiPoNa^*$? z*iF<(dnm`!lDZRWf2MIz>i2&~Dbj=A3&XOR4CjcWyo>0)U*~y69XDS)H zTdzvXj|y@#09Y*nYinK45zLQ@Ee$|H!O9N;zFD7-rr)jq8S_x*Gdh&fxg`7)2y%bD zSgDIBYWUEO`3bj4--7a5xSVM9m(4rifGY2FS?Ot`>mlb%(^mJX;4BxDO~5z&%>CH( z$NfVFtnXii-Sf$r1*@~$3Z7F6<~5A;1Vc|8|0kJ_Eb#a`BXEH+W-xd%e)IxE}>23BMwYdT%~4z~r%Cf)9a z2V62SGT7nCSZns%spHMbMMmn5S*!famwf(A1n^uu4-#lH`Nbc_yv6W+#?LLIB82e5 zb$;%cy&9e{9H0BGStANWNV~d6k+{^tJs1$p+o%$!SxZ6r>gY!t}%b`?90v?XdNT#GMvjYKit$hRe`lL{3=aZgHam$a04F?4hp#x#I?Qbl4EP_!9+FtS1H z`}RzW3CPFD6%H1ik?!n7nwgXU2DFNXqyW_V?50upY8(iLu6WVT1Gm*AgD=_L0xDx{ z%%VqpZuU3;1=jh5@qCpXh8B?S4u$#|m8F$Lk|gDjdYW`ryrDDRLz@rrxDD^}CJ!}# zj_5)#YPyMSENm|W{DONOQ+FSGw+-#qdH#>2)uvLlf9FXrD?c?wyO+Qu6cqr5V0)&L zRO=apRB&u_HM-Su!f0?JrD?aetuz%W1wHH2Vg+yC(_}roxt;!*;L&&)<78_P`$nzy z60nxUSCYWrttQA*Xj_g%$1_l%iVIKp>pnq|`=LdG!OB2jHIvPfBOoS!nJL6Hxy0bX z2VTu4m`-)|dsUL%5?wRnVqzEJ_U9GK+=5dI!2|@YF4Qt=KfP8O{-pO7bLKMf5ZhN& z)3_0TCyRtvc@uKKO333QZK`d)%1qhjQ~QqRM=-u5hm(z9}qr8m5k5qOL;MifGiFnqst2&be!lPGj;77S+>uOV5)`^_}1 zaqJkM@$aVBtqZo+;#MBH9G_GmnTv~+BXzpt=EuH3XB%D^Dyo7t_z%TsqjRua zy=q*(T0e*ZgNC# z8sCc4>Mm$JHnXN!szeI*L|oKZ~b2{c%LoihD;5 z;{ZZ@%6F7L6a&BbND)@dpvQVYkno@CV2gIo;s$m(}?{5L=-z!0g-M)zEF=F z1~#**TBKdh2KHFGBevVtIVH7OG*L5hzkMG<=K7X?zc*E>e-}nmV{fIrnh9QLCi25r ze57}*;R}s0OW1?3rv1LRY83(Tvds(9`Iba6RCbu2`YA98u?i)wc?a*RC!uHg-%nS6PyWL(U=jS!mH(S# z_<#1QZU29c0W=2fg81}rek?;10$C|~0r4vbnK>a2r};j>Aa0w18J>N&zb2MA+(sOr zSAPOwJ;BdT%C^ye=Cz6O#YKL|{l9O2e*c`>6`b6ASGE7D!qr&xz|k0ms8uznW{e($ z!uS_>fzf9d1O*VptN?UzKx*N_zc#F?*bfN(#YR)WEO-8TEC&C zGq2l3ncTVp;2D%2sQHD~moE;;3lItoWzjrPhpy5zl8>vV{FyrN?#>5Wbw2y6l(%7P zF*_=k-*#sXRTEI)D(rH5E1F~Eb9i5>{*;%9)#9P8X`pY`pJL&4Q@~tYa#wRGhOSh`2&VzN}+HUSBD3oO3t;ha|{_MSKI|y$FxJ$Ry>{_0^wl44ne7vg>FAk^v?rPt% z3SAniw?u;at_`7?@*`bCx?iMrAMOGL{byPG#mFiMmEp7602MoD11lB4{-f3C+TRXM zn=^K`t)bH#+5}_YhI(-Kpt#kKS{ZVNt6p$>SC0{YO2YK+yAP_`*V$r@Gy|V`k(Rot z8K49BH(LE4mZ9aZF9=Qe$Ceu04Ofd$D${F4GRrA*BF-sceP^h!6mos(68L-byv~U3<0@ zAV-v(Emwm4!VJ@!&0_H^Ddw`xPKsnmvX?ngg`^GUB7z42jDN1(^HEF>0z&==W&rtL zFaz2z%s`|^`oCd@*#E)|vccLA$&h9s3$*iMNQ8*8giVeKT!kq_vLf0b>ZD3!vRjet zj1T2%QY25heUTiC^e#I$M?w9)Ys(htkFw@j6(%Y-@LTQH+)sogz)ZzjC=} zXy&h64tZh5cr5g6{zt*@U;~LQ>NZ8l#~5F2*Yy(TKXN%YUCMvta-9Fl<+;?S5TmKs zP=If+>@&hZwgeQ)=nr*g?Z2B#Q=q}en!j>+&Rlrrq;SypHwCcqnE)O}IWQGX=#i1Y zS^Aa^NRZt4G8Kxi;C~0UW2KY90o!#!`{wF#4||swD?ZUX)ja8mS4Vd)ImAdrkUTkM7p&aN)Rpr8%Qz%@gMNq&2d=Vl?Owwc2Ny@VHHHY9ZqiC_h<` z1i?nMami9bTb0?!>hK148I!h*hr~Xp4w(W=SwnTk%=rO%*-~1S*rZ?A2~3lOKg?q} z>v7*8)9(oXuIgPdY7NXX{@s$tWK^5uyKpEZ^~PzO@L?g|84;#z9Q_^-e_P|Xv3;6i zXEv&(njQKypQU$`7?>b;Ge%6l9}Nyx*-4L2pKu4l4;S?6=@W_6eSHM}@4zr7iK`qoxs494~W?K*#q_d>OaNYVz}#+)Aa)S z*>$-D6}q!Y%O!xZYiAat7?~l!U{*)l_m3qeBWS`V6=HHJgo=F8#UQ3J2A}AYz%2tZ zKJ$l+uHp=ti^iG^`i)k@#L5|4dVTCY?C+PtncGCSyM;&nZCq^pegEA>8CG;q`VCim zI=)LfczQakWanmkJ8EU)52f(c|23*ZP6WX06P=<8>Zw<$b_rnJaJuK*ET?MV$C_kb!{4V4xq ze(xYri%Rk-vlh?9E+QEVYE#CR6bR-PQ68Iwrc^SSR9CVCnrrVMNsCf)Ig7CLf7d$5 zrFNu;LekjCiEf66)Yh)n-kSOKzi#=(xdcJQg$u^GoYi?aqN)oS$z!3MQ1&S6 z6Kg@)#ufFDmsw-*Pz~D~l*8`!5f)@_@6lD%kiM$Ak3fU|2O>cCHaR4*5;(uaJBbf} zWX+4@)a8@kZ;+wvzYE(2m;e9_|3|O-mu2`}*hYTO5&5rO{;!+xqw|NZd_By#5x)=4 z{RH+3|HKafne%1aE#G+Q+xWv%%Wv|>@hj|CCg-mGKHa7t%2VI1@w@Q~{Vw+%eE$3z zd#3#${L%j^?WQlXyZg?^U+LX=F7br;UiEbH-TUPDK7N<~`I!!X8hqSs?KGY_)j@9m zlxY2$ZrA1l@OAwuxYqu@U1aZ^t?PaI2=VQHPI=P*N@es@K=lT0=_*$9AZ2#bQ>x@i z%;>D8RLzx?*!B2#(|+YEGRA) z<8{`04ou6TQZWzgMT+iMBtTuj+cM4&abrD(ni0A4rnYqD$@Y-};cP z+E$Xv^4r*mobH?duEqR*VcZ%y#)=gdmKYE4&QM~2`@d+DVX?Ns1n>jjO3#mp~gYK~{D7y>iRaUE;#Lv zMoSk!#WpaddRBPqi3CM(dGv3IfK8+`u=eGTekIXF8J0|SZp8Jnujkq+L&1RK!vbSf z(kK4vXvW7`#uIT0@|iKxrhF(M(*;j8Q(HgauuSOmtIp59wdL=u z>7S$$41evERM8Ey4y2Br0GqZATJm)g4!qZrY`Gu#yf4y)B3>`WcwXoW z8v_uRKD^!IxB((j$fwUAW=FTy^br2odlG}SvzXk}%M;>jVj8p}wI+9aKy?W0aqNh8 zYbrlk;3eH(E+LAb#-fz3DrVKM+yU%S4it(a5r1&2C?}t334Bs<;J)u0^R|8oa6S%# zHDF6T{vsaQyF&>2zm6+Y`aG0S-2DtiWdFpSn>n-XVaztSeD>E zeBXAPI_S)V`A6|Pg(LU2`b4-p(M1i8DFbl1?l#QQ+#qH#@^7I)bPAIRP57Bw~IzTKOkIvg3o8eIq+44I^HsYPDLJQ_AT*!u zqi3e7F+7&XH4!qp7eMT+?pM@K1N(|hSV#(6pkt50#b-nP@8G@rbJMR(sE%uVVVT5m z#~$`PeJg^(@POkiD4M)WN+XBLniZD>R>99oTk_<-N5iM4_Udp&?3P)ZQ=bGFLGw#% zAgomksAKabEbq&j= zG1MjV>_eSj?nz}y6wEztx#?|8<5S%<*o^F)37sOKtHCg#Ol@Az1Y>(0dWaBA#3A~C zHH+d8%%CVo51)9UP0xJ$hBd^WO0S`u57 z>4#S2sOCz;atc7|-jh?*tp=buJk-nZFc-~b!9=qo@{h&smBei~<}djwGOy2Zl@6=L z_wE6$yXc(I4C7$KHgYl*8m@5imJ(YUg9Y}wry+K^w`2|#R${N^L&SL@!U8I)RCVhL z@2!8L9ReF|ApqWJqiFpBo0V+wQGXWk9qoRGwmB#lgz8Lt2sT>}3?wfpr#l5yPUu^# z5N?lRcgo<2oYpm@G|0=#^CCgXW7Fft#dswxmk7w}0W)QP+1M@!?U zurbOsi733n>1)lgV0Kc{1r5$09yI?LEn+9!y7p%ghYYNx7VNAMWGCZ_L;YQeVZlxa zBZH5DcODKAjzARtxslIAofv@B_Lyw4V>OiXQQbmwPIXEf>ju_Zc6&Vt29MhiI)|w- zkDX@X1ey#u3|JhcO`cwRD+xvaLWUNn*=Kc76_`7I_VpXm#JtrWvQ!{w3fhx%a8oj0 zlhGPrVj%S#!M+%6@2km*o%_+xwA=92c!x3VNOQwG;!`i!*wt`Cp76WF1xu^^`9|oC zfBA_LVRSSGUN~L&U?ZX4e{pI#u~1LlA~Zo#9K^5=1T6=ptFeL}G^tx#FH8aM=$rmdg<) z^P+7lKj8>)&mc|5T4Z5!F|`N5(ZtK?-DUt9pY~M6*6Dvk-e1GUBaMQ))|RaUI<4W? zjN%=HLVYI>MPuX-*dZqb4GtzqIBj+NgU+?P0Ht88{W8V?DY-p;E!Zc>=rO((ZId zkLdDwTgBC?7rH1KRp##O8)bYmuiE6_IdDR%Z@#eBY^n^7wyK_MKIir; zUq#r?_Va$;#Oh3|6%yh*sfk;s_>;_QKbDo_o}I^`Q{2%Rb)TEL z$$4vjxoolP_W;0IMWU;WU#vp&Rm%?mQpz}HpTGMvGWz+%v-ob-tzj@R%9>SCl+UQS zuM1+ldW3{wo3t~mJ(Rt6liV_4g&Q4C4U*4uC@_oq<~tbCCm|}fyFc5zj`6HtOS=il zBe_n6ROGEbWPOitVv6m74_o_& z-m1L<`&Gv0&Cym^B2b_>EbwOaVlxvih^#s@Xic$@i>>;gNrL$wo3KRobHLr&8q>ei z9h}!0w^hccudPE$tg3AG_83Bm++R|w`65avC+;g8L(*n{ax9dD+gz7sOc(igPWp8_ z+n;rs#b@wt*VGzC6OaZJ(uaH=p7@l%qb#NPxS94;;EGK{A+;|iqG|hlu+Maqr+mqD zh~R>`lyTG0k2R=9z(B!zpoPiXn;|B4PnUTnJaq&NnSMN`sbq8@YBzb!+3fNuk%Ut( zS^hD03QE2YdQe>Vz~gqmC9lwz;duA#mv{NvUu}`>ioKkFj|Hmvm*nPWRd}%#WGx2( zu1~9?yh$myb)#E@WZF4J8sCiku&+1|+qM}(YZVg?o`ye5jJ0n3bSZx+H^d7pXz<$s zyea8dUkh4lmAn^)M-mkiRxw(rWRj`9Ve_B9wxN!P5Q(S#pe*uM9KufCb*R6|0zqRK zf)E*C1Y9II;4xKPm|Qkw?RSg``klfygyHU9bB=>&A3ZkW$N3=WALHbqQZQ~owqaJ> zPTS_~vK$+`t;~jd05P4mupx2qF6FKY8)&NINJC9C!iVN~+J0(0@mK@dUr;NHSibV= zv`N47^oo`!!?bih_)%Y#YX5t31whyyOI((Ig?d}_ZH}bmT z=?5&5D4dm5Qh29_!?GQ2fguRkH24F0H~F&|do7wBLKR3cteU6MWYMKcYjitsm#sYh zId+D<4b%ky7$rhB^?HcTIt3kOTMypef_En`v==2L&HgF&mi{1G1S?c5W=5T{aKNXK zX*-7N1H!n!^;e`NuqlT^wZ%!2*xfBa@44(1w~76*OK1zB<{D~RPKqxa+GQ(Q#M>a@ zROX?_|MVCEB4Z!xH!{--jgEy-B=2CP(Q^q5ngO%=>iXf6oylD|Ro)m^y{IV2zp0c^ zBPW9P16B5>KN$`)emqxV2cxD!W~3#He1QEiBnk_@O_?%_CSRNDe8zOQ$c^QJb3bKw zvybPBEQ!;8`%r>a#S0rlDeknUh1r))%a@lFzDMqHXl&m9nF`=DqpjaXxLU)Wd^r{c zIZu6uU4K6b-_K!;^HO$WWji6tyCz9FbuBs_fqFdHo)+}}p{E*Gyt2A@%Zd(1FK?vA zd)Yl@wPea|oF`N02&;&>Da`Y4StYWR@vrV%ly0G6jZ;#DLzLGRU0};K_fDKtL->k< zHYh=nA@Qe_jyE`l{+*@9(cTj;v9O7J)t@TaEl+UFaBb}7UI~r-`3Ya0O(50r+VF5F zLp!vI7@qypj&hJ*`+N*!0RSyvR_xxhHT|)KB9c}djH~{`8r5?9P1)^D!*vl-xieAr zpmLxfKP%@9(Gi5dMF+)GeaxWNDp?0YW`uN`ccMkQq+I*=G|7aR1N(t1&=Qr7V>3dE zu#y)GXRW`5HWt4=$}?LtCe88&NV$~hR_Ha%HactNixVOv=d0#!o8-TfL@kcv79aW? zfS*^Zfoahq**y3SoP&}u}*-@+(2T?~OM zWRwc&+HMrSmDkB4{i`x6aRjo-ENSakx_9*)W5VLUCkiPT)PZK%*)b0G)Pz4$xi>f8p1r4u}Ne!^!i>sN_wJ z2o*|I@)yNyzys%+&<;&}AtQ4 zApa$YJ}%U`6j-4Q^^z6A<>C`7xIA7&3GB)vA!6Z$77LtUo+a`yxw3w3K3t)Qg{_%2sOvj0)x&K;CI>u0 zN|0pFiPe9ys>Kv7y25lhUjgKmKvLCnxb(UrF{O zRe5kKS9-rs>hFK0YEv*|3FL#WD{dL&Hg;r-rv=Y*RXQ@@M>mv|i=kdKg*8Y7{GW| z($)n&S=I0 z#07UtzW_e-1bUKp89WMImG+9Z!^`5hXUiy&0$4nDty;<4j`gCX`ojH31;^IF9Ak@J z`*~GSs2Y8PaB!qb#_D*Kh%AmVnHAb8j&{<5+ipeEL^Nx}RC-vD$tj?)QXfXL(KCD| zhpbjNFQNxV8FaPC%q$w_x&mA#+^sW?VUGj1`TP!R^2^BdxNuS3?Icw;A79LAl{Y1f zTnK|j%h0M1O+HM|8Vl&^#E^YKRUMPfSomQCGEHMiK3x(wi4;dSmXA2Ze1vJ(pS(UX zC2z9!iTP0I>q}{GK72`EB*LQ;)<)w?>6x-nlMTibKsQ+irHpP}c)VT0*+pZc%+`Ce z3%-dpj@C=sQc&IN2C->9h*|c$bGU3ftWf zcaZ5w-42mVLx=c|{!H{GZc^AZNV(Gv(q9A>-wbH!8+SePf{+vf{6rEOXytO6-##tc z#^QIaG5Dpe`0f%P97pPC#u%g>9+@rCh@bCpM9TWTLM^wAEqL12Zs~;x2MVBU|6Y!I z5Dd~yihU)j*)3zLlUqp8j|C<8?qyznKlv6tH#rTi|={I^|E}D zl10KL?p*xKHGE9MiX(3}`%7Y8K_BuNBoj1vv>JXzb1QT!?Zhqy8?UrOK;!jhZfI7} zw-!wiN|bF2&$OcBS1%WH7lP<;K$&Aa7lLrx+krI$bp zaV&1S9v6fdwk436OVFh_8A5v}YJFCen;_&P4P9clHOmc5{t`>Lxod1j5;(10EVY8s zV_mJEYvNA_Y0rR1gC`aK=#08pi5ZcwEF_731Qa1IF(n9Cn>R8$@N%usPJojv027&E zqbq|7a2mJL(tU3lJAU-=m&mP!DUI|1SSXrH9CkKJsJ`1bfg9m}s?yO+%br1!4Q0}M z3qoQSC_>*n{2T)elf03?a*fRg`p2H#VaAk+fudoZPj|`FIlhVK3J~ zd58Tpt<&D;;xTVzf%Wt*v~9AHr6D3=o}NEuhUGK#{kT20R~#?go%J8rkp?fAAG6?S pXVr}hb_Zl5lmhL_kh6W%jYb{S>^y%M)Vt~$p>zHR2l(&t{{x4#X!ZaA literal 0 HcmV?d00001 diff --git a/desktop/src/assets/octop-mascot-type.webp b/desktop/src/assets/octop-mascot-type.webp new file mode 100644 index 0000000000000000000000000000000000000000..8dbe995462c35808fe651f4b9c877ac2eb0e1934 GIT binary patch literal 378064 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

Z}-W;#Ey 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�W)VO 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 g7D&+t0000000000000000000000000000000JBe&Z~y=R literal 0 HcmV?d00001 diff --git a/desktop/src/assets/tray-icon.png b/desktop/src/assets/tray-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..8b84ff0e0f5cf890692e52adc05038600cbde6ad GIT binary patch literal 12269 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

+ ); } diff --git a/dashboard/src/layouts/PageShell.module.less b/dashboard/src/layouts/PageShell.module.less index 259a1d7e..d201bc68 100644 --- a/dashboard/src/layouts/PageShell.module.less +++ b/dashboard/src/layouts/PageShell.module.less @@ -83,7 +83,8 @@ @media (max-width: 767px) { flex: none; - overflow: visible; + overflow-x: hidden; + overflow-y: visible; :global(.octop-tabs-content-holder), :global(.ant-tabs-content-holder), @@ -92,7 +93,9 @@ :global(.octop-tabs-tabpane), :global(.ant-tabs-tabpane) { height: auto !important; - overflow: visible; + max-width: 100%; + overflow-x: hidden; + overflow-y: visible; } } } diff --git a/dashboard/src/layouts/PageShell.tsx b/dashboard/src/layouts/PageShell.tsx index b6cebfee..0e81f416 100644 --- a/dashboard/src/layouts/PageShell.tsx +++ b/dashboard/src/layouts/PageShell.tsx @@ -170,8 +170,12 @@ function PageShell({ background: "var(--fn-bg-container, var(--fn-bg-elevated))", borderRadius: 8, padding: contentPad, - overflow: pinBody ? "hidden" : "auto", + // Mobile: never create a page-level horizontal scrollbar; wide + // tables scroll via antd scroll.x inside their own wrapper. + overflowX: pinBody || isMobile ? "hidden" : "auto", + overflowY: pinBody ? "hidden" : "auto", minHeight: 0, + minWidth: 0, display: pinBody ? "flex" : undefined, flexDirection: pinBody ? "column" : undefined, }} diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index e7d448ea..ba8fdb7d 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -69,7 +69,7 @@ "SETUP_REQUIRED": "Initial setup is required.", "DATABASE_NOT_EMPTY": "The target database already has users. Use an empty database, or log in with the existing admin.", "BACKUP_DRIVER_MISMATCH": "This backup was made with a different database engine (SQLite vs PostgreSQL). Switch the runtime to match the backup, or create a new backup on the current engine. Cross-engine restore is not supported.", - "BACKUP_IN_PROGRESS": "A backup is already in progress. Try again shortly.", + "BACKUP_IN_PROGRESS": "A backup or restore is already in progress. Try again shortly.", "FORBIDDEN": "Permission denied.", "NOT_FOUND": "Not found.", "USER_DISABLED": "This account has been disabled.", @@ -998,6 +998,7 @@ "stream_stall": "The model stopped sending content while the connection stayed open. Click Retry, or try again later with another model. If this happens often, check the provider status or ask an admin to review stream timeout settings.", "rate_limit": "The model provider rate-limited this request. Wait a moment and retry, or switch to another model.", "auth": "Model authentication failed. Check that the API key under Settings → Models is correct and still valid.", + "insufficient_balance": "The model provider rejected this request due to insufficient balance or quota. Top up or upgrade the plan for this API key, then try again.", "context_length": "This conversation is too long for the model's context window. Start a new chat, or shorten the history and try again.", "recursion_limit": "The agent hit its max iteration / recursion limit before finishing. Open Configuration and raise Max Iterations, then try again.", "timeout_network": "Connecting to the model timed out or the network failed. Check your network and retry. If you use a proxy or self-hosted endpoint, confirm it is reachable.", @@ -1177,6 +1178,11 @@ "remoteBrowserTitle": "Remote Browser", "dockTerminalTitle": "Terminal", "dockPhoneTitle": "Remote Phone", + "dockToolUiTitle": "Plugin tool", + "dockToolUiMissing": "Tool result is no longer available in this conversation.", + "openToolUiInDock": "Open in side panel", + "toolUiDockedHint": "Moved to side panel", + "toolUiDockedOpen": "Open side panel", "loadEarlierMessages": "Load earlier messages" }, "tools": { @@ -1347,6 +1353,7 @@ "storedTitle": "Backup files", "storedDesc": "System backups are stored in the directory below. Download or restore with one click.", "createButton": "Create backup", + "creating": "Backing up…", "uploadButton": "Upload backup", "uploadSuccess": "Saved {{name}}", "uploadFailed": "Upload failed", @@ -1373,9 +1380,9 @@ "importButton": "Choose backup file", "uploading": "Uploading… {{percent}}%", "restoring": "Restoring…", - "importWarning": "Restore overwrites the current database and local workspaces, then hot-reloads models, experts, and channels. If you also restore config/env, restart the service manually for those to fully apply. This cannot be undone.", + "importWarning": "Restore overwrites the current database and local workspaces, then hot-reloads models, experts, and channels. While restore runs, database-backed APIs may be briefly unavailable — prefer a maintenance window. If you also restore config/env, restart the service manually for those to fully apply. This cannot be undone.", "importConfirmTitle": "Confirm restore", - "importConfirmBody": "Restore from “{{name}}”? Existing data will be overwritten. Models, experts, and channels are hot-reloaded afterward (no service restart). If you restore config/env, restart manually for those settings to take full effect.", + "importConfirmBody": "Restore from “{{name}}”? Existing data will be overwritten, and the service may be briefly unavailable while the database is replaced. Models, experts, and channels are hot-reloaded afterward (no service restart). If you restore config/env, restart manually for those settings to take full effect.", "importConfirmOk": "Restore", "importSuccess": "Restore complete ({{agents}} agents, {{files}} workspace files)", "importFailed": "Failed to restore backup", @@ -4198,35 +4205,67 @@ "modalEditTitle": "Edit user {{username}}" }, "adminSso": { + "panelTitle": "Single sign-on", + "panelDesc": "Connect an OpenID Connect identity provider so users can sign in with your organization account.", + "statusEnabled": "Enabled · {{name}}", + "statusDisabled": "Disabled", + "statusUnnamed": "Provider", + "guideStep1": "Fill issuer & client", + "guideStep2": "Copy redirect URI", + "guideStep3": "Save settings", + "guideStep4": "Test discovery", + "guideStep5": "Enable SSO", + "guideTitle": "Setup checklist", "enabled": "Enable single sign-on", "enabledHint": "Allow users to sign in through the configured OpenID Connect provider.", + "loginPreview": "Login page preview", + "loginPreviewDisabled": "This button appears on the login page only when SSO is enabled.", + "sectionProvider": "Identity provider", + "sectionProviderHint": "Choose a preset to fill defaults, then enter your issuer URL.", + "presetsLabel": "Quick start", + "presetAzure": "Azure AD", + "presetGoogle": "Google", + "presetKeycloak": "Keycloak", + "presetOkta": "Okta", "displayName": "Provider display name", + "displayNamePlaceholder": "Shown on the login button", "displayNameRequired": "Enter a provider display name", "issuer": "Issuer URL", "issuerHint": "The OpenID Connect issuer URL published by your identity provider.", "issuerRequired": "Enter a valid issuer URL", + "sectionCredentials": "OAuth credentials", + "sectionCredentialsHint": "Client ID and secret from your identity provider application registration.", "clientId": "Client ID", "clientIdRequired": "Enter the client ID", "clientSecret": "Client secret", "clientSecretHint": "Optional for public clients.", "clientSecretConfigured": "A client secret is configured. Leave blank to keep it unchanged.", + "clientSecretConfiguredTag": "Configured", "clientSecretPlaceholder": "Leave blank to keep the current secret", "scopes": "Scopes", + "scopesPlaceholder": "openid profile email", "scopesRequired": "Enter at least one scope", + "sectionAdvanced": "Advanced options", "dashboardOrigin": "Dashboard origin override", "dashboardOriginHint": "Optional public dashboard URL used after the identity provider redirects back.", "dashboardOriginInvalid": "Enter a valid dashboard origin URL", "redirectUri": "Redirect URI", "redirectUriHint": "Add this exact callback URL to your identity provider configuration.", + "redirectUriEmpty": "Save once to generate the redirect URI", + "redirectUriDocs": "In Azure AD, Google, Keycloak, or Okta, register this URL as an allowed redirect / callback URI.", "copy": "Copy", + "copied": "Copied", "copyRedirectUri": "Copy redirect URI", "copySuccess": "Redirect URI copied", "copyFailed": "Could not copy redirect URI", "save": "Save", + "discard": "Discard", + "unsavedChanges": "You have unsaved changes.", "saved": "Single sign-on settings saved", "saveFailed": "Could not save single sign-on settings", "loadFailed": "Could not load single sign-on settings", "testConnection": "Test connection", + "testNeedsSave": "Save your changes before testing.", "testSuccess": "OIDC connection succeeded", "testFailed": "OIDC connection test failed", "testHint": "Save changes before testing a new issuer or client ID." diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 748f30ac..27dac422 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -69,7 +69,7 @@ "SETUP_REQUIRED": "需要完成初始设置。", "DATABASE_NOT_EMPTY": "目标数据库已有用户。请改用空库,或直接登录现有管理员账户。", "BACKUP_DRIVER_MISMATCH": "该备份与当前数据库引擎不一致(SQLite 与 PostgreSQL 不能互恢)。请将运行时切回备份所用引擎后再恢复,或在当前引擎上重新备份。暂不支持跨引擎恢复。", - "BACKUP_IN_PROGRESS": "已有备份任务正在进行,请稍后再试。", + "BACKUP_IN_PROGRESS": "已有备份或恢复任务正在进行,请稍后再试。", "FORBIDDEN": "没有权限。", "NOT_FOUND": "未找到。", "USER_DISABLED": "账号已禁用。", @@ -998,6 +998,7 @@ "stream_stall": "模型响应中断:连接仍在,但长时间没有新内容。请点击「重试」,或稍后更换模型再试。若经常出现,请检查供应商状态,或联系管理员排查流式超时设置。", "rate_limit": "模型请求过于频繁,已被限流。请稍等片刻后重试,或切换其他模型。", "auth": "模型服务鉴权失败。请检查「设置 → 模型」中的 API Key 是否正确、是否过期。", + "insufficient_balance": "模型服务返回余额或额度不足。请为该 API Key 对应的账户充值或升级套餐后再试。", "context_length": "对话上下文过长,超出模型限制。请新开会话,或精简历史后再试。", "recursion_limit": "智能体已达到最大迭代次数(递归上限),任务尚未完成。请前往「运行配置」调高「最大迭代次数」后重试。", "timeout_network": "连接模型服务超时或网络异常。请检查网络后重试;若使用代理或自建服务,请确认其可达。", @@ -1177,6 +1178,11 @@ "remoteBrowserTitle": "远程浏览器", "dockTerminalTitle": "终端", "dockPhoneTitle": "远程手机", + "dockToolUiTitle": "插件工具", + "dockToolUiMissing": "该工具结果已不在当前对话中。", + "openToolUiInDock": "在侧栏打开", + "toolUiDockedHint": "已移到侧栏", + "toolUiDockedOpen": "打开侧栏", "loadEarlierMessages": "加载更早的消息" }, "tools": { @@ -1346,6 +1352,7 @@ "storedTitle": "备份文件", "storedDesc": "系统备份保存在以下目录,可下载或一键恢复。", "createButton": "新建备份", + "creating": "备份中…", "uploadButton": "上传备份", "uploadSuccess": "已保存 {{name}}", "uploadFailed": "上传失败", @@ -1371,10 +1378,10 @@ "importDesc": "从先前导出的 .tar.gz 归档恢复系统。建议在维护窗口操作。", "importButton": "选择备份文件", "uploading": "正在上传… {{percent}}%", - "restoring": "正在恢复…", - "importWarning": "恢复将覆盖当前数据库与本地工作区,并热加载模型、专家与通道。若同时恢复了 config/env,完整生效仍需手动重启服务。此操作不可撤销。", + "restoring": "恢复中…", + "importWarning": "恢复将覆盖当前数据库与本地工作区,并热加载模型、专家与通道。恢复期间依赖数据库的接口可能短暂不可用,建议在维护窗口操作。若同时恢复了 config/env,完整生效仍需手动重启服务。此操作不可撤销。", "importConfirmTitle": "确认恢复备份", - "importConfirmBody": "确定从「{{name}}」恢复?这将覆盖现有数据。恢复后会热加载模型、专家与通道,无需重启服务;若勾选恢复配置,config/env 需重启后才完全生效。", + "importConfirmBody": "确定从「{{name}}」恢复?这将覆盖现有数据,替换数据库期间服务可能短暂不可用。恢复后会热加载模型、专家与通道,无需重启服务;若勾选恢复配置,config/env 需重启后才完全生效。", "importConfirmOk": "恢复", "importSuccess": "恢复完成({{agents}} 个 Agent,{{files}} 个工作区文件)", "importFailed": "恢复备份失败", @@ -4337,35 +4344,67 @@ "modalEditTitle": "编辑用户 {{username}}" }, "adminSso": { + "panelTitle": "单点登录", + "panelDesc": "接入 OpenID Connect 身份提供商,让用户使用组织账号登录。", + "statusEnabled": "已启用 · {{name}}", + "statusDisabled": "未启用", + "statusUnnamed": "提供商", + "guideStep1": "填写签发者与客户端", + "guideStep2": "复制回调地址", + "guideStep3": "保存配置", + "guideStep4": "测试发现", + "guideStep5": "启用 SSO", + "guideTitle": "配置清单", "enabled": "启用单点登录", "enabledHint": "允许用户通过已配置的 OpenID Connect 身份提供商登录。", + "loginPreview": "登录页预览", + "loginPreviewDisabled": "仅在启用 SSO 后,登录页才会显示此按钮。", + "sectionProvider": "身份提供商", + "sectionProviderHint": "可先选择预设填充默认值,再填写签发者 URL。", + "presetsLabel": "快速开始", + "presetAzure": "Azure AD", + "presetGoogle": "Google", + "presetKeycloak": "Keycloak", + "presetOkta": "Okta", "displayName": "提供商显示名称", + "displayNamePlaceholder": "显示在登录按钮上", "displayNameRequired": "请输入提供商显示名称", "issuer": "签发者 URL", "issuerHint": "身份提供商公开的 OpenID Connect 签发者 URL。", "issuerRequired": "请输入有效的签发者 URL", + "sectionCredentials": "OAuth 凭证", + "sectionCredentialsHint": "来自身份提供商应用注册的客户端 ID 与密钥。", "clientId": "客户端 ID", "clientIdRequired": "请输入客户端 ID", "clientSecret": "客户端密钥", "clientSecretHint": "公共客户端可不填写。", "clientSecretConfigured": "已配置客户端密钥;留空将保留现有密钥。", + "clientSecretConfiguredTag": "已配置", "clientSecretPlaceholder": "留空以保留现有密钥", "scopes": "授权范围", + "scopesPlaceholder": "openid profile email", "scopesRequired": "请至少输入一个授权范围", + "sectionAdvanced": "高级选项", "dashboardOrigin": "控制台来源地址覆盖", "dashboardOriginHint": "可选。身份提供商回调后使用的公开控制台 URL。", "dashboardOriginInvalid": "请输入有效的控制台来源地址 URL", "redirectUri": "回调地址", "redirectUriHint": "请将此精确回调 URL 添加到身份提供商配置中。", + "redirectUriEmpty": "保存一次后生成回调地址", + "redirectUriDocs": "在 Azure AD、Google、Keycloak 或 Okta 中,将此 URL 注册为允许的重定向 / 回调地址。", "copy": "复制", + "copied": "已复制", "copyRedirectUri": "复制回调地址", "copySuccess": "回调地址已复制", "copyFailed": "无法复制回调地址", "save": "保存", + "discard": "放弃更改", + "unsavedChanges": "有未保存的更改。", "saved": "单点登录设置已保存", "saveFailed": "无法保存单点登录设置", "loadFailed": "无法加载单点登录设置", "testConnection": "测试连接", + "testNeedsSave": "请先保存更改再测试。", "testSuccess": "OIDC 连接成功", "testFailed": "OIDC 连接测试失败", "testHint": "更改签发者或客户端 ID 后,请先保存再测试。" diff --git a/dashboard/src/pages/Admin/Users/SsoPanel.test.tsx b/dashboard/src/pages/Admin/Users/SsoPanel.test.tsx index 93cbc50a..d563d94c 100644 --- a/dashboard/src/pages/Admin/Users/SsoPanel.test.tsx +++ b/dashboard/src/pages/Admin/Users/SsoPanel.test.tsx @@ -1,7 +1,6 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; -import { I18nextProvider } from "react-i18next"; -import i18n from "../../../i18n"; +import userEvent from "@testing-library/user-event"; const { getOidcConfig, putOidcConfig, testOidcConfig } = vi.hoisted(() => ({ getOidcConfig: vi.fn(), @@ -14,12 +13,16 @@ vi.mock("../../../api/modules/sso", () => ({ })); vi.mock("@/utils/antdMessage", () => ({ - message: { error: vi.fn(), success: vi.fn() }, + message: { error: vi.fn(), success: vi.fn(), warning: vi.fn() }, })); import SsoPanel from "./SsoPanel"; describe("", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("loads the provider configuration and displays its callback URL", async () => { getOidcConfig.mockResolvedValue({ enabled: true, @@ -32,11 +35,7 @@ describe("", () => { redirect_uri: "https://octop.example.com/api/auth/oidc/callback", }); - render( - - - , - ); + render(); await waitFor(() => expect(getOidcConfig).toHaveBeenCalledOnce()); expect(screen.getByDisplayValue("Acme SSO")).toBeInTheDocument(); @@ -45,5 +44,29 @@ describe("", () => { "https://octop.example.com/api/auth/oidc/callback", ), ).toBeInTheDocument(); + // Mocked t() returns the key; interpolation keeps {{name}} unless options used. + expect(screen.getByText("adminSso.statusEnabled")).toBeInTheDocument(); + }); + + it("applies an IdP preset into display name", async () => { + const user = userEvent.setup(); + getOidcConfig.mockResolvedValue({ + enabled: false, + display_name: "", + issuer: "", + client_id: "", + scopes: "openid profile email", + dashboard_origin: null, + has_client_secret: false, + redirect_uri: "", + }); + + render(); + + await waitFor(() => expect(getOidcConfig).toHaveBeenCalledOnce()); + await user.click( + screen.getByRole("button", { name: "adminSso.presetGoogle" }), + ); + expect(screen.getByDisplayValue("Google")).toBeInTheDocument(); }); }); diff --git a/dashboard/src/pages/Admin/Users/SsoPanel.tsx b/dashboard/src/pages/Admin/Users/SsoPanel.tsx index af387dae..ffeb62ca 100644 --- a/dashboard/src/pages/Admin/Users/SsoPanel.tsx +++ b/dashboard/src/pages/Admin/Users/SsoPanel.tsx @@ -1,6 +1,34 @@ -import { useCallback, useEffect, useState } from "react"; -import { Button, Form, Input, Space, Spin, Switch, Typography } from "antd"; -import { Copy, FlaskConical, Save } from "lucide-react"; +import { + useCallback, + useEffect, + useRef, + useState, + type ChangeEvent, +} from "react"; +import { + Alert, + Button, + Collapse, + Form, + Input, + Select, + Space, + Spin, + Switch, + Tag, + Tooltip, + Typography, +} from "antd"; +import { + Check, + CheckCircle2, + Copy, + FlaskConical, + KeyRound, + Lock, + Save, + XCircle, +} from "lucide-react"; import { useTranslation } from "react-i18next"; import { message } from "@/utils/antdMessage"; import { @@ -10,6 +38,8 @@ import { } from "../../../api/modules/sso"; import { apiErrorMessage } from "../../../utils/apiError"; import { copyText } from "../../../utils/copyText"; +import { TabPanelHeader } from "../../Settings/AdvancedSettings/TabPanelHeader"; +import styles from "./index.module.less"; interface SsoFormValues { enabled: boolean; @@ -17,21 +47,83 @@ interface SsoFormValues { issuer: string; client_id: string; client_secret?: string; - scopes: string; + scopes: string[]; dashboard_origin?: string; } +type TestResult = { ok: boolean; detail: string } | null; + +type IdpPresetId = "azure" | "google" | "keycloak" | "okta"; + +interface IdpPreset { + id: IdpPresetId; + labelKey: string; + displayName: string; + scopes: string[]; + issuerPlaceholder: string; +} + +const IDP_PRESETS: IdpPreset[] = [ + { + id: "azure", + labelKey: "adminSso.presetAzure", + displayName: "Microsoft", + scopes: ["openid", "profile", "email"], + issuerPlaceholder: "https://login.microsoftonline.com/{tenant}/v2.0", + }, + { + id: "google", + labelKey: "adminSso.presetGoogle", + displayName: "Google", + scopes: ["openid", "profile", "email"], + issuerPlaceholder: "https://accounts.google.com", + }, + { + id: "keycloak", + labelKey: "adminSso.presetKeycloak", + displayName: "Keycloak", + scopes: ["openid", "profile", "email"], + issuerPlaceholder: "https://keycloak.example.com/realms/{realm}", + }, + { + id: "okta", + labelKey: "adminSso.presetOkta", + displayName: "Okta", + scopes: ["openid", "profile", "email"], + issuerPlaceholder: "https://{domain}.okta.com", + }, +]; + +const SCOPE_OPTIONS = ["openid", "profile", "email", "offline_access"].map( + (value) => ({ value, label: value }), +); + +const GUIDE_STEPS = [ + "adminSso.guideStep1", + "adminSso.guideStep2", + "adminSso.guideStep3", + "adminSso.guideStep4", + "adminSso.guideStep5", +] as const; + function configToFormValues(config: OidcConfig): SsoFormValues { return { enabled: config.enabled, display_name: config.display_name, issuer: config.issuer, client_id: config.client_id, - scopes: config.scopes, + scopes: config.scopes + .split(/\s+/) + .map((s) => s.trim()) + .filter(Boolean), dashboard_origin: config.dashboard_origin ?? "", }; } +function normalizeIssuer(raw: string): string { + return raw.trim().replace(/\/+$/, ""); +} + export default function SsoPanel() { const { t } = useTranslation(); const [form] = Form.useForm(); @@ -40,40 +132,85 @@ export default function SsoPanel() { const [testing, setTesting] = useState(false); const [redirectUri, setRedirectUri] = useState(""); const [hasClientSecret, setHasClientSecret] = useState(false); + const [dirty, setDirty] = useState(false); + const [copied, setCopied] = useState(false); + const [testResult, setTestResult] = useState(null); + const [issuerPlaceholder, setIssuerPlaceholder] = useState( + "https://identity.example.com", + ); + const [activePreset, setActivePreset] = useState(null); + const hydratingRef = useRef(false); + + const enabled = Form.useWatch("enabled", form) ?? false; + const displayName = Form.useWatch("display_name", form) ?? ""; + const issuer = Form.useWatch("issuer", form) ?? ""; + const clientId = Form.useWatch("client_id", form) ?? ""; + + const applyConfig = useCallback( + (config: OidcConfig) => { + hydratingRef.current = true; + form.setFieldsValue(configToFormValues(config)); + form.setFieldValue("client_secret", undefined); + setRedirectUri(config.redirect_uri ?? ""); + setHasClientSecret(config.has_client_secret); + setDirty(false); + setTestResult(null); + setActivePreset(null); + queueMicrotask(() => { + hydratingRef.current = false; + }); + }, + [form], + ); const loadConfig = useCallback(async () => { setLoading(true); try { const config = await ssoApi.getOidcConfig(); - form.setFieldsValue(configToFormValues(config)); - setRedirectUri(config.redirect_uri ?? ""); - setHasClientSecret(config.has_client_secret); + applyConfig(config); } catch (error) { message.error(apiErrorMessage(error, t("adminSso.loadFailed"), t)); } finally { setLoading(false); } - }, [form, t]); + }, [applyConfig, t]); useEffect(() => { - void loadConfig(); - }, [loadConfig]); + let cancelled = false; + setLoading(true); + void (async () => { + try { + const config = await ssoApi.getOidcConfig(); + if (cancelled) return; + applyConfig(config); + } catch (error) { + if (cancelled) return; + message.error(apiErrorMessage(error, t("adminSso.loadFailed"), t)); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + // Mount-only load; reload goes through loadConfig(). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const saveConfig = async (values: SsoFormValues) => { setSaving(true); try { const body: OidcConfigPut = { - ...values, + enabled: values.enabled, + display_name: values.display_name.trim(), + issuer: normalizeIssuer(values.issuer), + client_id: values.client_id.trim(), client_secret: values.client_secret?.trim() || undefined, + scopes: values.scopes.join(" "), dashboard_origin: values.dashboard_origin?.trim() || null, }; const saved = await ssoApi.putOidcConfig(body); - form.setFieldsValue(configToFormValues(saved)); - form.setFieldValue("client_secret", undefined); - setHasClientSecret(saved.has_client_secret); - if (saved.redirect_uri) { - setRedirectUri(saved.redirect_uri); - } + applyConfig(saved); message.success(t("adminSso.saved")); } catch (error) { message.error(apiErrorMessage(error, t("adminSso.saveFailed"), t)); @@ -83,146 +220,455 @@ export default function SsoPanel() { }; const testConnection = async () => { + if (dirty) { + message.warning(t("adminSso.testNeedsSave")); + return; + } setTesting(true); + setTestResult(null); try { const result = await ssoApi.testOidcConfig(); - if (result.ok) { - message.success(result.detail || t("adminSso.testSuccess")); - } else { - message.error(result.detail || t("adminSso.testFailed")); - } + const detail = + result.detail || + (result.ok ? t("adminSso.testSuccess") : t("adminSso.testFailed")); + setTestResult({ ok: result.ok, detail }); + if (result.ok) message.success(detail); + else message.error(detail); } catch (error) { - message.error(apiErrorMessage(error, t("adminSso.testFailed"), t)); + const detail = apiErrorMessage(error, t("adminSso.testFailed"), t); + setTestResult({ ok: false, detail }); + message.error(detail); } finally { setTesting(false); } }; const copyRedirectUri = async () => { + if (!redirectUri) return; const ok = await copyText(redirectUri); - if (ok) message.success(t("adminSso.copySuccess")); - else message.error(t("adminSso.copyFailed")); + if (ok) { + message.success(t("adminSso.copySuccess")); + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); + } else { + message.error(t("adminSso.copyFailed")); + } + }; + + const applyPreset = (preset: IdpPreset) => { + form.setFieldsValue({ + display_name: preset.displayName, + scopes: preset.scopes, + }); + setIssuerPlaceholder(preset.issuerPlaceholder); + setActivePreset(preset.id); + setDirty(true); + setTestResult(null); }; + const guideStep = (() => { + if (!issuer.trim() || !clientId.trim()) return 0; + if (!redirectUri) return 1; + if (dirty) return 2; + if (!testResult?.ok) return 3; + if (!enabled) return 4; + return 5; + })(); + + const statusLabel = enabled + ? t("adminSso.statusEnabled", { + name: displayName.trim() || t("adminSso.statusUnnamed"), + }) + : t("adminSso.statusDisabled"); + return ( - - - form={form} - layout="vertical" - requiredMark={false} - onFinish={(values) => void saveConfig(values)} - style={{ maxWidth: 680 }} - initialValues={{ enabled: false, scopes: "openid profile email" }} - > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + {t("adminSso.redirectUriDocs")} + + + + {testResult && ( + + ) : ( + + ) + } + message={ + testResult.ok + ? t("adminSso.testSuccess") + : t("adminSso.testFailed") + } + description={testResult.detail} + closable + onClose={() => setTestResult(null)} + /> + )} + + + + form={form} + layout="vertical" + requiredMark={false} + onFinish={(values) => void saveConfig(values)} + onValuesChange={() => { + if (hydratingRef.current) return; + setDirty(true); + setTestResult(null); + }} + initialValues={{ + enabled: false, + scopes: ["openid", "profile", "email"], + }} + className={styles.ssoForm} > - {t("adminSso.testConnection")} - - - - {t("adminSso.testHint")} - - - +
+
+
+
+ {t("adminSso.enabled")} +
+

+ {t("adminSso.enabledHint")} +

+
+ + + +
+
+ +
+
+

+ {t("adminSso.sectionProvider")} +

+

+ {t("adminSso.sectionProviderHint")} +

+
+ +
+ + {t("adminSso.presetsLabel")} + +
+ {IDP_PRESETS.map((preset) => ( + + ))} +
+
+ + + + + ) => + e.target.value + } + > + { + const next = normalizeIssuer(e.target.value); + if (next !== e.target.value) { + form.setFieldValue("issuer", next); + if (!hydratingRef.current) setDirty(true); + } + }} + /> + +
+ +
+
+

+ {t("adminSso.sectionCredentials")} +

+

+ {t("adminSso.sectionCredentialsHint")} +

+
+
+ + + + + {t("adminSso.clientSecret")} + {hasClientSecret && ( + + + {t("adminSso.clientSecretConfiguredTag")} + + )} + + } + extra={ + hasClientSecret + ? t("adminSso.clientSecretConfigured") + : t("adminSso.clientSecretHint") + } + > + + +
+ { + if (!value || value.length === 0) { + throw new Error(t("adminSso.scopesRequired")); + } + }, + }, + ]} + > + + + ), + }, + ]} + /> + +
+
+ + + {dirty && ( + + )} +
+
+ {dirty ? ( + + {t("adminSso.unsavedChanges")} + + ) : ( + + {t("adminSso.testHint")} + + )} +
+
+ +
+ + ); } diff --git a/dashboard/src/pages/Admin/Users/UsersListPanel.tsx b/dashboard/src/pages/Admin/Users/UsersListPanel.tsx index f88c9d60..0129127a 100644 --- a/dashboard/src/pages/Admin/Users/UsersListPanel.tsx +++ b/dashboard/src/pages/Admin/Users/UsersListPanel.tsx @@ -2,7 +2,7 @@ * Admin → Users page (plan §14.7). * * List all users with role/disabled toggles, password reset, delete. - * Card and table views; default is card on mobile, table on desktop. The view switcher + refresh + + * Card and table views (default table). The view switcher + refresh + * new-user buttons live in a content-area toolbar (mirrors the Experts * page layout). Each row/card shows agent count; click opens a drawer * with that user's agents. diff --git a/dashboard/src/pages/Admin/Users/index.module.less b/dashboard/src/pages/Admin/Users/index.module.less index f31958f3..8e5fa8d2 100644 --- a/dashboard/src/pages/Admin/Users/index.module.less +++ b/dashboard/src/pages/Admin/Users/index.module.less @@ -993,3 +993,441 @@ gap: 2px; font-size: 12px; } + +/* ── SSO panel ──────────────────────────────────────────────────── */ + +.ssoPanel { + width: 100%; + min-width: 0; + padding-bottom: 88px; +} + +.ssoLayout { + display: grid; + grid-template-columns: 1fr; + gap: 16px; + align-items: start; + + @media (min-width: 1100px) { + grid-template-columns: minmax(0, 1fr) minmax(300px, 360px); + gap: 24px; + } +} + +.ssoAside { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; + order: -1; + + @media (min-width: 1100px) { + order: 0; + position: sticky; + top: 8px; + /* Place aside in the second column */ + grid-column: 2; + grid-row: 1; + } +} + +.ssoForm { + min-width: 0; + + @media (min-width: 1100px) { + grid-column: 1; + grid-row: 1; + } + + :global(.ant-form-item) { + margin-bottom: 16px; + } + + :global(.ant-form-item-label > label) { + color: var(--fn-text-secondary); + font-weight: 500; + } +} + +.ssoAsideTitle { + margin: 0 0 10px; + font-size: 13px; + font-weight: 600; + color: var(--fn-text-primary); +} + +.ssoAsideCard { + padding: 14px 16px; + border: 1px solid var(--fn-border-primary); + border-radius: var(--fn-radius-lg, 12px); + background: var(--fn-bg-primary); +} + +.ssoFieldGrid { + display: grid; + grid-template-columns: 1fr; + gap: 0 16px; + + @media (min-width: 720px) { + grid-template-columns: 1fr 1fr; + } +} + +.ssoStatusTagOn, +.ssoStatusTagOff { + display: inline-flex !important; + align-items: center; + gap: 6px; + margin: 0 !important; + padding: 2px 10px !important; + border-radius: 999px !important; + font-size: 12px !important; + font-weight: 500 !important; + line-height: 1.5 !important; + border: 1px solid transparent !important; +} + +.ssoStatusTagOn { + color: var(--fn-color-brand) !important; + background: var(--fn-color-brand-bg) !important; + border-color: var( + --fn-color-brand-border, + color-mix(in srgb, var(--fn-color-brand) 28%, transparent) + ) !important; +} + +.ssoStatusTagOff { + color: var(--fn-text-tertiary) !important; + background: var(--fn-bg-container, rgba(0, 0, 0, 0.04)) !important; + border-color: var(--fn-border-primary) !important; +} + +.ssoStatusDotOn, +.ssoStatusDotOff { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.ssoStatusDotOn { + background: var(--fn-color-brand); +} + +.ssoStatusDotOff { + background: var(--fn-text-tertiary); +} + +.ssoGuide { + margin: 0; + padding: 14px 16px; + border: 1px solid var(--fn-border-secondary); + border-radius: var(--fn-radius-lg, 12px); + background: var(--fn-bg-secondary, var(--fn-bg-elevated)); +} + +.ssoGuideList { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.ssoGuideItem { + display: flex; + align-items: flex-start; + gap: 8px; + font-size: 12px; + line-height: 1.45; + color: var(--fn-text-tertiary); +} + +.ssoGuideCurrent { + color: var(--fn-text-primary); + font-weight: 500; +} + +.ssoGuideDone { + color: var(--fn-text-secondary); +} + +.ssoGuideIndex { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 50%; + flex-shrink: 0; + font-size: 11px; + font-weight: 600; + font-variant-numeric: tabular-nums; + background: var(--fn-bg-container, rgba(0, 0, 0, 0.04)); + color: inherit; +} + +.ssoGuideDone .ssoGuideIndex { + color: var(--fn-color-brand); + background: var(--fn-color-brand-bg); +} + +.ssoGuideCurrent .ssoGuideIndex { + color: #fff; + background: var(--fn-color-brand); +} + +.ssoAlert { + margin: 0; +} + +.ssoSection { + margin-bottom: 16px; + padding: 16px 18px; + border: 1px solid var(--fn-border-primary); + border-radius: var(--fn-radius-lg, 12px); + background: var(--fn-bg-primary); +} + +.ssoSectionHeader { + margin-bottom: 14px; +} + +.ssoSectionTitle { + margin: 0; + font-size: 14px; + font-weight: 600; + line-height: 1.4; + color: var(--fn-text-primary); +} + +.ssoSectionHint { + margin: 4px 0 0; + font-size: 12px; + line-height: 1.5; + color: var(--fn-text-tertiary); +} + +.ssoEnableRow { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.ssoEnableText { + flex: 1; + min-width: 0; +} + +.ssoEnableSwitch { + margin: 0 !important; + flex-shrink: 0; +} + +.ssoPreviewBtn { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 40px; + padding: 0 12px; + border: 1px solid var(--fn-border-primary); + border-radius: 10px; + background: var(--fn-bg-elevated, var(--fn-bg-secondary)); + color: var(--fn-text-primary); + font-size: 13px; + font-weight: 500; + text-align: center; + pointer-events: none; + user-select: none; +} + +.ssoPreviewBtnMuted { + opacity: 0.55; +} + +.ssoPreviewHint { + margin: 6px 0 0; + font-size: 12px; + color: var(--fn-text-tertiary); +} + +.ssoPresets { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 14px; +} + +.ssoPresetsLabel { + font-size: 12px; + font-weight: 500; + color: var(--fn-text-tertiary); +} + +.ssoPresetChips { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.ssoPresetChip { + display: inline-flex; + align-items: center; + height: 28px; + padding: 0 10px; + border: 1px solid var(--fn-border-primary); + border-radius: 999px; + background: var(--fn-bg-elevated, var(--fn-bg-primary)); + color: var(--fn-text-secondary); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: + border-color 0.15s ease, + background 0.15s ease, + color 0.15s ease; + + &:hover { + border-color: var(--fn-border-strong, var(--fn-border-primary)); + color: var(--fn-text-primary); + } +} + +.ssoPresetChipActive { + border-color: var( + --fn-color-brand-border, + color-mix(in srgb, var(--fn-color-brand) 45%, transparent) + ); + background: var(--fn-color-brand-bg); + color: var(--fn-color-brand); +} + +.ssoSecretLabel { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.ssoSecretTag { + display: inline-flex !important; + align-items: center; + gap: 4px; + margin: 0 !important; + padding: 0 6px !important; + border: none !important; + border-radius: 4px !important; + font-size: 11px !important; + line-height: 18px !important; + color: var(--fn-color-brand) !important; + background: var(--fn-color-brand-bg) !important; +} + +.ssoAdvanced { + margin-bottom: 16px; + + :global(.ant-collapse-header) { + padding: 10px 4px !important; + font-size: 13px; + font-weight: 600; + color: var(--fn-text-secondary) !important; + } + + :global(.ant-collapse-content-box) { + padding: 0 4px 4px !important; + } +} + +.ssoRedirectCard { + margin: 0; + padding: 14px 16px; + border: 1px solid + var( + --fn-color-brand-border, + color-mix(in srgb, var(--fn-color-brand) 28%, transparent) + ); + border-radius: var(--fn-radius-lg, 12px); + background: var( + --fn-color-brand-bg, + color-mix(in srgb, var(--fn-color-brand) 6%, transparent) + ); +} + +.ssoRedirectHeader { + margin-bottom: 12px; +} + +.ssoRedirectRow { + width: 100%; +} + +.ssoRedirectInput { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; +} + +.ssoRedirectDocs { + margin: 10px 0 0 !important; + font-size: 12px !important; + line-height: 1.5; +} + +.ssoFooter { + position: sticky; + bottom: 0; + z-index: 2; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px 16px; + margin-top: 8px; + padding: 14px 0 4px; + border-top: 1px solid var(--fn-border-primary); + background: linear-gradient( + to top, + var(--fn-bg-primary) 70%, + color-mix(in srgb, var(--fn-bg-primary) 80%, transparent) + ); +} + +.ssoFooterActions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.ssoFooterMeta { + flex: 1; + min-width: 0; + font-size: 12px; + line-height: 1.5; +} + +.ssoDirtyHint { + color: #d48806; +} + +.ssoTestHint { + color: var(--fn-text-tertiary); +} + +@media (max-width: 640px) { + .ssoPanel { + padding-bottom: 24px; + } + + .ssoEnableRow { + flex-direction: column; + align-items: stretch; + } + + .ssoFooter { + position: static; + flex-direction: column; + align-items: stretch; + } +} diff --git a/dashboard/src/pages/Agent/Skills/components/SkillsTable.tsx b/dashboard/src/pages/Agent/Skills/components/SkillsTable.tsx index 3020f2b0..abae4174 100644 --- a/dashboard/src/pages/Agent/Skills/components/SkillsTable.tsx +++ b/dashboard/src/pages/Agent/Skills/components/SkillsTable.tsx @@ -1,6 +1,6 @@ -import { Popconfirm, Switch, Table, Tag } from "antd"; +import { Popconfirm, Switch, Table, Tag, Tooltip } from "antd"; import type { ColumnsType } from "antd/es/table"; -import { Trash2 } from "lucide-react"; +import { Eye, Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { SkillSpec } from "../useSkills"; import { useSkillDisplayName } from "../skillDisplayNames"; @@ -71,20 +71,23 @@ export default function SkillsTable({ { title: t("skills.table.actions", "操作"), key: "actions", - width: kind === "custom" ? "12%" : "8%", + width: kind === "custom" ? 88 : 56, align: "center", render: (_v, row) => (
- + + + {kind === "custom" && onDelete ? ( ({ onClick: () => onView(row), style: { cursor: "pointer" }, diff --git a/dashboard/src/pages/Chat/ChatToolDockContext.tsx b/dashboard/src/pages/Chat/ChatToolDockContext.tsx new file mode 100644 index 00000000..ef35825a --- /dev/null +++ b/dashboard/src/pages/Chat/ChatToolDockContext.tsx @@ -0,0 +1,70 @@ +import { createContext, useContext, useMemo, type ReactNode } from "react"; +import { dockToolUiTabId } from "./utils/dockToolUiTabId"; +import type { DockTab, DockTabId } from "./hooks/useChatDockPanel"; + +export interface OpenToolUiPanelOptions { + callId: string; + title?: string; + toolName?: string; +} + +interface ChatToolDockContextValue { + openToolUiPanel: (opts: OpenToolUiPanelOptions) => void; + closeToolUiPanel: (callId: string) => void; + focusToolUiPanel: (callId: string) => void; + isToolUiDocked: (callId: string | undefined) => boolean; +} + +const ChatToolDockContext = createContext( + null, +); + +export function ChatToolDockProvider({ + dockOpen, + openTabs, + activeTabId, + openToolUiPanel, + closeToolUiPanel, + focusToolUiPanel, + children, +}: { + dockOpen: boolean; + openTabs: DockTab[]; + activeTabId: DockTabId | null; + openToolUiPanel: (opts: OpenToolUiPanelOptions) => void; + closeToolUiPanel: (callId: string) => void; + focusToolUiPanel: (callId: string) => void; + children: ReactNode; +}) { + const activeToolUiCallId = useMemo(() => { + if (!dockOpen || activeTabId == null) return null; + const active = openTabs.find((tab) => tab.id === activeTabId); + return active?.kind === "toolUi" ? active.callId : null; + }, [dockOpen, openTabs, activeTabId]); + + const value = useMemo( + () => ({ + openToolUiPanel, + closeToolUiPanel, + focusToolUiPanel, + // Placeholder only while this tool's tab is the visible dock surface. + // Closing the dock, the tab, or switching away restores the chat card. + isToolUiDocked: (callId) => !!callId && activeToolUiCallId === callId, + }), + [activeToolUiCallId, openToolUiPanel, closeToolUiPanel, focusToolUiPanel], + ); + + return ( + + {children} + + ); +} + +export function useChatToolDock(): ChatToolDockContextValue | null { + return useContext(ChatToolDockContext); +} + +export function dockTabIdForToolCall(callId: string): DockTabId { + return dockToolUiTabId(callId); +} diff --git a/dashboard/src/pages/Chat/chatBrowserPanel.partial.less b/dashboard/src/pages/Chat/chatBrowserPanel.partial.less index bc26bfbd..2b0269c8 100644 --- a/dashboard/src/pages/Chat/chatBrowserPanel.partial.less +++ b/dashboard/src/pages/Chat/chatBrowserPanel.partial.less @@ -221,6 +221,39 @@ justify-content: center; } +.dockToolUiBody { + flex: 1; + min-width: 0; + min-height: 0; + width: 100%; + overflow: auto; + padding: 12px; + box-sizing: border-box; + + /* Stretch plugin cards that use chat-column maxWidth (often inline). */ + .toolUiRendererWrap, + [data-octop-tool-renderer], + [data-octop-plugin-ui], + .octop-builtin-ui-fallback { + display: block; + width: 100% !important; + max-width: 100% !important; + box-sizing: border-box; + } +} + +.dockToolUiMissing { + flex: 1; + min-height: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + color: var(--fn-text-tertiary); + font-size: 13px; + text-align: center; +} + .dockFileList { flex: 1; min-height: 0; diff --git a/dashboard/src/pages/Chat/chatMessages.partial.less b/dashboard/src/pages/Chat/chatMessages.partial.less index 7c9cc3f4..0d296912 100644 --- a/dashboard/src/pages/Chat/chatMessages.partial.less +++ b/dashboard/src/pages/Chat/chatMessages.partial.less @@ -709,6 +709,104 @@ max-width: 100%; } +.toolUiRendererWrap { + position: relative; + display: block; + width: 100%; + max-width: 100%; + min-width: 0; + box-sizing: border-box; + /* Normal block flow: cards with maxWidth keep filling up to that cap; + cards with width:100% fill the message column. Do not use fit-content / + inline-grid here — those shrink to content width. */ + + .toolUiDockActions { + position: absolute; + z-index: 2; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease; + } + + &:hover .toolUiDockActions, + &:focus-within .toolUiDockActions { + opacity: 1; + pointer-events: auto; + } + + @media (hover: none) { + .toolUiDockActions { + opacity: 1; + pointer-events: auto; + } + } +} + +.toolUiDockBtn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: 1px solid var(--fn-border-secondary); + border-radius: 8px; + background: color-mix(in srgb, var(--fn-bg-primary) 92%, transparent); + color: var(--fn-text-secondary); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08); + cursor: pointer; + backdrop-filter: blur(4px); + + &:hover { + color: var(--fn-color-brand); + border-color: color-mix(in srgb, var(--fn-color-brand) 38%, transparent); + } + + &:focus-visible { + outline: 2px solid var(--fn-color-brand); + outline-offset: 2px; + } +} + +.toolUiDockPlaceholder { + display: inline-flex; + flex-direction: row; + align-items: center; + gap: 8px; + max-width: 100%; + padding: 8px 12px; + border: 1px dashed var(--fn-border-secondary); + border-radius: 10px; + background: color-mix(in srgb, var(--fn-bg-secondary) 70%, transparent); + color: var(--fn-text-secondary); + text-align: left; + cursor: pointer; + + &:hover { + border-color: color-mix(in srgb, var(--fn-color-brand) 38%, transparent); + color: var(--fn-color-brand); + } + + &:focus-visible { + outline: 2px solid var(--fn-color-brand); + outline-offset: 2px; + } +} + +.toolUiDockPlaceholderTitle { + font-size: 13px; + font-weight: 500; + color: var(--fn-text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.toolUiDockPlaceholderHint { + font-size: 12px; + color: var(--fn-text-tertiary); + flex-shrink: 0; +} + .assistantTurnAnswer { min-width: 0; } diff --git a/dashboard/src/pages/Chat/components/ChatDockPanel.tsx b/dashboard/src/pages/Chat/components/ChatDockPanel.tsx index 11469835..135dab32 100644 --- a/dashboard/src/pages/Chat/components/ChatDockPanel.tsx +++ b/dashboard/src/pages/Chat/components/ChatDockPanel.tsx @@ -13,6 +13,7 @@ import { FilePen, FolderOpen, Globe, + Puzzle, RefreshCw, Terminal, X, @@ -29,6 +30,7 @@ import { dockFileBasename } from "../utils/dockFilePath"; import styles from "../index.module.less"; import ChatDockFileList from "./ChatDockFileList"; import FilePanelContent from "./FilePanelContent"; +import ChatDockToolUiContent from "./ChatDockToolUiContent"; const TerminalPage = lazy(() => import("../../Control/Terminal")); @@ -45,6 +47,8 @@ interface ChatDockPanelProps { onCloseTab: (id: DockTabId) => void; onOpenFile: (path: string) => void; browserEnvironment?: DisplayEnvironment; + threadId?: string | null; + isStreamingTurn?: boolean; /** * False while the chat dock shell is closed but keep-alive mounted. * Mirrors Workbench ``isVisible`` so terminal does not treat hide as a @@ -70,6 +74,8 @@ const ChatDockPanel: React.FC = ({ onCloseTab, onOpenFile, browserEnvironment = "desktop", + threadId = null, + isStreamingTurn = false, surfaceVisible = true, }) => { const { t } = useTranslation(); @@ -82,6 +88,10 @@ const ChatDockPanel: React.FC = ({ const [mountedFilePaths, setMountedFilePaths] = useState(() => openTabs.filter((tab) => tab.kind === "file").map((tab) => tab.path), ); + const [mountedToolUiCallIds, setMountedToolUiCallIds] = useState( + () => + openTabs.filter((tab) => tab.kind === "toolUi").map((tab) => tab.callId), + ); const [fileActionsByPath, setFileActionsByPath] = useState< Record >({}); @@ -98,6 +108,9 @@ const ChatDockPanel: React.FC = ({ const openFilePaths = new Set( openTabs.filter((tab) => tab.kind === "file").map((tab) => tab.path), ); + const openToolUiCallIds = new Set( + openTabs.filter((tab) => tab.kind === "toolUi").map((tab) => tab.callId), + ); setMountedFilePaths((prev) => { const next = prev.filter((path) => openFilePaths.has(path)); let changed = next.length !== prev.length; @@ -109,6 +122,17 @@ const ChatDockPanel: React.FC = ({ } return changed ? next : prev; }); + setMountedToolUiCallIds((prev) => { + const next = prev.filter((callId) => openToolUiCallIds.has(callId)); + let changed = next.length !== prev.length; + for (const callId of openToolUiCallIds) { + if (!next.includes(callId)) { + next.push(callId); + changed = true; + } + } + return changed ? next : prev; + }); setFileActionsByPath((prev) => { let changed = false; const next: Record = {}; @@ -172,6 +196,15 @@ const ChatDockPanel: React.FC = ({ {t("chat.dockTerminalTitle", "终端")} + ) : tab.kind === "toolUi" ? ( + <> + + + {tab.title ?? + tab.toolName ?? + t("chat.dockToolUiTitle", "Plugin tool")} + + ) : ( <> @@ -312,6 +345,26 @@ const ChatDockPanel: React.FC = ({
)} + + {mountedToolUiCallIds.map((callId) => { + const isActive = + activeTab?.kind === "toolUi" && activeTab.callId === callId; + return ( + + ); + })} ); diff --git a/dashboard/src/pages/Chat/components/ChatDockPanels.tsx b/dashboard/src/pages/Chat/components/ChatDockPanels.tsx index 26ce7dbd..e8c13c26 100644 --- a/dashboard/src/pages/Chat/components/ChatDockPanels.tsx +++ b/dashboard/src/pages/Chat/components/ChatDockPanels.tsx @@ -18,6 +18,8 @@ interface ChatDockPanelsProps { onCloseTab: (id: DockTabId) => void; onOpenFile: (path: string) => void; browserEnvironment: DisplayEnvironment; + threadId?: string | null; + isStreamingTurn?: boolean; onModeChange: (mode: PanelMode) => void; onClose: () => void; onResizeStart: ( @@ -50,6 +52,8 @@ export default function ChatDockPanels({ onCloseTab, onOpenFile, browserEnvironment, + threadId = null, + isStreamingTurn = false, onModeChange, onClose, onResizeStart, @@ -84,6 +88,8 @@ export default function ChatDockPanels({ onCloseTab={onCloseTab} onOpenFile={onOpenFile} browserEnvironment={browserEnvironment} + threadId={threadId} + isStreamingTurn={isStreamingTurn} surfaceVisible={visible} /> ); diff --git a/dashboard/src/pages/Chat/components/ChatDockToolUiContent.tsx b/dashboard/src/pages/Chat/components/ChatDockToolUiContent.tsx new file mode 100644 index 00000000..cfa0e934 --- /dev/null +++ b/dashboard/src/pages/Chat/components/ChatDockToolUiContent.tsx @@ -0,0 +1,45 @@ +import { useTranslation } from "react-i18next"; +import { useToolMessageByCallId } from "../hooks/useToolMessageByCallId"; +import { ToolDetailsInline } from "./MessageBubble"; +import styles from "../index.module.less"; + +interface ChatDockToolUiContentProps { + threadId: string | null; + callId: string; + agentId: string; + isStreamingTurn: boolean; +} + +export default function ChatDockToolUiContent({ + threadId, + callId, + agentId, + isStreamingTurn, +}: ChatDockToolUiContentProps) { + const { t } = useTranslation(); + const message = useToolMessageByCallId(threadId, callId); + + if (!message?.toolData) { + return ( +
+ {t( + "chat.dockToolUiMissing", + "Tool result is no longer available in this conversation.", + )} +
+ ); + } + + const isStreaming = message.status === "streaming" && isStreamingTurn; + + return ( +
+ +
+ ); +} diff --git a/dashboard/src/pages/Chat/components/MessageBubble.tsx b/dashboard/src/pages/Chat/components/MessageBubble.tsx index a96c26a8..9995d220 100644 --- a/dashboard/src/pages/Chat/components/MessageBubble.tsx +++ b/dashboard/src/pages/Chat/components/MessageBubble.tsx @@ -1,5 +1,5 @@ import { memo, useMemo, useState, useCallback, useRef, useEffect } from "react"; -import { Image, Button } from "antd"; +import { Image, Button, Tooltip } from "antd"; import { message as antMessage } from "@/utils/antdMessage"; import Markdown from "../../../components/Markdown/LazyMarkdown"; @@ -11,6 +11,7 @@ import { Volume2, Settings, GitFork, + PanelRight, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; @@ -51,6 +52,10 @@ import { import { BuiltinOctopUiFallback } from "../../../plugins/toolRenderers/builtin/BuiltinOctopUiFallback"; import { ToolUiErrorBoundary } from "../../../plugins/toolRenderers/ToolUiErrorBoundary"; import { lookupPluginIdForTool } from "../../../plugins/toolRenderers/toolPluginIndex"; +import { useChatToolDock } from "../ChatToolDockContext"; +import { useToolUiDockButtonStyle } from "../hooks/useToolUiDockButtonStyle"; +import type { ParsedToolOutput } from "../../../plugins/toolRenderers/types"; +import type { ToolRendererRegistration } from "../../../plugins/toolRenderers/types"; interface MessageBubbleProps { message: ChatMessage; @@ -312,19 +317,42 @@ function CopyButton({ text }: { text: string }) { ); } +function canDockToolUi( + registration: ToolRendererRegistration | null, + parsed: ParsedToolOutput, +): boolean { + if (parsed.octopUi) return true; + return ( + registration != null && + registration.id !== "default" && + registration.pluginId !== "builtin" + ); +} + +function toolUiDockLabel( + toolData: NonNullable, +): string { + return toolData.displayName?.trim() || toolData.name?.trim() || "Tool"; +} + export function ToolDetailsInline({ toolData, isStreaming, onAcpPermissionSelect, hideMediaPreview = false, agentId = null, + forceInline = false, }: { toolData: NonNullable; isStreaming: boolean; onAcpPermissionSelect?: (message: string) => void; hideMediaPreview?: boolean; agentId?: string | null; + /** When true, skip dock attach / placeholder (full renderer in side panel). */ + forceInline?: boolean; }) { + const { t } = useTranslation(); + const toolDock = useChatToolDock(); // Plugin UIs load async after mount — bump forces resolve() to re-run. const rendererVersion = useToolRendererVersion(); const parsed = useMemo( @@ -385,13 +413,77 @@ export function ToolDetailsInline({ agentId, }; + const dockable = canDockToolUi(registration ?? null, parsed); + const callId = toolData.callId; + const isDocked = + !forceInline && + dockable && + !!callId && + (toolDock?.isToolUiDocked(callId) ?? false); + + const showDockButton = + !forceInline && !isDocked && dockable && !!callId && !!toolDock; + const { wrapRef, buttonStyle } = useToolUiDockButtonStyle(showDockButton, [ + toolData.output, + registration?.id, + rendererVersion, + ]); + + const openInDock = useCallback(() => { + if (!callId || !toolDock) return; + toolDock.openToolUiPanel({ + callId, + title: toolUiDockLabel(toolData), + toolName: toolData.name, + }); + }, [callId, toolData, toolDock]); + + const dockActions = showDockButton ? ( +
+ + + +
+ ) : null; + + if (isDocked) { + return ( + + ); + } + if (registration && registration.id !== "default") { const Comp = registration.component; return ( -
+
+ {dockActions}
); } @@ -399,8 +491,13 @@ export function ToolDetailsInline({ // Structured plugin envelope without a loaded custom renderer — still show a card. if (parsed.octopUi) { return ( -
+
+ {dockActions}
); } diff --git a/dashboard/src/pages/Chat/hooks/useChatDockPanel.test.ts b/dashboard/src/pages/Chat/hooks/useChatDockPanel.test.ts index c89e1828..0fa60191 100644 --- a/dashboard/src/pages/Chat/hooks/useChatDockPanel.test.ts +++ b/dashboard/src/pages/Chat/hooks/useChatDockPanel.test.ts @@ -115,6 +115,64 @@ describe("useChatDockPanel tabs", () => { expect(result.current.dockOpen).toBe(false); }); + it("openToolUiTab dedupes by callId and focuses the tool tab", () => { + const { result } = renderHook(() => useChatDockPanel(false)); + act(() => { + result.current.openToolUiTab({ + callId: "call-1", + title: "Demo card", + toolName: "demo_card", + }); + }); + act(() => { + result.current.openToolUiTab({ + callId: "call-1", + title: "Demo card", + }); + }); + expect(result.current.dockOpen).toBe(true); + expect( + result.current.openTabs.filter((t) => t.kind === "toolUi"), + ).toHaveLength(1); + expect(result.current.activeTabId).toBe("toolUi:call-1"); + expect(result.current.openTabs[0]).toMatchObject({ + kind: "toolUi", + callId: "call-1", + title: "Demo card", + }); + }); + + it("focusToolUiTab reopens dock on an existing tool tab", () => { + const { result } = renderHook(() => useChatDockPanel(false)); + act(() => { + result.current.openToolUiTab({ callId: "call-2", title: "Card" }); + result.current.handleClose(); + }); + expect(result.current.dockOpen).toBe(false); + // Closing the dock drops toolUi tabs so the message stream restores. + expect( + result.current.openTabs.filter((t) => t.kind === "toolUi"), + ).toHaveLength(0); + act(() => { + result.current.openToolUiTab({ callId: "call-2", title: "Card" }); + }); + expect(result.current.dockOpen).toBe(true); + expect(result.current.activeTabId).toBe("toolUi:call-2"); + }); + + it("handleClose removes toolUi tabs but keeps other tabs", () => { + const { result } = renderHook(() => useChatDockPanel(false)); + act(() => { + result.current.openBrowserTab(); + result.current.openToolUiTab({ callId: "call-3", title: "Card" }); + }); + act(() => { + result.current.handleClose(); + }); + expect(result.current.dockOpen).toBe(false); + expect(result.current.openTabs.map((t) => t.id)).toEqual(["browser"]); + }); + it("does not expose deprecated dismiss / kind aliases", () => { const { result } = renderHook(() => useChatDockPanel(false)); expect(result.current).not.toHaveProperty("userDismissedRef"); diff --git a/dashboard/src/pages/Chat/hooks/useChatDockPanel.ts b/dashboard/src/pages/Chat/hooks/useChatDockPanel.ts index 8be9d2eb..d6ae45da 100644 --- a/dashboard/src/pages/Chat/hooks/useChatDockPanel.ts +++ b/dashboard/src/pages/Chat/hooks/useChatDockPanel.ts @@ -6,6 +6,7 @@ import { isHostAbsolutePath, normalizeDockFilePath, } from "../utils/dockFilePath"; +import { dockToolUiTabId } from "../utils/dockToolUiTabId"; import { usePanelResize, type PanelSizes } from "./usePanelResize"; const PANEL_MODE_KEY = "octop:chat-dock:mode"; @@ -20,7 +21,14 @@ export type DockTab = | { id: "files"; kind: "files" } | { id: "browser"; kind: "browser" } | { id: "terminal"; kind: "terminal" } - | { id: string; kind: "file"; path: string }; + | { id: string; kind: "file"; path: string } + | { + id: string; + kind: "toolUi"; + callId: string; + title?: string; + toolName?: string; + }; export type DockTabId = DockTab["id"]; @@ -120,6 +128,16 @@ export function useChatDockPanel(isMobile: boolean, agentId?: string | null) { const handleClose = useCallback(() => { setDockOpen(false); + // Closing the dock restores tool UIs to the message stream. + setOpenTabs((prev) => { + const next = prev.filter((t) => t.kind !== "toolUi"); + setActiveTabId((current) => { + if (current == null) return null; + if (next.some((t) => t.id === current)) return current; + return next[0]?.id ?? null; + }); + return next; + }); }, []); const openFileList = useCallback(() => { @@ -174,6 +192,39 @@ export function useChatDockPanel(isMobile: boolean, agentId?: string | null) { openDock(); }, [openDock]); + const openToolUiTab = useCallback( + (opts: { callId: string; title?: string; toolName?: string }) => { + const callId = opts.callId?.trim(); + if (!callId) return; + const id = dockToolUiTabId(callId); + setOpenTabs((prev) => { + if (prev.some((t) => t.id === id)) return prev; + return [ + ...prev, + { + id, + kind: "toolUi" as const, + callId, + title: opts.title, + toolName: opts.toolName, + }, + ]; + }); + setActiveTabId(id); + openDock(); + }, + [openDock], + ); + + const focusToolUiTab = useCallback( + (callId: string) => { + const id = dockToolUiTabId(callId); + setActiveTabId(id); + openDock(); + }, + [openDock], + ); + /** Toggle dock open/closed around a dedicated tab (browser / terminal). */ const toggleDockTab = useCallback( (tab: Extract) => { @@ -263,6 +314,8 @@ export function useChatDockPanel(isMobile: boolean, agentId?: string | null) { toggleBrowserPanel, openTerminalTab, toggleTerminalPanel, + openToolUiTab, + focusToolUiTab, closeTab, setActiveTab, }; diff --git a/dashboard/src/pages/Chat/hooks/useToolMessageByCallId.ts b/dashboard/src/pages/Chat/hooks/useToolMessageByCallId.ts new file mode 100644 index 00000000..9f7cff5a --- /dev/null +++ b/dashboard/src/pages/Chat/hooks/useToolMessageByCallId.ts @@ -0,0 +1,24 @@ +import { useSyncExternalStore } from "react"; +import * as chatStore from "./chatStore"; +import type { ChatMessage } from "./useChat"; + +/** Live tool message row for a ``toolData.callId`` in the active thread. */ +export function useToolMessageByCallId( + threadId: string | null, + callId: string, +): ChatMessage | null { + return useSyncExternalStore( + (onStoreChange) => + threadId ? chatStore.subscribe(threadId, onStoreChange) : () => {}, + () => { + if (!threadId) return null; + const { messages } = chatStore.getSnapshot(threadId); + return ( + messages.find( + (m) => m.toolData?.callId === callId && m.toolData != null, + ) ?? null + ); + }, + () => null, + ); +} diff --git a/dashboard/src/pages/Chat/hooks/useToolUiDockButtonStyle.ts b/dashboard/src/pages/Chat/hooks/useToolUiDockButtonStyle.ts new file mode 100644 index 00000000..e3b41880 --- /dev/null +++ b/dashboard/src/pages/Chat/hooks/useToolUiDockButtonStyle.ts @@ -0,0 +1,73 @@ +import { + useCallback, + useLayoutEffect, + useRef, + useState, + type CSSProperties, + type RefObject, +} from "react"; + +const DOCK_BTN_SIZE = 28; +const DOCK_BTN_INSET = 8; + +function findToolUiCard(root: HTMLElement): HTMLElement | null { + return ( + root.querySelector("[data-octop-plugin-ui]") ?? + root.querySelector(".octop-builtin-ui-fallback") ?? + null + ); +} + +/** + * Place the dock button at the top-right of the plugin card without changing + * the card's layout width (avoid fit-content / inline-grid shrink-to-fit). + */ +export function useToolUiDockButtonStyle( + enabled: boolean, + deps: unknown[] = [], +): { + wrapRef: RefObject; + buttonStyle: CSSProperties | undefined; +} { + const wrapRef = useRef(null); + const [buttonStyle, setButtonStyle] = useState(); + + const update = useCallback(() => { + const root = wrapRef.current; + if (!root || !enabled) { + setButtonStyle(undefined); + return; + } + const card = findToolUiCard(root); + if (!card) { + setButtonStyle({ top: DOCK_BTN_INSET, right: DOCK_BTN_INSET }); + return; + } + const rootRect = root.getBoundingClientRect(); + const cardRect = card.getBoundingClientRect(); + setButtonStyle({ + top: Math.max(0, cardRect.top - rootRect.top) + DOCK_BTN_INSET, + left: + Math.max(0, cardRect.right - rootRect.left) - + DOCK_BTN_SIZE - + DOCK_BTN_INSET, + }); + }, [enabled]); + + useLayoutEffect(() => { + update(); + if (!enabled) return; + const root = wrapRef.current; + if (!root) return; + + const ro = new ResizeObserver(() => update()); + ro.observe(root); + const card = findToolUiCard(root); + if (card) ro.observe(card); + + return () => ro.disconnect(); + // eslint-disable-next-line react-hooks/exhaustive-deps -- caller passes render-invalidating deps + }, [enabled, update, ...deps]); + + return { wrapRef, buttonStyle }; +} diff --git a/dashboard/src/pages/Chat/index.tsx b/dashboard/src/pages/Chat/index.tsx index 4ae773a6..5c8f2bc7 100644 --- a/dashboard/src/pages/Chat/index.tsx +++ b/dashboard/src/pages/Chat/index.tsx @@ -57,6 +57,10 @@ import { prefetchVoiceConfig } from "../../hooks/useVoiceConfig"; import { isSharedExpertViewer } from "../../utils/sharedExpert"; import ChatDockPanels from "./components/ChatDockPanels"; import { ChatFilePreviewProvider } from "./ChatFilePreviewContext"; +import { + ChatToolDockProvider, + dockTabIdForToolCall, +} from "./ChatToolDockContext"; import ChatSidebarPanel from "./components/ChatSidebarPanel"; import ChatTitleBar from "./components/ChatTitleBar"; import ChatComposerChrome from "./components/ChatComposerChrome"; @@ -69,7 +73,10 @@ import { apiErrorMessage } from "../../utils/apiError"; import PwaInstallPrompt from "../../components/PwaInstallPrompt"; import { promptNeedsUserInput } from "../../utils/quickInputPrefill"; import { OPEN_NAV_RECORDS_EVENT } from "../../layouts/chatHistoryRail"; -import { usePluginToolUis } from "../../plugins/toolRenderers"; +import { + usePluginToolUis, + setPluginUiDockHandlers, +} from "../../plugins/toolRenderers"; import styles from "./index.module.less"; export default function ChatPage() { @@ -317,10 +324,27 @@ function ChatPageInner() { openBrowserTab, toggleBrowserPanel, toggleTerminalPanel, + openToolUiTab, + focusToolUiTab, closeTab: closeDockTab, setActiveTab: setDockActiveTab, } = useChatDockPanel(isMobile, resolvedAgentId); + const closeToolUiPanel = useCallback( + (callId: string) => { + closeDockTab(dockTabIdForToolCall(callId)); + }, + [closeDockTab], + ); + + useEffect(() => { + setPluginUiDockHandlers({ + openSidePanel: openToolUiTab, + closeSidePanel: closeToolUiPanel, + }); + return () => setPluginUiDockHandlers({}); + }, [openToolUiTab, closeToolUiPanel]); + const composerSession = useMemo( () => sessions.find((session) => session.id === activeThreadId) ?? null, [sessions, activeThreadId], @@ -829,364 +853,384 @@ function ChatPageInner() { return ( - {chatHistoryRail ? createPortal(chatSidebarPanel, chatHistoryRail) : null} -
- {/* Main chat area */} -
- {/* Mobile toolbar — session list + optional title + agent profile */} - {isMobile && ( -
- - {activeSessionTitle && ( -
- {activeSessionTitle} -
- )} - {resolvedAgentId && !sharedExpertViewer && ( -
- - + {activeSessionTitle && ( +
- - -
- )} -
- )} - - {!isMobile && activeSession && activeSessionTitle && ( - - )} - - {memoryMaintVisible && memoryMaint && ( - - )} - - {historyMigration.visible && historyMigration.status && ( - void historyMigration.start()} - /> - )} + {activeSessionTitle} +
+ )} + {resolvedAgentId && !sharedExpertViewer && ( +
+ + +
+ )} +
+ )} -
- {!agentChatReady || noAgents ? ( - - ) : showWelcome ? ( - - ) : ( - 0 && !isMobile - ? openFileList - : undefined - } + )} + + {historyMigration.visible && historyMigration.status && ( + void historyMigration.start()} /> )} -
- {!isMobile && - !dockOpen && - !agentProfileOpen && - !workspaceDrawerOpen && ( -
- {/* PWA install first when available — same column as browser / experts. */} - - {resolvedAgentId && !sharedExpertViewer && ( - <> +
+ {!agentChatReady || noAgents ? ( + + ) : showWelcome ? ( + + ) : ( + 0 && + !isMobile + ? openFileList + : undefined + } + /> + )} +
+ + {!isMobile && + !dockOpen && + !agentProfileOpen && + !workspaceDrawerOpen && ( +
+ {/* PWA install first when available — same column as browser / experts. */} + + {resolvedAgentId && !sharedExpertViewer && ( + <> + + + + + + + + + + + + )} + {!sharedExpertViewer && panelFilePaths.length > 0 && ( + {panelFilePaths.length > 1 && ( + + {panelFilePaths.length > 99 + ? "99+" + : panelFilePaths.length} + + )} + )} + {canTerminal && ( - - )} - {!sharedExpertViewer && panelFilePaths.length > 0 && ( - - - - {panelFilePaths.length > 1 && ( - - {panelFilePaths.length > 99 - ? "99+" - : panelFilePaths.length} - - )} - - - )} - {canTerminal && ( + )} - )} - - - - - -
- )} +
+ )} - - { - prefillInputRef.current = ""; - }} - availableModels={availableModels} - selectedModel={selectedModel} - onModelChange={setSelectedModel} - reasoningMode={reasoningMode} - reasoningEffort={reasoningEffort} - onReasoningChange={handleReasoningChange} - availableConnectors={chatConnectors} - selectedConnectors={selectedConnectors} - onConnectorsChange={handleConnectorsChange} - availableKnowledgeBases={chatKnowledgeBases} - selectedKnowledgeBaseIds={selectedKnowledgeBaseIds} - onKnowledgeBaseIdsChange={handleKnowledgeBaseIdsChange} - availableSkills={chatSkills} - selectedSkills={selectedSkills} - onSkillsChange={handleSkillsChange} - availableAgents={chatAgentOptions} - selectedTargetAgents={selectedTargetAgents} - onTargetAgentsChange={setSelectedTargetAgents} - agentId={resolvedAgentId} + + { + prefillInputRef.current = ""; + }} + availableModels={availableModels} + selectedModel={selectedModel} + onModelChange={setSelectedModel} + reasoningMode={reasoningMode} + reasoningEffort={reasoningEffort} + onReasoningChange={handleReasoningChange} + availableConnectors={chatConnectors} + selectedConnectors={selectedConnectors} + onConnectorsChange={handleConnectorsChange} + availableKnowledgeBases={chatKnowledgeBases} + selectedKnowledgeBaseIds={selectedKnowledgeBaseIds} + onKnowledgeBaseIdsChange={handleKnowledgeBaseIdsChange} + availableSkills={chatSkills} + selectedSkills={selectedSkills} + onSkillsChange={handleSkillsChange} + availableAgents={chatAgentOptions} + selectedTargetAgents={selectedTargetAgents} + onTargetAgentsChange={setSelectedTargetAgents} + agentId={resolvedAgentId} + threadId={activeThreadId} + defaultModel={activeAgent?.default_model ?? null} + contextUsedTokens={contextUsedTokens} + contextMaxTokens={contextMaxTokens} + /> +
+ + -
- - - {!sharedExpertViewer && ( - <> - setAgentProfileOpen(false)} - /> - setWorkspaceDrawerOpen(false)} - /> - - )} -
+ {!sharedExpertViewer && ( + <> + setAgentProfileOpen(false)} + /> + setWorkspaceDrawerOpen(false)} + /> + + )} + + ); } diff --git a/dashboard/src/pages/Chat/utils/dockToolUiTabId.ts b/dashboard/src/pages/Chat/utils/dockToolUiTabId.ts new file mode 100644 index 00000000..3bd1c50f --- /dev/null +++ b/dashboard/src/pages/Chat/utils/dockToolUiTabId.ts @@ -0,0 +1,4 @@ +/** Stable dock tab id for a tool call's plugin UI panel. */ +export function dockToolUiTabId(callId: string): string { + return `toolUi:${callId}`; +} diff --git a/dashboard/src/pages/Control/CronJobs/components/columns.tsx b/dashboard/src/pages/Control/CronJobs/components/columns.tsx index 62e1c229..a95cf3b0 100644 --- a/dashboard/src/pages/Control/CronJobs/components/columns.tsx +++ b/dashboard/src/pages/Control/CronJobs/components/columns.tsx @@ -26,6 +26,8 @@ interface ColumnHandlers { onDelete: (jobId: string) => void; t: TFunction; timeZone: string; + /** Pin leading/trailing columns (disable on narrow screens). */ + stickyColumns?: boolean; } function channelLabel(channel: string, t: TFunction): string { @@ -36,13 +38,14 @@ function channelLabel(channel: string, t: TFunction): string { export const createColumns = ( handlers: ColumnHandlers, ): ColumnsType => { + const sticky = handlers.stickyColumns !== false; return [ { title: handlers.t("cronJobs.col.id"), dataIndex: "id", key: "id", width: 120, - fixed: "left", + fixed: sticky ? "left" : undefined, ellipsis: true, onHeaderCell: () => ({ style: { paddingLeft: 28 } }), render: (id: string, record: CronJob) => { @@ -113,7 +116,7 @@ export const createColumns = ( dataIndex: "enabled", key: "enabled", width: 100, - fixed: "left", + fixed: sticky ? "left" : undefined, render: (enabled: boolean) => ( { const menuItems: MenuProps["items"] = [ { diff --git a/dashboard/src/pages/Control/CronJobs/index.module.less b/dashboard/src/pages/Control/CronJobs/index.module.less index 7c434671..af3916ac 100644 --- a/dashboard/src/pages/Control/CronJobs/index.module.less +++ b/dashboard/src/pages/Control/CronJobs/index.module.less @@ -13,16 +13,28 @@ /* ── Table (aligned with admin/users .userTable) ─────────────── */ .cronTable { + width: 100%; + min-width: 0; + max-width: 100%; + + :global(.octop-table-wrapper), + :global(.ant-table-wrapper) { + max-width: 100%; + } + + :global(.octop-table), :global(.ant-table) { background: transparent; } + :global(.octop-table-container), :global(.ant-table-container) { border: 1px solid var(--fn-border-primary); border-radius: var(--fn-radius-lg, 12px); overflow: hidden; } + :global(.octop-table-thead > tr > th), :global(.ant-table-thead > tr > th) { background: var(--fn-bg-secondary, var(--fn-bg-elevated)); color: var(--fn-text-tertiary); @@ -32,21 +44,28 @@ white-space: nowrap; } + :global(.octop-table-tbody > tr > td), :global(.ant-table-tbody > tr > td) { padding: 10px 12px; vertical-align: middle; } + :global(.octop-table-cell-fix-left), + :global(.octop-table-cell-fix-right), :global(.ant-table-cell-fix-left), :global(.ant-table-cell-fix-right) { background: var(--fn-bg-primary, #fff) !important; } + :global(.octop-table-thead .octop-table-cell-fix-left), + :global(.octop-table-thead .octop-table-cell-fix-right), :global(.ant-table-thead .ant-table-cell-fix-left), :global(.ant-table-thead .ant-table-cell-fix-right) { background: var(--fn-bg-secondary, #fafafa) !important; } + :global(.octop-table-row:hover > .octop-table-cell-fix-left), + :global(.octop-table-row:hover > .octop-table-cell-fix-right), :global(.ant-table-row:hover > .ant-table-cell-fix-left), :global(.ant-table-row:hover > .ant-table-cell-fix-right) { background: var(--fn-bg-hover, rgba(0, 0, 0, 0.02)) !important; @@ -98,6 +117,9 @@ .listShell { position: relative; transition: opacity 0.15s ease; + width: 100%; + min-width: 0; + max-width: 100%; } .listShellBusy { diff --git a/dashboard/src/pages/Control/CronJobs/index.tsx b/dashboard/src/pages/Control/CronJobs/index.tsx index 7071d49b..546fb27c 100644 --- a/dashboard/src/pages/Control/CronJobs/index.tsx +++ b/dashboard/src/pages/Control/CronJobs/index.tsx @@ -230,6 +230,7 @@ function CronJobsPage() { onDelete: handleDelete, t, timeZone: cronTimezone, + stickyColumns: !isMobile, }); // Until the user picks an agent there is nothing to fetch and no scope @@ -354,7 +355,7 @@ function CronJobsPage() { dataSource={jobs} rowKey="id" size="middle" - scroll={{ x: "max-content" }} + scroll={{ x: 1300 }} pagination={{ pageSize: 10, showSizeChanger: false, diff --git a/dashboard/src/pages/Experts/components/AgentExpertsTable.tsx b/dashboard/src/pages/Experts/components/AgentExpertsTable.tsx index ecda869e..e3f87140 100644 --- a/dashboard/src/pages/Experts/components/AgentExpertsTable.tsx +++ b/dashboard/src/pages/Experts/components/AgentExpertsTable.tsx @@ -38,6 +38,7 @@ import ToolCatalogDrawer from "./ToolCatalogDrawer"; import { request } from "../../../api/request"; import type { OctopAgent } from "../../../context/AgentContext"; import { useAgent } from "../../../context/AgentContext"; +import { useIsMobile } from "../../../hooks/useIsMobile"; import MbtiPersonaTag from "../../../components/MbtiPersonaTag"; import MbtiCatalogDrawer from "./MbtiCatalogDrawer"; import { ExpertIcon } from "./iconForName"; @@ -82,6 +83,7 @@ export default function AgentExpertsTable({ }: AgentExpertsTableProps) { const { t } = useTranslation(); const navigate = useNavigate(); + const isMobile = useIsMobile(); const { setActiveAgent, refresh: refreshAgents } = useAgent(); const [localStates, setLocalStates] = useState>({}); const [actionLoadingId, setActionLoadingId] = useState(null); @@ -112,6 +114,7 @@ export default function AgentExpertsTable({ const [scrollY, setScrollY] = useState(360); useLayoutEffect(() => { + if (isMobile) return; const el = tableWrapRef.current; if (!el) return; @@ -130,7 +133,7 @@ export default function AgentExpertsTable({ ro.disconnect(); window.removeEventListener("resize", update); }; - }, [agents.length]); + }, [agents.length, isMobile]); const openMbtiCatalog = useCallback((agentId: string) => { setMbtiAgentId(agentId); @@ -267,7 +270,7 @@ export default function AgentExpertsTable({ title: t("experts.table.name", "名称"), dataIndex: "name", width: 160, - fixed: "left", + fixed: isMobile ? undefined : "left", render: (name: string, row) => (
{ const state = localStates[row.agent_id] ?? row.state; const isTransient = TRANSIENT.has(state); @@ -559,7 +562,7 @@ export default function AgentExpertsTable({ rowKey="agent_id" dataSource={agents} columns={columns} - scroll={{ x: 1370, y: scrollY }} + scroll={isMobile ? { x: 1370 } : { x: 1370, y: scrollY }} pagination={{ defaultPageSize: 10, showSizeChanger: true, diff --git a/dashboard/src/pages/Experts/index.module.less b/dashboard/src/pages/Experts/index.module.less index 2f11ca0c..02f9f5d2 100644 --- a/dashboard/src/pages/Experts/index.module.less +++ b/dashboard/src/pages/Experts/index.module.less @@ -1739,6 +1739,14 @@ .expertsTable { margin: 12px 0 0; + width: 100%; + min-width: 0; + max-width: 100%; + + :global(.octop-table-wrapper), + :global(.ant-table-wrapper) { + max-width: 100%; + } :global(.octop-table-pagination) { margin: 12px 0 0 !important; diff --git a/dashboard/src/pages/KnowledgeBases/index.tsx b/dashboard/src/pages/KnowledgeBases/index.tsx index 9cfad61c..027cf322 100644 --- a/dashboard/src/pages/KnowledgeBases/index.tsx +++ b/dashboard/src/pages/KnowledgeBases/index.tsx @@ -1485,7 +1485,8 @@ export default function KnowledgeBasesPage() { {t("knowledgeBases.documentLimit", { count: fileCount, - max: selected?.max_documents ?? limits.max_docs_per_kb, + max: + selected?.max_documents ?? limits.max_docs_per_kb, })}
@@ -1616,7 +1617,8 @@ export default function KnowledgeBasesPage() { type="info" showIcon message={t("knowledgeBases.documentLimitReached", { - count: selected?.max_documents ?? limits.max_docs_per_kb, + count: + selected?.max_documents ?? limits.max_docs_per_kb, })} /> ) : null} @@ -1733,6 +1735,7 @@ export default function KnowledgeBasesPage() { size="small" rowKey="id" pagination={false} + scroll={{ x: 720 }} dataSource={folderEntries} onRow={(document) => ({ onClick: document.is_dir diff --git a/dashboard/src/pages/Settings/BackupRestore/index.tsx b/dashboard/src/pages/Settings/BackupRestore/index.tsx index 1f458bb2..d12172a9 100644 --- a/dashboard/src/pages/Settings/BackupRestore/index.tsx +++ b/dashboard/src/pages/Settings/BackupRestore/index.tsx @@ -34,6 +34,7 @@ import { type AutoBackupSettings, type BackupFileItem, } from "../../../api/modules/backup"; +import { useBackupOperation } from "../../../context/BackupOperationContext"; import { useServiceRestartContext } from "../../../context/ServiceRestartContext"; import { useIsMobile } from "../../../hooks/useIsMobile"; import { useServerTimezone } from "../../../hooks/useServerTimezone"; @@ -75,6 +76,7 @@ function parseIntervalSeconds(spec: string): number | null { interface BackupFileCardProps { row: BackupFileItem; downloading: boolean; + restoringThis: boolean; busy: boolean; timeZone: string; onDownload: (row: BackupFileItem) => void; @@ -85,6 +87,7 @@ interface BackupFileCardProps { function BackupFileCard({ row, downloading, + restoringThis, busy, timeZone, onDownload, @@ -122,10 +125,11 @@ function BackupFileCard({ { - void (async () => { - setUploadPercent(0); - try { - await backupApi.uploadBackup(file, (p) => - setUploadPercent(p), - ); - setUploadPercent(100); - message.success( - t("backup.uploadSuccess", { name: file.name }), - ); - await refresh(); - } catch (err: unknown) { - const detail = - err instanceof Error ? err.message : String(err); - message.error(detail || t("backup.uploadFailed")); - } finally { - setUploadPercent(null); - } - })(); + void uploadBackup(file); return false; }} > @@ -457,7 +447,7 @@ export default function BackupRestorePanel() { {t("common.refresh")}
- {(uploadPercent !== null || restoreProgress) && ( + {(uploadPercent !== null || restoring || backingUp) && (
{uploadPercent !== null ? ( <> @@ -466,13 +456,20 @@ export default function BackupRestorePanel() {
- ) : ( + ) : restoring ? ( <>
{t("backup.restoring")}
+ ) : ( + <> +
+ {t("backup.creating")} +
+ + )}
)} @@ -494,6 +491,7 @@ export default function BackupRestorePanel() { key={row.name} row={row} downloading={downloading === row.name} + restoringThis={restoringThisName === row.name} busy={busy} timeZone={serverTimezone} onDownload={onDownload} @@ -635,7 +633,7 @@ export default function BackupRestorePanel() { disabled={busy && !autoRunning} onClick={() => void onRunAuto()} > - {t("backup.autoRunNow")} + {autoRunning ? t("backup.creating") : t("backup.autoRunNow")} @@ -652,14 +650,16 @@ export default function BackupRestorePanel() { } }} onOk={() => void confirmRestore()} - okText={t("backup.importConfirmOk")} + okText={restoring ? t("backup.restoring") : t("backup.importConfirmOk")} cancelText={t("common.cancel")} confirmLoading={restoring} okButtonProps={{ danger: true, disabled: busy && !restoring }} cancelButtonProps={{ disabled: restoring }} >

- {t("backup.importConfirmBody", { name: pendingRestore?.name ?? "" })} + {t("backup.importConfirmBody", { + name: pendingRestore?.name ?? restoreTarget ?? "", + })}

([]); const [loading, setLoading] = useState(true); - const { viewMode, setViewMode, showCardView } = useCardTableView( + const { isMobile, viewMode, setViewMode, showCardView } = useCardTableView( loadViewMode(), ); const [actionLoadingId, setActionLoadingId] = useState(null); @@ -144,7 +144,7 @@ export default function OctopAgentsPage() { title: t("adminAgents.columns.name"), dataIndex: "name", width: 140, - fixed: "left", + fixed: isMobile ? undefined : "left", render: (name: string, row: OctopAgent) => ( + + + {canMutate && onDelete ? ( ({ diff --git a/dashboard/src/plugins/toolRenderers/host.ts b/dashboard/src/plugins/toolRenderers/host.ts index a03225c0..b1a43aec 100644 --- a/dashboard/src/plugins/toolRenderers/host.ts +++ b/dashboard/src/plugins/toolRenderers/host.ts @@ -13,6 +13,19 @@ import type { } from "./types"; let contextOverride: Partial = {}; +let dockHandlers: { + openSidePanel?: (opts: { + callId: string; + title?: string; + toolName?: string; + }) => void; + closeSidePanel?: (callId: string) => void; +} = {}; + +/** Chat page wires dock open/close for plugin ``host.openSidePanel``. */ +export function setPluginUiDockHandlers(handlers: typeof dockHandlers): void { + dockHandlers = handlers; +} /** Chat page sets agent/thread so plugin UIs can call scoped APIs. */ export function setPluginUiToolContext( @@ -56,6 +69,12 @@ function createHost(defaultPluginId: string): OctopPluginUIHost { request(path: string, init?: RequestInit) { return request(path, init); }, + openSidePanel(opts) { + dockHandlers.openSidePanel?.(opts); + }, + closeSidePanel(callId) { + dockHandlers.closeSidePanel?.(callId); + }, }; } diff --git a/dashboard/src/plugins/toolRenderers/index.ts b/dashboard/src/plugins/toolRenderers/index.ts index 7682ec98..d6a5d18a 100644 --- a/dashboard/src/plugins/toolRenderers/index.ts +++ b/dashboard/src/plugins/toolRenderers/index.ts @@ -3,6 +3,7 @@ export { builtinPluginHost, createPluginUiHost, setPluginUiToolContext, + setPluginUiDockHandlers, } from "./host"; export { loadInstalledPluginUis, diff --git a/dashboard/src/plugins/toolRenderers/types.ts b/dashboard/src/plugins/toolRenderers/types.ts index 9a430dab..be024839 100644 --- a/dashboard/src/plugins/toolRenderers/types.ts +++ b/dashboard/src/plugins/toolRenderers/types.ts @@ -53,6 +53,12 @@ export interface ToolRendererRegistration { component: ComponentType; } +export interface OpenSidePanelOptions { + callId: string; + title?: string; + toolName?: string; +} + export interface OctopPluginUIHost { registerRenderer( reg: Omit & { pluginId?: string }, @@ -62,6 +68,10 @@ export interface OctopPluginUIHost { patchResult(callId: string, nextData: unknown): void; /** Authenticated Octop API request (path starts with ``/`` under ``/api``). */ request(path: string, init?: RequestInit): Promise; + /** Open this tool call's UI in the chat side dock (one tab per callId). */ + openSidePanel(opts: OpenSidePanelOptions): void; + /** Close the side dock tab for this tool call, if open. */ + closeSidePanel(callId: string): void; } /** Shape expected from ``ui/dist/index.js``. */ diff --git a/dashboard/src/styles/layout.css b/dashboard/src/styles/layout.css index 44879355..89e4dfba 100644 --- a/dashboard/src/styles/layout.css +++ b/dashboard/src/styles/layout.css @@ -311,16 +311,13 @@ html[data-theme="dark"] .octop-ant-switch-checked { padding: 0; } - /* Force tables to scroll horizontally on mobile */ + /* Tables scroll inside their own wrapper on mobile — do not set + min-width on .octop-table (that expands the page and creates a + second, page-level horizontal scrollbar). */ .ant-table-wrapper, - [class*="-table-wrapper"] { - overflow-x: auto !important; - -webkit-overflow-scrolling: touch; - } - - .ant-table, - [class*="-table"] { - min-width: 600px; + .octop-table-wrapper { + max-width: 100%; + min-width: 0; } /* Modal responsive — max width on mobile */ diff --git a/dashboard/src/utils/chatStreamError.test.ts b/dashboard/src/utils/chatStreamError.test.ts index 52ab443a..910eb4ca 100644 --- a/dashboard/src/utils/chatStreamError.test.ts +++ b/dashboard/src/utils/chatStreamError.test.ts @@ -29,6 +29,30 @@ describe("classifyChatStreamError", () => { ).toBe("stream_errors.auth"); }); + it("classifies insufficient balance / 402", () => { + const msg = + "Error code: 402 - {'error': {'message': 'Insufficient Balance', " + + "'type': 'unknown_error', 'param': None, 'code': 'invalid_request_error'}}"; + expect(classifyChatStreamError(msg)).toBe( + "stream_errors.insufficient_balance", + ); + expect( + classifyChatStreamError( + "HTTP 402 POST https://api.example.com/v1/chat: Insufficient Balance", + ), + ).toBe("stream_errors.insufficient_balance"); + expect(chatStreamErrorAction(msg)).toEqual({ + path: "/admin/models", + labelKey: "modelConfig.configureButton", + }); + }); + + it("classifies HTTP 5xx as provider_unavailable", () => { + expect(classifyChatStreamError("HTTP 503: service overloaded")).toBe( + "stream_errors.provider_unavailable", + ); + }); + it("classifies LangGraph recursion limit as recursion_limit", () => { const msg = "Recursion limit of 2 reached without hitting a stop condition. " + diff --git a/dashboard/src/utils/chatStreamError.ts b/dashboard/src/utils/chatStreamError.ts index bdb6dd12..f5ed50e2 100644 --- a/dashboard/src/utils/chatStreamError.ts +++ b/dashboard/src/utils/chatStreamError.ts @@ -6,6 +6,7 @@ const _STREAM_ERROR_KEYS = [ "stream_errors.stream_stall", "stream_errors.rate_limit", "stream_errors.auth", + "stream_errors.insufficient_balance", "stream_errors.context_length", "stream_errors.recursion_limit", "stream_errors.timeout_network", @@ -26,6 +27,10 @@ const STREAM_ERROR_ACTIONS: Partial> = path: "/admin/models", labelKey: "modelConfig.configureButton", }, + "stream_errors.insufficient_balance": { + path: "/admin/models", + labelKey: "modelConfig.configureButton", + }, "stream_errors.recursion_limit": { path: "/agent-config", labelKey: "chat.goToAgentConfig", @@ -64,6 +69,7 @@ export function classifyChatStreamError( if ( lower.includes("error code: 429") || + lower.includes("http 429") || lower.includes("rate_limit") || compact.includes("ratelimiterror") || lower.includes("too many requests") @@ -71,8 +77,26 @@ export function classifyChatStreamError( return "stream_errors.rate_limit"; } + if ( + lower.includes("error code: 402") || + lower.includes("http 402") || + lower.includes("insufficient balance") || + lower.includes("insufficient_quota") || + lower.includes("insufficient credits") || + lower.includes("exceeded your current quota") || + lower.includes("payment_required") || + lower.includes("billing_not_active") || + lower.includes("arrearage") || + msg.includes("余额不足") || + msg.includes("账户余额") || + msg.includes("欠费") + ) { + return "stream_errors.insufficient_balance"; + } + if ( lower.includes("error code: 401") || + lower.includes("http 401") || lower.includes("invalid_api_key") || lower.includes("incorrect api key") || compact.includes("authenticationerror") || @@ -108,7 +132,10 @@ export function classifyChatStreamError( lower.includes("service unavailable") || lower.includes("error code: 500") || lower.includes("error code: 502") || - lower.includes("error code: 503") + lower.includes("error code: 503") || + lower.includes("http 500") || + lower.includes("http 502") || + lower.includes("http 503") ) { return "stream_errors.provider_unavailable"; } diff --git a/src/octop/api/routers/backup.py b/src/octop/api/routers/backup.py index b55e07d8..905427d1 100644 --- a/src/octop/api/routers/backup.py +++ b/src/octop/api/routers/backup.py @@ -2,33 +2,43 @@ from __future__ import annotations +import asyncio import logging +import os +import tempfile +from pathlib import Path from typing import Any, cast -from fastapi import APIRouter, Depends, File, Query, UploadFile -from fastapi.responses import StreamingResponse +from fastapi import APIRouter, BackgroundTasks, Depends, File, Query, UploadFile +from fastapi.responses import FileResponse from pydantic import BaseModel, Field from octop.api.common.content_disposition import content_disposition from octop.api.deps import get_server, require_permission -from octop.config import load_config +from octop.config import DatabaseConfig, load_config from octop.infra.backup.auto import ( AUTO_BACKUP_JOB_ID, - BACKUP_LOCK, backup_config_from_payload, + backup_status_payload, + hold_backup_lock, + raise_if_backup_busy, run_auto_backup, update_server_backup_config, ) from octop.infra.backup.store import ( + BackupFileInfo, delete_backup_file, list_backup_files, normalize_backup_filename, - read_backup_file, + place_backup_file, + resolve_backup_path, write_backup_file, ) from octop.infra.backup.system_archive import create_system_backup, restore_system_backup +from octop.infra.db.pool import DatabasePool from octop.infra.db.repos.audit import ACTOR_ADMIN from octop.infra.errors import ErrorCode, OctopError +from octop.infra.utils.paths import PathLayout logger = logging.getLogger(__name__) @@ -63,6 +73,83 @@ def _auto_settings_payload(server: Any) -> dict[str, Any]: } +def _raise_if_backup_busy() -> None: + raise_if_backup_busy() + + +def _create_and_store_manual_backup( + *, + paths: PathLayout, + agent_rows: list[Any], + pool: DatabasePool, + db_config: DatabaseConfig, +) -> BackupFileInfo: + """Sync create + place for ``asyncio.to_thread`` (keeps the event loop free).""" + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) / "backup.tar.gz" + filename = create_system_backup( + paths=paths, + agent_rows=agent_rows, + pool=pool, + db_config=db_config, + dest=tmp_path, + ) + return place_backup_file(paths, filename, tmp_path) + + +def _restore_stored_backup( + *, + paths: PathLayout, + filename: str, + pool: DatabasePool, + db_config: DatabaseConfig, + restore_config: bool, + owner_user_id: int, +) -> dict[str, Any]: + """Sync path-based restore for ``asyncio.to_thread``.""" + archive = resolve_backup_path(paths, filename) + return restore_system_backup( + archive, + paths=paths, + pool=pool, + db_config=db_config, + restore_config=restore_config, + owner_user_id=owner_user_id, + ) + + +def _create_ephemeral_backup( + *, + paths: PathLayout, + agent_rows: list[Any], + pool: DatabasePool, + db_config: DatabaseConfig, +) -> tuple[Path, str]: + """Write a backup to a temp file; caller must delete after the response is sent.""" + fd, name = tempfile.mkstemp(prefix="octop-export-", suffix=".tar.gz") + os.close(fd) + tmp_path = Path(name) + try: + filename = create_system_backup( + paths=paths, + agent_rows=agent_rows, + pool=pool, + db_config=db_config, + dest=tmp_path, + ) + except Exception: + tmp_path.unlink(missing_ok=True) + raise + return tmp_path, filename + + +def _unlink_quiet(path: Path) -> None: + try: + path.unlink(missing_ok=True) + except OSError: + logger.warning("failed to remove ephemeral backup %s", path, exc_info=True) + + async def _rehydrate_runtime_after_restore(server: Any) -> None: """Sync restored providers/agents, IM channels, and cron (no process restart).""" runtime = getattr(server, "app_runtime", None) @@ -101,6 +188,14 @@ async def list_backups( } +@router.get("/backup/status", summary="Whether a backup or restore is in progress") +async def get_backup_status( + _: Any = Depends(require_permission("backup")), +) -> dict[str, Any]: + """Return lock state for dashboard busy UI across navigation / refresh.""" + return backup_status_payload() + + @router.get("/backup/auto", summary="Get automatic backup settings") async def get_auto_backup_settings( _: Any = Depends(require_permission("backup")), @@ -128,11 +223,7 @@ async def run_auto_backup_now( server: Any = Depends(get_server), ) -> dict[str, Any]: """Create one automatic backup immediately (same path as the scheduled job).""" - if BACKUP_LOCK.locked(): - raise OctopError( - ErrorCode.BACKUP_IN_PROGRESS, - "a backup is already in progress", - ) + _raise_if_backup_busy() entry = await run_auto_backup(server) if entry is None: raise OctopError( @@ -149,38 +240,35 @@ async def create_backup( ) -> dict[str, Any]: """Create a full backup archive and persist it under ``backups_dir``.""" assert server.services is not None - if BACKUP_LOCK.locked(): - raise OctopError( - ErrorCode.BACKUP_IN_PROGRESS, - "a backup is already in progress", - ) - async with BACKUP_LOCK: - data, filename = create_system_backup( + _raise_if_backup_busy() + async with hold_backup_lock("create"): + entry = await asyncio.to_thread( + _create_and_store_manual_backup, paths=server.paths, agent_rows=_agent_rows(server), pool=server.services.db, db_config=server.services.config.database, ) - entry = write_backup_file(server.paths, filename, data) return {"ok": True, "item": entry.to_dict()} @router.get( "/backup/files/{filename}", summary="Download a stored backup archive", - response_class=StreamingResponse, + response_class=FileResponse, ) async def download_backup_file( filename: str, _: Any = Depends(require_permission("backup")), server: Any = Depends(get_server), -) -> StreamingResponse: - """Stream a backup file from ``backups_dir``.""" +) -> FileResponse: + """Stream a backup file from ``backups_dir`` without loading it into memory.""" safe = normalize_backup_filename(filename) - data = read_backup_file(server.paths, safe) - return StreamingResponse( - iter([data]), + path = await asyncio.to_thread(resolve_backup_path, server.paths, safe) + return FileResponse( + path, media_type="application/gzip", + filename=safe, headers={"Content-Disposition": content_disposition(safe)}, ) @@ -200,19 +288,26 @@ async def restore_backup_file( Restored ``config.json`` / ``env`` still require a process restart to take effect. LightClaw migration archives reassign imported ownership to the restoring admin. + + Heavy disk/DB work runs in a worker thread so the asyncio event loop stays + responsive; concurrent create/restore/auto backup is rejected via + ``BACKUP_LOCK``. SQLite restore still holds the shared DB lock for the + merge window, so DB-backed APIs may briefly queue during that phase. """ assert server.services is not None + _raise_if_backup_busy() safe = normalize_backup_filename(filename) - raw = read_backup_file(server.paths, safe) - result = restore_system_backup( - raw, - paths=server.paths, - pool=server.services.db, - db_config=server.services.config.database, - restore_config=restore_config, - owner_user_id=int(user.id), - ) - await _rehydrate_runtime_after_restore(server) + async with hold_backup_lock("restore"): + result = await asyncio.to_thread( + _restore_stored_backup, + paths=server.paths, + filename=safe, + pool=server.services.db, + db_config=server.services.config.database, + restore_config=restore_config, + owner_user_id=int(user.id), + ) + await _rehydrate_runtime_after_restore(server) server.services.audit_repo.write( actor=getattr(user, "username", None) or ACTOR_ADMIN, action="backup.restore", @@ -230,29 +325,35 @@ async def remove_backup_file( ) -> None: """Remove a backup archive from ``backups_dir``.""" safe = normalize_backup_filename(filename) - delete_backup_file(server.paths, safe) + await asyncio.to_thread(delete_backup_file, server.paths, safe) @router.get( "/backup/export", summary="Download full system backup (ephemeral)", - response_class=StreamingResponse, + response_class=FileResponse, ) async def export_backup( + background_tasks: BackgroundTasks, _: Any = Depends(require_permission("backup")), server: Any = Depends(get_server), -) -> StreamingResponse: +) -> FileResponse: """Create and stream a backup without persisting to ``backups_dir``.""" assert server.services is not None - data, filename = create_system_backup( - paths=server.paths, - agent_rows=_agent_rows(server), - pool=server.services.db, - db_config=server.services.config.database, - ) - return StreamingResponse( - iter([data]), + _raise_if_backup_busy() + async with hold_backup_lock("export"): + tmp_path, filename = await asyncio.to_thread( + _create_ephemeral_backup, + paths=server.paths, + agent_rows=_agent_rows(server), + pool=server.services.db, + db_config=server.services.config.database, + ) + background_tasks.add_task(_unlink_quiet, tmp_path) + return FileResponse( + tmp_path, media_type="application/gzip", + filename=filename, headers={"Content-Disposition": content_disposition(filename)}, ) @@ -272,5 +373,5 @@ async def import_backup( name = file.filename or "uploaded-backup.tar.gz" safe = normalize_backup_filename(name) - entry = write_backup_file(server.paths, safe, raw) + entry = await asyncio.to_thread(write_backup_file, server.paths, safe, raw) return {"ok": True, "item": entry.to_dict()} diff --git a/src/octop/api/routers/providers.py b/src/octop/api/routers/providers.py index e030f13f..c9c49634 100644 --- a/src/octop/api/routers/providers.py +++ b/src/octop/api/routers/providers.py @@ -8,7 +8,7 @@ from types import SimpleNamespace from typing import Any, cast -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Request from pydantic import BaseModel from octop.api.deps import current_user, get_server, require_permission @@ -36,6 +36,7 @@ poll_device_token, request_device_code, ) +from octop.infra.utils.locale import resolve_request_locale from octop.infra.utils.ulid import new_ulid logger = logging.getLogger(__name__) @@ -297,6 +298,7 @@ async def admin_delete_provider( @admin_router.post("/test-draft", summary="Test unsaved provider draft") async def admin_test_provider_draft( body: ProviderTestDraftBody, + request: Request, _: Any = Depends(require_permission("providers")), ) -> dict[str, Any]: """Probe connectivity for a provider draft before it is saved.""" @@ -315,12 +317,18 @@ async def admin_test_provider_draft( extra_json=body.extra_json, embedding=body.embedding, ) - return await probe_provider_row(row, model_id=model_id, embedding=body.embedding) + return await probe_provider_row( + row, + model_id=model_id, + embedding=body.embedding, + locale=resolve_request_locale(request), + ) @admin_router.post("/fetch-models", summary="List models from an OpenAI-compatible draft") async def admin_fetch_provider_models( body: ProviderFetchModelsBody, + request: Request, _: Any = Depends(require_permission("providers")), ) -> dict[str, Any]: """Fetch remote model ids via OpenAI-compatible ``GET /models`` (openai kind only).""" @@ -337,6 +345,7 @@ async def admin_fetch_provider_models( base_url=(body.base_url or "").strip() or None, api_key=api_key, extra_headers=provider_headers(draft) or None, + locale=resolve_request_locale(request), ) @@ -456,6 +465,7 @@ async def codex_oauth_logout( @admin_router.post("/{provider_id}/test") async def admin_test_provider( provider_id: int, + request: Request, body: ProviderTestBody | None = None, _: Any = Depends(require_permission("providers")), server: Any = Depends(get_server), @@ -467,4 +477,9 @@ async def admin_test_provider( row = await _maybe_refresh_codex_row(server, row) model_id = body.model_id if body else None embedding = body.embedding if body else None - return await probe_provider_row(row, model_id=model_id, embedding=embedding) + return await probe_provider_row( + row, + model_id=model_id, + embedding=embedding, + locale=resolve_request_locale(request), + ) diff --git a/src/octop/api/routers/setup.py b/src/octop/api/routers/setup.py index 7387453b..8a7f7dda 100644 --- a/src/octop/api/routers/setup.py +++ b/src/octop/api/routers/setup.py @@ -390,6 +390,7 @@ async def resume_wizard(server: Any = Depends(get_server)) -> dict[str, Any]: @router.post("/setup/test-provider", summary="Test provider draft connectivity") async def test_provider_draft( body: ProviderTestBody, + request: Request, server: Any = Depends(get_server), authorization: str | None = Header(default=None), ) -> dict[str, Any]: @@ -405,7 +406,7 @@ async def test_provider_draft( base_url=body.base_url, model_id=body.model_id, ) - return await probe_provider_row(row) + return await probe_provider_row(row, locale=resolve_request_locale(request)) @router.post("/setup/finish", summary="Finish setup wizard") diff --git a/src/octop/cli/commands/backup.py b/src/octop/cli/commands/backup.py index 8503b9f5..f706bb85 100644 --- a/src/octop/cli/commands/backup.py +++ b/src/octop/cli/commands/backup.py @@ -2,12 +2,14 @@ from __future__ import annotations +import tempfile from pathlib import Path import click from octop.config import load_config from octop.infra.backup.auto import create_and_store_auto_backup +from octop.infra.backup.store import place_backup_file from octop.infra.backup.system_archive import create_system_backup, restore_system_backup from octop.infra.db.factory import open_database from octop.infra.db.migrate import run_migrations @@ -39,21 +41,29 @@ def create(output: Path | None, home: Path | None) -> None: run_migrations(db) services = build_shared_services(db=db, paths=paths, config=config) rows = services.agent_repo.list_all() - data, suggested = create_system_backup( - paths=paths, - agent_rows=rows, - pool=db, - db_config=config.database, - ) - db.close() + try: + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) / "backup.tar.gz" + suggested = create_system_backup( + paths=paths, + agent_rows=rows, + pool=db, + db_config=config.database, + dest=tmp_path, + ) + if output is None: + entry = place_backup_file(paths, suggested, tmp_path) + dest = paths.backup_file(entry.name) + size = entry.size + else: + dest = output + dest.parent.mkdir(parents=True, exist_ok=True) + tmp_path.replace(dest) + size = dest.stat().st_size + finally: + db.close() - if output is None: - paths.ensure_backups_dir() - dest = paths.backup_file(suggested) - else: - dest = output - dest.write_bytes(data) - click.echo(f"wrote {dest} ({len(data)} bytes)") + click.echo(f"wrote {dest} ({size} bytes)") @backup.group("auto") @@ -133,9 +143,8 @@ def restore( paths = _paths(home) config = load_config(paths.config) db = open_database(config, paths) - raw = archive.read_bytes() result = restore_system_backup( - raw, + archive, paths=paths, pool=db, db_config=config.database, diff --git a/src/octop/cli/commands/init.py b/src/octop/cli/commands/init.py index ea59ef89..78fdaeda 100644 --- a/src/octop/cli/commands/init.py +++ b/src/octop/cli/commands/init.py @@ -80,37 +80,40 @@ def init( apply_env_file(env_file_path(paths.root)) config = load_config(paths.config) db = open_database(config, paths) - run_migrations(db) + try: + run_migrations(db) - username = admin_username - password = admin_password - display_name = admin_display_name + username = admin_username + password = admin_password + display_name = admin_display_name - if not non_interactive: - from octop.cli.support import prompts as _prompts + if not non_interactive: + from octop.cli.support import prompts as _prompts - if not username: - username = _prompts.text("Admin username:") - if not password: - password = _prompts.password("Admin password:") - if display_name is None: - display_name = _prompts.text("Display name (optional):", default="") or None + if not username: + username = _prompts.text("Admin username:") + if not password: + password = _prompts.password("Admin password:") + if display_name is None: + display_name = _prompts.text("Display name (optional):", default="") or None - if not username: - click.echo("error: admin username is required", err=True) - raise SystemExit(1) - try: - validate_password_policy(password or "") - except OctopError as exc: - click.echo(f"error: {exc.message}", err=True) - raise SystemExit(1) from None + if not username: + click.echo("error: admin username is required", err=True) + raise SystemExit(1) + try: + validate_password_policy(password or "") + except OctopError as exc: + click.echo(f"error: {exc.message}", err=True) + raise SystemExit(1) from None - UserRepo(db).create( - username=username, - password_hash=hash_password(password or ""), - role="admin", - display_name=display_name, - ) + UserRepo(db).create( + username=username, + password_hash=hash_password(password or ""), + role="admin", + display_name=display_name, + ) + finally: + db.close() click.echo(f"\u2705 Octop bootstrapped at {home}") click.echo(f" admin user: {username}") diff --git a/src/octop/cli/support/embedded_ops.py b/src/octop/cli/support/embedded_ops.py index 9d801864..394f3c32 100644 --- a/src/octop/cli/support/embedded_ops.py +++ b/src/octop/cli/support/embedded_ops.py @@ -67,13 +67,13 @@ def fetch_thread_history(agent_id: str, thread_id: str, *, limit: int = 50) -> A async def probe_provider_async(provider_id: int, *, model_id: str | None = None) -> dict[str, Any]: - from octop.cli.support.db import open_cli_services + from octop.cli.support.db import open_cli_services, resolve_cli_locale with open_cli_services() as svc: row = svc.provider_repo.get(provider_id) if row is None: raise OctopError(ErrorCode.NOT_FOUND, "provider not found") - return await probe_provider_row(row, model_id=model_id) + return await probe_provider_row(row, model_id=model_id, locale=resolve_cli_locale()) def probe_provider(provider_id: int, *, model_id: str | None = None) -> dict[str, Any]: diff --git a/src/octop/i18n/domains/stream.py b/src/octop/i18n/domains/stream.py index 2d21d543..05d4bb6c 100644 --- a/src/octop/i18n/domains/stream.py +++ b/src/octop/i18n/domains/stream.py @@ -10,6 +10,7 @@ STREAM_STALL = f"{_PREFIX}stream_errors.stream_stall" RATE_LIMIT = f"{_PREFIX}stream_errors.rate_limit" AUTH = f"{_PREFIX}stream_errors.auth" +INSUFFICIENT_BALANCE = f"{_PREFIX}stream_errors.insufficient_balance" CONTEXT_LENGTH = f"{_PREFIX}stream_errors.context_length" RECURSION_LIMIT = f"{_PREFIX}stream_errors.recursion_limit" TIMEOUT_NETWORK = f"{_PREFIX}stream_errors.timeout_network" @@ -19,6 +20,7 @@ __all__ = [ "AUTH", "CONTEXT_LENGTH", + "INSUFFICIENT_BALANCE", "MODEL_CALL_FAILED", "PROVIDER_UNAVAILABLE", "RATE_LIMIT", @@ -64,14 +66,32 @@ def classify_stream_error_message(message: str) -> str | None: if ( "error code: 429" in lower + or "http 429" in lower or "rate_limit" in lower or "ratelimiterror" in compact or "too many requests" in lower ): return RATE_LIMIT + if ( + "error code: 402" in lower + or "http 402" in lower + or "insufficient balance" in lower + or "insufficient_quota" in lower + or "insufficient credits" in lower + or "exceeded your current quota" in lower + or "payment_required" in lower + or "billing_not_active" in lower + or "arrearage" in lower + or "余额不足" in msg + or "账户余额" in msg + or "欠费" in msg + ): + return INSUFFICIENT_BALANCE + if ( "error code: 401" in lower + or "http 401" in lower or "invalid_api_key" in lower or "incorrect api key" in lower or "authenticationerror" in compact @@ -105,6 +125,9 @@ def classify_stream_error_message(message: str) -> str | None: or "error code: 500" in lower or "error code: 502" in lower or "error code: 503" in lower + or "http 500" in lower + or "http 502" in lower + or "http 503" in lower ): return PROVIDER_UNAVAILABLE diff --git a/src/octop/i18n/en.json b/src/octop/i18n/en.json index e52d594d..96ec5fad 100644 --- a/src/octop/i18n/en.json +++ b/src/octop/i18n/en.json @@ -286,7 +286,7 @@ "SETUP_REQUIRED": "Initial setup is required.", "DATABASE_NOT_EMPTY": "The target database already has users. Use an empty database, or log in with the existing admin.", "BACKUP_DRIVER_MISMATCH": "This backup was made with a different database engine (SQLite vs PostgreSQL). Switch the runtime to match the backup, or create a new backup on the current engine. Cross-engine restore is not supported.", - "BACKUP_IN_PROGRESS": "A backup is already in progress. Try again shortly.", + "BACKUP_IN_PROGRESS": "A backup or restore is already in progress. Try again shortly.", "FORBIDDEN": "Permission denied.", "NOT_FOUND": "Not found.", "USER_DISABLED": "This account has been disabled.", @@ -551,6 +551,7 @@ "stream_stall": "The model stopped sending content while the connection stayed open. Click Retry, or try again later with another model. If this happens often, check the provider status or ask an admin to review stream timeout settings.", "rate_limit": "The model provider rate-limited this request. Wait a moment and retry, or switch to another model.", "auth": "Model authentication failed. Check that the API key under Settings → Models is correct and still valid.", + "insufficient_balance": "The model provider rejected this request due to insufficient balance or quota. Top up or upgrade the plan for this API key, then try again.", "context_length": "This conversation is too long for the model's context window. Start a new chat, or shorten the history and try again.", "recursion_limit": "The agent hit its max iteration / recursion limit before finishing. Open Configuration and raise Max Iterations, then try again.", "timeout_network": "Connecting to the model timed out or the network failed. Check your network and retry. If you use a proxy or self-hosted endpoint, confirm it is reachable.", diff --git a/src/octop/i18n/zh.json b/src/octop/i18n/zh.json index b6861e94..e8057f13 100644 --- a/src/octop/i18n/zh.json +++ b/src/octop/i18n/zh.json @@ -286,7 +286,7 @@ "SETUP_REQUIRED": "需要完成初始设置。", "DATABASE_NOT_EMPTY": "目标数据库已有用户。请改用空库,或直接登录现有管理员账户。", "BACKUP_DRIVER_MISMATCH": "该备份与当前数据库引擎不一致(SQLite 与 PostgreSQL 不能互恢)。请将运行时切回备份所用引擎后再恢复,或在当前引擎上重新备份。暂不支持跨引擎恢复。", - "BACKUP_IN_PROGRESS": "已有备份任务正在进行,请稍后再试。", + "BACKUP_IN_PROGRESS": "已有备份或恢复任务正在进行,请稍后再试。", "FORBIDDEN": "没有权限。", "NOT_FOUND": "未找到。", "USER_DISABLED": "账号已禁用。", @@ -551,6 +551,7 @@ "stream_stall": "模型响应中断:连接仍在,但长时间没有新内容。请点击「重试」,或稍后更换模型再试。若经常出现,请检查供应商状态,或联系管理员排查流式超时设置。", "rate_limit": "模型请求过于频繁,已被限流。请稍等片刻后重试,或切换其他模型。", "auth": "模型服务鉴权失败。请检查「设置 → 模型」中的 API Key 是否正确、是否过期。", + "insufficient_balance": "模型服务返回余额或额度不足。请为该 API Key 对应的账户充值或升级套餐后再试。", "context_length": "对话上下文过长,超出模型限制。请新开会话,或精简历史后再试。", "recursion_limit": "智能体已达到最大迭代次数(递归上限),任务尚未完成。请前往「运行配置」调高「最大迭代次数」后重试。", "timeout_network": "连接模型服务超时或网络异常。请检查网络后重试;若使用代理或自建服务,请确认其可达。", diff --git a/src/octop/infra/agents/providers/probe.py b/src/octop/infra/agents/providers/probe.py index cd4b554d..0d8fae1c 100644 --- a/src/octop/infra/agents/providers/probe.py +++ b/src/octop/infra/agents/providers/probe.py @@ -149,7 +149,16 @@ def _embeddings_url(base_url: str | None) -> str: return f"{root}/embeddings" -async def _probe_embedding_endpoint(row: Any, *, model_id: str) -> dict[str, Any]: +def _friendly_probe_error(exc: BaseException | str, *, locale: str) -> str: + """Map raw provider exceptions / HTTP bodies to localized guidance when known.""" + from octop.i18n.domains.stream import stream_error_message + + return stream_error_message(str(exc), locale) + + +async def _probe_embedding_endpoint( + row: Any, *, model_id: str, locale: str = "en" +) -> dict[str, Any]: """POST OpenAI-compatible ``{base}/embeddings`` and time the round-trip.""" started = time.perf_counter() url = _embeddings_url(getattr(row, "base_url", None)) @@ -166,7 +175,7 @@ async def _probe_embedding_endpoint(row: Any, *, model_id: str) -> dict[str, Any ) except Exception as exc: logger.info("embedding probe failed for %s: %s", getattr(row, "name", "?"), exc) - return {"ok": False, "error": str(exc)} + return {"ok": False, "error": _friendly_probe_error(exc, locale=locale)} if response.status_code >= 400: detail = response.text.strip() @@ -175,7 +184,7 @@ async def _probe_embedding_endpoint(row: Any, *, model_id: str) -> dict[str, Any error = f"HTTP {response.status_code} POST {url}" if detail: error = f"{error}: {detail}" - return {"ok": False, "error": error} + return {"ok": False, "error": _friendly_probe_error(error, locale=locale)} try: payload = response.json() @@ -198,7 +207,11 @@ async def _probe_embedding_endpoint(row: Any, *, model_id: str) -> dict[str, Any async def probe_provider_row( - row: Any, *, model_id: str | None = None, embedding: bool | None = None + row: Any, + *, + model_id: str | None = None, + embedding: bool | None = None, + locale: str = "en", ) -> dict[str, Any]: """Probe a provider: chat models get a one-token ping; embedding models POST /embeddings.""" from octop.infra.agents.providers.model_flags import is_onnx_local_provider @@ -213,18 +226,20 @@ async def probe_provider_row( result = await probe_local_model(_onnx_probe_model_id(row, model_id)) if result.get("latency_ms") is not None: result["latency_ms"] = int(result["latency_ms"]) + if not result.get("ok") and result.get("error"): + result["error"] = _friendly_probe_error(str(result["error"]), locale=locale) return result mid = _probe_model_id(row, model_id) if _should_probe_embedding(row, model_id=mid, embedding=embedding): - return await _probe_embedding_endpoint(row, model_id=mid) + return await _probe_embedding_endpoint(row, model_id=mid, locale=locale) started = time.perf_counter() try: chat = build_probe_chat_model(row, model_id=mid) result = await asyncio.wait_for(chat.ainvoke("ping"), timeout=30.0) except Exception as exc: logger.info("provider probe failed for %s: %s", getattr(row, "name", "?"), exc) - return {"ok": False, "error": str(exc)} + return {"ok": False, "error": _friendly_probe_error(exc, locale=locale)} latency_ms = int((time.perf_counter() - started) * 1000) _ = getattr(result, "content", None) return {"ok": True, "latency_ms": latency_ms} @@ -240,6 +255,7 @@ async def fetch_openai_compatible_models( base_url: str | None, api_key: str, extra_headers: dict[str, str] | None = None, + locale: str = "en", ) -> dict[str, Any]: """List models via OpenAI-compatible ``GET {base}/models``.""" url = _models_list_url(base_url) @@ -251,7 +267,7 @@ async def fetch_openai_compatible_models( response = await client.get(url, headers=headers) except Exception as exc: logger.info("provider fetch-models failed for %s: %s", url, exc) - return {"ok": False, "error": str(exc)} + return {"ok": False, "error": _friendly_probe_error(exc, locale=locale)} if response.status_code >= 400: detail = response.text.strip() @@ -260,7 +276,7 @@ async def fetch_openai_compatible_models( error = f"HTTP {response.status_code}" if detail: error = f"{error}: {detail}" - return {"ok": False, "error": error} + return {"ok": False, "error": _friendly_probe_error(error, locale=locale)} try: payload = response.json() diff --git a/src/octop/infra/backup/auto.py b/src/octop/infra/backup/auto.py index 6bb8c998..2f4c2aff 100644 --- a/src/octop/infra/backup/auto.py +++ b/src/octop/infra/backup/auto.py @@ -5,16 +5,19 @@ import asyncio import json import logging +import tempfile +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager from dataclasses import replace from pathlib import Path -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, Literal, cast from octop.config import BackupConfig, OctopConfig, load_config from octop.infra.backup.store import ( BackupFileInfo, is_auto_backup_filename, + place_backup_file, prune_auto_backups, - write_backup_file, ) from octop.infra.backup.system_archive import create_system_backup from octop.infra.cron.trigger import build_trigger @@ -34,12 +37,47 @@ _AUTO_FILENAME_PREFIX = "octop-auto-backup-" _MANUAL_FILENAME_PREFIX = "octop-backup-" +BackupOperation = Literal["create", "restore", "auto", "export"] + _lock = asyncio.Lock() +_active_operation: BackupOperation | None = None # Shared with manual create so concurrent backups do not overlap. BACKUP_LOCK = _lock +def get_backup_operation() -> BackupOperation | None: + """Return the in-flight backup/restore kind, or ``None`` when idle.""" + if not _lock.locked(): + return None + return _active_operation + + +def backup_status_payload() -> dict[str, Any]: + op = get_backup_operation() + return {"busy": op is not None, "operation": op} + + +@asynccontextmanager +async def hold_backup_lock(operation: BackupOperation) -> AsyncIterator[None]: + """Acquire ``BACKUP_LOCK`` and publish *operation* for status polling.""" + global _active_operation + async with _lock: + _active_operation = operation + try: + yield + finally: + _active_operation = None + + +def raise_if_backup_busy() -> None: + if _lock.locked(): + raise OctopError( + ErrorCode.BACKUP_IN_PROGRESS, + "a backup or restore is already in progress", + ) + + def to_auto_backup_filename(suggested: str) -> str: """Map ``octop-backup-*.tar.gz`` → ``octop-auto-backup-*.tar.gz``.""" name = Path(suggested).name @@ -105,14 +143,17 @@ def create_and_store_auto_backup( retention_count: int, ) -> tuple[BackupFileInfo, list[str]]: """Create a full system backup with the auto filename prefix and prune.""" - data, suggested = create_system_backup( - paths=paths, - agent_rows=agent_rows, - pool=pool, - db_config=db_config, - ) - filename = to_auto_backup_filename(suggested) - entry = write_backup_file(paths, filename, data) + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) / "backup.tar.gz" + suggested = create_system_backup( + paths=paths, + agent_rows=agent_rows, + pool=pool, + db_config=db_config, + dest=tmp_path, + ) + filename = to_auto_backup_filename(suggested) + entry = place_backup_file(paths, filename, tmp_path) deleted = prune_auto_backups(paths, keep=retention_count) return entry, deleted @@ -123,7 +164,7 @@ async def run_auto_backup(server: OctopServer) -> BackupFileInfo | None: logger.info("auto backup skipped: another backup is already running") return None - async with _lock: + async with hold_backup_lock("auto"): config = load_config(server.paths.config) if server.services is None: logger.warning("auto backup skipped: server services not ready") diff --git a/src/octop/infra/backup/store.py b/src/octop/infra/backup/store.py index aa9260c8..21f4e5a6 100644 --- a/src/octop/infra/backup/store.py +++ b/src/octop/infra/backup/store.py @@ -3,6 +3,7 @@ from __future__ import annotations import re +import shutil from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path @@ -74,49 +75,66 @@ def list_backup_files(paths: PathLayout) -> list[BackupFileInfo]: continue if not any(path.name.endswith(suffix) for suffix in _BACKUP_SUFFIXES): continue - stat = path.stat() - modified = _iso_utc_from_timestamp(stat.st_mtime) - created = resolve_backup_created_at(path.name, path, mtime=stat.st_mtime) - out.append( - BackupFileInfo( - name=path.name, - size=stat.st_size, - modified_at=modified, - created_at=created, - ) - ) + out.append(backup_file_info(path)) return out -def write_backup_file(paths: PathLayout, filename: str, data: bytes) -> BackupFileInfo: - paths.ensure_backups_dir() - safe = normalize_backup_filename(filename) - dest = paths.backup_file(safe) - dest.write_bytes(data) - stat = dest.stat() +def backup_file_info(path: Path) -> BackupFileInfo: + """Build ``BackupFileInfo`` from an existing archive path.""" + path = Path(path) + if not path.is_file(): + raise OctopError(ErrorCode.NOT_FOUND, f"backup not found: {path.name}") + stat = path.stat() modified = _iso_utc_from_timestamp(stat.st_mtime) - created = resolve_backup_created_at(safe, dest, mtime=stat.st_mtime) + created = resolve_backup_created_at(path.name, path, mtime=stat.st_mtime) return BackupFileInfo( - name=safe, + name=path.name, size=stat.st_size, modified_at=modified, created_at=created, ) -def read_backup_file(paths: PathLayout, filename: str) -> bytes: +def write_backup_file(paths: PathLayout, filename: str, data: bytes) -> BackupFileInfo: + paths.ensure_backups_dir() safe = normalize_backup_filename(filename) - path = paths.backup_file(safe) - if not path.is_file(): - raise OctopError(ErrorCode.NOT_FOUND, f"backup not found: {safe}") - return path.read_bytes() + dest = paths.backup_file(safe) + dest.write_bytes(data) + return backup_file_info(dest) -def delete_backup_file(paths: PathLayout, filename: str) -> None: +def place_backup_file(paths: PathLayout, filename: str, src: Path) -> BackupFileInfo: + """Move *src* into ``backups_dir`` under *filename* (atomic replace when possible).""" + paths.ensure_backups_dir() + safe = normalize_backup_filename(filename) + dest = paths.backup_file(safe) + src = Path(src) + if src.resolve() == dest.resolve(): + return backup_file_info(dest) + dest.parent.mkdir(parents=True, exist_ok=True) + try: + src.replace(dest) + except OSError: + shutil.copy2(src, dest) + src.unlink(missing_ok=True) + return backup_file_info(dest) + + +def resolve_backup_path(paths: PathLayout, filename: str) -> Path: + """Return the on-disk path for a stored backup (must exist).""" safe = normalize_backup_filename(filename) path = paths.backup_file(safe) if not path.is_file(): raise OctopError(ErrorCode.NOT_FOUND, f"backup not found: {safe}") + return path + + +def read_backup_file(paths: PathLayout, filename: str) -> bytes: + return resolve_backup_path(paths, filename).read_bytes() + + +def delete_backup_file(paths: PathLayout, filename: str) -> None: + path = resolve_backup_path(paths, filename) path.unlink() diff --git a/src/octop/infra/backup/system_archive.py b/src/octop/infra/backup/system_archive.py index 8e45ef2b..53a87904 100644 --- a/src/octop/infra/backup/system_archive.py +++ b/src/octop/infra/backup/system_archive.py @@ -45,47 +45,65 @@ _PG_DUMP_ARC = f"{_DB_DIR}/octop.dump" _MIGRATION_VERSION_SUFFIX = "-migrated-from-lightclaw" +# Align with workspace zip export; keep backups smaller / faster. +_SKIP_DIR_NAMES = frozenset( + { + ".git", + "__pycache__", + ".venv", + "venv", + "node_modules", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + ".tox", + ".next", + ".turbo", + "dist", + "build", + } +) + def _timestamp() -> str: return datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") +def suggested_backup_filename() -> str: + """Canonical basename for a newly created manual backup archive.""" + return f"octop-backup-{_timestamp()}.tar.gz" + + +def _should_skip_path(rel: Path) -> bool: + return any(part in _SKIP_DIR_NAMES for part in rel.parts) + + def _add_dir(tf: tarfile.TarFile, src: Path, arc_root: str) -> None: if not src.is_dir(): return for path in sorted(src.rglob("*")): if not path.is_file(): continue - rel = path.relative_to(src).as_posix() - tf.add(path, arcname=f"{arc_root}/{rel}") + rel = path.relative_to(src) + if _should_skip_path(rel): + continue + tf.add(path, arcname=f"{arc_root}/{rel.as_posix()}") -def create_system_backup( +def _build_manifest( *, paths: PathLayout, agent_rows: list[Any], pool: DatabasePool, - db_config: DatabaseConfig, -) -> tuple[bytes, str]: - """Build a ``.tar.gz`` archive and return ``(bytes, suggested_filename)``.""" + db_arc: str, + database_driver: str, + database_dump_format: str, + env_path: Path, +) -> BackupManifest: try: schema_version = _current_version(pool) except Exception: schema_version = 0 - - if pool.dialect == "postgresql": - db_arc = _PG_DUMP_ARC - database_driver = "postgresql" - database_dump_format = "pg_custom" - else: - db_arc = _SQLITE_DB_ARC - database_driver = "sqlite" - database_dump_format = "sqlite_file" - if not isinstance(pool, SqlitePool): - raise OctopError(ErrorCode.INTERNAL_ERROR, "sqlite backup requires SqlitePool") - if not pool.path.is_file(): - raise OctopError(ErrorCode.NOT_FOUND, f"database not found: {pool.path}") - agents = [ AgentBackupEntry( agent_id=str(row.agent_id), @@ -94,8 +112,7 @@ def create_system_backup( ) for row in agent_rows ] - env_path = env_file_path(paths.root) - manifest = BackupManifest( + return BackupManifest( manifest_version=MANIFEST_VERSION, octop_version=__version__, schema_version=schema_version, @@ -109,57 +126,106 @@ def create_system_backup( includes_env=env_path.is_file(), ) - buf = io.BytesIO() - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - db_dest = root / db_arc - if pool.dialect == "postgresql": - dump_postgres(db_config.postgresql_conninfo(), db_dest) - else: - assert isinstance(pool, SqlitePool) - snapshot_sqlite_file(pool.path, db_dest) - - manifest_path = root / _MANIFEST_NAME - manifest_path.write_text(manifest.to_json(), encoding="utf-8") - - if paths.config.is_file(): - cfg_dir = root / _CONFIG_DIR - cfg_dir.mkdir(parents=True, exist_ok=True) - shutil.copy2(paths.config, cfg_dir / "config.json") - if env_path.is_file(): - cfg_dir = root / _CONFIG_DIR - cfg_dir.mkdir(parents=True, exist_ok=True) - shutil.copy2(env_path, cfg_dir / "env") - - with tarfile.open(fileobj=buf, mode="w:gz") as tf: - tf.add(manifest_path, arcname=_MANIFEST_NAME) - tf.add(db_dest, arcname=db_arc) + +def create_system_backup( + *, + paths: PathLayout, + agent_rows: list[Any], + pool: DatabasePool, + db_config: DatabaseConfig, + dest: Path, +) -> str: + """Write a ``.tar.gz`` archive to *dest* (streamed to disk). + + Returns the suggested basename (``octop-backup-….tar.gz``). Callers that + need a different name should rename/move *dest* afterward. + """ + if pool.dialect == "postgresql": + db_arc = _PG_DUMP_ARC + database_driver = "postgresql" + database_dump_format = "pg_custom" + else: + db_arc = _SQLITE_DB_ARC + database_driver = "sqlite" + database_dump_format = "sqlite_file" + if not isinstance(pool, SqlitePool): + raise OctopError(ErrorCode.INTERNAL_ERROR, "sqlite backup requires SqlitePool") + if not pool.path.is_file(): + raise OctopError(ErrorCode.NOT_FOUND, f"database not found: {pool.path}") + + env_path = env_file_path(paths.root) + manifest = _build_manifest( + paths=paths, + agent_rows=agent_rows, + pool=pool, + db_arc=db_arc, + database_driver=database_driver, + database_dump_format=database_dump_format, + env_path=env_path, + ) + filename = suggested_backup_filename() + dest = Path(dest) + dest.parent.mkdir(parents=True, exist_ok=True) + partial = dest.with_name(dest.name + ".partial") + + try: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + db_dest = root / db_arc + if pool.dialect == "postgresql": + dump_postgres(db_config.postgresql_conninfo(), db_dest) + else: + assert isinstance(pool, SqlitePool) + snapshot_sqlite_file(pool.path, db_dest) + + manifest_path = root / _MANIFEST_NAME + manifest_path.write_text(manifest.to_json(), encoding="utf-8") + if paths.config.is_file(): - tf.add(root / _CONFIG_DIR / "config.json", arcname=f"{_CONFIG_DIR}/config.json") + cfg_dir = root / _CONFIG_DIR + cfg_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(paths.config, cfg_dir / "config.json") if env_path.is_file(): - tf.add(root / _CONFIG_DIR / "env", arcname=f"{_CONFIG_DIR}/env") - for row in agent_rows: - ws = workspace_dir_from_config_json( - getattr(row, "config_json", None), - paths=paths, - agent_id=str(row.agent_id), - ) - if ws.is_dir(): - _add_dir(tf, ws, f"{_WORKSPACES_DIR}/{row.agent_id}") - if paths.skill_packages_dir.is_dir(): - _add_dir(tf, paths.skill_packages_dir, _SKILL_PACKAGES_DIR) + cfg_dir = root / _CONFIG_DIR + cfg_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(env_path, cfg_dir / "env") + + with tarfile.open(partial, mode="w:gz") as tf: + tf.add(manifest_path, arcname=_MANIFEST_NAME) + tf.add(db_dest, arcname=db_arc) + if paths.config.is_file(): + tf.add( + root / _CONFIG_DIR / "config.json", + arcname=f"{_CONFIG_DIR}/config.json", + ) + if env_path.is_file(): + tf.add(root / _CONFIG_DIR / "env", arcname=f"{_CONFIG_DIR}/env") + for row in agent_rows: + ws = workspace_dir_from_config_json( + getattr(row, "config_json", None), + paths=paths, + agent_id=str(row.agent_id), + ) + if ws.is_dir(): + _add_dir(tf, ws, f"{_WORKSPACES_DIR}/{row.agent_id}") + if paths.skill_packages_dir.is_dir(): + _add_dir(tf, paths.skill_packages_dir, _SKILL_PACKAGES_DIR) + + partial.replace(dest) + except Exception: + partial.unlink(missing_ok=True) + raise - filename = f"octop-backup-{_timestamp()}.tar.gz" - return buf.getvalue(), filename + return filename -def _extract_manifest(members: dict[str, bytes]) -> BackupManifest: - raw = members.get(_MANIFEST_NAME) - if raw is None: +def _extract_manifest_from_dir(extracted: Path) -> BackupManifest: + manifest_path = extracted / _MANIFEST_NAME + if not manifest_path.is_file(): raise OctopError(ErrorCode.SLASH_BAD_ARGS, "backup archive missing manifest.json") try: - manifest = BackupManifest.load_text(raw.decode("utf-8")) - except (json.JSONDecodeError, ValueError, TypeError) as exc: + manifest = BackupManifest.load_text(manifest_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, ValueError, TypeError, OSError) as exc: raise OctopError(ErrorCode.SLASH_BAD_ARGS, f"invalid manifest: {exc}") from exc if manifest.manifest_version != MANIFEST_VERSION: raise OctopError( @@ -169,17 +235,18 @@ def _extract_manifest(members: dict[str, bytes]) -> BackupManifest: return manifest -def _read_tar_members(data: bytes) -> dict[str, bytes]: - out: dict[str, bytes] = {} - with tarfile.open(fileobj=io.BytesIO(data), mode="r:*") as tf: - for member in tf.getmembers(): - if not member.isfile(): - continue - extracted = tf.extractfile(member) - if extracted is None: - continue - out[member.name.replace("\\", "/")] = extracted.read() - return out +def _extract_archive(source: Path | bytes, dest_dir: Path) -> None: + """Extract *source* into *dest_dir* without holding the whole archive in a dict.""" + dest_dir.mkdir(parents=True, exist_ok=True) + if isinstance(source, bytes): + with tarfile.open(fileobj=io.BytesIO(source), mode="r:*") as tf: + # Python 3.12+: refuse path traversal / special files. + tf.extractall(dest_dir, filter=tarfile.data_filter) + return + if not Path(source).is_file(): + raise OctopError(ErrorCode.NOT_FOUND, f"backup not found: {source}") + with tarfile.open(source, mode="r:*") as tf: + tf.extractall(dest_dir, filter=tarfile.data_filter) def _is_migration_backup(manifest: BackupManifest) -> bool: @@ -187,8 +254,22 @@ def _is_migration_backup(manifest: BackupManifest) -> bool: return manifest.octop_version.endswith(_MIGRATION_VERSION_SUFFIX) +def _iter_extracted_files(root: Path, prefix: str) -> list[tuple[str, Path]]: + """Return ``(archive-relative-posix, on-disk path)`` under *prefix*.""" + base = root / prefix + if not base.is_dir(): + return [] + out: list[tuple[str, Path]] = [] + for path in sorted(base.rglob("*")): + if not path.is_file(): + continue + rel = path.relative_to(root).as_posix() + out.append((rel, path)) + return out + + def restore_system_backup( - data: bytes, + source: Path | bytes, *, paths: PathLayout, pool: DatabasePool, @@ -199,6 +280,8 @@ def restore_system_backup( ) -> dict[str, Any]: """Restore database, workspaces, and optional config from a tar.gz archive. + *source* may be a filesystem path (preferred) or in-memory bytes (tests / legacy). + ``preserve_users`` controls whether the *current* Octop instance's login credentials (``users`` rows + ``secrets.jwt``) are written back after the database is replaced: @@ -215,72 +298,69 @@ def restore_system_backup( performing the restore) receives all imported ``user_id`` ownership. When omitted, the first preserved admin (else first preserved user) is used. """ - members = _read_tar_members(data) - manifest = _extract_manifest(members) - is_migration = _is_migration_backup(manifest) - - # Resolve effective preserve_users flag before touching the DB. - effective_preserve_users = is_migration if preserve_users is None else preserve_users - - archive_driver = manifest.database_driver or "sqlite" - if archive_driver != pool.dialect: - raise OctopError( - ErrorCode.BACKUP_DRIVER_MISMATCH, - f"backup database_driver={archive_driver!r} does not match " - f"runtime dialect={pool.dialect!r}; cross-engine restore is refused", - status=400, - details={"archive_driver": archive_driver, "runtime_driver": pool.dialect}, - ) - - db_blob = members.get(manifest.db_file) - if db_blob is None: - raise OctopError(ErrorCode.SLASH_BAD_ARGS, "backup archive missing database file") - - # Capture current login credentials before overwriting the DB (only when needed). - saved_users: list[tuple[object, ...]] = [] - saved_jwt: bytes | None = None - if effective_preserve_users and pool is not None: - saved_users = capture_users_from_pool(pool) - saved_jwt = capture_jwt_secret_from_pool(pool) - - effective_owner: int | None = None - if is_migration: - effective_owner = owner_user_id - if effective_owner is None: - effective_owner = infer_owner_user_id(saved_users) - if effective_owner is not None and saved_users: - saved_ids = {int(str(row[0])) for row in saved_users} - if int(effective_owner) not in saved_ids: - raise OctopError( - ErrorCode.SLASH_BAD_ARGS, - f"owner_user_id={effective_owner} is not among current users", - status=400, - ) - - ownership_remap: dict[str, int] | None = None with tempfile.TemporaryDirectory() as tmp: + extracted = Path(tmp) / "extracted" + _extract_archive(source, extracted) + manifest = _extract_manifest_from_dir(extracted) + is_migration = _is_migration_backup(manifest) + + # Resolve effective preserve_users flag before touching the DB. + effective_preserve_users = is_migration if preserve_users is None else preserve_users + + archive_driver = manifest.database_driver or "sqlite" + if archive_driver != pool.dialect: + raise OctopError( + ErrorCode.BACKUP_DRIVER_MISMATCH, + f"backup database_driver={archive_driver!r} does not match " + f"runtime dialect={pool.dialect!r}; cross-engine restore is refused", + status=400, + details={"archive_driver": archive_driver, "runtime_driver": pool.dialect}, + ) + + db_path = extracted / manifest.db_file + if not db_path.is_file(): + raise OctopError(ErrorCode.SLASH_BAD_ARGS, "backup archive missing database file") + + # Capture current login credentials before overwriting the DB (only when needed). + saved_users: list[tuple[object, ...]] = [] + saved_jwt: bytes | None = None + if effective_preserve_users and pool is not None: + saved_users = capture_users_from_pool(pool) + saved_jwt = capture_jwt_secret_from_pool(pool) + + effective_owner: int | None = None + if is_migration: + effective_owner = owner_user_id + if effective_owner is None: + effective_owner = infer_owner_user_id(saved_users) + if effective_owner is not None and saved_users: + saved_ids = {int(str(row[0])) for row in saved_users} + if int(effective_owner) not in saved_ids: + raise OctopError( + ErrorCode.SLASH_BAD_ARGS, + f"owner_user_id={effective_owner} is not among current users", + status=400, + ) + + ownership_remap: dict[str, int] | None = None if pool.dialect == "postgresql": - dump_path = Path(tmp) / "octop.dump" - dump_path.write_bytes(db_blob) - restore_postgres(db_config.postgresql_conninfo(), dump_path) + restore_postgres(db_config.postgresql_conninfo(), db_path) else: - backup_db = Path(tmp) / "octop.db" - backup_db.write_bytes(db_blob) if isinstance(pool, SqlitePool): - restore_sqlite_into_pool(backup_db, pool) + restore_sqlite_into_pool(db_path, pool) else: raise OctopError(ErrorCode.INTERNAL_ERROR, "sqlite restore requires SqlitePool") if restore_config: - cfg_blob = members.get(f"{_CONFIG_DIR}/config.json") - if cfg_blob is not None: + cfg_path = extracted / _CONFIG_DIR / "config.json" + if cfg_path.is_file(): paths.config.parent.mkdir(parents=True, exist_ok=True) - paths.config.write_bytes(cfg_blob) - env_blob = members.get(f"{_CONFIG_DIR}/env") - if env_blob is not None: + shutil.copy2(cfg_path, paths.config) + env_blob_path = extracted / _CONFIG_DIR / "env" + if env_blob_path.is_file(): env_path = env_file_path(paths.root) env_path.parent.mkdir(parents=True, exist_ok=True) - env_path.write_bytes(env_blob) + shutil.copy2(env_blob_path, env_path) # Migration ownership remap must run after the target owner exists and # before pruning backup placeholder users (avoids ON DELETE CASCADE). @@ -303,14 +383,12 @@ def restore_system_backup( prefix = f"{_WORKSPACES_DIR}/" agent_repo = AgentRepo(pool) workspace_by_agent: dict[str, Path] = {} - for name, blob in members.items(): - if not name.startswith(prefix): + for rel, src_file in _iter_extracted_files(extracted, _WORKSPACES_DIR): + file_rel = rel[len(prefix) :] + if "/" not in file_rel: continue - rel = name[len(prefix) :] - if "/" not in rel: - continue - agent_id, _, file_rel = rel.partition("/") - if not agent_id or not file_rel: + agent_id, _, rest = file_rel.partition("/") + if not agent_id or not rest: continue dest_root = workspace_by_agent.get(agent_id) if dest_root is None: @@ -321,9 +399,9 @@ def restore_system_backup( agent_id=agent_id, ) workspace_by_agent[agent_id] = dest_root - dest = dest_root / file_rel + dest = dest_root / rest dest.parent.mkdir(parents=True, exist_ok=True) - dest.write_bytes(blob) + shutil.copy2(src_file, dest) restored_workspaces += 1 # Replace skill-package files wholesale so leftover package dirs cannot @@ -333,15 +411,13 @@ def restore_system_backup( paths.skill_packages_dir.mkdir(parents=True, exist_ok=True) restored_skill_package_files = 0 skill_packages_prefix = f"{_SKILL_PACKAGES_DIR}/" - for name, blob in members.items(): - if not name.startswith(skill_packages_prefix): - continue - rel = name[len(skill_packages_prefix) :] - if not rel: + for rel, src_file in _iter_extracted_files(extracted, _SKILL_PACKAGES_DIR): + rest = rel[len(skill_packages_prefix) :] + if not rest: continue - dest = paths.skill_packages_dir / rel + dest = paths.skill_packages_dir / rest dest.parent.mkdir(parents=True, exist_ok=True) - dest.write_bytes(blob) + shutil.copy2(src_file, dest) restored_skill_package_files += 1 # LightClaw migration exports (and older Octop backups) may ship schema v1 diff --git a/tests/integration/test_postgresql_control_plane.py b/tests/integration/test_postgresql_control_plane.py index 249dcd0e..9ddba0dc 100644 --- a/tests/integration/test_postgresql_control_plane.py +++ b/tests/integration/test_postgresql_control_plane.py @@ -208,17 +208,19 @@ class Row: agent_id = "agent01" name = "Test" - data, _ = create_system_backup( + archive = tmp_path / "pg-backup.tar.gz" + create_system_backup( paths=layout, agent_rows=[Row()], pool=pool, db_config=db_config, + dest=archive, ) _reset_public_schema(pool) run_migrations(pool) result = restore_system_backup( - data, + archive, paths=layout, pool=pool, db_config=db_config, diff --git a/tests/integration/test_provider_test_endpoint.py b/tests/integration/test_provider_test_endpoint.py index 425c0cc7..4cc9f7bb 100644 --- a/tests/integration/test_provider_test_endpoint.py +++ b/tests/integration/test_provider_test_endpoint.py @@ -48,10 +48,15 @@ async def test_provider_test_embedding_skips_chat_probe( c, _srv, auth, pid = env_with_provider_record async def fake_probe( - row: Any, *, model_id: str | None = None, embedding: bool | None = None + row: Any, + *, + model_id: str | None = None, + embedding: bool | None = None, + locale: str = "en", ) -> dict[str, Any]: assert embedding is True assert model_id == "text-embedding-3-small" + _ = locale return {"ok": True, "latency_ms": 12} with patch( diff --git a/tests/unit/api/test_backup_restore_rehydrate.py b/tests/unit/api/test_backup_restore_rehydrate.py index fcefb722..b71e82fd 100644 --- a/tests/unit/api/test_backup_restore_rehydrate.py +++ b/tests/unit/api/test_backup_restore_rehydrate.py @@ -1,4 +1,4 @@ -"""Unit tests for admin backup restore rehydrate.""" +"""Unit tests for admin backup restore rehydrate and non-blocking paths.""" from __future__ import annotations @@ -8,6 +8,9 @@ import pytest from octop.api.routers import backup as backup_router +from octop.infra.backup.auto import BACKUP_LOCK +from octop.infra.backup.store import BackupFileInfo +from octop.infra.errors import ErrorCode, OctopError @pytest.mark.asyncio @@ -27,11 +30,10 @@ async def test_restore_backup_file_rehydrates_providers_channels_and_cron( } monkeypatch.setattr(backup_router, "normalize_backup_filename", lambda name: name) - monkeypatch.setattr(backup_router, "read_backup_file", lambda *_a, **_k: b"fake-archive") monkeypatch.setattr( backup_router, - "restore_system_backup", - lambda *_a, **_k: restored, + "_restore_stored_backup", + lambda **_k: restored, ) server = MagicMock() @@ -72,11 +74,10 @@ async def test_restore_backup_file_skips_rehydrate_without_runtime( "restore_config": False, } monkeypatch.setattr(backup_router, "normalize_backup_filename", lambda name: name) - monkeypatch.setattr(backup_router, "read_backup_file", lambda *_a, **_k: b"fake-archive") monkeypatch.setattr( backup_router, - "restore_system_backup", - lambda *_a, **_k: restored, + "_restore_stored_backup", + lambda **_k: restored, ) server = MagicMock() @@ -115,3 +116,74 @@ async def test_rehydrate_reloads_channels_and_cron_even_if_provider_rehydrate_fa on_provider_changed.assert_awaited_once_with() reload_channels.assert_awaited_once_with() reload_cron.assert_awaited_once_with() + + +@pytest.mark.asyncio +async def test_create_backup_offloads_to_thread(monkeypatch: pytest.MonkeyPatch) -> None: + """Manual create must not run tar/sqlite work on the event loop.""" + entry = BackupFileInfo( + name="octop-backup-20260101T000000Z.tar.gz", + size=12, + modified_at="2026-01-01T00:00:00+00:00", + created_at="2026-01-01T00:00:00+00:00", + ) + called: dict[str, Any] = {} + + def _fake_create(**kwargs: Any) -> BackupFileInfo: + called.update(kwargs) + return entry + + monkeypatch.setattr(backup_router, "_create_and_store_manual_backup", _fake_create) + monkeypatch.setattr(backup_router, "_agent_rows", lambda _server: []) + + server = MagicMock() + server.services = MagicMock() + server.services.db = object() + server.services.config.database = object() + server.paths = object() + server.app_runtime = MagicMock() + + result = await backup_router.create_backup(_=None, server=server) + + assert result == {"ok": True, "item": entry.to_dict()} + assert called["pool"] is server.services.db + + +@pytest.mark.asyncio +async def test_create_backup_rejects_when_lock_held() -> None: + await BACKUP_LOCK.acquire() + try: + with pytest.raises(OctopError) as exc_info: + await backup_router.create_backup(_=None, server=MagicMock(services=MagicMock())) + assert exc_info.value.code == ErrorCode.BACKUP_IN_PROGRESS + finally: + BACKUP_LOCK.release() + + +@pytest.mark.asyncio +async def test_restore_backup_rejects_when_lock_held(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(backup_router, "normalize_backup_filename", lambda name: name) + await BACKUP_LOCK.acquire() + try: + with pytest.raises(OctopError) as exc_info: + await backup_router.restore_backup_file( + filename="octop-backup.tar.gz", + restore_config=True, + user=MagicMock(id=1, username="admin"), + server=MagicMock(services=MagicMock()), + ) + assert exc_info.value.code == ErrorCode.BACKUP_IN_PROGRESS + finally: + BACKUP_LOCK.release() + + +@pytest.mark.asyncio +async def test_backup_status_reports_idle_and_busy() -> None: + from octop.infra.backup.auto import hold_backup_lock + + idle = await backup_router.get_backup_status(_=None) + assert idle == {"busy": False, "operation": None} + + async with hold_backup_lock("create"): + busy = await backup_router.get_backup_status(_=None) + assert busy == {"busy": True, "operation": "create"} diff --git a/tests/unit/backup/test_system_archive.py b/tests/unit/backup/test_system_archive.py index 9c6df61f..400fc501 100644 --- a/tests/unit/backup/test_system_archive.py +++ b/tests/unit/backup/test_system_archive.py @@ -42,7 +42,7 @@ def test_manifest_roundtrip_includes_driver_fields() -> None: assert loaded.db_file == "db/octop.dump" -def test_roundtrip_backup(layout: PathLayout) -> None: +def test_roundtrip_backup(layout: PathLayout, tmp_path: Path) -> None: db_path = layout.db pool = SqlitePool(db_path) run_migrations(pool) @@ -63,11 +63,13 @@ class Row: agent_id = "agent01" name = "Test" - data, _name = create_system_backup( + archive = tmp_path / "roundtrip.tar.gz" + create_system_backup( paths=layout, agent_rows=[Row()], pool=pool, db_config=DatabaseConfig(), + dest=archive, ) pool.close() @@ -78,7 +80,7 @@ class Row: run_migrations(restore_pool) result = restore_system_backup( - data, + archive, paths=restore_layout, pool=restore_pool, db_config=DatabaseConfig(), @@ -98,7 +100,7 @@ class Row: ) assert json.loads(restore_layout.config.read_text(encoding="utf-8"))["port"] == 8088 - with tarfile.open(fileobj=BytesIO(data), mode="r:gz") as tf: + with tarfile.open(archive, mode="r:gz") as tf: manifest = json.loads(tf.extractfile("manifest.json").read().decode("utf-8")) assert manifest["manifest_version"] == MANIFEST_VERSION assert manifest["database_driver"] == "sqlite" @@ -117,21 +119,54 @@ class Row: name = "Test" config_json = json.dumps({"workspace_dir": str(custom)}) - data, _name = create_system_backup( + archive = tmp_path / "custom-ws-backup.tar.gz" + create_system_backup( paths=layout, agent_rows=[Row()], pool=pool, db_config=DatabaseConfig(), + dest=archive, ) pool.close() - with tarfile.open(fileobj=BytesIO(data), mode="r:gz") as tf: + with tarfile.open(archive, mode="r:gz") as tf: names = tf.getnames() assert "workspaces/agent01/SOUL.md" in names assert not (layout.agent_workspace("agent01") / "SOUL.md").exists() -def test_restore_replaces_stale_skill_package_files(layout: PathLayout) -> None: +def test_backup_skips_junk_directories(layout: PathLayout, tmp_path: Path) -> None: + pool = SqlitePool(layout.db) + run_migrations(pool) + ws = layout.ensure_agent_workspace("agent01") + (ws / "keep.txt").write_text("ok", encoding="utf-8") + (ws / "node_modules").mkdir() + (ws / "node_modules" / "pkg.js").write_text("skip", encoding="utf-8") + (ws / ".git").mkdir() + (ws / ".git" / "config").write_text("skip", encoding="utf-8") + + class Row: + agent_id = "agent01" + name = "Test" + + archive = tmp_path / "skip-junk.tar.gz" + create_system_backup( + paths=layout, + agent_rows=[Row()], + pool=pool, + db_config=DatabaseConfig(), + dest=archive, + ) + pool.close() + + with tarfile.open(archive, mode="r:gz") as tf: + names = set(tf.getnames()) + assert "workspaces/agent01/keep.txt" in names + assert "workspaces/agent01/node_modules/pkg.js" not in names + assert "workspaces/agent01/.git/config" not in names + + +def test_restore_replaces_stale_skill_package_files(layout: PathLayout, tmp_path: Path) -> None: pool = SqlitePool(layout.db) run_migrations(pool) with pool.connect() as conn: @@ -148,11 +183,13 @@ class Row: agent_id = "agent01" name = "Test" - data, _ = create_system_backup( + archive = tmp_path / "packages.tar.gz" + create_system_backup( paths=layout, agent_rows=[Row()], pool=pool, db_config=DatabaseConfig(), + dest=archive, ) pool.close() @@ -164,7 +201,7 @@ class Row: stale.write_text("---\nname: old\ndescription: stale\n---\n", encoding="utf-8") restore_system_backup( - data, + archive, paths=restore_layout, pool=restore_pool, db_config=DatabaseConfig(), @@ -178,11 +215,12 @@ class Row: def _make_migration_backup( layout: PathLayout, + dest: Path, *, username: str = "lc_user", jwt_secret: bytes | None = b"foreign-jwt-from-migration-backup!!!!", -) -> tuple[bytes, SqlitePool]: - """Build a fake LightClaw migration backup and return (data, source_pool).""" +) -> SqlitePool: + """Build a fake LightClaw migration backup at *dest*; return source_pool.""" pool = SqlitePool(layout.db) run_migrations(pool) with pool.connect() as conn: @@ -238,15 +276,16 @@ class Row: agent_id = "agent-lc" name = "LC Agent" - data, _ = create_system_backup( + create_system_backup( paths=layout, agent_rows=[Row()], pool=pool, db_config=DatabaseConfig(), + dest=dest, ) # Rewrite octop_version to signal a LightClaw migration backup. members: dict[str, bytes] = {} - with tarfile.open(fileobj=BytesIO(data), mode="r:gz") as tf: + with tarfile.open(dest, mode="r:gz") as tf: for m in tf.getmembers(): if m.isfile(): f = tf.extractfile(m) @@ -255,13 +294,12 @@ class Row: manifest_obj = json.loads(members["manifest.json"]) manifest_obj["octop_version"] = manifest_obj["octop_version"] + "-migrated-from-lightclaw" members["manifest.json"] = json.dumps(manifest_obj).encode() - buf = BytesIO() - with tarfile.open(fileobj=buf, mode="w:gz") as tf: + with tarfile.open(dest, mode="w:gz") as tf: for name, blob in members.items(): info = tarfile.TarInfo(name=name) info.size = len(blob) tf.addfile(info, BytesIO(blob)) - return buf.getvalue(), pool + return pool def test_migration_restore_preserves_current_users_and_imported_agents( @@ -283,7 +321,8 @@ def test_migration_restore_preserves_current_users_and_imported_agents( # --- source: simulate a LightClaw migration export with one agent --- src_layout = PathLayout(tmp_path / "src") src_layout.root.mkdir() - migration_data, src_pool = _make_migration_backup(src_layout, username="lc_user") + migration_archive = tmp_path / "migration.tar.gz" + src_pool = _make_migration_backup(src_layout, migration_archive, username="lc_user") src_pool.close() # --- target: a fresh Octop instance with its own admin user + JWT --- @@ -303,7 +342,7 @@ def test_migration_restore_preserves_current_users_and_imported_agents( ) result = restore_system_backup( - migration_data, + migration_archive, paths=tgt_layout, pool=tgt_pool, db_config=DatabaseConfig(), @@ -350,7 +389,8 @@ def test_migration_restore_remaps_ownership_to_admin_user_id_2(tmp_path: Path) - """Migration import assigns agents/channels/cron to the restoring admin (id=2).""" src_layout = PathLayout(tmp_path / "src") src_layout.root.mkdir() - migration_data, src_pool = _make_migration_backup(src_layout, username="lc_user") + migration_archive = tmp_path / "migration-remap.tar.gz" + src_pool = _make_migration_backup(src_layout, migration_archive, username="lc_user") src_pool.close() tgt_layout = PathLayout(tmp_path / "tgt") @@ -366,7 +406,7 @@ def test_migration_restore_remaps_ownership_to_admin_user_id_2(tmp_path: Path) - ) result = restore_system_backup( - migration_data, + migration_archive, paths=tgt_layout, pool=tgt_pool, db_config=DatabaseConfig(), @@ -425,7 +465,8 @@ def test_migration_restore_via_none_autodetect(tmp_path: Path) -> None: """preserve_users=None auto-detects the migration flag from octop_version.""" src_layout = PathLayout(tmp_path / "src") src_layout.root.mkdir() - migration_data, src_pool = _make_migration_backup(src_layout, username="lc_auto") + migration_archive = tmp_path / "migration-auto.tar.gz" + src_pool = _make_migration_backup(src_layout, migration_archive, username="lc_auto") src_pool.close() tgt_layout = PathLayout(tmp_path / "tgt") @@ -440,7 +481,7 @@ def test_migration_restore_via_none_autodetect(tmp_path: Path) -> None: # preserve_users=None — should auto-detect and preserve result = restore_system_backup( - migration_data, + migration_archive, paths=tgt_layout, pool=tgt_pool, db_config=DatabaseConfig(), @@ -458,7 +499,7 @@ def test_migration_restore_via_none_autodetect(tmp_path: Path) -> None: tgt_pool.close() -def test_refuse_cross_engine_restore(layout: PathLayout) -> None: +def test_refuse_cross_engine_restore(layout: PathLayout, tmp_path: Path) -> None: pool = SqlitePool(layout.db) run_migrations(pool) @@ -466,15 +507,17 @@ class Row: agent_id = "a1" name = "n" - data, _ = create_system_backup( + archive = tmp_path / "cross-engine.tar.gz" + create_system_backup( paths=layout, agent_rows=[Row()], pool=pool, db_config=DatabaseConfig(), + dest=archive, ) # Rewrite manifest to pretend it's a postgres dump. members: dict[str, bytes] = {} - with tarfile.open(fileobj=BytesIO(data), mode="r:gz") as tf: + with tarfile.open(archive, mode="r:gz") as tf: for m in tf.getmembers(): if m.isfile(): f = tf.extractfile(m) @@ -483,15 +526,14 @@ class Row: manifest = json.loads(members["manifest.json"]) manifest["database_driver"] = "postgresql" members["manifest.json"] = json.dumps(manifest).encode() - buf = BytesIO() - with tarfile.open(fileobj=buf, mode="w:gz") as tf: + with tarfile.open(archive, mode="w:gz") as tf: for name, blob in members.items(): info = tarfile.TarInfo(name=name) info.size = len(blob) tf.addfile(info, BytesIO(blob)) with pytest.raises(OctopError) as excinfo: restore_system_backup( - buf.getvalue(), + archive, paths=layout, pool=pool, db_config=DatabaseConfig(), diff --git a/tests/unit/cli/test_init_cmd.py b/tests/unit/cli/test_init_cmd.py index c646288d..c9492eb1 100644 --- a/tests/unit/cli/test_init_cmd.py +++ b/tests/unit/cli/test_init_cmd.py @@ -14,8 +14,13 @@ @pytest.fixture def fake_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + octop_home = tmp_path / ".octop" monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("OCTOP_HOME", str(octop_home)) monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + for key in list(os.environ): + if key.startswith("OCTOP_DATABASE_"): + monkeypatch.delenv(key, raising=False) return tmp_path @@ -90,9 +95,10 @@ def test_init_force_resets(fake_home: Path) -> None: "TestPass34", "--yes", ] - runner.invoke(cli, args_a) + r1 = runner.invoke(cli, args_a) + assert r1.exit_code == 0, r1.output or str(r1.exception) r2 = runner.invoke(cli, args_b) - assert r2.exit_code == 0, r2.output + assert r2.exit_code == 0, r2.output or str(r2.exception) from octop.infra.db.pool import SqlitePool from octop.infra.db.repos.users import UserRepo diff --git a/tests/unit/i18n/test_stream.py b/tests/unit/i18n/test_stream.py index b2bc75c6..9e756757 100644 --- a/tests/unit/i18n/test_stream.py +++ b/tests/unit/i18n/test_stream.py @@ -33,6 +33,22 @@ def test_classify_rate_limit() -> None: ) +def test_classify_insufficient_balance() -> None: + msg = ( + "Error code: 402 - {'error': {'message': 'Insufficient Balance', " + "'type': 'unknown_error', 'param': None, 'code': 'invalid_request_error'}}" + ) + assert classify_stream_error_message(msg) == "octop:stream_errors.insufficient_balance" + assert ( + classify_stream_error_message("HTTP 402 POST https://api.example.com/v1/embeddings: quota") + == "octop:stream_errors.insufficient_balance" + ) + assert ( + classify_stream_error_message("You exceeded your current quota, please check billing") + == "octop:stream_errors.insufficient_balance" + ) + + def test_classify_auth() -> None: assert ( classify_stream_error_message("Error code: 401 - Incorrect API key provided") @@ -40,6 +56,17 @@ def test_classify_auth() -> None: ) +def test_classify_provider_unavailable_http_status() -> None: + assert ( + classify_stream_error_message("HTTP 503 POST https://api.example.com/v1/embeddings") + == "octop:stream_errors.provider_unavailable" + ) + assert ( + classify_stream_error_message("Error code: 502 - Bad Gateway") + == "octop:stream_errors.provider_unavailable" + ) + + def test_classify_context_length() -> None: assert ( classify_stream_error_message( @@ -85,6 +112,14 @@ def test_format_stream_error_zh_guidance() -> None: assert "LANGCHAIN" not in text +def test_format_insufficient_balance_zh() -> None: + msg = "Error code: 402 - {'error': {'message': 'Insufficient Balance'}}" + text = format_stream_error(msg, "zh") + assert "余额" in text or "额度" in text + assert "402" not in text + assert "Insufficient Balance" not in text + + def test_format_recursion_limit_zh_guides_to_config() -> None: msg = ( "Recursion limit of 2 reached without hitting a stop condition. " diff --git a/tests/unit/test_provider_fetch_models.py b/tests/unit/test_provider_fetch_models.py index 13c8896d..949d0fee 100644 --- a/tests/unit/test_provider_fetch_models.py +++ b/tests/unit/test_provider_fetch_models.py @@ -93,7 +93,28 @@ async def test_fetch_models_auth_error() -> None: ) assert result["ok"] is False - assert "401" in result["error"] or "invalid" in result["error"].lower() + assert "API key" in result["error"] + assert "401" not in result["error"] + + +@pytest.mark.asyncio +async def test_fetch_models_insufficient_balance_zh() -> None: + response = _mock_response(402, {"error": {"message": "Insufficient Balance"}}) + mock_client = AsyncMock() + mock_client.get = AsyncMock(return_value=response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + + with patch("octop.infra.agents.providers.probe.httpx.AsyncClient", return_value=mock_client): + result = await fetch_openai_compatible_models( + base_url="https://api.example.com/v1", + api_key="sk-test", + locale="zh", + ) + + assert result["ok"] is False + assert "余额" in result["error"] or "额度" in result["error"] + assert "402" not in result["error"] @pytest.mark.asyncio @@ -131,3 +152,25 @@ async def test_fetch_models_connection_error() -> None: assert result["ok"] is False assert result["error"] + # ConnectError string often includes "connection" → timeout_network guidance + assert "401" not in result["error"] + + +@pytest.mark.asyncio +async def test_fetch_models_server_error_friendly() -> None: + response = _mock_response(503, {"error": {"message": "overloaded"}}) + mock_client = AsyncMock() + mock_client.get = AsyncMock(return_value=response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + + with patch("octop.infra.agents.providers.probe.httpx.AsyncClient", return_value=mock_client): + result = await fetch_openai_compatible_models( + base_url="https://api.example.com/v1", + api_key="sk-test", + locale="en", + ) + + assert result["ok"] is False + assert "temporarily unavailable" in result["error"].lower() + assert "503" not in result["error"] diff --git a/tests/unit/test_provider_probe.py b/tests/unit/test_provider_probe.py index 7ca1e43c..19ec9989 100644 --- a/tests/unit/test_provider_probe.py +++ b/tests/unit/test_provider_probe.py @@ -116,8 +116,37 @@ async def test_embedding_probe_reports_http_error() -> None: result = await probe_provider_row(_embedding_row()) assert result["ok"] is False - assert "401" in result["error"] - assert "POST https://api.example.com/v1/embeddings" in result["error"] + assert "API key" in result["error"] + assert "401" not in result["error"] + + +@pytest.mark.asyncio +async def test_chat_probe_maps_insufficient_balance() -> None: + row = SimpleNamespace( + name="HAI", + kind="openai", + base_url="https://api.example.com/v1", + api_key="sk-test", + extra_json=None, + get_models=lambda: [{"id": "gpt-4o-mini", "name": "gpt-4o-mini"}], + ) + fake = AsyncMock() + fake.ainvoke = AsyncMock( + side_effect=RuntimeError( + "Error code: 402 - {'error': {'message': 'Insufficient Balance', " + "'type': 'unknown_error', 'param': None, 'code': 'invalid_request_error'}}" + ) + ) + with patch( + "octop.infra.agents.providers.probe.build_probe_chat_model", + return_value=fake, + ): + result = await probe_provider_row(row, model_id="gpt-4o-mini", locale="zh") + + assert result["ok"] is False + assert "余额" in result["error"] or "额度" in result["error"] + assert "402" not in result["error"] + assert "Insufficient Balance" not in result["error"] @pytest.mark.asyncio From eaf0c41c447981ea365f29f6976f0e610dbf6156 Mon Sep 17 00:00:00 2001 From: HUANG Cheng Date: Fri, 28 Aug 2026 10:57:44 +0800 Subject: [PATCH 11/28] feat(connectors): add OAuth for custom MCP servers (#452) Probe-driven OAuth discovery from MCP URL, unified oauth/start API, encrypted token storage, and Custom Connectors UI for save-then-probe and one-click authorization. Fixes #337 --- CHANGELOG.md | 4 + dashboard/src/api/modules/connectors.ts | 71 ++- dashboard/src/locales/en.json | 15 +- dashboard/src/locales/zh.json | 15 +- .../Agent/Connectors/CustomMcpServerCard.tsx | 155 ++++- .../pages/Agent/Connectors/CustomMcpTab.tsx | 595 ++++++++++++++---- .../pages/Agent/Connectors/customMcpUtils.ts | 55 ++ .../pages/Agent/Connectors/index.module.less | 83 ++- .../src/pages/Agent/Connectors/index.tsx | 2 +- .../components/modals/ProviderConfigModal.tsx | 11 +- src/octop/api/routers/connectors.py | 250 ++++++-- src/octop/infra/connectors/custom_mcp.py | 121 +++- src/octop/infra/connectors/oauth/__init__.py | 2 + src/octop/infra/connectors/oauth/discovery.py | 190 ++++++ src/octop/infra/connectors/oauth/registry.py | 223 ++++++- src/octop/infra/connectors/probe.py | 40 +- src/octop/infra/connectors/service.py | 106 +++- tests/integration/test_connectors_api.py | 38 +- tests/unit/connectors/test_oauth_discovery.py | 106 ++++ 19 files changed, 1825 insertions(+), 257 deletions(-) create mode 100644 src/octop/infra/connectors/oauth/discovery.py create mode 100644 tests/unit/connectors/test_oauth_discovery.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d02a7d66..da6347a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ ## [Unreleased] +### 新增 + +- 自定义 MCP 连接器支持 OAuth:从 MCP URL 自动发现授权端点,统一 `POST /connectors/oauth/start` 启动授权;保存后可探测、一键授权,Bearer 注入 harness spec;Custom Connectors UI 支持启用与「对话默认选中」分离、保存后可选探测 + ### 变更 - Docker / FnOS 镜像对外统一为 `ghcr.io/tencentcloud/octop`(仍同步推送 Docker Hub;移除 vanilla 镜像标签) diff --git a/dashboard/src/api/modules/connectors.ts b/dashboard/src/api/modules/connectors.ts index 376271fd..07100e8c 100644 --- a/dashboard/src/api/modules/connectors.ts +++ b/dashboard/src/api/modules/connectors.ts @@ -82,11 +82,23 @@ export interface ConnectorProbeResult { tool_count?: number; tools?: { name: string; description: string }[]; error?: string; + error_type?: string; status_code?: number; + oauth?: { + available: boolean; + issuer?: string; + resource?: string; + }; } export type CustomMcpTransport = "streamable_http" | "stdio"; +export interface CustomMcpOAuthPreview { + configured?: boolean; + required?: boolean; + expires_at?: number; +} + export interface CustomMcpServerSpec { transport: CustomMcpTransport; url?: string; @@ -99,8 +111,13 @@ export interface CustomMcpServerSpec { display_name?: string; /** When true, chat composer pre-selects this MCP server. */ default_open?: boolean; + oauth?: CustomMcpOAuthPreview; } +export type OAuthStartTarget = + | { type: "catalog"; kind: string } + | { type: "custom_mcp"; server_name: string }; + export type CustomMcpServers = Record; export interface ConnectorCliInstallResult { @@ -144,7 +161,9 @@ export const connectorsApi = { listInstances: () => request("/connector-instances"), getInstance: (instanceId: string) => - request(`/connector-instances/${instanceId}`), + request( + `/connector-instances/${encodeURIComponent(instanceId)}`, + ), createInstance: (body: { kind: string; @@ -158,23 +177,41 @@ export const connectorsApi = { }), deleteInstance: (instanceId: string) => - request(`/connector-instances/${instanceId}`, { method: "DELETE" }), + request(`/connector-instances/${encodeURIComponent(instanceId)}`, { + method: "DELETE", + }), patchInstance: ( instanceId: string, body: { status?: "active" | "disabled"; default_open?: boolean }, ) => - request(`/connector-instances/${instanceId}`, { - method: "PATCH", - body: JSON.stringify(body), - }), + request( + `/connector-instances/${encodeURIComponent(instanceId)}`, + { + method: "PATCH", + body: JSON.stringify(body), + }, + ), testInstance: (instanceId: string) => - request(`/connector-instances/${instanceId}/test`, { - method: "POST", - }), + request( + `/connector-instances/${encodeURIComponent(instanceId)}/test`, + { + method: "POST", + }, + ), - oauthStart: (kind: string, redirectAfter?: string) => + oauthStart: (target: OAuthStartTarget, redirectAfter?: string) => + request<{ authorize_url: string; state_id: string }>( + "/connectors/oauth/start", + { + method: "POST", + body: JSON.stringify({ target, redirect_after: redirectAfter }), + }, + ), + + /** @deprecated Prefer oauthStart with `{ type: "catalog", kind }`. */ + oauthStartCatalog: (kind: string, redirectAfter?: string) => request<{ authorize_url: string; state_id: string }>( `/connectors/oauth/${kind}/start`, { @@ -184,9 +221,12 @@ export const connectorsApi = { ), oauthPending: (stateId: string) => - request<{ kind: string; tokens: Record }>( - `/connectors/oauth/pending/${stateId}`, - ), + request<{ + kind: string; + tokens: Record; + server_name?: string; + applied?: boolean; + }>(`/connectors/oauth/pending/${stateId}`), authorizeUrl: (kind: string) => request<{ authorize_url: string | null }>( @@ -284,7 +324,10 @@ export const connectorsApi = { body: JSON.stringify({ servers }), }), - patchCustomMcpServer: (name: string, body: { enabled: boolean }) => + patchCustomMcpServer: ( + name: string, + body: { enabled?: boolean; default_open?: boolean }, + ) => request<{ servers: CustomMcpServers }>( `/connectors/custom-mcp/servers/${encodeURIComponent(name)}`, { diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index ba8fdb7d..83fa3e73 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -4347,9 +4347,22 @@ "duplicateName": "Server IDs must be unique", "emptyName": "Server ID is required", "probeNeedConfig": "Fill in the configuration before probing", + "probeOnSave": "Probe connection after save", + "probeOnSaveHint": "Octop will request your MCP URL to verify connectivity. If sign-in is required, we will guide you through OAuth.", + "probeNeedsOAuth": "This MCP requires OAuth before it can be used", + "probeComplete": "Connection verified", + "oauthConfigured": "OAuth authorization completed", + "oauthAuthorizeHint": "One-click OAuth saves your config first, then opens sign-in; we verify the connection again afterward.", + "oauthBeforeEnable": "Complete OAuth authorization before enabling this server", + "enable": "Enable connector", + "enableHint": "When off, agents will not load tools from this MCP.", + "defaultOpen": "Selected by default in chat", + "defaultOpenHint": "When on, your Dashboard, IM, and Cron (with no manual picks) include this MCP by default.", + "defaultOpenRequiresEnable": "Enable the connector first.", + "authorizing": "Authorizing…", "probeOk": "Probe ok — found {{count}} tools", "deleteConfirm": "Delete MCP server \"{{name}}\"?", - "deleteConfirmHint": "Changes take effect after you click Save." + "deleteConfirmHint": "Saved servers are removed immediately; unsaved drafts are dropped from this editor only." }, "configuredBadge": "Connected", "clickToConnect": "Click to connect", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 27dac422..29b6a425 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -4486,9 +4486,22 @@ "duplicateName": "服务器 ID 不能重复", "emptyName": "请填写服务器 ID", "probeNeedConfig": "请先填写完整配置再探测", + "probeOnSave": "保存后自动探测连接", + "probeOnSaveHint": "将向您填写的 MCP 地址发起请求以验证可用性;若需登录,会引导您完成 OAuth。", + "probeNeedsOAuth": "此 MCP 需要 OAuth 授权才能访问", + "probeComplete": "连接正常", + "oauthConfigured": "已完成 OAuth 授权", + "oauthAuthorizeHint": "点击「一键授权」将先保存配置再打开登录页;完成后我们会自动再次验证连接。", + "oauthBeforeEnable": "请先完成 OAuth 授权后再启用", + "enable": "启用连接器", + "enableHint": "关闭后 Agent 不会加载此 MCP 的工具。", + "defaultOpen": "对话默认选中", + "defaultOpenHint": "开启后,你的 Dashboard、IM 与 Cron(未手动选连接器时)会默认带上此 MCP。", + "defaultOpenRequiresEnable": "需先启用连接器。", + "authorizing": "授权中…", "probeOk": "探测成功,发现 {{count}} 个工具", "deleteConfirm": "确定删除 MCP 服务器「{{name}}」?", - "deleteConfirmHint": "删除后需点击保存才会生效。" + "deleteConfirmHint": "已保存的服务器将立即删除;尚未保存的配置只会从当前编辑中移除。" }, "configuredBadge": "已连接", "clickToConnect": "点击连接", diff --git a/dashboard/src/pages/Agent/Connectors/CustomMcpServerCard.tsx b/dashboard/src/pages/Agent/Connectors/CustomMcpServerCard.tsx index 489a269b..d17a9bdb 100644 --- a/dashboard/src/pages/Agent/Connectors/CustomMcpServerCard.tsx +++ b/dashboard/src/pages/Agent/Connectors/CustomMcpServerCard.tsx @@ -21,23 +21,31 @@ import styles from "./index.module.less"; interface CustomMcpServerCardProps { card: ServerCardState; probing: boolean; + authorizing?: boolean; + oauthAvailable?: boolean; probeTools?: { name: string; description: string }[]; transportOptions: { value: string; label: string }[]; onUpdate: (key: string, patch: Partial) => void; onToggleEnabled: (enabled: boolean) => void; - onRemove: () => void; + onRemove: () => void | Promise; onProbe: () => void; + onAuthorize?: () => void; + onDefaultOpenChange?: (defaultOpen: boolean) => void; } export function CustomMcpServerCard({ card, probing, + authorizing = false, + oauthAvailable = false, probeTools, transportOptions, onUpdate, onToggleEnabled, onRemove, onProbe, + onAuthorize, + onDefaultOpenChange, }: CustomMcpServerCardProps) { const { t } = useTranslation(); const { modal } = App.useApp(); @@ -64,20 +72,36 @@ export function CustomMcpServerCard({ }), content: t( "connectors.customMcp.deleteConfirmHint", - "删除后需点击保存才会生效。", + "已保存的服务器将立即删除;尚未保存的配置只会从当前编辑中移除。", ), okText: t("common.delete"), okButtonProps: { danger: true }, cancelText: t("common.cancel"), - onOk: onRemove, + onOk: () => Promise.resolve(onRemove()), }); }; + const authPending = isHttp && oauthAvailable && !card.oauthConfigured; + const effectiveEnabled = !authPending && card.enabled; + const showOAuthConnectLink = + isHttp && card.collapsed && authPending && onAuthorize; + const connectLabel = t("connectors.clickToConnect", "点击连接"); + + const handleConnectClick = () => { + if (oauthAvailable && onAuthorize) { + onAuthorize(); + } + }; + return (
- +
+ + {t("connectors.customMcp.enable", "启用连接器")} + + +
+ {showOAuthConnectLink ? ( +
+ +
+ ) : null} + {!card.collapsed ? (
@@ -263,46 +308,100 @@ export function CustomMcpServerCard({ )}
- +
- onUpdate(card.key, { defaultOpen: checked }) - } + disabled={!effectiveEnabled} + onChange={(checked) => { + if (onDefaultOpenChange) { + onDefaultOpenChange(checked); + return; + } + onUpdate(card.key, { defaultOpen: checked }); + }} /> - {!card.defaultOpen ? ( + {!effectiveEnabled ? ( {t( - "connectors.defaultOpenHint", - "关闭时需在对话中手动勾选才会注入工具。", + "connectors.customMcp.defaultOpenRequiresEnable", + "需先启用连接器。", + )} + + ) : !card.defaultOpen ? ( + + {t( + "connectors.customMcp.defaultOpenHint", + "开启后,你的 Dashboard、IM 与 Cron(未手动选连接器时)会默认带上此 MCP。", )} ) : null}
- {card.defaultOpen ? ( + {effectiveEnabled && card.defaultOpen ? ( ) : null}
-
- -
+ {isHttp && card.oauthConfigured ? ( + + ) : null} + + {isHttp && oauthAvailable && !card.oauthConfigured ? ( + + {t("connectors.oneClickOAuth", "一键授权")} + + ) : undefined + } + /> + ) : null} + + {isHttp ? ( +
+ +
+ ) : null} - {probeTools !== undefined ? ( + {isHttp && probeTools !== undefined ? (
([]); const [jsonText, setJsonText] = useState("{}"); const [jsonError, setJsonError] = useState(null); + const [probeOnSave, setProbeOnSave] = useState( + () => localStorage.getItem(PROBE_ON_SAVE_KEY) === "1", + ); + const [oauthAvailable, setOauthAvailable] = useState>( + {}, + ); + const [authorizingKey, setAuthorizingKey] = useState(null); + const [persistedNames, setPersistedNames] = useState>( + () => new Set(), + ); + + useEffect(() => { + localStorage.setItem(PROBE_ON_SAVE_KEY, probeOnSave ? "1" : "0"); + }, [probeOnSave]); + + const applySavedServers = ( + servers: CustomMcpServers, + prevCards: ServerCardState[], + ) => { + const nextCards = mergeCustomMcpCards(servers, prevCards); + setCards(nextCards); + setPersistedNames(new Set(Object.keys(servers))); + setJsonText(JSON.stringify(servers, null, 2)); + setOauthAvailable(oauthHintsFromServers(servers, nextCards)); + return nextCards; + }; const load = useCallback(async () => { setLoading(true); try { const { servers } = await connectorsApi.getCustomMcp(); - const nextCards = serversToCards(servers); - setCards(nextCards); + applySavedServers(servers, []); setProbeResults({}); - setJsonText(JSON.stringify(servers, null, 2)); setJsonError(null); } catch (e) { console.error(e); @@ -64,6 +92,35 @@ export function CustomMcpTab() { void load(); }, [load]); + const persistServerPatch = async ( + card: ServerCardState, + apiPatch: { enabled?: boolean; default_open?: boolean }, + localPatch: Partial, + ) => { + const optimisticCards = cards.map((item) => + item.key === card.key ? { ...item, ...localPatch } : item, + ); + updateCard(card.key, localPatch); + const name = card.name.trim(); + if (!name || !persistedNames.has(name)) { + return; + } + try { + const { servers } = await connectorsApi.patchCustomMcpServer( + name, + apiPatch, + ); + applySavedServers(servers, optimisticCards); + notifyConnectorsChanged(); + } catch (e) { + console.error(e); + message.error( + apiErrorMessage(e, t("connectors.customMcp.saveFailed", "保存失败"), t), + ); + await load(); + } + }; + const syncJsonFromCards = useCallback((nextCards: ServerCardState[]) => { try { const servers = cardsToServers(nextCards); @@ -138,18 +195,45 @@ export function CustomMcpTab() { setMode("visual"); }; - const handleRemove = (key: string) => { - setCards((prev) => { - const next = prev.filter((card) => card.key !== key); - syncJsonFromCards(next); + const handleRemove = async (key: string) => { + const card = cards.find((item) => item.key === key); + const serverName = card?.name.trim() ?? ""; + const nextCards = cards.filter((item) => item.key !== key); + setCards(nextCards); + syncJsonFromCards(nextCards); + setProbeResults((prev) => { + if (!(key in prev)) return prev; + const next = { ...prev }; + delete next[key]; return next; }); - setProbeResults((prev) => { + setOauthAvailable((prev) => { if (!(key in prev)) return prev; const next = { ...prev }; delete next[key]; return next; }); + + if (!serverName || !persistedNames.has(serverName)) { + return; + } + + try { + await connectorsApi.deleteInstance(`custom:${serverName}`); + setPersistedNames((prev) => { + const next = new Set(prev); + next.delete(serverName); + return next; + }); + notifyConnectorsChanged(); + message.success(t("connectors.deleteSuccess", "已删除")); + } catch (e) { + console.error(e); + message.error( + apiErrorMessage(e, t("connectors.deleteFailed", "删除失败"), t), + ); + await load(); + } }; const clearProbeResult = (key: string) => { @@ -159,6 +243,96 @@ export function CustomMcpTab() { delete next[key]; return next; }); + setOauthAvailable((prev) => { + if (!(key in prev)) return prev; + const next = { ...prev }; + delete next[key]; + return next; + }); + }; + + const applyProbeResult = ( + card: ServerCardState, + result: ConnectorProbeResult, + options: { fromSave?: boolean } = {}, + ) => { + const fromSave = options.fromSave === true; + if (result.ok) { + const tools = result.tools ?? []; + setProbeResults((prev) => ({ ...prev, [card.key]: tools })); + setOauthAvailable((prev) => ({ ...prev, [card.key]: false })); + if (!fromSave) { + updateCard(card.key, { collapsed: false }); + } + if (!fromSave) { + if (tools.length === 0) { + message.success( + t("connectors.probeToolsEmpty", "连接正常,但未发现可用工具"), + ); + } else { + message.success(t("connectors.customMcp.probeComplete", "连接正常")); + } + } + return; + } + setProbeResults((prev) => { + const next = { ...prev }; + delete next[card.key]; + return next; + }); + if (result.oauth?.available) { + setOauthAvailable((prev) => ({ ...prev, [card.key]: true })); + if (fromSave) { + updateCard(card.key, { enabled: false, defaultOpen: false }); + } else { + updateCard(card.key, { + collapsed: false, + enabled: false, + defaultOpen: false, + }); + } + if (!fromSave) { + message.warning( + t( + "connectors.customMcp.probeNeedsOAuth", + "此 MCP 需要 OAuth 授权才能访问", + ), + ); + } + return; + } + setOauthAvailable((prev) => ({ ...prev, [card.key]: false })); + if (!fromSave) { + message.error(result.error ?? t("connectors.probeFailed", "探测失败")); + } + }; + + const runProbe = async ( + card: ServerCardState, + options: { byName?: boolean; fromSave?: boolean } = {}, + ) => { + const byName = options.byName === true; + const fromSave = options.fromSave === true; + setProbingKey(card.key); + clearProbeResult(card.key); + try { + let result: ConnectorProbeResult; + if (byName || card.oauthConfigured) { + result = await connectorsApi.testCustomMcp({ name: card.name.trim() }); + } else { + const map = cardsToServers([card]); + const server = map[card.name.trim()]; + result = await connectorsApi.testCustomMcp({ server }); + } + applyProbeResult(card, result, { fromSave }); + } catch (e) { + console.error(e); + message.error( + apiErrorMessage(e, t("connectors.probeFailed", "探测失败"), t), + ); + } finally { + setProbingKey(null); + } }; const resolveServersForSave = (): CustomMcpServers | null => { @@ -207,13 +381,19 @@ export function CustomMcpTab() { setSaving(true); try { const saved = await connectorsApi.putCustomMcp(servers); - const nextCards = serversToCards(saved.servers); - setCards(nextCards); - setJsonText(JSON.stringify(saved.servers, null, 2)); + const nextCards = applySavedServers(saved.servers, cards); notifyConnectorsChanged(); message.success( t("connectors.customMcp.saveSuccess", "自定义 MCP 已保存"), ); + if (probeOnSave && showProbeSection) { + for (const card of nextCards) { + if (card.transport !== "streamable_http" || !card.url.trim()) { + continue; + } + await runProbe(card, { byName: true, fromSave: true }); + } + } } catch (e) { console.error(e); message.error( @@ -225,39 +405,145 @@ export function CustomMcpTab() { }; const handleProbe = async (card: ServerCardState) => { - let server: CustomMcpServerSpec; try { - const map = cardsToServers([card]); - server = map[card.name.trim()]; + cardsToServers([card]); } catch { message.warning( t("connectors.customMcp.probeNeedConfig", "请先填写完整配置再探测"), ); return; } - setProbingKey(card.key); - clearProbeResult(card.key); + await runProbe(card); + }; + + const handleOAuth = async (card: ServerCardState) => { + const serverName = card.name.trim(); + if (!serverName) { + message.warning(t("connectors.customMcp.emptyName", "请填写服务器名称")); + return; + } + const popup = window.open("", "octop-oauth", "width=520,height=720"); + if (!popup) { + message.error( + t( + "connectors.oauthPopupBlocked", + "授权窗口被浏览器拦截,请允许本站弹出窗口后重试", + ), + ); + return; + } + + setAuthorizingKey(card.key); + let settled = false; + let pollTimer: ReturnType | undefined; + let timeoutTimer: ReturnType | undefined; + let stateId = ""; + + const cleanup = () => { + if (pollTimer !== undefined) clearInterval(pollTimer); + if (timeoutTimer !== undefined) clearTimeout(timeoutTimer); + window.removeEventListener("message", onMessage); + }; + + const finish = async () => { + if (settled) return; + settled = true; + cleanup(); + try { + popup.close(); + } catch { + // ignore + } + setAuthorizingKey(null); + try { + const { servers } = await connectorsApi.getCustomMcp(); + const nextCards = applySavedServers(servers, cards); + const refreshed = nextCards.find((c) => c.name.trim() === serverName); + if (refreshed) { + await runProbe(refreshed, { byName: true }); + } + message.success( + t("connectors.oauthConfigured", "已授权,可直接探测或保存"), + ); + } catch (e) { + console.error(e); + message.error( + apiErrorMessage( + e, + t("connectors.oauthFailed", "获取授权结果失败"), + t, + ), + ); + } + }; + + const claimPending = async () => { + if (!stateId || settled) return; + try { + const pending = await connectorsApi.oauthPending(stateId); + if (pending.applied || pending.server_name) { + await finish(); + } + } catch { + // keep polling until timeout + } + }; + + const onMessage = (ev: MessageEvent) => { + if (ev.data?.type !== "octop:connector-oauth") return; + if (ev.data.state_id !== stateId) return; + void claimPending(); + }; + try { - const result = await connectorsApi.testCustomMcp({ server }); - if (result.ok) { - const tools = result.tools ?? []; - setProbeResults((prev) => ({ ...prev, [card.key]: tools })); - updateCard(card.key, { collapsed: false }); - if (tools.length === 0) { - message.success( - t("connectors.probeToolsEmpty", "连接正常,但未发现可用工具"), - ); + const servers = resolveServersForSave(); + if (!servers) { + try { + popup.close(); + } catch { + // ignore } - } else { - message.error(result.error ?? t("connectors.probeFailed", "探测失败")); + setAuthorizingKey(null); + return; } + const saved = await connectorsApi.putCustomMcp(servers); + applySavedServers(saved.servers, cards); + notifyConnectorsChanged(); + + const { authorize_url, state_id } = await connectorsApi.oauthStart( + { type: "custom_mcp", server_name: serverName }, + window.location.pathname, + ); + stateId = state_id; + window.addEventListener("message", onMessage); + pollTimer = setInterval(() => { + void claimPending(); + }, 1200); + timeoutTimer = setTimeout(() => { + if (settled) return; + settled = true; + cleanup(); + setAuthorizingKey(null); + message.error( + t("connectors.oauthTimedOut", "授权超时,请重试一键授权"), + ); + }, 120_000); + popup.location.replace(authorize_url); } catch (e) { - console.error(e); + cleanup(); + setAuthorizingKey(null); + try { + popup.close(); + } catch { + // ignore + } message.error( - apiErrorMessage(e, t("connectors.probeFailed", "探测失败"), t), + apiErrorMessage( + e, + t("connectors.oauthStartFailed", "无法启动 OAuth"), + t, + ), ); - } finally { - setProbingKey(null); } }; @@ -269,6 +555,21 @@ export function CustomMcpTab() { [], ); + const showProbeSection = useMemo(() => { + if (mode === "visual") { + return hasHttpProbeTargets(cards); + } + try { + const parsed = JSON.parse(jsonText) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return false; + } + return hasHttpProbeTargets(parsed as CustomMcpServers); + } catch { + return false; + } + }, [mode, cards, jsonText]); + if (loading) { return (
@@ -277,6 +578,39 @@ export function CustomMcpTab() { ); } + const saveFooter = ( +
+ {showProbeSection ? ( +
+ setProbeOnSave(e.target.checked)} + > + {t("connectors.customMcp.probeOnSave", "保存后自动探测连接")} + +

+ {t( + "connectors.customMcp.probeOnSaveHint", + "将向您填写的 MCP 地址发起请求以验证可用性;若需登录,会引导您完成 OAuth。", + )} +

+
+ ) : null} + +
+ ); + return (
@@ -307,97 +641,128 @@ export function CustomMcpTab() {
{mode === "json" ? ( -
- { - setJsonText(e.target.value); - setJsonError(null); - }} - autoSize={{ minRows: 16, maxRows: 32 }} - className={styles.customMcpJsonArea} - spellCheck={false} - /> - {jsonError ? ( -
{jsonError}
- ) : null} -
- ) : ( <> -
- - +
+
+ { + setJsonText(e.target.value); + setJsonError(null); + }} + autoSize={{ minRows: 16, maxRows: 32 }} + className={styles.customMcpJsonArea} + spellCheck={false} + /> + {jsonError ? ( +
{jsonError}
+ ) : null} +
- -
-
- {t("connectors.customMcp.listTitle", "已添加的服务器")} - {cards.length > 0 ? ( - - {cards.length} + {saveFooter} + + ) : ( + <> +
+
+ +
- {cards.length === 0 ? ( -
- {t( - "connectors.customMcp.emptyList", - "尚未添加自定义 MCP,点击上方按钮开始配置", - )} -
- ) : ( -
- {cards.map((card) => ( - - updateCard(card.key, { enabled }) - } - onRemove={() => handleRemove(card.key)} - onProbe={() => void handleProbe(card)} - /> - ))} +
+
+ {t("connectors.customMcp.listTitle", "已添加的服务器")} + {cards.length > 0 ? ( + + {cards.length} + + ) : null}
- )} + + {cards.length === 0 ? ( +
+ {t( + "connectors.customMcp.emptyList", + "尚未添加自定义 MCP,点击上方按钮开始配置", + )} +
+ ) : ( +
+ {cards.map((card) => ( + { + if ( + oauthAvailable[card.key] && + !card.oauthConfigured && + enabled + ) { + message.warning( + t( + "connectors.customMcp.oauthBeforeEnable", + "请先完成 OAuth 授权后再启用", + ), + ); + return; + } + void persistServerPatch( + card, + { + enabled, + ...(enabled ? {} : { default_open: false }), + }, + { + enabled, + ...(enabled ? {} : { defaultOpen: false }), + }, + ); + }} + onDefaultOpenChange={(defaultOpen) => { + void persistServerPatch( + card, + { default_open: defaultOpen }, + { defaultOpen }, + ); + }} + onRemove={() => handleRemove(card.key)} + onProbe={() => void handleProbe(card)} + onAuthorize={() => void handleOAuth(card)} + /> + ))} +
+ )} +
+ {saveFooter} )} - -
- -
); } diff --git a/dashboard/src/pages/Agent/Connectors/customMcpUtils.ts b/dashboard/src/pages/Agent/Connectors/customMcpUtils.ts index a5975468..bfc3850f 100644 --- a/dashboard/src/pages/Agent/Connectors/customMcpUtils.ts +++ b/dashboard/src/pages/Agent/Connectors/customMcpUtils.ts @@ -19,8 +19,12 @@ export interface ServerCardState { enabled: boolean; defaultOpen: boolean; collapsed: boolean; + oauthConfigured: boolean; + oauthExpiresAt?: number; } +export const PROBE_ON_SAVE_KEY = "octop.customMcp.probeOnSave"; + export const EXAMPLE_JSON = `{ "deepwiki": { "display_name": "DeepWiki", @@ -122,6 +126,8 @@ export function serversToCards(servers: CustomMcpServers): ServerCardState[] { enabled: spec.enabled !== false, defaultOpen: spec.default_open === true, collapsed: true, + oauthConfigured: spec.oauth?.configured === true, + oauthExpiresAt: spec.oauth?.expires_at, })); } @@ -171,6 +177,54 @@ export function cardsToServers(cards: ServerCardState[]): CustomMcpServers { return servers; } +/** Whether any server uses HTTP transport (probe / OAuth apply to these only). */ +export function hasHttpProbeTargets( + cards: ServerCardState[] | CustomMcpServers, +): boolean { + if (Array.isArray(cards)) { + return cards.some((card) => card.transport === "streamable_http"); + } + return Object.values(cards).some( + (spec) => + spec && + typeof spec === "object" && + (spec as CustomMcpServerSpec).transport !== "stdio", + ); +} + +export function mergeCustomMcpCards( + servers: CustomMcpServers, + prevCards: ServerCardState[], +): ServerCardState[] { + return serversToCards(servers).map((card) => { + const prev = prevCards.find((c) => c.name.trim() === card.name.trim()); + if (!prev) return card; + return { + ...card, + key: prev.key, + collapsed: prev.collapsed, + }; + }); +} + +export function oauthHintsFromServers( + servers: CustomMcpServers, + cards: ServerCardState[], +): Record { + const hints: Record = {}; + for (const card of cards) { + const spec = servers[card.name.trim()]; + if ( + card.transport === "streamable_http" && + spec?.oauth?.required && + spec.oauth?.configured !== true + ) { + hints[card.key] = true; + } + } + return hints; +} + export function newCard( transport: CustomMcpTransport, index: number, @@ -190,6 +244,7 @@ export function newCard( enabled: true, defaultOpen: false, collapsed: false, + oauthConfigured: false, }; } diff --git a/dashboard/src/pages/Agent/Connectors/index.module.less b/dashboard/src/pages/Agent/Connectors/index.module.less index 1487c918..c3d0a19a 100644 --- a/dashboard/src/pages/Agent/Connectors/index.module.less +++ b/dashboard/src/pages/Agent/Connectors/index.module.less @@ -697,6 +697,16 @@ justify-content: flex-start; } +.customMcpPanel { + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px; + border: 1px solid var(--fn-border-secondary); + border-radius: var(--fn-radius-lg); + background: var(--fn-bg-primary); +} + .customMcpJsonEditor { display: flex; flex-direction: column; @@ -926,6 +936,47 @@ flex-shrink: 0; } +.customMcpEnableControl { + display: flex; + align-items: center; + gap: 6px; + margin-right: 4px; +} + +.customMcpEnableLabel { + font-size: 12px; + font-weight: 500; + color: var(--fn-text-secondary); + white-space: nowrap; +} + +.customMcpCardFooter { + display: flex; + align-items: center; + padding: 0 16px 14px; + margin-top: -4px; +} + +.customMcpConnectLink { + font-size: 12px; + color: var(--mcp-accent); + font-weight: 500; + background: none; + border: none; + padding: 0; + cursor: pointer; + text-align: left; + + &:hover:not(:disabled) { + text-decoration: underline; + } + + &:disabled { + opacity: 0.65; + cursor: wait; + } +} + .customMcpCardBody { display: flex; flex-direction: column; @@ -979,6 +1030,36 @@ .customMcpFooter { display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 16px; + border: 1px solid var(--fn-border-secondary); + border-radius: var(--fn-radius-lg); + background: var(--fn-bg-primary); +} + +.customMcpFooterMain { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + min-width: 0; +} + +.customMcpFooterHint { + margin: 0; + padding-left: 24px; + font-size: 12px; + line-height: 1.45; + color: var(--fn-text-tertiary); +} + +.customMcpFooterSave { + flex-shrink: 0; + align-self: center; +} + +.customMcpFooterSaveOnly { justify-content: flex-end; - padding-top: 4px; } diff --git a/dashboard/src/pages/Agent/Connectors/index.tsx b/dashboard/src/pages/Agent/Connectors/index.tsx index abccf84e..a3b7e542 100644 --- a/dashboard/src/pages/Agent/Connectors/index.tsx +++ b/dashboard/src/pages/Agent/Connectors/index.tsx @@ -785,7 +785,7 @@ function ConnectorConfigDrawer({ try { const { authorize_url, state_id } = await connectorsApi.oauthStart( - entry.kind, + { type: "catalog", kind: entry.kind }, "/connectors", ); stateId = state_id; diff --git a/dashboard/src/pages/Settings/Models/components/modals/ProviderConfigModal.tsx b/dashboard/src/pages/Settings/Models/components/modals/ProviderConfigModal.tsx index d61d1974..4a3d0ec9 100644 --- a/dashboard/src/pages/Settings/Models/components/modals/ProviderConfigModal.tsx +++ b/dashboard/src/pages/Settings/Models/components/modals/ProviderConfigModal.tsx @@ -7,7 +7,16 @@ * with local model list, download, and delete UI */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { App, Button, Divider, Form, Input, Modal, Progress, Select } from "antd"; +import { + App, + Button, + Divider, + Form, + Input, + Modal, + Progress, + Select, +} from "antd"; import { Download, Key, Loader2, Trash2, X, Zap } from "lucide-react"; import { useTranslation } from "react-i18next"; diff --git a/src/octop/api/routers/connectors.py b/src/octop/api/routers/connectors.py index 0624d749..8e43901a 100644 --- a/src/octop/api/routers/connectors.py +++ b/src/octop/api/routers/connectors.py @@ -11,7 +11,7 @@ from fastapi import APIRouter, Depends, Query, Request from fastapi.responses import HTMLResponse -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator from octop.api.common.public_base import resolve_public_base from octop.api.deps import current_user, get_server, require_permission @@ -23,13 +23,14 @@ from octop.infra.connectors.catalog import ( catalog_entry_to_dict, get_catalog_entry, - get_mcp_oauth_remote, list_catalog, ) from octop.infra.connectors.custom_mcp import ( CUSTOM_MCP_KIND, is_custom_mcp_kind, + oauth_configured, parse_synthetic_instance_id, + redact_servers_for_api, ) from octop.infra.connectors.default_open import ( build_instance_config_json, @@ -50,7 +51,11 @@ load_oauth_ctx, oauth_ready_for_kind, save_oauth_ctx, - start_oauth, + start_oauth_for_target, +) +from octop.infra.connectors.oauth.registry import ( + oauth_state_kind_for_target, + oauth_target_requires_https, ) from octop.infra.connectors.probe import ( prepare_probe_credentials, @@ -95,6 +100,15 @@ class PatchInstanceBody(BaseModel): class OAuthStartBody(BaseModel): redirect_after: str | None = None + target: dict[str, Any] | None = Field( + default=None, + description='OAuth target, e.g. {"type":"catalog","kind":"notion"} or ' + '{"type":"custom_mcp","server_name":"my-server"}', + ) + + +class OAuthStartLegacyBody(BaseModel): + redirect_after: str | None = None class ExchangeAuthCodeBody(BaseModel): @@ -127,7 +141,14 @@ class CustomMcpPutBody(BaseModel): class CustomMcpServerPatchBody(BaseModel): - enabled: bool + enabled: bool | None = None + default_open: bool | None = None + + @model_validator(mode="after") + def _require_one_field(self) -> CustomMcpServerPatchBody: + if self.enabled is None and self.default_open is None: + raise ValueError("provide enabled and/or default_open") + return self class CustomMcpTestBody(BaseModel): @@ -137,6 +158,94 @@ class CustomMcpTestBody(BaseModel): server: dict[str, Any] | None = None +def _resolve_custom_mcp_url(svc: ConnectorService, user_id: int, server_name: str) -> str: + saved = svc.get_custom_servers(user_id) + raw = saved.get(server_name) + if not isinstance(raw, dict): + raise OctopError( + ErrorCode.CONNECTOR_NOT_FOUND, + f"custom MCP server {server_name!r} not found; save configuration before OAuth", + ) + if str(raw.get("transport") or "") not in ("streamable_http", "http"): + raise OctopError( + ErrorCode.CONNECTOR_KIND_UNSUPPORTED, + "OAuth is only supported for streamable HTTP custom MCP servers", + ) + mcp_url = str(raw.get("url") or "").strip() + if not mcp_url: + raise OctopError(ErrorCode.CONNECTOR_INVALID_CREDENTIALS, "server url is required") + return mcp_url + + +async def _begin_oauth_flow( + *, + request: Request, + user: Any, + server: Any, + target: dict[str, Any], + redirect_after: str | None, +) -> dict[str, Any]: + target_type = str(target.get("type") or "").strip() + if target_type == "catalog": + kind = str(target.get("kind") or "").strip() + if not oauth_ready_for_kind(kind, server.services.settings_repo): + raise OctopError( + ErrorCode.CONNECTOR_INVALID_CREDENTIALS, + f"OAuth for {kind} is not available", + ) + elif target_type != "custom_mcp": + raise OctopError(ErrorCode.CONNECTOR_INVALID_CREDENTIALS, "unsupported oauth target") + + state = secrets.token_urlsafe(24) + state_id = new_ulid() + base = resolve_public_base(request) + redirect_uri = f"{base}/api/connectors/oauth/callback" + if oauth_target_requires_https(target) and _is_public_http_uri(redirect_uri): + label = str(target.get("kind") or target.get("server_name") or "MCP") + raise OctopError( + ErrorCode.CONNECTOR_OAUTH_HTTPS_REQUIRED, + f"{label} OAuth callbacks require HTTPS for non-loopback addresses", + ) + + mcp_url: str | None = None + if target_type == "custom_mcp": + server_name = str(target.get("server_name") or "").strip() + if not server_name: + raise OctopError( + ErrorCode.CONNECTOR_INVALID_CREDENTIALS, + "custom_mcp target requires server_name", + ) + mcp_url = _resolve_custom_mcp_url(_connector_service(server), user.id, server_name) + + try: + authorize_url, verifier, ctx = await start_oauth_for_target( + target=target, + redirect_uri=redirect_uri, + state=state, + settings_repo=server.services.settings_repo, + mcp_url=mcp_url, + ) + except ValueError as exc: + raise OctopError(ErrorCode.CONNECTOR_INVALID_CREDENTIALS, str(exc)) from exc + except Exception as exc: + logger.exception("oauth start failed for target %s", target) + raise OctopError( + ErrorCode.CONNECTOR_INVALID_CREDENTIALS, + f"无法启动 OAuth: {exc}", + ) from exc + + server.services.repos.connector_repo.create_oauth_state( + state_id=state_id, + state=state, + user_id=user.id, + kind=oauth_state_kind_for_target(target), + code_verifier=verifier, + redirect_after=redirect_after, + ) + save_oauth_ctx(server.services.settings_repo, state_id, ctx) + return {"authorize_url": authorize_url, "state_id": state_id} + + def _connector_service(server: Any) -> ConnectorService: return ConnectorService( repo=server.services.repos.connector_repo, @@ -322,7 +431,7 @@ async def get_custom_mcp( server: Any = Depends(get_server), ) -> dict[str, Any]: """Return the user's custom MCP server map (langchain-mcp-adapters shape).""" - servers = _connector_service(server).get_custom_servers(user.id) + servers = _connector_service(server).get_custom_servers_for_api(user.id) return {"servers": servers} @@ -345,12 +454,12 @@ async def put_custom_mcp( payload=str(len(servers)), ) _schedule_connector_reload(server, user.id) - return {"servers": servers} + return {"servers": redact_servers_for_api(servers)} @router.patch( "/connectors/custom-mcp/servers/{server_name}", - summary="Enable or disable one custom MCP server", + summary="Patch one custom MCP server", ) async def patch_custom_mcp_server( server_name: str, @@ -358,10 +467,15 @@ async def patch_custom_mcp_server( user: Any = Depends(current_user), server: Any = Depends(get_server), ) -> dict[str, Any]: - """Toggle ``enabled`` for a single custom MCP server without rewriting others.""" + """Update ``enabled`` and/or ``default_open`` for one custom MCP server.""" svc = _connector_service(server) try: - servers = svc.patch_custom_server_enabled(user.id, server_name, enabled=body.enabled) + servers = svc.patch_custom_server( + user.id, + server_name, + enabled=body.enabled, + default_open=body.default_open, + ) except KeyError as exc: raise OctopError( ErrorCode.CONNECTOR_NOT_FOUND, f"custom MCP server {server_name!r} not found" @@ -369,7 +483,7 @@ async def patch_custom_mcp_server( except ValueError as exc: raise OctopError(ErrorCode.CONNECTOR_INVALID_CREDENTIALS, str(exc)) from exc _schedule_connector_reload(server, user.id) - return {"servers": servers} + return {"servers": redact_servers_for_api(servers)} @router.post("/connectors/custom-mcp/test", summary="Probe a custom MCP server") @@ -396,7 +510,16 @@ async def test_custom_mcp( ErrorCode.CONNECTOR_INVALID_CREDENTIALS, "provide name or server spec to probe", ) - return await probe_custom_mcp_server(spec) + result = await probe_custom_mcp_server(spec) + if body.name: + try: + if result.get("oauth", {}).get("available") and not oauth_configured(spec): + svc.note_custom_server_oauth_required(user.id, body.name, required=True) + elif result.get("ok") or not result.get("oauth", {}).get("available"): + svc.note_custom_server_oauth_required(user.id, body.name, required=False) + except KeyError: + pass + return result @router.get("/connector-instances/{instance_id}", summary="Get connector instance") @@ -946,57 +1069,41 @@ async def auth_exchange_code( return {"credentials": tokens} -@router.post("/connectors/oauth/{kind}/start", summary="Start OAuth flow") -async def oauth_start( - kind: str, +@router.post("/connectors/oauth/start", summary="Start OAuth flow (unified)") +async def oauth_start_unified( body: OAuthStartBody, request: Request, user: Any = Depends(current_user), server: Any = Depends(get_server), ) -> dict[str, Any]: - """Begin browser OAuth: returns `authorize_url` and `state_id` to poll after redirect.""" - if not oauth_ready_for_kind(kind, server.services.settings_repo): - raise OctopError( - ErrorCode.CONNECTOR_INVALID_CREDENTIALS, - f"OAuth for {kind} is not available", - ) - - state = secrets.token_urlsafe(24) - state_id = new_ulid() - base = resolve_public_base(request) - redirect_uri = f"{base}/api/connectors/oauth/callback" - if get_mcp_oauth_remote(kind) is not None and _is_public_http_uri(redirect_uri): - raise OctopError( - ErrorCode.CONNECTOR_OAUTH_HTTPS_REQUIRED, - f"{kind} OAuth callbacks require HTTPS for non-loopback addresses", - ) + """Begin browser OAuth for a catalog connector or custom MCP server.""" + if not body.target or not isinstance(body.target, dict): + raise OctopError(ErrorCode.CONNECTOR_INVALID_CREDENTIALS, "oauth target is required") + return await _begin_oauth_flow( + request=request, + user=user, + server=server, + target=body.target, + redirect_after=body.redirect_after, + ) - try: - authorize_url, verifier, ctx = await start_oauth( - kind=kind, - redirect_uri=redirect_uri, - state=state, - settings_repo=server.services.settings_repo, - ) - except ValueError as exc: - raise OctopError(ErrorCode.CONNECTOR_INVALID_CREDENTIALS, str(exc)) from exc - except Exception as exc: - logger.exception("oauth start failed for %s", kind) - raise OctopError( - ErrorCode.CONNECTOR_INVALID_CREDENTIALS, - f"无法启动 OAuth: {exc}", - ) from exc - server.services.repos.connector_repo.create_oauth_state( - state_id=state_id, - state=state, - user_id=user.id, - kind=kind, - code_verifier=verifier, +@router.post("/connectors/oauth/{kind}/start", summary="Start OAuth flow (legacy)") +async def oauth_start_legacy( + kind: str, + body: OAuthStartLegacyBody, + request: Request, + user: Any = Depends(current_user), + server: Any = Depends(get_server), +) -> dict[str, Any]: + """Legacy catalog-only alias for :func:`oauth_start_unified`.""" + return await _begin_oauth_flow( + request=request, + user=user, + server=server, + target={"type": "catalog", "kind": kind}, redirect_after=body.redirect_after, ) - save_oauth_ctx(server.services.settings_repo, state_id, ctx) - return {"authorize_url": authorize_url, "state_id": state_id} @router.get("/connectors/oauth/callback", summary="OAuth callback") @@ -1040,11 +1147,39 @@ async def oauth_callback( logger.exception("oauth callback failed for %s", row.kind) return HTMLResponse(f"Token 交换失败: {exc}", status_code=400) + pending_payload: dict[str, Any] = { + "user_id": row.user_id, + "kind": row.kind, + "tokens": tokens, + } + if row.kind == CUSTOM_MCP_KIND: + server_name = str(ctx.get("server_name") or "") + issuer = str(ctx.get("issuer") or "") + resource = str(ctx.get("resource") or "").strip() or None + try: + svc = _connector_service(server) + svc.apply_custom_server_oauth( + row.user_id, + server_name, + tokens, + issuer=issuer, + resource=resource, + ) + _schedule_connector_reload(server, row.user_id) + pending_payload["server_name"] = server_name + pending_payload["applied"] = True + except Exception as exc: + logger.exception("custom MCP oauth apply failed for %s", server_name) + return HTMLResponse( + f"保存授权失败: {exc}", + status_code=400, + ) + # Store tokens in a short-lived settings key for frontend pickup, or auto-create instance. pending_key = f"connector.oauth.pending.{row.state_id}" server.services.settings_repo.set( pending_key, - json.dumps({"user_id": row.user_id, "kind": row.kind, "tokens": tokens}), + json.dumps(pending_payload), ) redirect = row.redirect_after or "/connectors" html = f""" @@ -1079,7 +1214,12 @@ async def oauth_pending( if int(data.get("user_id") or 0) != user.id: raise OctopError(ErrorCode.FORBIDDEN, "not your oauth session") server.services.settings_repo.delete(key) - return {"kind": data.get("kind"), "tokens": data.get("tokens") or {}} + return { + "kind": data.get("kind"), + "tokens": data.get("tokens") or {}, + "server_name": data.get("server_name"), + "applied": data.get("applied"), + } async def validate_chat_mcp_servers( diff --git a/src/octop/infra/connectors/custom_mcp.py b/src/octop/infra/connectors/custom_mcp.py index 95ea281d..90320e3c 100644 --- a/src/octop/infra/connectors/custom_mcp.py +++ b/src/octop/infra/connectors/custom_mcp.py @@ -13,6 +13,9 @@ _SERVER_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$") _META_KEYS = frozenset({"enabled", "display_name", "default_open"}) +_OAUTH_KEY = "oauth" +_SECRET_KEYS = frozenset({_OAUTH_KEY}) +_HARNESS_STRIP_KEYS = _META_KEYS | _SECRET_KEYS _DISPLAY_NAME_MAX = 64 _MCP_STREAMABLE_HTTP_ACCEPT = "application/json, text/event-stream" @@ -164,6 +167,10 @@ def normalize_server_spec(name: str, raw: Any) -> dict[str, Any]: if env: spec["env"] = env + oauth = raw.get(_OAUTH_KEY) + if isinstance(oauth, dict) and str(oauth.get("access_token") or "").strip(): + spec[_OAUTH_KEY] = dict(oauth) + return spec @@ -199,15 +206,127 @@ def validate_servers_map( return out +def oauth_tokens_from_spec(spec: dict[str, Any]) -> dict[str, Any]: + raw = spec.get(_OAUTH_KEY) + return dict(raw) if isinstance(raw, dict) else {} + + +def oauth_configured(spec: dict[str, Any]) -> bool: + oauth = oauth_tokens_from_spec(spec) + return bool(str(oauth.get("access_token") or "").strip()) + + +def oauth_required(spec: dict[str, Any]) -> bool: + if oauth_configured(spec): + return False + oauth = oauth_tokens_from_spec(spec) + return oauth.get("required") is True + + +def set_oauth_required_in_spec(spec: dict[str, Any], *, required: bool) -> dict[str, Any]: + """Persist or clear the dashboard hint that OAuth is needed (no tokens).""" + out = dict(spec) + if oauth_configured(out): + return out + oauth = dict(oauth_tokens_from_spec(out)) + if required: + oauth["required"] = True + oauth.pop("access_token", None) + oauth.pop("refresh_token", None) + out[_OAUTH_KEY] = oauth + return out + oauth.pop("required", None) + if str(oauth.get("access_token") or "").strip(): + out[_OAUTH_KEY] = oauth + else: + out.pop(_OAUTH_KEY, None) + return out + + +def build_oauth_storage( + tokens: dict[str, Any], + *, + issuer: str, + resource: str | None, +) -> dict[str, Any]: + out: dict[str, Any] = { + "access_token": str(tokens["access_token"]), + "oauth_issuer": issuer.rstrip("/"), + } + refresh = str(tokens.get("refresh_token") or "").strip() + if refresh: + out["refresh_token"] = refresh + if tokens.get("expires_at") is not None: + out["expires_at"] = int(tokens["expires_at"]) + if resource: + out["oauth_resource"] = resource + client_id = str(tokens.get("oauth_client_id") or "").strip() + if client_id: + out["oauth_client_id"] = client_id + client_secret = tokens.get("oauth_client_secret") + if client_secret: + out["oauth_client_secret"] = str(client_secret) + return out + + +def redact_server_for_api(spec: dict[str, Any]) -> dict[str, Any]: + """Remove secrets; expose oauth preview for the dashboard.""" + out = {k: v for k, v in spec.items() if k != _OAUTH_KEY} + if oauth_configured(spec): + oauth = oauth_tokens_from_spec(spec) + preview: dict[str, Any] = {"configured": True} + if oauth.get("expires_at") is not None: + preview["expires_at"] = oauth["expires_at"] + out[_OAUTH_KEY] = preview + elif oauth_required(spec): + out[_OAUTH_KEY] = {"configured": False, "required": True} + return out + + +def redact_servers_for_api(servers: dict[str, Any]) -> dict[str, Any]: + return { + name: redact_server_for_api(spec) + for name, spec in servers.items() + if isinstance(spec, dict) + } + + +def merge_preserved_oauth( + new_servers: dict[str, Any], + existing: dict[str, Any], +) -> dict[str, Any]: + """Drop client-supplied oauth blobs; keep stored tokens per server name.""" + out: dict[str, Any] = {} + for name, raw in new_servers.items(): + if not isinstance(raw, dict): + out[name] = raw + continue + spec = dict(raw) + spec.pop(_OAUTH_KEY, None) + old = existing.get(name) + if isinstance(old, dict): + old_oauth = old.get(_OAUTH_KEY) + if isinstance(old_oauth, dict) and str(old_oauth.get("access_token") or "").strip(): + spec[_OAUTH_KEY] = dict(old_oauth) + elif isinstance(old_oauth, dict) and old_oauth.get("required") is True: + spec[_OAUTH_KEY] = {"required": True} + out[name] = spec + return out + + def harness_spec_for_server(spec: dict[str, Any]) -> dict[str, Any]: """Strip Octop meta keys; keep langchain-mcp-adapters connection fields.""" - out = {k: v for k, v in spec.items() if k not in _META_KEYS} + out = {k: v for k, v in spec.items() if k not in _HARNESS_STRIP_KEYS} # Ensure stdio always has args list for adapters. if out.get("transport") == "stdio" and "args" not in out: out["args"] = [] # Streamable HTTP MCP requires both content types (same as built-in remote). if out.get("transport") == "streamable_http": headers = {str(k): str(v) for k, v in dict(out.get("headers") or {}).items()} + oauth = oauth_tokens_from_spec(spec) + token = str(oauth.get("access_token") or "").strip() + if token: + headers["Authorization"] = f"Bearer {token}" headers.setdefault("Accept", _MCP_STREAMABLE_HTTP_ACCEPT) out["headers"] = headers return out diff --git a/src/octop/infra/connectors/oauth/__init__.py b/src/octop/infra/connectors/oauth/__init__.py index 255c8311..c1937f6b 100644 --- a/src/octop/infra/connectors/oauth/__init__.py +++ b/src/octop/infra/connectors/oauth/__init__.py @@ -13,6 +13,7 @@ refresh_oauth_credentials, save_oauth_ctx, start_oauth, + start_oauth_for_target, ) __all__ = [ @@ -28,4 +29,5 @@ "refresh_oauth_credentials", "save_oauth_ctx", "start_oauth", + "start_oauth_for_target", ] diff --git a/src/octop/infra/connectors/oauth/discovery.py b/src/octop/infra/connectors/oauth/discovery.py new file mode 100644 index 00000000..b6ac0ff5 --- /dev/null +++ b/src/octop/infra/connectors/oauth/discovery.py @@ -0,0 +1,190 @@ +"""Discover MCP OAuth issuers from a remote MCP URL (RFC 9728 + MCP authorization).""" + +from __future__ import annotations + +import re +from typing import Any +from urllib.parse import urljoin, urlparse + +from octop.infra.connectors.oauth.mcp import fetch_authorization_metadata +from octop.infra.utils.ssrf_guard import UnsafeOutboundUrl, safe_request, validate_https_url + +_RESOURCE_METADATA_PARAM = re.compile( + r'resource_metadata\s*=\s*"([^"]+)"', + re.IGNORECASE, +) + + +def _mcp_url_host(url: str) -> str: + return (urlparse(url).hostname or "").lower().rstrip(".") + + +def _is_loopback_mcp_url(url: str) -> bool: + host = _mcp_url_host(url) + return host in {"localhost", "127.0.0.1", "::1"} + + +def build_protected_resource_metadata_urls( + mcp_url: str, + *, + www_auth_resource_metadata: str | None = None, +) -> list[str]: + """Ordered PRM discovery URLs per MCP authorization / RFC 9728.""" + urls: list[str] = [] + if www_auth_resource_metadata: + urls.append(www_auth_resource_metadata.strip()) + parsed = urlparse(mcp_url) + base = f"{parsed.scheme}://{parsed.netloc}" + path = parsed.path or "" + if path and path != "/": + urls.append(urljoin(base, f"/.well-known/oauth-protected-resource{path}")) + urls.append(urljoin(base, "/.well-known/oauth-protected-resource")) + # De-dupe while preserving order. + seen: set[str] = set() + out: list[str] = [] + for item in urls: + if item and item not in seen: + seen.add(item) + out.append(item) + return out + + +def parse_www_authenticate_resource_metadata(header_value: str | None) -> str | None: + if not header_value: + return None + match = _RESOURCE_METADATA_PARAM.search(header_value) + if not match: + return None + return match.group(1).strip() or None + + +async def _fetch_prm_document(url: str) -> dict[str, Any] | None: + try: + validate_https_url(url, field="resource_metadata") + except UnsafeOutboundUrl: + return None + try: + resp = await safe_request("GET", url, timeout=15.0) + except Exception: + return None + if resp.status_code >= 400: + return None + try: + data = resp.json() + except Exception: + return None + if not isinstance(data, dict): + return None + servers = data.get("authorization_servers") + if not isinstance(servers, list) or not servers: + return None + return data + + +async def _probe_401_resource_metadata(mcp_url: str) -> str | None: + """Unauthenticated MCP initialize may return WWW-Authenticate with PRM URL.""" + parsed = urlparse(mcp_url) + if parsed.scheme not in ("http", "https"): + return None + if _is_loopback_mcp_url(mcp_url): + return None + if parsed.scheme != "https": + return None + try: + validate_https_url(mcp_url, field="mcp_url") + except UnsafeOutboundUrl: + return None + try: + resp = await safe_request( + "POST", + mcp_url, + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "octop", "version": "0.1"}, + }, + }, + headers={"Accept": "application/json, text/event-stream"}, + timeout=15.0, + ) + except Exception: + return None + if resp.status_code not in (401, 403): + return None + return parse_www_authenticate_resource_metadata(resp.headers.get("WWW-Authenticate")) + + +def _normalize_issuer(raw: str, *, mcp_url: str) -> str: + text = raw.strip().rstrip("/") + if text.startswith("http://") or text.startswith("https://"): + return text + base = f"{urlparse(mcp_url).scheme}://{urlparse(mcp_url).netloc}" + return urljoin(base, text).rstrip("/") + + +async def discover_oauth_from_mcp_url(mcp_url: str) -> dict[str, Any]: + """Return OAuth discovery details for a remote MCP HTTP endpoint. + + Result shape:: + + { + "available": bool, + "issuer": str | None, + "resource": str | None, + "metadata": dict | None, # AS metadata when available + "scopes_supported": list[str] | None, + "error": str | None, + } + """ + url = mcp_url.strip() + if not url: + return {"available": False, "error": "empty mcp url"} + if _is_loopback_mcp_url(url): + return {"available": False, "error": "loopback MCP does not use remote OAuth"} + + www_auth_prm = await _probe_401_resource_metadata(url) + prm_doc: dict[str, Any] | None = None + for candidate in build_protected_resource_metadata_urls( + url, + www_auth_resource_metadata=www_auth_prm, + ): + prm_doc = await _fetch_prm_document(candidate) + if prm_doc is not None: + break + + if prm_doc is None: + return {"available": False, "error": "protected resource metadata not found"} + + resource = str(prm_doc.get("resource") or "").strip() or url + servers = prm_doc.get("authorization_servers") + if not isinstance(servers, list) or not servers: + return {"available": False, "error": "authorization_servers missing"} + + issuer = _normalize_issuer(str(servers[0]), mcp_url=url) + try: + metadata = await fetch_authorization_metadata(issuer) + except Exception as exc: + return {"available": False, "error": str(exc), "issuer": issuer, "resource": resource} + + if not metadata.get("registration_endpoint"): + return { + "available": False, + "error": "authorization server does not support dynamic client registration", + "issuer": issuer, + "resource": resource, + } + + scopes_raw = metadata.get("scopes_supported") + scopes = [str(s) for s in scopes_raw if s] if isinstance(scopes_raw, list) else None + return { + "available": True, + "issuer": issuer, + "resource": resource, + "metadata": metadata, + "scopes_supported": scopes, + "error": None, + } diff --git a/src/octop/infra/connectors/oauth/registry.py b/src/octop/infra/connectors/oauth/registry.py index 9a9f9fa3..969807d7 100644 --- a/src/octop/infra/connectors/oauth/registry.py +++ b/src/octop/infra/connectors/oauth/registry.py @@ -6,6 +6,8 @@ from typing import Any from octop.infra.connectors.catalog import get_catalog_entry, get_mcp_oauth_remote +from octop.infra.connectors.custom_mcp import CUSTOM_MCP_KIND +from octop.infra.connectors.oauth.discovery import discover_oauth_from_mcp_url from octop.infra.connectors.oauth.mcp import ( build_authorize_url, exchange_authorization_code, @@ -119,6 +121,49 @@ async def exchange_pasted_auth_code( raise ValueError(f"auth code exchange not supported for {kind}") +async def _start_mcp_oauth_from_discovery( + *, + flow: str, + kind: str, + redirect_uri: str, + state: str, + issuer: str, + resource: str | None, + metadata: dict[str, Any], + server_name: str | None = None, + mcp_url: str | None = None, + scope: str | None = None, +) -> tuple[str, str, dict[str, Any]]: + verifier, challenge = new_pkce_pair() + reg = await register_dynamic_client(metadata, issuer=issuer, redirect_uri=redirect_uri) + client_id = str(reg["client_id"]) + client_secret = str(reg.get("client_secret") or "") or None + url = build_authorize_url( + metadata, + client_id=client_id, + redirect_uri=redirect_uri, + state=state, + code_challenge=challenge, + scope=scope, + resource=resource, + ) + ctx: dict[str, Any] = { + "flow": flow, + "kind": kind, + "metadata": metadata, + "client_id": client_id, + "client_secret": client_secret, + "resource": resource, + "redirect_uri": redirect_uri, + "issuer": issuer, + } + if server_name: + ctx["server_name"] = server_name + if mcp_url: + ctx["mcp_url"] = mcp_url + return url, verifier, ctx + + async def start_oauth( *, kind: str, @@ -126,38 +171,136 @@ async def start_oauth( state: str, settings_repo: Any, ) -> tuple[str, str, dict[str, Any]]: - """Return authorize_url, code_verifier, ctx to persist for callback.""" + """Return authorize_url, code_verifier, ctx for a catalog connector kind.""" del settings_repo - verifier, challenge = new_pkce_pair() - - if kind in mcp_oauth_kinds(): - issuer = issuer_for_kind(kind) - metadata = await fetch_authorization_metadata(issuer) - reg = await register_dynamic_client(metadata, issuer=issuer, redirect_uri=redirect_uri) - client_id = str(reg["client_id"]) - client_secret = str(reg.get("client_secret") or "") or None - resource = resource_for_kind(kind) - url = build_authorize_url( - metadata, - client_id=client_id, + if kind not in mcp_oauth_kinds(): + raise ValueError(f"oauth not supported for {kind}") + issuer = issuer_for_kind(kind) + metadata = await fetch_authorization_metadata(issuer) + resource = resource_for_kind(kind) + return await _start_mcp_oauth_from_discovery( + flow="mcp", + kind=kind, + redirect_uri=redirect_uri, + state=state, + issuer=issuer, + resource=resource, + metadata=metadata, + scope=_scopes_for_kind(kind, metadata), + ) + + +async def start_oauth_for_target( + *, + target: dict[str, Any], + redirect_uri: str, + state: str, + settings_repo: Any, + mcp_url: str | None = None, +) -> tuple[str, str, dict[str, Any]]: + """Unified OAuth start for catalog connectors and custom MCP servers.""" + target_type = str(target.get("type") or "").strip() + if target_type == "catalog": + kind = str(target.get("kind") or "").strip() + if not kind: + raise ValueError("catalog target requires kind") + return await start_oauth( + kind=kind, redirect_uri=redirect_uri, state=state, - code_challenge=challenge, - scope=_scopes_for_kind(kind, metadata), - resource=resource, + settings_repo=settings_repo, ) - ctx = { - "flow": "mcp", - "kind": kind, - "metadata": metadata, - "client_id": client_id, - "client_secret": client_secret, - "resource": resource, - "redirect_uri": redirect_uri, - } - return url, verifier, ctx + if target_type == "custom_mcp": + server_name = str(target.get("server_name") or "").strip() + url = str(mcp_url or target.get("mcp_url") or "").strip() + if not server_name: + raise ValueError("custom_mcp target requires server_name") + if not url: + raise ValueError("custom MCP server url is required; save the server first") + return await start_custom_mcp_oauth( + server_name=server_name, + mcp_url=url, + redirect_uri=redirect_uri, + state=state, + ) + raise ValueError(f"unsupported oauth target type: {target_type!r}") + - raise ValueError(f"oauth not supported for {kind}") +def oauth_state_kind_for_target(target: dict[str, Any]) -> str: + target_type = str(target.get("type") or "").strip() + if target_type == "catalog": + return str(target.get("kind") or "").strip() + if target_type == "custom_mcp": + return CUSTOM_MCP_KIND + raise ValueError(f"unsupported oauth target type: {target_type!r}") + + +def oauth_target_requires_https(target: dict[str, Any]) -> bool: + target_type = str(target.get("type") or "").strip() + if target_type == "catalog": + kind = str(target.get("kind") or "").strip() + return get_mcp_oauth_remote(kind) is not None + return target_type == "custom_mcp" + + +async def start_custom_mcp_oauth( + *, + server_name: str, + mcp_url: str, + redirect_uri: str, + state: str, + discovery: dict[str, Any] | None = None, +) -> tuple[str, str, dict[str, Any]]: + """Start OAuth for a user-defined MCP server (issuer discovered from MCP URL).""" + found = discovery if discovery is not None else await discover_oauth_from_mcp_url(mcp_url) + if not found.get("available"): + raise ValueError(str(found.get("error") or "OAuth not available for this MCP URL")) + issuer = str(found["issuer"]) + resource = str(found.get("resource") or "").strip() or None + metadata = found.get("metadata") + if not isinstance(metadata, dict): + metadata = await fetch_authorization_metadata(issuer) + scopes_raw = metadata.get("scopes_supported") + scope = ( + " ".join(str(s) for s in scopes_raw if s) + if isinstance(scopes_raw, list) and scopes_raw + else None + ) + return await _start_mcp_oauth_from_discovery( + flow="custom_mcp", + kind=CUSTOM_MCP_KIND, + redirect_uri=redirect_uri, + state=state, + issuer=issuer, + resource=resource, + metadata=metadata, + server_name=server_name, + mcp_url=mcp_url, + scope=scope, + ) + + +async def refresh_custom_mcp_oauth(oauth: dict[str, Any]) -> dict[str, Any]: + refresh = str(oauth.get("refresh_token") or "").strip() + issuer = str(oauth.get("oauth_issuer") or "").strip() + client_id = str(oauth.get("oauth_client_id") or "").strip() + if not refresh or not issuer or not client_id: + return oauth + metadata = await fetch_authorization_metadata(issuer) + client_secret_raw = oauth.get("oauth_client_secret") + secret = str(client_secret_raw) if client_secret_raw else None + resource = str(oauth.get("oauth_resource") or "").strip() or None + refreshed = await refresh_access_token( + metadata, + issuer=issuer, + client_id=client_id, + client_secret=secret, + refresh_token=refresh, + resource=resource, + ) + merged = dict(oauth) + merged.update(refreshed) + return merged async def exchange_oauth_code( @@ -172,6 +315,32 @@ async def exchange_oauth_code( ctx = load_oauth_ctx(settings_repo, state_id) flow = ctx.get("flow") + if flow == "custom_mcp": + metadata = ctx.get("metadata") + if not isinstance(metadata, dict): + issuer = str(ctx.get("issuer") or "") + if not issuer: + raise ValueError("missing custom MCP oauth issuer") + metadata = await fetch_authorization_metadata(issuer) + client_id = str(ctx.get("client_id") or "") + client_secret_raw = ctx.get("client_secret") + secret = str(client_secret_raw) if client_secret_raw else None + if not client_id: + raise ValueError("missing custom MCP oauth client_id") + resource = str(ctx.get("resource") or "") or None + exchange_redirect = str(ctx.get("redirect_uri") or "").strip() or redirect_uri + issuer = str(ctx.get("issuer") or "") + return await exchange_authorization_code( + metadata, + issuer=issuer, + client_id=client_id, + client_secret=secret, + code=code, + redirect_uri=exchange_redirect, + code_verifier=code_verifier, + resource=resource, + ) + if flow == "mcp" or kind in mcp_oauth_kinds(): metadata = ctx.get("metadata") if not isinstance(metadata, dict): diff --git a/src/octop/infra/connectors/probe.py b/src/octop/infra/connectors/probe.py index 1ac5dd82..c5c05347 100644 --- a/src/octop/infra/connectors/probe.py +++ b/src/octop/infra/connectors/probe.py @@ -23,6 +23,7 @@ ) from octop.infra.connectors.gateway.protocol import handle_mcp_request from octop.infra.connectors.gateway.registry import probe_gateway_credentials +from octop.infra.connectors.oauth.discovery import discover_oauth_from_mcp_url from octop.infra.utils.ssrf_guard import UnsafeOutboundUrl, safe_request logger = logging.getLogger(__name__) @@ -124,6 +125,30 @@ def _probe_mcp_http_error(exc: httpx.HTTPStatusError, *, kind: str) -> dict[str, } +async def _maybe_attach_oauth_discovery( + result: dict[str, Any], + *, + url: str, + headers: dict[str, str], +) -> dict[str, Any]: + """When auth fails without a bearer token, try MCP OAuth discovery.""" + if result.get("ok") is not False or result.get("error_type") != "auth": + return result + auth_header = str(headers.get("Authorization") or "").strip() + if auth_header.lower().startswith("bearer ") and len(auth_header) > 7: + return result + discovery = await discover_oauth_from_mcp_url(url) + if discovery.get("available"): + result["oauth"] = { + "available": True, + "issuer": discovery.get("issuer"), + "resource": discovery.get("resource"), + } + else: + result["oauth"] = {"available": False} + return result + + def _probe_mcp_mcp_error(exc: McpError, *, kind: str) -> dict[str, Any]: """Map an MCP-level error (e.g. server closing the initialize stream). @@ -431,6 +456,16 @@ async def probe_connector( } +def format_probe_exception(exc: BaseException) -> str: + """Flatten TaskGroup / ExceptionGroup errors for API responses.""" + if isinstance(exc, BaseExceptionGroup): + parts = [format_probe_exception(sub) for sub in exc.exceptions] + joined = "; ".join(part for part in parts if part) + return joined or str(exc) + msg = str(exc).strip() + return msg or type(exc).__name__ + + async def probe_custom_mcp_server(spec: dict[str, Any]) -> dict[str, Any]: """Probe one user-defined MCP server (streamable_http or stdio).""" from octop.infra.connectors.custom_mcp import harness_spec_for_server, normalize_server_spec @@ -448,7 +483,8 @@ async def probe_custom_mcp_server(spec: dict[str, Any]) -> dict[str, Any]: headers = {str(k): str(v) for k, v in dict(connection.get("headers") or {}).items()} # Ensure streamable Accept if caller omitted it. headers.setdefault("Accept", "application/json, text/event-stream") - return await probe_streamable_http_mcp(url, headers, kind="custom-mcp") + result = await probe_streamable_http_mcp(url, headers, kind="custom-mcp") + return await _maybe_attach_oauth_discovery(result, url=url, headers=headers) if transport == "stdio": return await _probe_stdio_mcp(connection) @@ -485,4 +521,4 @@ async def _probe_stdio_mcp(connection: dict[str, Any]) -> dict[str, Any]: return {"ok": False, "error": "stdio MCP probe timed out"} except Exception as exc: logger.exception("stdio MCP probe failed") - return {"ok": False, "error": str(exc)} + return {"ok": False, "error": format_probe_exception(exc)} diff --git a/src/octop/infra/connectors/service.py b/src/octop/infra/connectors/service.py index c4c1eae2..b0128ce7 100644 --- a/src/octop/infra/connectors/service.py +++ b/src/octop/infra/connectors/service.py @@ -14,11 +14,17 @@ from octop.infra.connectors.custom_mcp import ( CUSTOM_MCP_DISPLAY_NAME, CUSTOM_MCP_KIND, + build_oauth_storage, enabled_harness_configs, expand_custom_instances, extract_servers, is_custom_mcp_kind, + merge_preserved_oauth, + oauth_configured, + oauth_tokens_from_spec, + redact_servers_for_api, server_enabled, + set_oauth_required_in_spec, validate_servers_map, wrap_servers, ) @@ -29,6 +35,7 @@ start_user_device_login, ) from octop.infra.connectors.oauth import refresh_oauth_credentials +from octop.infra.connectors.oauth.registry import refresh_custom_mcp_oauth from octop.infra.db.repos.connectors import ConnectorRepo, ConnectorRow from octop.infra.db.repos.secrets import SecretRepo from octop.infra.errors import ErrorCode, OctopError @@ -140,7 +147,15 @@ def get_custom_servers(self, user_id: int) -> dict[str, Any]: return {} return extract_servers(self.decrypt(row.instance_id)) + def get_custom_servers_for_api(self, user_id: int) -> dict[str, Any]: + return redact_servers_for_api(self.get_custom_servers(user_id)) + def put_custom_servers(self, user_id: int, servers: dict[str, Any]) -> dict[str, Any]: + existing = self.get_custom_servers(user_id) + merged = merge_preserved_oauth(servers, existing) + return self._save_custom_servers(user_id, merged) + + def _save_custom_servers(self, user_id: int, servers: dict[str, Any]) -> dict[str, Any]: normalized = validate_servers_map( servers, reserved_names=self.reserved_builtin_mcp_names(user_id), @@ -167,38 +182,110 @@ def put_custom_servers(self, user_id: int, servers: dict[str, Any]) -> dict[str, ) return normalized + def apply_custom_server_oauth( + self, + user_id: int, + server_name: str, + tokens: dict[str, Any], + *, + issuer: str, + resource: str | None, + ) -> dict[str, Any]: + servers = dict(self.get_custom_servers(user_id)) + if server_name not in servers: + raise KeyError(server_name) + access = str(tokens.get("access_token") or "").strip() + if not access: + raise ValueError("missing access_token") + spec = dict(servers[server_name]) + spec["oauth"] = build_oauth_storage(tokens, issuer=issuer, resource=resource) + spec = set_oauth_required_in_spec(spec, required=False) + servers[server_name] = spec + return self._save_custom_servers(user_id, servers) + + async def ensure_fresh_custom_servers(self, user_id: int) -> dict[str, Any]: + servers = dict(self.get_custom_servers(user_id)) + changed = False + now = int(time.time()) + for name, raw in list(servers.items()): + if not isinstance(raw, dict): + continue + oauth = oauth_tokens_from_spec(raw) + if not oauth_configured(raw): + continue + expires_at = oauth.get("expires_at") + refresh = str(oauth.get("refresh_token") or "").strip() + if not refresh: + continue + if expires_at and int(expires_at) > now + 120: + continue + try: + refreshed = await refresh_custom_mcp_oauth(oauth) + except Exception: + continue + spec = dict(raw) + spec["oauth"] = refreshed + servers[name] = spec + changed = True + if not changed: + return servers + return self._save_custom_servers(user_id, servers) + def patch_custom_server_enabled( self, user_id: int, server_name: str, *, enabled: bool, + ) -> dict[str, Any]: + return self.patch_custom_server(user_id, server_name, enabled=enabled) + + def patch_custom_server( + self, + user_id: int, + server_name: str, + *, + enabled: bool | None = None, + default_open: bool | None = None, ) -> dict[str, Any]: servers = dict(self.get_custom_servers(user_id)) if server_name not in servers: raise KeyError(server_name) spec = dict(servers[server_name]) - spec["enabled"] = enabled + if enabled is not None: + spec["enabled"] = enabled + if not enabled: + spec.pop("default_open", None) + if default_open is not None: + if default_open: + spec["default_open"] = True + else: + spec.pop("default_open", None) servers[server_name] = spec return self.put_custom_servers(user_id, servers) - def patch_custom_server_default_open( + def note_custom_server_oauth_required( self, user_id: int, server_name: str, *, - default_open: bool, + required: bool, ) -> dict[str, Any]: servers = dict(self.get_custom_servers(user_id)) if server_name not in servers: raise KeyError(server_name) spec = dict(servers[server_name]) - if default_open: - spec["default_open"] = True - else: - spec.pop("default_open", None) - servers[server_name] = spec - return self.put_custom_servers(user_id, servers) + servers[server_name] = set_oauth_required_in_spec(spec, required=required) + return self._save_custom_servers(user_id, servers) + + def patch_custom_server_default_open( + self, + user_id: int, + server_name: str, + *, + default_open: bool, + ) -> dict[str, Any]: + return self.patch_custom_server(user_id, server_name, default_open=default_open) def list_instances_for_api(self, user_id: int) -> list[dict[str, Any]]: """Built-in rows + expanded custom servers (hide parent custom-mcp row).""" @@ -307,6 +394,7 @@ async def mcp_configs_for_user(self, user_id: int) -> dict[str, Any]: creds=creds, config=self._config, ) + await self.ensure_fresh_custom_servers(user_id) configs.update(self.custom_harness_configs(user_id)) return configs diff --git a/tests/integration/test_connectors_api.py b/tests/integration/test_connectors_api.py index 1f377958..4fcb61e2 100644 --- a/tests/integration/test_connectors_api.py +++ b/tests/integration/test_connectors_api.py @@ -206,7 +206,7 @@ async def test_oauth_start_public_http_notion_error_is_actionable(tmp_octop_home await bootstrap_admin(c, tmp_octop_home) auth = await auth_header(c) mocked_start = AsyncMock() - with patch("octop.api.routers.connectors.start_oauth", mocked_start): + with patch("octop.api.routers.connectors.start_oauth_for_target", mocked_start): r = await c.post( "/api/connectors/oauth/notion/start", headers={**auth, "host": "58.87.70.170"}, @@ -305,3 +305,39 @@ async def test_cli_status_available_to_non_admin(env, monkeypatch: pytest.Monkey r = await c.get("/api/connectors/feishu-cli/cli-status", headers=user_auth) assert r.status_code == 200 assert r.json()["installed"] is False + + +async def test_patch_custom_mcp_server_default_open_only(env): + c, _, auth, _ = env + put = await c.put( + "/api/connectors/custom-mcp", + headers=auth, + json={ + "servers": { + "linear": { + "transport": "streamable_http", + "url": "https://mcp.linear.app/mcp", + "enabled": True, + } + } + }, + ) + assert put.status_code == 200 + + patch = await c.patch( + "/api/connectors/custom-mcp/servers/linear", + headers=auth, + json={"default_open": True}, + ) + assert patch.status_code == 200 + servers = patch.json()["servers"] + assert servers["linear"]["default_open"] is True + assert servers["linear"]["enabled"] is True + + patch_off = await c.patch( + "/api/connectors/custom-mcp/servers/linear", + headers=auth, + json={"default_open": False}, + ) + assert patch_off.status_code == 200 + assert "default_open" not in patch_off.json()["servers"]["linear"] diff --git a/tests/unit/connectors/test_oauth_discovery.py b/tests/unit/connectors/test_oauth_discovery.py new file mode 100644 index 00000000..5658ed5b --- /dev/null +++ b/tests/unit/connectors/test_oauth_discovery.py @@ -0,0 +1,106 @@ +"""Unit tests for MCP OAuth discovery and unified oauth targets.""" + +from __future__ import annotations + +import pytest + +from octop.infra.connectors.custom_mcp import ( + build_oauth_storage, + harness_spec_for_server, + merge_preserved_oauth, + redact_server_for_api, +) +from octop.infra.connectors.oauth.discovery import ( + build_protected_resource_metadata_urls, + parse_www_authenticate_resource_metadata, +) +from octop.infra.connectors.oauth.registry import ( + oauth_state_kind_for_target, + oauth_target_requires_https, +) + + +def test_build_protected_resource_metadata_urls_path_and_root(): + urls = build_protected_resource_metadata_urls("https://example.com/mcp") + assert urls == [ + "https://example.com/.well-known/oauth-protected-resource/mcp", + "https://example.com/.well-known/oauth-protected-resource", + ] + + +def test_build_protected_resource_metadata_urls_prefers_www_auth_header(): + header_url = "https://example.com/.well-known/oauth-protected-resource/custom" + urls = build_protected_resource_metadata_urls( + "https://example.com/mcp", + www_auth_resource_metadata=header_url, + ) + assert urls[0] == header_url + + +def test_parse_www_authenticate_resource_metadata(): + raw = 'Bearer error="invalid_token", resource_metadata="https://mcp.example.com/prm"' + assert parse_www_authenticate_resource_metadata(raw) == "https://mcp.example.com/prm" + + +def test_oauth_target_helpers(): + assert oauth_state_kind_for_target({"type": "catalog", "kind": "notion"}) == "notion" + assert oauth_state_kind_for_target({"type": "custom_mcp", "server_name": "x"}) == ("custom-mcp") + assert oauth_target_requires_https({"type": "catalog", "kind": "notion"}) is True + assert oauth_target_requires_https({"type": "custom_mcp", "server_name": "x"}) is True + assert oauth_target_requires_https({"type": "catalog", "kind": "tencent-docs"}) is False + + +def test_merge_preserved_oauth_and_redaction(): + existing = { + "srv": { + "transport": "streamable_http", + "url": "https://example.com/mcp", + "oauth": {"access_token": "secret", "expires_at": 123}, + } + } + incoming = { + "srv": { + "transport": "streamable_http", + "url": "https://example.com/mcp", + "oauth": {"access_token": "client-sent"}, + } + } + merged = merge_preserved_oauth(incoming, existing) + assert merged["srv"]["oauth"]["access_token"] == "secret" + preview = redact_server_for_api(merged["srv"]) + assert preview["oauth"] == {"configured": True, "expires_at": 123} + assert "access_token" not in preview["oauth"] + + +def test_oauth_required_hint_redaction_and_merge(): + existing = { + "srv": { + "transport": "streamable_http", + "url": "https://example.com/mcp", + "oauth": {"required": True}, + } + } + incoming = {"srv": {"transport": "streamable_http", "url": "https://example.com/mcp"}} + merged = merge_preserved_oauth(incoming, existing) + assert merged["srv"]["oauth"] == {"required": True} + preview = redact_server_for_api(merged["srv"]) + assert preview["oauth"] == {"configured": False, "required": True} + + +def test_harness_spec_injects_oauth_bearer(): + spec = { + "transport": "streamable_http", + "url": "https://example.com/mcp", + "oauth": build_oauth_storage( + {"access_token": "tok123", "refresh_token": "r", "expires_at": 1}, + issuer="https://example.com", + resource="https://example.com/mcp", + ), + } + harness = harness_spec_for_server(spec) + assert harness["headers"]["Authorization"] == "Bearer tok123" + + +def test_oauth_state_kind_invalid(): + with pytest.raises(ValueError, match="unsupported oauth target"): + oauth_state_kind_for_target({"type": "unknown"}) From a6d9caafc3e97cd46c4f63d12c7c02a971468dda Mon Sep 17 00:00:00 2001 From: liukewia Date: Sat, 29 Aug 2026 09:50:35 +0800 Subject: [PATCH 12/28] ci: run desktop packaging on PRs (macOS + Windows) (#469) * chore: update octop-desktop workflow to limit PR builds and refine platform selection - Adjusted the GitHub Actions workflow to trigger builds for pull requests affecting the `desktop/` directory, limiting them to `darwin-arm64` and `darwin-amd64` platforms only. - Enhanced platform selection logic to differentiate between pull request and tag dispatch events, ensuring appropriate builds are executed based on the event type. - Updated README to reflect changes in CI behavior and clarify build processes for different scenarios. * chore: enhance octop-desktop workflow with dynamic platform selection - Introduced a new job to dynamically select platforms based on the event type, allowing for more flexible builds. - Updated the matrix configuration to utilize the output from the platform selection step, streamlining the build process. - Removed redundant build decision steps, simplifying the workflow and improving clarity. * ci: build darwin and windows on desktop PRs PR packaging should cover all macOS and Windows arches; keep Linux for tags/dispatch only. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- .github/workflows/octop-desktop.yml | 103 +++++++++++++++------------- desktop/README.md | 7 +- 2 files changed, 60 insertions(+), 50 deletions(-) diff --git a/.github/workflows/octop-desktop.yml b/.github/workflows/octop-desktop.yml index 4a453911..df079568 100644 --- a/.github/workflows/octop-desktop.yml +++ b/.github/workflows/octop-desktop.yml @@ -4,11 +4,16 @@ name: Octop Desktop Package # 打的 tag 不会触发 push workflow)。产物由本 workflow 的 release job upsert 到同一 # GitHub Release。workflow_dispatch 保留给手工补包 / 按平台重跑。 # 不要对任意分支 push 跑六平台矩阵。 +# pull_request(desktop/**)打 darwin-* + windows-*,不跑 linux。 on: push: tags: - "v*" + pull_request: + paths: + - "desktop/**" + - ".github/workflows/octop-desktop.yml" workflow_dispatch: inputs: platforms: @@ -69,9 +74,57 @@ jobs: if-no-files-found: error retention-days: 7 + # Job-level `if` cannot read `matrix.*` (GitHub 422). Filter here so only + # selected runners start — PRs → darwin-* + windows-*; dispatch honors `platforms`. + select-platforms: + name: Select platforms + if: github.event_name != 'workflow_dispatch' || github.event.inputs.attach_from_run == '' + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set.outputs.matrix }} + steps: + - id: set + env: + EVENT_NAME: ${{ github.event_name }} + PLATFORMS: ${{ github.event.inputs.platforms || 'all' }} + run: | + set -euo pipefail + python3 <<'PY' + import json + import os + + all_plats = [ + {"plat": "linux-amd64", "arch": "amd64", "os": "ubuntu-latest"}, + {"plat": "linux-arm64", "arch": "arm64", "os": "ubuntu-24.04-arm"}, + {"plat": "darwin-arm64", "arch": "arm64", "os": "macos-14"}, + {"plat": "darwin-amd64", "arch": "amd64", "os": "macos-15-intel"}, + {"plat": "windows-amd64", "arch": "amd64", "os": "windows-latest"}, + {"plat": "windows-arm64", "arch": "arm64", "os": "windows-11-arm"}, + ] + if os.environ["EVENT_NAME"] == "pull_request": + sel = "darwin-arm64,darwin-amd64,windows-amd64,windows-arm64" + else: + sel = os.environ.get("PLATFORMS", "all").lower().replace(" ", "") + known = {row["plat"] for row in all_plats} + if sel == "all": + include = all_plats + else: + wanted = {part for part in sel.split(",") if part} + unknown = wanted - known + if unknown: + raise SystemExit(f"unknown platform(s): {', '.join(sorted(unknown))}") + include = [row for row in all_plats if row["plat"] in wanted] + if not include: + raise SystemExit(f"no platforms selected: {sel}") + matrix = json.dumps({"include": include}, separators=(",", ":")) + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh: + fh.write(f"matrix={matrix}\n") + print(matrix) + PY + package: name: "${{ matrix.plat }}" - needs: frontend + needs: [frontend, select-platforms] runs-on: ${{ matrix.os }} # Matrix runners are native for each plat; pin host detection so x64 Git Bash # on windows-11-arm does not mis-classify the job as windows-amd64 cross-build. @@ -79,61 +132,25 @@ jobs: GREEN_HOST_PLAT: ${{ matrix.plat }} strategy: fail-fast: false - matrix: - include: - - plat: linux-amd64 - arch: amd64 - os: ubuntu-latest - - plat: linux-arm64 - arch: arm64 - os: ubuntu-24.04-arm - - plat: darwin-arm64 - arch: arm64 - os: macos-14 - - plat: darwin-amd64 - arch: amd64 - os: macos-15-intel - - plat: windows-amd64 - arch: amd64 - os: windows-latest - - plat: windows-arm64 - arch: arm64 - os: windows-11-arm + matrix: ${{ fromJSON(needs.select-platforms.outputs.matrix) }} defaults: run: shell: bash steps: - uses: actions/checkout@v5 - - name: Decide whether to build this platform - id: want - run: | - set -euo pipefail - sel="${{ github.event.inputs.platforms || 'all' }}" - sel="$(echo "$sel" | tr '[:upper:]' '[:lower:]' | tr -d ' ')" - plat="${{ matrix.plat }}" - if [[ "$sel" == "all" || ",$sel," == *",$plat,"* ]]; then - echo "build=true" >> "$GITHUB_OUTPUT" - else - echo "build=false" >> "$GITHUB_OUTPUT" - echo "Skipping ${plat} (selection=${sel})" - fi - - uses: actions/download-artifact@v5 - if: steps.want.outputs.build == 'true' with: name: dashboard-dist path: src/octop/dashboard - uses: astral-sh/setup-uv@v6 - if: steps.want.outputs.build == 'true' with: enable-cache: true python-version: "3.12" # GitHub Cache outages must not fail the build — PBS download is cheap enough. - name: Cache python-build-standalone downloads - if: steps.want.outputs.build == 'true' continue-on-error: true uses: actions/cache@v5 with: @@ -141,13 +158,11 @@ jobs: key: pbs-${{ env.PBS_TAG }}-${{ env.PBS_PY }}-${{ matrix.plat }} - name: Bootstrap portable CPython - if: steps.want.outputs.build == 'true' env: GREEN_HOST_PLAT: ${{ matrix.plat }} run: bash desktop/portable/bootstrap-runtime.sh "${{ matrix.plat }}" - name: Assemble green zip - if: steps.want.outputs.build == 'true' env: GREEN_HOST_PLAT: ${{ matrix.plat }} run: | @@ -156,7 +171,6 @@ jobs: bash desktop/portable/package.sh "${{ matrix.plat }}" - name: Smoke import (native host only) - if: steps.want.outputs.build == 'true' run: | set -euo pipefail staging="desktop/portable/release/Octop-${{ matrix.plat }}" @@ -192,23 +206,20 @@ jobs: fi - uses: actions/setup-go@v6 - if: steps.want.outputs.build == 'true' with: go-version: "1.25.x" cache-dependency-path: desktop/src/go.sum - name: Install Linux desktop build dependencies - if: steps.want.outputs.build == 'true' && runner.os == 'Linux' + if: runner.os == 'Linux' run: | sudo apt-get update sudo apt-get install -y libgtk-4-dev libwebkitgtk-6.0-dev - name: Install Wails v3 CLI - if: steps.want.outputs.build == 'true' run: go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-beta.13 - name: Package desktop app with bundled portable runtime - if: steps.want.outputs.build == 'true' working-directory: desktop/src run: >- wails3 task package @@ -220,7 +231,6 @@ jobs: # (Octop-.zip containing another Octop-.zip). Affects every plat. # With archive:false, artifact name is the filename (name: is ignored). - uses: actions/upload-artifact@v7 - if: steps.want.outputs.build == 'true' with: path: desktop/portable/release/Octop-${{ matrix.plat }}.zip archive: false @@ -228,7 +238,6 @@ jobs: retention-days: 14 - name: Upload bundled desktop package - if: steps.want.outputs.build == 'true' uses: actions/upload-artifact@v7 with: path: desktop/src/bin/Octop-Desktop-${{ matrix.plat }}.* diff --git a/desktop/README.md b/desktop/README.md index b8f2873f..3775409e 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -25,9 +25,10 @@ From repo root: make -f desktop/portable/Makefile green ``` -CI: `.github/workflows/octop-portable.yml` builds all six native platform/arch -variants. Each job first creates the green zip and then packages the matching -Wails application. +CI: `.github/workflows/octop-desktop.yml` builds native platform/arch variants. +`v*` tags and `workflow_dispatch` (platforms=`all`) run all six; pull requests +that touch `desktop/` build `darwin-*` and `windows-*` (amd64 + arm64). Each job +first creates the green zip and then packages the matching Wails application. ## Build a complete desktop release From 1fc14af60d287925e24a4be6e78e01bf3950dce7 Mon Sep 17 00:00:00 2001 From: Panda <163635413+Pandakingxbc@users.noreply.github.com> Date: Sat, 29 Aug 2026 10:51:48 +0900 Subject: [PATCH 13/28] fix(channels): filter malformed thinking output (#470) Co-authored-by: leoxyang --- src/octop/infra/gateway/gateway.py | 3 +- .../infra/gateway/process/response_mode.py | 4 +- src/octop/infra/utils/llm_text.py | 28 +++++++++++-- tests/unit/api/test_chat_polish.py | 10 +++++ tests/unit/gateway/test_gateway_push.py | 42 +++++++++++++++++++ tests/unit/gateway/test_response_mode.py | 21 ++++++++++ 6 files changed, 103 insertions(+), 5 deletions(-) diff --git a/src/octop/infra/gateway/gateway.py b/src/octop/infra/gateway/gateway.py index 00bc6ac9..be37465d 100644 --- a/src/octop/infra/gateway/gateway.py +++ b/src/octop/infra/gateway/gateway.py @@ -34,6 +34,7 @@ WebSocketChannel, WebSocketHub, ) +from octop.infra.utils.llm_text import strip_thinking from octop.infra.utils.locale import DEFAULT_LOCALE, Locale if TYPE_CHECKING: @@ -452,7 +453,7 @@ async def push_text_from_session( async for chunk in self._agent_manager.stream(agent_id, request): if chunk.get("type") in ("token", "delta"): parts.append(str(chunk.get("content") or chunk.get("text") or "")) - outbound = "".join(parts).strip() or "(empty)" + outbound = strip_thinking("".join(parts)) or "(empty)" if virtual_stream: self._bump_dashboard_session(session, session_key, text) diff --git a/src/octop/infra/gateway/process/response_mode.py b/src/octop/infra/gateway/process/response_mode.py index 93cc79a0..13f83bc5 100644 --- a/src/octop/infra/gateway/process/response_mode.py +++ b/src/octop/infra/gateway/process/response_mode.py @@ -14,6 +14,8 @@ TextContent, ) +from octop.infra.utils.llm_text import strip_thinking + ChannelResponseMode = Literal["invoke", "stream"] DEFAULT_CHANNEL_RESPONSE_MODE: ChannelResponseMode = "invoke" @@ -81,7 +83,7 @@ async def collapse_to_invoke_response( if event.type == MessageEventType.COMPLETED: content: list[ContentPart] = [] - final_text = text_buffer.strip() + final_text = strip_thinking(text_buffer) if final_text: content.append(TextContent(text=final_text)) content.extend(media_buffer) diff --git a/src/octop/infra/utils/llm_text.py b/src/octop/infra/utils/llm_text.py index 9f0630d7..dd34c1f9 100644 --- a/src/octop/infra/utils/llm_text.py +++ b/src/octop/infra/utils/llm_text.py @@ -11,14 +11,36 @@ from typing import Any _THINKING_RE = re.compile( - r"[\s\S]*?\s*", + r"<(?:think|thinking)>[\s\S]*?\s*", re.IGNORECASE, ) +_THINKING_OPEN_RE = re.compile(r"<(?:think|thinking)>", re.IGNORECASE) +_THINKING_CLOSE_RE = re.compile(r"", re.IGNORECASE) def strip_thinking(text: str) -> str: - """Remove ``...`` blocks from model output.""" - return _THINKING_RE.sub("", text).strip() + """Remove tagged or malformed thinking prefixes from model output. + + Some OpenAI-compatible model routers return reasoning in ordinary + ``content`` and only emit the closing ```` marker. Treat a + closing marker that appears before any opening marker as the boundary + between the hidden prefix and the user-visible answer. + """ + cleaned = _THINKING_RE.sub("", text) + + while True: + close_match = _THINKING_CLOSE_RE.search(cleaned) + if close_match is None: + break + open_match = _THINKING_OPEN_RE.search(cleaned) + if open_match is not None and open_match.start() < close_match.start(): + break + cleaned = cleaned[close_match.end() :] + + open_match = _THINKING_OPEN_RE.search(cleaned) + if open_match is not None: + cleaned = cleaned[: open_match.start()] + return cleaned.strip() def llm_text_content(result: Any) -> str: diff --git a/tests/unit/api/test_chat_polish.py b/tests/unit/api/test_chat_polish.py index a1a32833..9f1e0f47 100644 --- a/tests/unit/api/test_chat_polish.py +++ b/tests/unit/api/test_chat_polish.py @@ -107,6 +107,16 @@ def test_strip_thinking_removes_redacted_block() -> None: assert _strip_thinking(raw) == "Polished prompt" +def test_strip_thinking_removes_orphan_closing_prefix() -> None: + raw = "internal reasoning without an opening tag\nVisible answer" + assert _strip_thinking(raw) == "Visible answer" + + +def test_strip_thinking_removes_unclosed_thinking_suffix() -> None: + raw = "Visible answer\ntruncated internal reasoning" + assert _strip_thinking(raw) == "Visible answer" + + def test_llm_text_content_strips_thinking_from_string_message() -> None: class Msg: content = "planFinal text" diff --git a/tests/unit/gateway/test_gateway_push.py b/tests/unit/gateway/test_gateway_push.py index 67c2487d..d3dcb794 100644 --- a/tests/unit/gateway/test_gateway_push.py +++ b/tests/unit/gateway/test_gateway_push.py @@ -229,6 +229,48 @@ async def stream_with_token(_agent_id, _request): assert gateway._channel_manager.push_text.await_args.args[2] == "AI reply" +@pytest.mark.asyncio +async def test_push_text_from_session_cron_agent_strips_orphan_thinking_prefix( + gateway: Gateway, +) -> None: + sk = ThreadRegistry.make_key( + agent_id="a1", + channel_type="weixin", + channel_subject_id="wx_1", + ) + gateway.thread_registry._threads.insert( + thread_id="thr_weixin_cron", + agent_id="a1", + user_id=1, + channel_type="weixin", + session_key=sk, + ) + gateway.thread_registry._sessions.upsert( + session_key=sk, + agent_id="a1", + user_id=1, + channel_type="weixin", + chat_type="dm", + thread_id="thr_weixin_cron", + channel_subject_id="wx_1", + channel_chat_type="dm", + channel_metadata={"channel_type": "weixin"}, + channel_id="ch-1", + ) + + async def stream_with_thinking_leak(_agent_id, _request): + yield {"type": "token", "content": "internal reasoning"} + yield {"type": "token", "content": ""} + yield {"type": "token", "content": "最终学习内容"} + + gateway._agent_manager.stream = stream_with_thinking_leak + + await gateway.push_text_from_session("a1", sk, "run agent", task_type="agent") + + gateway._channel_manager.push_text.assert_awaited_once() + assert gateway._channel_manager.push_text.await_args.args[2] == "最终学习内容" + + @pytest.mark.asyncio async def test_push_text_from_session_dashboard_agent_pushes_ws(gateway: Gateway) -> None: sk = ThreadRegistry.dashboard_key(agent_id="a1", user_id=1) diff --git a/tests/unit/gateway/test_response_mode.py b/tests/unit/gateway/test_response_mode.py index 7bbe1777..0aee5044 100644 --- a/tests/unit/gateway/test_response_mode.py +++ b/tests/unit/gateway/test_response_mode.py @@ -49,6 +49,27 @@ async def test_invoke_discards_progress_before_tool_and_emits_final_once() -> No assert text.text == "这是最终答案。" +@pytest.mark.asyncio +async def test_invoke_strips_orphan_thinking_prefix_from_final_text() -> None: + source = _events( + MessageEvent.delta("Let me inspect another source. "), + MessageEvent.delta("This is internal reasoning."), + MessageEvent.delta(""), + MessageEvent.delta("【每日指南学习】最终内容"), + MessageEvent.completed(), + ) + + result = [event async for event in collapse_to_invoke_response(source)] + + assert [event.type for event in result] == [ + MessageEventType.MESSAGE, + MessageEventType.COMPLETED, + ] + text = result[0].content[0] + assert isinstance(text, TextContent) + assert text.text == "【每日指南学习】最终内容" + + @pytest.mark.asyncio async def test_invoke_preserves_tool_media_with_final_text() -> None: attachment = FileContent(filename="report.pdf", data="cGRm") From b984ba84a076bb883dfca496f8674d34399965c8 Mon Sep 17 00:00:00 2001 From: jubaoliang Date: Sat, 29 Aug 2026 10:44:00 +0800 Subject: [PATCH 14/28] chore: sync develop onto main after accidental main merges (#473) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(desktop): Octop 桌面客户端 (#462) * feat: add multi-platform green portable packaging Ship relocatable Octop-.zip via scripts/green (portable CPython, frozen uv.lock deps, Windows pywin32 DLL fix) and octop-portable.yml. No OOB overlay or product trimming; first run uses upstream setup. Co-authored-by: Harry * feat(desktop): Wails v3 shell with green portable under ~/.octop Move green packaging to desktop/portable, add a Wails v3 client that downloads Octop-.zip, spawns launch.py with OCTOP_HOME=~/.octop, and exposes locale/theme/autostart/tray/macOS caffeinate settings. Green upgrades install into packages via OCTOP_GREEN_PACKAGES. Co-authored-by: Harry * fix(desktop): add Wails v3 Taskfile and build/config.yml wails3 build/dev look for Taskfile.yml and build/config.yml in desktop/src. Un-ignore that tree from the repo-wide build/ gitignore. Co-authored-by: Harry * 初始化桌面客户端 * add drag and toggle * add prevent sleep * update * change dock icon * clean package/useless files * add Attach zips to GitHub Release * pack file into windows bin-file * Packages a production app * chanage workflow name * chanage workflow name * linux package in single ifle * linux package in single ifle * 在浏览器中打开_blank 的链接 * upgrade python to 3.12 --------- Co-authored-by: Cursor Agent Co-authored-by: Harry Co-authored-by: 杨树海 * ci: cascade desktop package on release and stop all-branch builds (#463) Auto Tag dispatches Octop Desktop with Release/Docker. Restrict octop-desktop.yml to v* tags + workflow_dispatch only. Co-authored-by: jubaoliang * 优化客户端升级逻辑,支持页面自动重载 (#468) Co-authored-by: 杨树海 --------- Co-authored-by: Harry <56063441+forcemeter@users.noreply.github.com> Co-authored-by: Cursor Agent Co-authored-by: Harry Co-authored-by: 杨树海 Co-authored-by: jubaoliang --- dashboard/src/api/modules/update.ts | 2 + .../AdvancedSettings/UpdateConfig.tsx | 12 ++- desktop/package-dev.sh | 73 +++++++++++++++++++ src/octop/api/routers/update.py | 20 +++++ tests/integration/test_update_api.py | 1 + tests/unit/api/test_update_router.py | 26 +++++++ 6 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 desktop/package-dev.sh diff --git a/dashboard/src/api/modules/update.ts b/dashboard/src/api/modules/update.ts index b2dcff9b..74d98d19 100644 --- a/dashboard/src/api/modules/update.ts +++ b/dashboard/src/api/modules/update.ts @@ -7,6 +7,8 @@ export interface UpdateStatus { is_editable: boolean; /** Non-null when the process was launched via `octop service start` (systemd or launchd). */ service_mode: "systemd" | "launchd" | null; + /** True when Octop is spawned by the Wails desktop shell (or ``OCTOP_DESKTOP=1``). */ + desktop?: boolean; error: string | null; last_check_time: string | null; /** Markdown changelog for latest_version, null if not available. */ diff --git a/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.tsx b/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.tsx index 9bab4e7b..f15cf167 100644 --- a/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.tsx +++ b/dashboard/src/pages/Settings/AdvancedSettings/UpdateConfig.tsx @@ -150,9 +150,10 @@ export default function UpdateConfig() { const [checking, setChecking] = useState(false); const [upgrading, setUpgrading] = useState(false); const [progress, setProgress] = useState(null); - const { restartPhase, isRestarting, requestRestart } = + const { restartPhase, isRestarting, requestRestart, executeRestart } = useServiceRestartContext(); const pollTimerRef = useRef | null>(null); + const autoRestartedRef = useRef(false); useEffect(() => { updateApi @@ -167,6 +168,13 @@ export default function UpdateConfig() { }; }, []); + useEffect(() => { + if (progress?.status !== "complete" || !status?.desktop) return; + if (restartPhase !== "idle" || autoRestartedRef.current) return; + autoRestartedRef.current = true; + void executeRestart(); + }, [progress?.status, status?.desktop, restartPhase, executeRestart]); + const handleCheck = useCallback(async () => { setChecking(true); try { @@ -207,6 +215,7 @@ export default function UpdateConfig() { const handleUpgrade = useCallback(async () => { setUpgrading(true); setProgress(null); + autoRestartedRef.current = false; try { const started = await updateApi.triggerUpgrade(); setProgress({ @@ -394,6 +403,7 @@ export default function UpdateConfig() {
{restartPhase === "idle" && + !status?.desktop && (isServiceMode ? (
diff --git a/desktop/package-dev.sh b/desktop/package-dev.sh new file mode 100644 index 00000000..e9f1f135 --- /dev/null +++ b/desktop/package-dev.sh @@ -0,0 +1,73 @@ +#!/bin/bash +# Desktop shell against a source Octop (hot reload). Not ~/.octop/portable. +# Starts Octop in this shell and stops it when wails3 / this script exits. +set -euo pipefail + +REPO="$(cd "$(dirname "$0")/.." && pwd)" +PORT="${OCTOP_PORT:-8088}" +URL="${OCTOP_DESKTOP_URL:-http://127.0.0.1:${PORT}}" +HEALTH="${URL%/}/api/health" +WAILS3_PKG="github.com/wailsapp/wails/v3/cmd/wails3@latest" + +missing=0 +if ! command -v go >/dev/null 2>&1; then + missing=1 + echo "go is not installed (need Go 1.25+)." >&2 + echo " macOS: brew install go" >&2 + echo " Linux: https://go.dev/dl/" >&2 + echo " then: export PATH=\"\$(go env GOPATH)/bin:\$PATH\"" >&2 +fi +if ! command -v wails3 >/dev/null 2>&1; then + missing=1 + echo "wails3 is not installed." >&2 + echo " go install ${WAILS3_PKG}" >&2 + echo " export PATH=\"\$(go env GOPATH)/bin:\$PATH\"" >&2 +fi +if [[ "$missing" -eq 1 ]]; then + exit 1 +fi + +octop_pid="" +cleaned=0 + +cleanup() { + [[ "$cleaned" -eq 1 ]] && return + cleaned=1 + trap - EXIT INT TERM + if [[ -n "$octop_pid" ]] && kill -0 "$octop_pid" 2>/dev/null; then + kill -TERM "$octop_pid" 2>/dev/null || true + wait "$octop_pid" 2>/dev/null || true + fi +} +trap cleanup EXIT INT TERM + +if curl -sf -o /dev/null --max-time 1 "$HEALTH"; then + echo "Octop already listening at ${URL}; stop it first so this script can own the process." >&2 + exit 1 +fi + +echo "starting octop --reload on port ${PORT}" +( + cd "$REPO" + exec env OCTOP_DESKTOP=1 uv run octop run --reload --host 127.0.0.1 --port "$PORT" +) & +octop_pid=$! + +for _ in $(seq 1 60); do + if curl -sf -o /dev/null --max-time 1 "$HEALTH"; then + break + fi + if ! kill -0 "$octop_pid" 2>/dev/null; then + echo "octop exited before becoming healthy" >&2 + exit 1 + fi + sleep 0.5 +done + +if ! curl -sf -o /dev/null --max-time 1 "$HEALTH"; then + echo "octop did not become healthy at ${URL}" >&2 + exit 1 +fi + +cd "$REPO/desktop/src" +OCTOP_DESKTOP_URL="$URL" wails3 dev diff --git a/src/octop/api/routers/update.py b/src/octop/api/routers/update.py index 3f49f4a2..3daf573b 100644 --- a/src/octop/api/routers/update.py +++ b/src/octop/api/routers/update.py @@ -4,6 +4,8 @@ import asyncio import logging +import os +import sys import time from typing import Any @@ -25,6 +27,7 @@ fetch_pypi_info, get_editable_path, get_local_version, + green_packages_dir, is_newer, parse_changelog_for_version, run_upgrade, @@ -58,6 +61,7 @@ def _build_status( "has_update": has_update, "is_editable": get_editable_path() is not None, "service_mode": detect_service_mode(), + "desktop": _is_desktop_process(), "error": error, "last_check_time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "release_notes": release_notes if has_update else None, @@ -146,6 +150,19 @@ async def upgrade_progress( } +def _is_desktop_process() -> bool: + if green_packages_dir() is not None: + return True + raw = (os.environ.get("OCTOP_DESKTOP") or "").strip().lower() + return raw in {"1", "true", "yes", "on"} + + +def _restart_desktop_process() -> None: + time.sleep(0.4) + argv = list(getattr(sys, "orig_argv", None) or [sys.executable, *sys.argv]) + os.execv(argv[0], argv) + + def _restart_service_task(runtime: ServiceRuntime) -> None: try: restart_service(runtime) @@ -158,6 +175,9 @@ async def restart_service_endpoint( background_tasks: BackgroundTasks, _: Any = Depends(require_permission("update")), ) -> dict[str, Any]: + if _is_desktop_process(): + background_tasks.add_task(_restart_desktop_process) + return {"status": "restarting", "service_mode": "desktop"} mode = detect_service_mode() if mode is None: raise OctopError( diff --git a/tests/integration/test_update_api.py b/tests/integration/test_update_api.py index 163299f6..3fb9c72c 100644 --- a/tests/integration/test_update_api.py +++ b/tests/integration/test_update_api.py @@ -16,6 +16,7 @@ async def test_update_status_shape(env_admin_client: Any) -> None: "has_update", "is_editable", "service_mode", + "desktop", "error", "last_check_time", "release_notes", diff --git a/tests/unit/api/test_update_router.py b/tests/unit/api/test_update_router.py index bcb6f54a..f62a3077 100644 --- a/tests/unit/api/test_update_router.py +++ b/tests/unit/api/test_update_router.py @@ -68,6 +68,7 @@ async def test_restart_endpoint_schedules_background_restart( )() monkeypatch.setattr(update_router, "detect_service_mode", lambda: "systemd") + monkeypatch.setattr(update_router, "_is_desktop_process", lambda: False) monkeypatch.setattr(update_router, "build_runtime", lambda mode: fake_runtime) monkeypatch.setattr(update_router, "is_service_installed", lambda *_, **__: True) monkeypatch.setattr( @@ -102,6 +103,7 @@ async def test_restart_endpoint_rejects_when_service_not_installed( )() monkeypatch.setattr(update_router, "detect_service_mode", lambda: "systemd") + monkeypatch.setattr(update_router, "_is_desktop_process", lambda: False) monkeypatch.setattr(update_router, "build_runtime", lambda mode: fake_runtime) monkeypatch.setattr(update_router, "is_service_installed", lambda *_, **__: False) monkeypatch.setattr( @@ -121,6 +123,30 @@ async def test_restart_endpoint_rejects_when_service_not_installed( assert bg.tasks == [] +@pytest.mark.asyncio +async def test_restart_endpoint_desktop_schedules_process_exec( + monkeypatch: pytest.MonkeyPatch, +) -> None: + called: list[bool] = [] + monkeypatch.setattr(update_router, "_is_desktop_process", lambda: True) + monkeypatch.setattr( + update_router, + "_restart_desktop_process", + lambda: called.append(True), + ) + + from fastapi import BackgroundTasks + + bg = BackgroundTasks() + result = await update_router.restart_service_endpoint(bg, _=None) + + assert result == {"status": "restarting", "service_mode": "desktop"} + assert called == [] + for task in bg.tasks: + await task() + assert called == [True] + + @pytest.mark.asyncio async def test_upgrade_worker_records_mirror_errors(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( From 17effe430685376c068904877444f18393c33fb0 Mon Sep 17 00:00:00 2001 From: jubaoliang Date: Sat, 29 Aug 2026 16:46:25 +0800 Subject: [PATCH 15/28] fix(connectors): mark custom MCP OAuth reauth when refresh fails (#474) Expired tokens that cannot be refreshed now prompt the dashboard to re-authorize. Cache OAuth discovery for an hour, and localize callback and start-error copy. --- CHANGELOG.md | 1 + docs/api.md | 11 +- src/octop/api/routers/connectors.py | 55 ++++++-- src/octop/i18n/en.json | 10 ++ src/octop/i18n/zh.json | 10 ++ src/octop/infra/connectors/custom_mcp.py | 14 +- src/octop/infra/connectors/oauth/discovery.py | 26 +++- src/octop/infra/connectors/service.py | 24 +++- tests/integration/test_connectors_api.py | 125 +++++++++++++++++- tests/unit/connectors/test_custom_mcp.py | 87 ++++++++++++ tests/unit/connectors/test_oauth_discovery.py | 54 ++++++++ 11 files changed, 399 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da6347a7..db6e08c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ ### 变更 +- 自定义 MCP OAuth:token 刷新失败且已过期时自动标记需重新授权;OAuth 发现结果进程内缓存;callback 页与 OAuth 启动错误 i18n 化 - Docker / FnOS 镜像对外统一为 `ghcr.io/tencentcloud/octop`(仍同步推送 Docker Hub;移除 vanilla 镜像标签) - FnOS FPK 在 Release 成功后自动构建:复用 GHCR 镜像与 Release wheel,不再重复编镜像 - 正式发版时 Auto Tag 同步触发桌面客户端打包,产物挂到同一 `v*` GitHub Release(不再对任意分支 push 跑六平台矩阵) diff --git a/docs/api.md b/docs/api.md index af26b367..2afcbcad 100644 --- a/docs/api.md +++ b/docs/api.md @@ -314,10 +314,19 @@ for non-`/` paths. | `GET` | `/connectors/auth/{kind}/info` | user | auth flow info | | `GET` | `/connectors/auth/{kind}/authorize-url` | user | build the authorize URL | | `POST` | `/connectors/auth/{kind}/exchange-code` | user | exchange auth code | -| `POST` | `/connectors/oauth/{kind}/start` | user | start an OAuth flow | +| `POST` | `/connectors/oauth/start` | user | start OAuth (catalog or custom MCP via `target`) | +| `POST` | `/connectors/oauth/{kind}/start` | user | legacy catalog OAuth start | +| `PUT` | `/connectors/custom-mcp` | user | save custom MCP server map | +| `PATCH` | `/connectors/custom-mcp/servers/{name}` | user | patch `enabled` / `default_open` on one server | +| `POST` | `/connectors/custom-mcp/test` | user | probe a custom MCP server (inline spec or saved name) | | `GET` | `/connectors/oauth/callback` | public | OAuth redirect target | | `GET` | `/connectors/oauth/pending/{state_id}` | user | poll the OAuth result | +Custom MCP OAuth (streamable HTTP, public HTTPS URL only): Octop discovers the authorization +server from the MCP URL (401 / RFC 9728 protected-resource metadata), requires dynamic client +registration (DCR), stores encrypted tokens in the custom MCP spec, and injects `Authorization: +Bearer` when loading tools. Loopback MCP URLs do not use remote OAuth discovery. + ## Internal MCP (harness agents) | Method | Path | Auth | Notes | diff --git a/src/octop/api/routers/connectors.py b/src/octop/api/routers/connectors.py index 8e43901a..634199ce 100644 --- a/src/octop/api/routers/connectors.py +++ b/src/octop/api/routers/connectors.py @@ -15,6 +15,7 @@ from octop.api.common.public_base import resolve_public_base from octop.api.deps import current_user, get_server, require_permission +from octop.i18n import tr from octop.infra.connectors.builder import ( mcp_server_name, normalize_weiyun_mcp_token, @@ -65,6 +66,7 @@ from octop.infra.connectors.service import ConnectorService from octop.infra.db.repos.connectors import ConnectorRepo from octop.infra.errors import ErrorCode, OctopError +from octop.infra.utils.locale import resolve_request_locale from octop.infra.utils.ulid import new_ulid logger = logging.getLogger(__name__) @@ -158,6 +160,18 @@ class CustomMcpTestBody(BaseModel): server: dict[str, Any] | None = None +def _oauth_callback_html( + request: Request, + message_key: str, + *, + status_code: int = 200, + **fmt: Any, +) -> HTMLResponse: + locale = resolve_request_locale(request) + message = tr(f"connector.oauth.{message_key}", locale, **fmt) + return HTMLResponse(f"{message}", status_code=status_code) + + def _resolve_custom_mcp_url(svc: ConnectorService, user_id: int, server_name: str) -> str: saved = svc.get_custom_servers(user_id) raw = saved.get(server_name) @@ -229,10 +243,7 @@ async def _begin_oauth_flow( raise OctopError(ErrorCode.CONNECTOR_INVALID_CREDENTIALS, str(exc)) from exc except Exception as exc: logger.exception("oauth start failed for target %s", target) - raise OctopError( - ErrorCode.CONNECTOR_INVALID_CREDENTIALS, - f"无法启动 OAuth: {exc}", - ) from exc + raise OctopError(ErrorCode.CONNECTOR_INVALID_CREDENTIALS, str(exc)) from exc server.services.repos.connector_repo.create_oauth_state( state_id=state_id, @@ -1116,14 +1127,27 @@ async def oauth_callback( ) -> HTMLResponse: """OAuth redirect target. Exchanges the code and stores credentials. No JWT required.""" if error: - return HTMLResponse(f"授权失败: {error}", status_code=400) + return _oauth_callback_html( + request, + "callback_auth_failed", + status_code=400, + error=error, + ) if not code or not state: - return HTMLResponse("缺少 code 或 state", status_code=400) + return _oauth_callback_html( + request, + "callback_missing_params", + status_code=400, + ) repo = server.services.repos.connector_repo row = repo.consume_oauth_state(state) if row is None: - return HTMLResponse("无效或过期的 state", status_code=400) + return _oauth_callback_html( + request, + "callback_invalid_state", + status_code=400, + ) base = resolve_public_base(request) redirect_uri = f"{base}/api/connectors/oauth/callback" @@ -1145,7 +1169,12 @@ async def oauth_callback( delete_oauth_ctx(server.services.settings_repo, row.state_id) except Exception as exc: logger.exception("oauth callback failed for %s", row.kind) - return HTMLResponse(f"Token 交换失败: {exc}", status_code=400) + return _oauth_callback_html( + request, + "callback_token_exchange_failed", + status_code=400, + detail=str(exc), + ) pending_payload: dict[str, Any] = { "user_id": row.user_id, @@ -1170,9 +1199,11 @@ async def oauth_callback( pending_payload["applied"] = True except Exception as exc: logger.exception("custom MCP oauth apply failed for %s", server_name) - return HTMLResponse( - f"保存授权失败: {exc}", + return _oauth_callback_html( + request, + "callback_save_failed", status_code=400, + detail=str(exc), ) # Store tokens in a short-lived settings key for frontend pickup, or auto-create instance. @@ -1182,6 +1213,8 @@ async def oauth_callback( json.dumps(pending_payload), ) redirect = row.redirect_after or "/connectors" + locale = resolve_request_locale(request) + success_message = tr("connector.oauth.callback_success", locale) html = f""" -

授权完成,可关闭此窗口。

+

{success_message}

""" return HTMLResponse(html) diff --git a/src/octop/i18n/en.json b/src/octop/i18n/en.json index 96ec5fad..a5ee2fc0 100644 --- a/src/octop/i18n/en.json +++ b/src/octop/i18n/en.json @@ -280,6 +280,16 @@ } } }, + "connector": { + "oauth": { + "callback_auth_failed": "Authorization failed: {error}", + "callback_missing_params": "Missing authorization code or state.", + "callback_invalid_state": "Invalid or expired authorization state.", + "callback_token_exchange_failed": "Token exchange failed: {detail}", + "callback_save_failed": "Failed to save authorization: {detail}", + "callback_success": "Authorization complete. You may close this window." + } + }, "errors": { "AUTH_FAILED": "Authentication failed.", "TOKEN_EXPIRED": "Session expired. Please sign in again.", diff --git a/src/octop/i18n/zh.json b/src/octop/i18n/zh.json index e8057f13..fc52b85b 100644 --- a/src/octop/i18n/zh.json +++ b/src/octop/i18n/zh.json @@ -280,6 +280,16 @@ } } }, + "connector": { + "oauth": { + "callback_auth_failed": "授权失败:{error}", + "callback_missing_params": "缺少 code 或 state。", + "callback_invalid_state": "无效或过期的 state。", + "callback_token_exchange_failed": "Token 交换失败:{detail}", + "callback_save_failed": "保存授权失败:{detail}", + "callback_success": "授权完成,可关闭此窗口。" + } + }, "errors": { "AUTH_FAILED": "认证失败。", "TOKEN_EXPIRED": "登录已过期,请重新登录。", diff --git a/src/octop/infra/connectors/custom_mcp.py b/src/octop/infra/connectors/custom_mcp.py index 90320e3c..9485aba5 100644 --- a/src/octop/infra/connectors/custom_mcp.py +++ b/src/octop/infra/connectors/custom_mcp.py @@ -168,8 +168,11 @@ def normalize_server_spec(name: str, raw: Any) -> dict[str, Any]: spec["env"] = env oauth = raw.get(_OAUTH_KEY) - if isinstance(oauth, dict) and str(oauth.get("access_token") or "").strip(): - spec[_OAUTH_KEY] = dict(oauth) + if isinstance(oauth, dict): + if str(oauth.get("access_token") or "").strip(): + spec[_OAUTH_KEY] = dict(oauth) + elif oauth.get("required") is True: + spec[_OAUTH_KEY] = {"required": True} return spec @@ -223,6 +226,13 @@ def oauth_required(spec: dict[str, Any]) -> bool: return oauth.get("required") is True +def mark_oauth_reauth_required(spec: dict[str, Any]) -> dict[str, Any]: + """Drop stale OAuth tokens and flag the dashboard to prompt re-authorization.""" + out = {k: v for k, v in spec.items() if k != _OAUTH_KEY} + out[_OAUTH_KEY] = {"required": True} + return out + + def set_oauth_required_in_spec(spec: dict[str, Any], *, required: bool) -> dict[str, Any]: """Persist or clear the dashboard hint that OAuth is needed (no tokens).""" out = dict(spec) diff --git a/src/octop/infra/connectors/oauth/discovery.py b/src/octop/infra/connectors/oauth/discovery.py index b6ac0ff5..78b7efdd 100644 --- a/src/octop/infra/connectors/oauth/discovery.py +++ b/src/octop/infra/connectors/oauth/discovery.py @@ -3,6 +3,7 @@ from __future__ import annotations import re +import time from typing import Any from urllib.parse import urljoin, urlparse @@ -14,6 +15,14 @@ re.IGNORECASE, ) +_DISCOVERY_CACHE_TTL_SEC = 3600.0 +_discovery_cache: dict[str, tuple[float, dict[str, Any]]] = {} + + +def clear_oauth_discovery_cache() -> None: + """Clear the in-process OAuth discovery cache (tests only).""" + _discovery_cache.clear() + def _mcp_url_host(url: str) -> str: return (urlparse(url).hostname or "").lower().rstrip(".") @@ -126,7 +135,11 @@ def _normalize_issuer(raw: str, *, mcp_url: str) -> str: return urljoin(base, text).rstrip("/") -async def discover_oauth_from_mcp_url(mcp_url: str) -> dict[str, Any]: +async def discover_oauth_from_mcp_url( + mcp_url: str, + *, + use_cache: bool = True, +) -> dict[str, Any]: """Return OAuth discovery details for a remote MCP HTTP endpoint. Result shape:: @@ -141,6 +154,17 @@ async def discover_oauth_from_mcp_url(mcp_url: str) -> dict[str, Any]: } """ url = mcp_url.strip() + if use_cache: + cached = _discovery_cache.get(url) + if cached is not None and cached[0] > time.time(): + return cached[1] + result = await _discover_oauth_from_mcp_url_uncached(url) + if use_cache and result.get("available"): + _discovery_cache[url] = (time.time() + _DISCOVERY_CACHE_TTL_SEC, result) + return result + + +async def _discover_oauth_from_mcp_url_uncached(url: str) -> dict[str, Any]: if not url: return {"available": False, "error": "empty mcp url"} if _is_loopback_mcp_url(url): diff --git a/src/octop/infra/connectors/service.py b/src/octop/infra/connectors/service.py index b0128ce7..a012e7e4 100644 --- a/src/octop/infra/connectors/service.py +++ b/src/octop/infra/connectors/service.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import logging import time from pathlib import Path from typing import Any @@ -19,6 +20,7 @@ expand_custom_instances, extract_servers, is_custom_mcp_kind, + mark_oauth_reauth_required, merge_preserved_oauth, oauth_configured, oauth_tokens_from_spec, @@ -42,6 +44,10 @@ from octop.infra.utils.paths import PathLayout from octop.infra.utils.ulid import new_ulid +logger = logging.getLogger(__name__) + +_OAUTH_REFRESH_SKEW_SEC = 120 + def list_user_connector_instances( repo: ConnectorRepo, @@ -213,15 +219,29 @@ async def ensure_fresh_custom_servers(self, user_id: int) -> dict[str, Any]: oauth = oauth_tokens_from_spec(raw) if not oauth_configured(raw): continue - expires_at = oauth.get("expires_at") + expires_at_raw = oauth.get("expires_at") + expires_at = int(expires_at_raw) if expires_at_raw is not None else None refresh = str(oauth.get("refresh_token") or "").strip() + if expires_at is not None and expires_at <= now and not refresh: + servers[name] = mark_oauth_reauth_required(dict(raw)) + changed = True + continue if not refresh: continue - if expires_at and int(expires_at) > now + 120: + if expires_at is not None and expires_at > now + _OAUTH_REFRESH_SKEW_SEC: continue try: refreshed = await refresh_custom_mcp_oauth(oauth) except Exception: + logger.warning( + "custom MCP oauth refresh failed for %r (user_id=%s)", + name, + user_id, + exc_info=True, + ) + if expires_at is not None and expires_at <= now: + servers[name] = mark_oauth_reauth_required(dict(raw)) + changed = True continue spec = dict(raw) spec["oauth"] = refreshed diff --git a/tests/integration/test_connectors_api.py b/tests/integration/test_connectors_api.py index 4fcb61e2..9bffb107 100644 --- a/tests/integration/test_connectors_api.py +++ b/tests/integration/test_connectors_api.py @@ -7,8 +7,11 @@ import pytest +from octop.infra.connectors.custom_mcp import CUSTOM_MCP_KIND +from octop.infra.connectors.oauth.registry import save_oauth_ctx +from octop.infra.utils.ulid import new_ulid from tests.support.app import octop_client, write_octop_config -from tests.support.auth import auth_header, bootstrap_admin, create_user +from tests.support.auth import auth_header, bootstrap_admin, create_user, resolve_user_id from tests.support.http import ws_chat_turn @@ -341,3 +344,123 @@ async def test_patch_custom_mcp_server_default_open_only(env): ) assert patch_off.status_code == 200 assert "default_open" not in patch_off.json()["servers"]["linear"] + + +async def test_custom_mcp_oauth_callback_applies_tokens(env): + c, srv, auth, _ = env + user_id = await resolve_user_id(c, auth, "admin") + + put = await c.put( + "/api/connectors/custom-mcp", + headers=auth, + json={ + "servers": { + "my-oauth-mcp": { + "transport": "streamable_http", + "url": "https://mcp.example.com/mcp", + "enabled": False, + } + } + }, + ) + assert put.status_code == 200 + + oauth_state = "test-oauth-state-xyz" + state_id = new_ulid() + srv.services.repos.connector_repo.create_oauth_state( + state_id=state_id, + state=oauth_state, + user_id=user_id, + kind=CUSTOM_MCP_KIND, + code_verifier="verifier123", + redirect_after="/connectors", + ) + save_oauth_ctx( + srv.services.settings_repo, + state_id, + { + "flow": "custom_mcp", + "kind": CUSTOM_MCP_KIND, + "server_name": "my-oauth-mcp", + "issuer": "https://auth.example.com", + "resource": "https://mcp.example.com/mcp", + "client_id": "cid", + "client_secret": None, + "redirect_uri": "http://testserver/api/connectors/oauth/callback", + "metadata": { + "authorization_endpoint": "https://auth.example.com/authorize", + "token_endpoint": "https://auth.example.com/token", + }, + }, + ) + + with patch( + "octop.api.routers.connectors.exchange_oauth_code", + new_callable=AsyncMock, + return_value={ + "access_token": "new-access-token", + "refresh_token": "refresh-tok", + "expires_at": 9_999_999_999, + }, + ): + callback = await c.get( + f"/api/connectors/oauth/callback?code=authcode&state={oauth_state}", + follow_redirects=False, + ) + assert callback.status_code == 200 + assert "octop:connector-oauth" in callback.text + + stored = await c.get("/api/connectors/custom-mcp", headers=auth) + assert stored.status_code == 200 + assert stored.json()["servers"]["my-oauth-mcp"]["oauth"]["configured"] is True + + pending = await c.get(f"/api/connectors/oauth/pending/{state_id}", headers=auth) + assert pending.status_code == 200 + body = pending.json() + assert body["applied"] is True + assert body["server_name"] == "my-oauth-mcp" + + +async def test_custom_mcp_oauth_start_unified(env): + c, _, auth, _ = env + put = await c.put( + "/api/connectors/custom-mcp", + headers=auth, + json={ + "servers": { + "oauth-srv": { + "transport": "streamable_http", + "url": "https://mcp.example.com/mcp", + } + } + }, + ) + assert put.status_code == 200 + + mocked_start = AsyncMock( + return_value=( + "https://auth.example.com/authorize?state=x", + "verifier", + {"flow": "custom_mcp"}, + ) + ) + with ( + patch("octop.api.routers.connectors._is_public_http_uri", return_value=False), + patch("octop.api.routers.connectors.start_oauth_for_target", mocked_start), + ): + r = await c.post( + "/api/connectors/oauth/start", + headers=auth, + json={ + "target": {"type": "custom_mcp", "server_name": "oauth-srv"}, + "redirect_after": "/connectors", + }, + ) + assert r.status_code == 200 + data = r.json() + assert data["authorize_url"].startswith("https://auth.example.com/") + assert data["state_id"] + mocked_start.assert_awaited_once() + call_kwargs = mocked_start.await_args.kwargs + assert call_kwargs["target"] == {"type": "custom_mcp", "server_name": "oauth-srv"} + assert call_kwargs["mcp_url"] == "https://mcp.example.com/mcp" diff --git a/tests/unit/connectors/test_custom_mcp.py b/tests/unit/connectors/test_custom_mcp.py index 1ff9aac6..531db4cf 100644 --- a/tests/unit/connectors/test_custom_mcp.py +++ b/tests/unit/connectors/test_custom_mcp.py @@ -357,3 +357,90 @@ def test_list_default_open_mcp_server_names(svc: ConnectorService, db: SqlitePoo assert mcp_server_name("tencent-docs", iid) in names assert "always" in names assert "opt" not in names + + +@pytest.mark.asyncio +async def test_ensure_fresh_custom_servers_marks_reauth_when_refresh_fails_expired( + svc: ConnectorService, + db: SqlitePool, +) -> None: + import time + from unittest.mock import AsyncMock, patch + + uid = _ensure_user(db) + expired_at = int(time.time()) - 60 + svc.put_custom_servers( + uid, + { + "srv": { + "transport": "streamable_http", + "url": "https://example.com/mcp", + } + }, + ) + svc.apply_custom_server_oauth( + uid, + "srv", + { + "access_token": "old", + "refresh_token": "refresh-me", + "expires_at": expired_at, + "oauth_client_id": "cid", + }, + issuer="https://example.com", + resource="https://example.com/mcp", + ) + with patch( + "octop.infra.connectors.service.refresh_custom_mcp_oauth", + new_callable=AsyncMock, + side_effect=ValueError("refresh failed"), + ): + await svc.ensure_fresh_custom_servers(uid) + + preview = svc.get_custom_servers_for_api(uid) + assert preview["srv"]["oauth"] == {"configured": False, "required": True} + stored = svc.get_custom_servers(uid)["srv"]["oauth"] + assert stored == {"required": True} + + +@pytest.mark.asyncio +async def test_ensure_fresh_custom_servers_keeps_valid_token_when_refresh_fails( + svc: ConnectorService, + db: SqlitePool, +) -> None: + import time + from unittest.mock import AsyncMock, patch + + uid = _ensure_user(db) + expires_at = int(time.time()) + 60 + svc.put_custom_servers( + uid, + { + "srv": { + "transport": "streamable_http", + "url": "https://example.com/mcp", + } + }, + ) + svc.apply_custom_server_oauth( + uid, + "srv", + { + "access_token": "still-valid", + "refresh_token": "refresh-me", + "expires_at": expires_at, + "oauth_client_id": "cid", + }, + issuer="https://example.com", + resource="https://example.com/mcp", + ) + with patch( + "octop.infra.connectors.service.refresh_custom_mcp_oauth", + new_callable=AsyncMock, + side_effect=ValueError("refresh failed"), + ): + await svc.ensure_fresh_custom_servers(uid) + + preview = svc.get_custom_servers_for_api(uid) + assert preview["srv"]["oauth"]["configured"] is True + assert svc.get_custom_servers(uid)["srv"]["oauth"]["access_token"] == "still-valid" diff --git a/tests/unit/connectors/test_oauth_discovery.py b/tests/unit/connectors/test_oauth_discovery.py index 5658ed5b..39b84211 100644 --- a/tests/unit/connectors/test_oauth_discovery.py +++ b/tests/unit/connectors/test_oauth_discovery.py @@ -7,11 +7,14 @@ from octop.infra.connectors.custom_mcp import ( build_oauth_storage, harness_spec_for_server, + mark_oauth_reauth_required, merge_preserved_oauth, redact_server_for_api, ) from octop.infra.connectors.oauth.discovery import ( build_protected_resource_metadata_urls, + clear_oauth_discovery_cache, + discover_oauth_from_mcp_url, parse_www_authenticate_resource_metadata, ) from octop.infra.connectors.oauth.registry import ( @@ -104,3 +107,54 @@ def test_harness_spec_injects_oauth_bearer(): def test_oauth_state_kind_invalid(): with pytest.raises(ValueError, match="unsupported oauth target"): oauth_state_kind_for_target({"type": "unknown"}) + + +def test_mark_oauth_reauth_required_clears_tokens(): + spec = { + "transport": "streamable_http", + "url": "https://example.com/mcp", + "oauth": build_oauth_storage( + {"access_token": "secret", "refresh_token": "r", "expires_at": 1}, + issuer="https://example.com", + resource="https://example.com/mcp", + ), + } + marked = mark_oauth_reauth_required(spec) + assert marked["oauth"] == {"required": True} + preview = redact_server_for_api(marked) + assert preview["oauth"] == {"configured": False, "required": True} + + +@pytest.mark.asyncio +async def test_discover_oauth_from_mcp_url_uses_cache(monkeypatch: pytest.MonkeyPatch) -> None: + clear_oauth_discovery_cache() + calls = 0 + cached_result = { + "available": True, + "issuer": "https://auth.example.com", + "resource": "https://example.com/mcp", + "metadata": {"registration_endpoint": "https://auth.example.com/reg"}, + "scopes_supported": None, + "error": None, + } + + async def fake_uncached(url: str) -> dict[str, object]: + nonlocal calls + calls += 1 + assert url == "https://example.com/mcp" + return cached_result + + monkeypatch.setattr( + "octop.infra.connectors.oauth.discovery._discover_oauth_from_mcp_url_uncached", + fake_uncached, + ) + + first = await discover_oauth_from_mcp_url("https://example.com/mcp") + second = await discover_oauth_from_mcp_url("https://example.com/mcp") + assert first == cached_result + assert second == cached_result + assert calls == 1 + + bypass = await discover_oauth_from_mcp_url("https://example.com/mcp", use_cache=False) + assert bypass == cached_result + assert calls == 2 From b8e7ecdf01cf44785c163564bc91b9220b6e8fdc Mon Sep 17 00:00:00 2001 From: Panda <163635413+Pandakingxbc@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:27:11 +0900 Subject: [PATCH 16/28] feat(connectors): add WeKnora and Dify integrations (#475) Co-authored-by: leoxyang --- CHANGELOG.md | 2 + dashboard/src/api/modules/connectors.ts | 21 ++ dashboard/src/assets/connectors/dify.svg | 4 + dashboard/src/assets/connectors/index.ts | 4 + dashboard/src/assets/connectors/weknora.svg | 4 + .../Connectors/guidedConnectorUtils.test.ts | 40 +++ .../Agent/Connectors/guidedConnectorUtils.ts | 45 +++ .../pages/Agent/Connectors/index.module.less | 25 ++ .../src/pages/Agent/Connectors/index.tsx | 294 +++++++++++++++++- src/octop/api/routers/connectors.py | 18 ++ src/octop/infra/connectors/builder.py | 52 +++- src/octop/infra/connectors/catalog.py | 96 ++++++ src/octop/infra/connectors/custom_mcp.py | 9 +- .../connectors/gateway/adapters/weknora.py | 239 ++++++++++++++ .../infra/connectors/gateway/registry.py | 2 + src/octop/infra/connectors/probe.py | 30 +- tests/unit/test_connectors.py | 236 ++++++++++++++ 17 files changed, 1113 insertions(+), 8 deletions(-) create mode 100644 dashboard/src/assets/connectors/dify.svg create mode 100644 dashboard/src/assets/connectors/weknora.svg create mode 100644 dashboard/src/pages/Agent/Connectors/guidedConnectorUtils.test.ts create mode 100644 dashboard/src/pages/Agent/Connectors/guidedConnectorUtils.ts create mode 100644 src/octop/infra/connectors/gateway/adapters/weknora.py diff --git a/CHANGELOG.md b/CHANGELOG.md index db6e08c8..edbef7b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ ### 新增 - 自定义 MCP 连接器支持 OAuth:从 MCP URL 自动发现授权端点,统一 `POST /connectors/oauth/start` 启动授权;保存后可探测、一键授权,Bearer 注入 harness spec;Custom Connectors UI 支持启用与「对话默认选中」分离、保存后可选探测 +- 连接器新增 WeKnora 与 Dify:WeKnora 提供只读知识库列表、检索和文档阅读工具;Dify 直接接入应用发布的 Streamable HTTP MCP。连接器目录支持声明式凭证字段,Dashboard、IM 与 Cron 复用现有 MCP 注入链路 +- WeKnora / Dify 增加引导式接入:控制台入口、智能剪贴板粘贴、Dify MCP URL 即时校验,以及 WeKnora 默认本机部署只读检测 ### 变更 diff --git a/dashboard/src/api/modules/connectors.ts b/dashboard/src/api/modules/connectors.ts index 07100e8c..4b2dde42 100644 --- a/dashboard/src/api/modules/connectors.ts +++ b/dashboard/src/api/modules/connectors.ts @@ -18,6 +18,17 @@ export interface ConnectorCatalogEntry { supports_quick_auth?: boolean; oauth_mode?: "dynamic" | "configured" | null; oauth_ready?: boolean; + credential_fields?: ConnectorCredentialField[]; +} + +export interface ConnectorCredentialField { + key: string; + label: string; + field_type: "text" | "password" | "url" | "tags"; + required: boolean; + placeholder?: string | null; + help?: string | null; + secret: boolean; } export interface ConnectorAuthInfo { @@ -42,6 +53,7 @@ export interface ConnectorInstance { } export interface ConnectorCredentialsPreview { + [key: string]: unknown; token_configured?: boolean; oauth_configured?: boolean; expires_at?: number; @@ -91,6 +103,12 @@ export interface ConnectorProbeResult { }; } +export interface WeKnoraLocalDetection { + found: boolean; + base_url?: string; + console_url?: string; +} + export type CustomMcpTransport = "streamable_http" | "stdio"; export interface CustomMcpOAuthPreview { @@ -158,6 +176,9 @@ export interface FeishuUserAuthCompleteResult { export const connectorsApi = { catalog: () => request("/connectors/catalog"), + detectLocalWeKnora: () => + request("/connectors/weknora/detect-local"), + listInstances: () => request("/connector-instances"), getInstance: (instanceId: string) => diff --git a/dashboard/src/assets/connectors/dify.svg b/dashboard/src/assets/connectors/dify.svg new file mode 100644 index 00000000..b0b4ba21 --- /dev/null +++ b/dashboard/src/assets/connectors/dify.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/src/assets/connectors/index.ts b/dashboard/src/assets/connectors/index.ts index 15aa1264..067b3c77 100644 --- a/dashboard/src/assets/connectors/index.ts +++ b/dashboard/src/assets/connectors/index.ts @@ -2,6 +2,7 @@ import tencentArdot from "./tencent-ardot.png"; import baiduMap from "./baidu-map.png"; import ctripWendao from "./ctrip-wendao.png"; import dida365 from "./dida365.png"; +import dify from "./dify.svg"; import feishuCli from "./feishu-cli.png"; import fliggy from "./fliggy.png"; import meituanTravel from "./meituan-travel.png"; @@ -16,6 +17,7 @@ import tencentLexiang from "./tencent-lexiang.png"; import tencentWeiyun from "./tencent-weiyun.png"; import wechatReading from "./wechat-reading.png"; import wecomCli from "./wecom-cli.png"; +import weknora from "./weknora.svg"; import youdaoNote from "./youdao-note.png"; import yuandian from "./yuandian.png"; @@ -36,10 +38,12 @@ export const CONNECTOR_LOGOS: Record = { "tencent-meeting": tencentMeeting, notion, dida365: dida365, + dify, "tencent-news": tencentNews, "wechat-reading": wechatReading, "youdao-note": youdaoNote, "tencent-weiyun": tencentWeiyun, + weknora, }; export function getConnectorLogo(kind: string): string | undefined { diff --git a/dashboard/src/assets/connectors/weknora.svg b/dashboard/src/assets/connectors/weknora.svg new file mode 100644 index 00000000..8dd0fc59 --- /dev/null +++ b/dashboard/src/assets/connectors/weknora.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/src/pages/Agent/Connectors/guidedConnectorUtils.test.ts b/dashboard/src/pages/Agent/Connectors/guidedConnectorUtils.test.ts new file mode 100644 index 00000000..4078f4ca --- /dev/null +++ b/dashboard/src/pages/Agent/Connectors/guidedConnectorUtils.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { + consoleUrlFromServiceUrl, + extractHttpUrl, + isDifyMcpServerUrl, + isGuidedConnector, +} from "./guidedConnectorUtils"; + +describe("guided connector helpers", () => { + it("extracts a pasted URL without surrounding prose", () => { + expect( + extractHttpUrl( + "MCP Server URL: https://dify.example.com/mcp/server/code/mcp。", + ), + ).toBe("https://dify.example.com/mcp/server/code/mcp"); + expect(extractHttpUrl("sk-secret")).toBeNull(); + }); + + it("recognizes only Dify app MCP server routes", () => { + expect( + isDifyMcpServerUrl("https://dify.example.com/mcp/server/server-code/mcp"), + ).toBe(true); + expect(isDifyMcpServerUrl("https://dify.example.com/v1/chat")).toBe(false); + }); + + it("derives safe console origins", () => { + expect( + consoleUrlFromServiceUrl("weknora", "http://127.0.0.1:8080/api/v1"), + ).toBe("http://127.0.0.1"); + expect( + consoleUrlFromServiceUrl( + "dify", + "https://dify.example.com/mcp/server/code/mcp", + ), + ).toBe("https://dify.example.com"); + expect(isGuidedConnector("weknora")).toBe(true); + expect(isGuidedConnector("notion")).toBe(false); + }); +}); diff --git a/dashboard/src/pages/Agent/Connectors/guidedConnectorUtils.ts b/dashboard/src/pages/Agent/Connectors/guidedConnectorUtils.ts new file mode 100644 index 00000000..c0d17294 --- /dev/null +++ b/dashboard/src/pages/Agent/Connectors/guidedConnectorUtils.ts @@ -0,0 +1,45 @@ +export type GuidedConnectorKind = "weknora" | "dify"; + +export function isGuidedConnector( + kind: string | undefined, +): kind is GuidedConnectorKind { + return kind === "weknora" || kind === "dify"; +} + +export function extractHttpUrl(text: string): string | null { + const match = text.trim().match(/https?:\/\/[^\s<>"']+/i); + if (!match) return null; + return match[0].replace(/[),.;,。;)]+$/u, ""); +} + +export function isDifyMcpServerUrl(value: string): boolean { + try { + const url = new URL(value.trim()); + return ( + ["http:", "https:"].includes(url.protocol) && + /^\/mcp\/server\/[^/]+\/mcp\/?$/.test(url.pathname) + ); + } catch { + return false; + } +} + +export function consoleUrlFromServiceUrl( + kind: GuidedConnectorKind, + value: string, +): string | null { + try { + const url = new URL(value.trim()); + if (!["http:", "https:"].includes(url.protocol)) return null; + if ( + kind === "weknora" && + ["127.0.0.1", "localhost"].includes(url.hostname) && + url.port === "8080" + ) { + return `${url.protocol}//${url.hostname}`; + } + return url.origin; + } catch { + return null; + } +} diff --git a/dashboard/src/pages/Agent/Connectors/index.module.less b/dashboard/src/pages/Agent/Connectors/index.module.less index c3d0a19a..032e1b52 100644 --- a/dashboard/src/pages/Agent/Connectors/index.module.less +++ b/dashboard/src/pages/Agent/Connectors/index.module.less @@ -487,6 +487,31 @@ border-radius: var(--fn-radius-md); } +.guidedSetup { + margin-bottom: 12px; + padding: 10px 12px; + border: 1px solid var(--fn-card-border-normal); + border-radius: var(--fn-radius-md); + background: color-mix( + in srgb, + var(--connector-accent, var(--fn-color-brand)) 4%, + var(--fn-bg-primary) + ); + font-size: 13px; + color: var(--fn-text-secondary); + + ol { + margin: 6px 0 0; + padding-left: 20px; + line-height: 1.65; + } +} + +.guidedSetupTitle { + color: var(--fn-text-primary); + font-weight: 600; +} + .guideLinks { display: flex; flex-wrap: wrap; diff --git a/dashboard/src/pages/Agent/Connectors/index.tsx b/dashboard/src/pages/Agent/Connectors/index.tsx index a3b7e542..662fabcb 100644 --- a/dashboard/src/pages/Agent/Connectors/index.tsx +++ b/dashboard/src/pages/Agent/Connectors/index.tsx @@ -44,6 +44,12 @@ import { mailProviderById, } from "./connectorDefs"; import { notifyConnectorsChanged } from "./customMcpUtils"; +import { + consoleUrlFromServiceUrl, + extractHttpUrl, + isDifyMcpServerUrl, + isGuidedConnector, +} from "./guidedConnectorUtils"; import { useConnectorInstances } from "./useConnectors"; import styles from "./index.module.less"; @@ -109,6 +115,18 @@ function buildCredentials( credentials.sdk_id = values.sdk_id; const secret_key = String(values.secret_key ?? "").trim(); if (secret_key) credentials.secret_key = secret_key; + } else if (entry.auth_kind === "custom_fields") { + for (const field of entry.credential_fields ?? []) { + const text = String(values[field.key] ?? "").trim(); + if (!text) continue; + credentials[field.key] = + field.field_type === "tags" + ? text + .split(",") + .map((item) => item.trim()) + .filter(Boolean) + : text; + } } return credentials; } @@ -146,6 +164,17 @@ function previewToFormValues( if (entry.auth_kind === "oauth2" && preview.oauth_configured) { values.access_token = "__configured__"; } + if (entry.auth_kind === "custom_fields") { + for (const field of entry.credential_fields ?? []) { + if (field.secret) continue; + const value = preview[field.key]; + if (Array.isArray(value)) { + values[field.key] = value.join(", "); + } else if (value !== undefined && value !== null) { + values[field.key] = value; + } + } + } return values; } @@ -178,9 +207,32 @@ function hasFreshCredentialInput( if (entry.auth_kind === "api_credentials") { return Boolean(String(values.secret_key ?? "").trim()); } + if (entry.auth_kind === "custom_fields") { + return (entry.credential_fields ?? []).some( + (field) => + field.secret && Boolean(String(values[field.key] ?? "").trim()), + ); + } return false; } +function customCredentialConfigChanged( + entry: ConnectorCatalogEntry, + values: Record, + preview: ConnectorCredentialsPreview, +): boolean { + if (entry.auth_kind !== "custom_fields") return false; + return (entry.credential_fields ?? []).some((field) => { + if (field.secret) return false; + const current = String(values[field.key] ?? "").trim(); + const storedValue = preview[field.key]; + const stored = Array.isArray(storedValue) + ? storedValue.join(", ") + : String(storedValue ?? "").trim(); + return current !== stored; + }); +} + function openAuthorizeLabel( kind: string, t: (key: string, fallback: string) => string, @@ -216,7 +268,7 @@ function secretFieldRules(required: boolean) { function configuredExtra( preview: ConnectorCredentialsPreview | undefined, - key: keyof ConnectorCredentialsPreview, + key: string, t: (key: string, fallback: string) => string, ) { if (!preview?.[key]) return undefined; @@ -260,6 +312,10 @@ function ConnectorConfigDrawer({ null, ); const [installingCli, setInstallingCli] = useState(false); + const [detectingLocalWeKnora, setDetectingLocalWeKnora] = useState(false); + const [detectedWeKnoraConsole, setDetectedWeKnoraConsole] = useState< + string | null + >(null); const [feishuUserAuth, setFeishuUserAuth] = useState(null); const [feishuUserAuthBusy, setFeishuUserAuthBusy] = useState(false); @@ -272,6 +328,8 @@ function ConnectorConfigDrawer({ const hasStoredCredentials = Boolean(instance?.has_credentials); const mailProvider = Form.useWatch("mail_provider", form) ?? "qq"; const defaultOpen = Form.useWatch("default_open", form) === true; + const weknoraBaseUrl = String(Form.useWatch("base_url", form) ?? ""); + const difyMcpUrl = String(Form.useWatch("mcp_url", form) ?? ""); const selectedMailProvider = mailProviderById(String(mailProvider)); const draftScope = entry ? instance @@ -296,6 +354,7 @@ function ConnectorConfigDrawer({ setInstanceDetail(null); setProbeResult(null); setCliInfo(null); + setDetectedWeKnoraConsole(null); setFeishuUserAuth(null); setFeishuUserReady(false); setFeishuAuthNeedsReauth(false); @@ -685,6 +744,87 @@ function ConnectorConfigDrawer({ } }; + const handleGuidedPaste = async () => { + if (!entry || !isGuidedConnector(entry.kind)) return; + try { + const text = (await navigator.clipboard.readText()).trim(); + if (!text) { + message.warning(t("connectors.clipboardEmpty", "剪贴板为空")); + return; + } + const pastedUrl = extractHttpUrl(text); + if (entry.kind === "dify") { + if (!pastedUrl) { + message.warning( + t("connectors.difyPasteUrlRequired", "剪贴板中没有 MCP URL"), + ); + return; + } + form.setFieldValue("mcp_url", pastedUrl); + await form.validateFields(["mcp_url"]); + } else if (pastedUrl) { + form.setFieldValue("base_url", pastedUrl); + } else { + form.setFieldValue("api_key", text); + } + saveFormDraft( + draftScope, + form.getFieldsValue() as Record, + ); + message.success(t("connectors.pasteSuccess", "已粘贴")); + } catch (error) { + if (error && typeof error === "object" && "errorFields" in error) { + message.warning( + t( + "connectors.difyMcpUrlInvalid", + "请粘贴 Dify 访问点提供的完整 MCP Server URL", + ), + ); + return; + } + message.error( + t("connectors.clipboardDenied", "无法读取剪贴板,请手动粘贴"), + ); + } + }; + + const handleDetectLocalWeKnora = async () => { + if (detectingLocalWeKnora) return; + setDetectingLocalWeKnora(true); + try { + const result = await connectorsApi.detectLocalWeKnora(); + if (!result.found || !result.base_url) { + message.warning( + t( + "connectors.weknoraNotFound", + "未在 OCTOP 主机的 127.0.0.1:8080 检测到 WeKnora", + ), + ); + return; + } + form.setFieldValue("base_url", result.base_url); + setDetectedWeKnoraConsole(result.console_url ?? null); + saveFormDraft( + draftScope, + form.getFieldsValue() as Record, + ); + message.success( + t("connectors.weknoraFound", "已检测到本机 WeKnora 并填入地址"), + ); + } catch (error) { + console.error(error); + message.error( + apiErrorMessage( + error, + t("connectors.weknoraDetectFailed", "检测本机 WeKnora 失败"), + t, + ), + ); + } finally { + setDetectingLocalWeKnora(false); + } + }; + const handleOAuth = async () => { if (!entry || authorizing) return; const popup = window.open("", "octop-oauth", "width=520,height=720"); @@ -843,17 +983,35 @@ function ConnectorConfigDrawer({ Boolean(preview.bot_id) && String(values.bot_id ?? "").trim() !== String(preview.bot_id ?? "").trim(); - const identityChanged = feishuAppIdChanged || wecomBotIdChanged; - if (identityChanged && !freshSecret) { + const customConfigChanged = customCredentialConfigChanged( + entry, + values, + preview, + ); + const customHasStoredSecret = (entry.credential_fields ?? []).some( + (field) => field.secret && preview[`${field.key}_configured`] === true, + ); + const identityChanged = + feishuAppIdChanged || wecomBotIdChanged || customConfigChanged; + if ( + identityChanged && + !freshSecret && + (entry.auth_kind !== "custom_fields" || customHasStoredSecret) + ) { message.warning( entry.kind === "feishu-cli" ? t( "connectors.probeNeedSecretAfterAppIdChange", "App ID 已修改,请填写 App Secret 后再探测", ) - : t( + : entry.kind === "wecom-cli" + ? t( "connectors.probeNeedSecretAfterBotIdChange", "Bot ID 已修改,请填写 Secret 后再探测", + ) + : t( + "connectors.probeNeedSecretAfterConfigChange", + "连接配置已修改,请重新填写密钥后再探测", ), ); return; @@ -959,6 +1117,15 @@ function ConnectorConfigDrawer({ const guideUrl = authInfo?.guide_url ?? entry.guide_url ?? entry.doc_url; const manualUrl = authInfo?.manual_url ?? entry.manual_url ?? guideUrl; const authHint = authInfo?.auth_hint ?? entry.auth_hint; + const guidedKind = isGuidedConnector(entry.kind) ? entry.kind : null; + const guidedServiceUrl = + guidedKind === "weknora" ? weknoraBaseUrl : difyMcpUrl; + const guidedConsoleUrl = + detectedWeKnoraConsole ?? + (guidedKind + ? consoleUrlFromServiceUrl(guidedKind, guidedServiceUrl) + : null) ?? + guideUrl; const preview = instanceDetail?.credentials_preview; const secretRequired = !hasStoredCredentials; @@ -1018,6 +1185,45 @@ function ConnectorConfigDrawer({ {authHint &&
{authHint}
} + {guidedKind && ( +
+
+ {t("connectors.guidedSetup", "快速接入")} +
+ {guidedKind === "weknora" ? ( +
    +
  1. + {t("connectors.weknoraStep1", "打开 WeKnora 并创建 API Key")} +
  2. +
  3. + {t( + "connectors.weknoraStep2", + "检测本机服务,或手动填写部署地址", + )} +
  4. +
  5. + {t("connectors.guidedStepProbe", "粘贴凭证后探测并保存")} +
  6. +
+ ) : ( +
    +
  1. + {t("connectors.difyStep1", "在 Dify 中发布应用或工作流")} +
  2. +
  3. + {t( + "connectors.difyStep2", + "在访问点启用 MCP 并复制完整 Server URL", + )} +
  4. +
  5. + {t("connectors.guidedStepProbe", "粘贴凭证后探测并保存")} +
  6. +
+ )} +
+ )} + {guideUrl && !hideGuideLink && (
@@ -1027,6 +1233,35 @@ function ConnectorConfigDrawer({ )}
+ {guidedKind && ( + <> + + {guidedKind === "weknora" && ( + + )} + + + )} {entry && isHostCliConnector(entry.kind) && ( <> {canInstallCli && ( @@ -1235,6 +1470,57 @@ function ConnectorConfigDrawer({ + {entry.auth_kind === "custom_fields" && + (entry.credential_fields ?? []).map((field) => { + const isSecret = field.secret || field.field_type === "password"; + const input = isSecret ? ( + + ) : ( + + ); + return ( + + !value || isDifyMcpServerUrl(String(value)) + ? Promise.resolve() + : Promise.reject( + new Error( + t( + "connectors.difyMcpUrlInvalid", + "请粘贴 Dify 访问点提供的完整 MCP Server URL", + ), + ), + ), + }, + ] + : []), + ]} + extra={ + configuredExtra(preview, `${field.key}_configured`, t) ?? + field.help + } + > + {input} + + ); + })} + {entry.auth_kind === "personal_token" && ( dict[str, Any]: preview["sdk_id"] = str(creds["sdk_id"]) if str(creds.get("secret_key") or "").strip(): preview["secret_key_configured"] = True + elif entry.auth_kind == "custom_fields": + for field in entry.credential_fields: + value = creds.get(field.key) + if field.secret: + if str(value or "").strip(): + preview[f"{field.key}_configured"] = True + elif value not in (None, "", []): + preview[field.key] = value return preview @@ -427,6 +436,15 @@ async def get_catalog( ] +@router.get("/connectors/weknora/detect-local", summary="Detect local WeKnora") +async def detect_weknora_on_octop_host( + user: Any = Depends(current_user), +) -> dict[str, Any]: + """Check WeKnora's fixed default loopback health endpoint (no persistence).""" + del user + return await detect_local_weknora() + + @router.get("/connector-instances", summary="List connector instances") async def list_instances( user: Any = Depends(current_user), diff --git a/src/octop/infra/connectors/builder.py b/src/octop/infra/connectors/builder.py index e4354004..a7521c8e 100644 --- a/src/octop/infra/connectors/builder.py +++ b/src/octop/infra/connectors/builder.py @@ -5,7 +5,7 @@ import re import secrets from typing import Any -from urllib.parse import quote +from urllib.parse import quote, urlsplit, urlunsplit from octop.config import OctopConfig from octop.infra.connectors.catalog import ( @@ -13,6 +13,7 @@ get_catalog_entry, is_mcp_oauth_remote, ) +from octop.infra.connectors.custom_mcp import validate_mcp_http_url from octop.infra.connectors.mail_servers import resolve_mail_servers from octop.infra.utils.ulid import new_ulid @@ -38,6 +39,18 @@ def normalize_weiyun_mcp_token(raw: str) -> str: return text +def normalize_weknora_base_url(raw: str) -> str: + """Normalize a WeKnora origin or API base to its `/api/v1` root.""" + url = validate_mcp_http_url(raw) + parsed = urlsplit(url) + if parsed.query or parsed.fragment: + raise ValueError("base_url must not contain a query string or fragment") + path = parsed.path.rstrip("/") + if not path.endswith("/api/v1"): + path = f"{path}/api/v1" + return urlunsplit((parsed.scheme, parsed.netloc, path, "", "")) + + def mcp_server_name(kind: str, instance_id: str) -> str: return f"{kind}__{instance_id}" @@ -73,6 +86,13 @@ def build_http_mcp_spec( def _build_remote_spec(entry: ConnectorCatalogEntry, creds: dict[str, Any]) -> dict[str, Any]: + if entry.kind == "dify": + url = validate_mcp_http_url(str(creds.get("mcp_url") or "")) + return { + "transport": "http", + "url": url, + "headers": _mcp_http_headers(), + } if entry.kind == "tencent-docs": token = str(creds.get("token") or "") spec: dict[str, Any] = { @@ -362,6 +382,34 @@ def validate_create_credentials( "internal_token": internal_token, } + if entry.auth_kind == "custom_fields": + if entry.kind == "weknora": + base_url = normalize_weknora_base_url(str(credentials.get("base_url") or "")) + out = { + "base_url": base_url, + "internal_token": new_internal_token(), + } + api_key = str(credentials.get("api_key") or "").strip() + tenant_id = str(credentials.get("tenant_id") or "").strip() + raw_ids = credentials.get("knowledge_base_ids") + if api_key: + out["api_key"] = api_key + if tenant_id: + out["tenant_id"] = tenant_id + if isinstance(raw_ids, list): + ids = [str(item).strip() for item in raw_ids if str(item).strip()] + else: + ids = [part.strip() for part in str(raw_ids or "").split(",") if part.strip()] + if ids: + out["knowledge_base_ids"] = list(dict.fromkeys(ids)) + return out + if entry.kind == "dify": + mcp_url = validate_mcp_http_url(str(credentials.get("mcp_url") or "")) + if "/mcp/server/" not in mcp_url or not mcp_url.rstrip("/").endswith("/mcp"): + raise ValueError("mcp_url must be a Dify MCP Server URL ending in /mcp") + return {"mcp_url": mcp_url} + raise ValueError(f"unsupported custom_fields connector kind: {kind}") + raise ValueError(f"unsupported auth_kind: {entry.auth_kind}") @@ -375,6 +423,8 @@ def _redact_mcp_configs_for_log(configs: dict[str, Any]) -> dict[str, Any]: url = str(entry.get("url") or "") if "token=" in url: entry["url"] = url.split("token=", 1)[0] + "token=***" + elif "/mcp/server/" in url: + entry["url"] = re.sub(r"(/mcp/server/)[^/?#]+", r"\1***", url) headers = entry.get("headers") if isinstance(headers, dict): redacted = dict(headers) diff --git a/src/octop/infra/connectors/catalog.py b/src/octop/infra/connectors/catalog.py index ddbc3430..8dd1acfc 100644 --- a/src/octop/infra/connectors/catalog.py +++ b/src/octop/infra/connectors/catalog.py @@ -13,8 +13,23 @@ "imap_app_password", "session_cookie", "api_credentials", + "custom_fields", ] +CredentialFieldType = Literal["text", "password", "url", "tags"] +RemoteTransport = Literal["raw_http", "streamable_http", "sse"] + + +@dataclass(frozen=True) +class ConnectorCredentialField: + key: str + label: str + field_type: CredentialFieldType = "text" + required: bool = True + placeholder: str | None = None + help: str | None = None + secret: bool = False + @dataclass(frozen=True) class ConnectorCatalogEntry: @@ -43,6 +58,8 @@ class ConnectorCatalogEntry: oauth_resource: str | None = None oauth_scopes: str | None = None mcp_user_agent: str | None = None + credential_fields: tuple[ConnectorCredentialField, ...] = () + remote_transport: RemoteTransport = "raw_http" def is_mcp_oauth_remote(entry: ConnectorCatalogEntry) -> bool: @@ -67,6 +84,73 @@ def get_mcp_oauth_remote(kind: str) -> ConnectorCatalogEntry | None: _CATALOG: tuple[ConnectorCatalogEntry, ...] = ( + ConnectorCatalogEntry( + kind="weknora", + name="WeKnora", + description="连接 WeKnora 私有知识库,提供只读检索与文档阅读", + auth_kind="custom_fields", + doc_url="https://github.com/Tencent/WeKnora", + icon="weknora", + color="#0052d9", + phase="available", + mcp_mode="gateway", + guide_url="https://github.com/Tencent/WeKnora/blob/main/docs/api/README.md", + auth_hint="填写 WeKnora 服务地址;API Key 与 Tenant ID 按部署的鉴权设置填写。", + credential_fields=( + ConnectorCredentialField( + key="base_url", + label="服务地址", + field_type="url", + placeholder="http://127.0.0.1:8080 或 https://weknora.example.com", + help="未包含 /api/v1 时会自动补齐", + ), + ConnectorCredentialField( + key="api_key", + label="API Key", + field_type="password", + required=False, + placeholder="sk-...(无鉴权部署可留空)", + secret=True, + ), + ConnectorCredentialField( + key="tenant_id", + label="Tenant ID", + required=False, + help="平台级 API Key 需要指定工作空间时填写", + ), + ConnectorCredentialField( + key="knowledge_base_ids", + label="默认知识库 ID", + field_type="tags", + required=False, + placeholder="kb-123, kb-456", + help="留空时检索当前凭证可见的全部知识库", + ), + ), + ), + ConnectorCatalogEntry( + kind="dify", + name="Dify", + description="连接 Dify 已发布应用或工作流的 MCP 服务", + auth_kind="custom_fields", + doc_url="https://docs.dify.ai/", + icon="dify", + color="#1c64f2", + phase="available", + mcp_mode="remote", + auth_hint="在 Dify 应用的访问点中启用 MCP,然后粘贴完整的 MCP Server URL。", + credential_fields=( + ConnectorCredentialField( + key="mcp_url", + label="MCP Server URL", + field_type="url", + placeholder="https://dify.example.com/mcp/server//mcp", + help="完整 URL 含访问标识,将按密钥加密保存", + secret=True, + ), + ), + remote_transport="streamable_http", + ), ConnectorCatalogEntry( kind="tencent-docs", name="腾讯文档", @@ -432,5 +516,17 @@ def catalog_entry_to_dict( "auth_hint": entry.auth_hint, "oauth_mode": oauth_mode, "oauth_ready": oauth_ready, + "credential_fields": [ + { + "key": field.key, + "label": field.label, + "field_type": field.field_type, + "required": field.required, + "placeholder": field.placeholder, + "help": field.help, + "secret": field.secret, + } + for field in entry.credential_fields + ], "supports_quick_auth": entry.phase == "available" and entry.auth_kind != "api_credentials", } diff --git a/src/octop/infra/connectors/custom_mcp.py b/src/octop/infra/connectors/custom_mcp.py index 9485aba5..7ffb4385 100644 --- a/src/octop/infra/connectors/custom_mcp.py +++ b/src/octop/infra/connectors/custom_mcp.py @@ -102,7 +102,12 @@ def _normalize_args(raw: Any) -> list[str]: return [str(item) for item in raw] -def _validate_http_url(url: str) -> str: +def validate_mcp_http_url(url: str) -> str: + """Validate a user-configured MCP/connector URL. + + Local loopback deployments may use HTTP. Remote deployments must use + public HTTPS and pass the shared SSRF guard. + """ text = url.strip() if not text: raise ValueError("url is required") @@ -150,7 +155,7 @@ def normalize_server_spec(name: str, raw: Any) -> dict[str, Any]: spec["display_name"] = display_name if transport == "streamable_http": - url = _validate_http_url(str(raw.get("url") or "")) + url = validate_mcp_http_url(str(raw.get("url") or "")) spec["url"] = url headers = _normalize_headers(raw.get("headers")) if headers: diff --git a/src/octop/infra/connectors/gateway/adapters/weknora.py b/src/octop/infra/connectors/gateway/adapters/weknora.py new file mode 100644 index 00000000..bb062502 --- /dev/null +++ b/src/octop/infra/connectors/gateway/adapters/weknora.py @@ -0,0 +1,239 @@ +"""Read-only WeKnora knowledge-base gateway adapter.""" + +from __future__ import annotations + +import json +from typing import Any +from urllib.parse import quote + +import httpx + +from octop.infra.connectors.builder import normalize_weknora_base_url + +_MAX_RESULTS = 8 +_MAX_CHUNK_CHARS = 1200 + +TOOLS: list[dict[str, Any]] = [ + { + "name": "list_knowledge_bases", + "description": "List WeKnora knowledge bases visible to the configured credential.", + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "search", + "description": ( + "Search WeKnora knowledge bases and return ranked source passages. " + "Use the returned knowledge_id with read_document for surrounding context." + ), + "inputSchema": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query"}, + "knowledge_base_ids": { + "type": "array", + "items": {"type": "string"}, + "description": "Optional knowledge-base scope; defaults to connector config", + }, + "max_results": { + "type": "integer", + "minimum": 1, + "maximum": _MAX_RESULTS, + "description": f"Maximum passages (1-{_MAX_RESULTS})", + }, + }, + "required": ["query"], + }, + }, + { + "name": "read_document", + "description": "Read one WeKnora document and its ordered chunks by knowledge_id.", + "inputSchema": { + "type": "object", + "properties": { + "knowledge_id": {"type": "string", "description": "Document knowledge ID"}, + "page": {"type": "integer", "minimum": 1, "description": "Page, default 1"}, + "page_size": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "description": "Chunks per page, default 20", + }, + }, + "required": ["knowledge_id"], + }, + }, +] + + +def list_tools() -> list[dict[str, Any]]: + return TOOLS + + +def _headers(creds: dict[str, Any]) -> dict[str, str]: + headers = {"Accept": "application/json"} + api_key = str(creds.get("api_key") or "").strip() + tenant_id = str(creds.get("tenant_id") or "").strip() + if api_key: + headers["X-API-Key"] = api_key + if tenant_id: + headers["X-Tenant-ID"] = tenant_id + return headers + + +def _request( + creds: dict[str, Any], + method: str, + path: str, + *, + params: dict[str, Any] | None = None, + body: dict[str, Any] | None = None, +) -> dict[str, Any]: + base_url = normalize_weknora_base_url(str(creds.get("base_url") or "")) + try: + with httpx.Client(timeout=30.0, follow_redirects=False) as client: + response = client.request( + method, + f"{base_url}{path}", + headers=_headers(creds), + params=params, + json=body, + ) + response.raise_for_status() + payload = response.json() + except httpx.HTTPStatusError as exc: + status = exc.response.status_code + raise ValueError(f"WeKnora request failed: HTTP {status}") from exc + except (httpx.HTTPError, ValueError) as exc: + if isinstance(exc, ValueError) and str(exc).startswith("WeKnora request failed"): + raise + raise ValueError(f"WeKnora request failed: {exc}") from exc + if not isinstance(payload, dict): + raise ValueError("WeKnora returned an invalid JSON response") + if payload.get("success") is False: + reason = payload.get("message") or payload.get("error") or "request rejected" + raise ValueError(f"WeKnora request failed: {reason}") + return payload + + +def _data_list(payload: dict[str, Any]) -> list[dict[str, Any]]: + data = payload.get("data") + if isinstance(data, list): + return [item for item in data if isinstance(item, dict)] + return [] + + +def _configured_ids(creds: dict[str, Any], raw: Any = None) -> list[str]: + value = raw if raw is not None else creds.get("knowledge_base_ids") + if isinstance(value, list): + ids = [str(item).strip() for item in value if str(item).strip()] + else: + ids = [part.strip() for part in str(value or "").split(",") if part.strip()] + return list(dict.fromkeys(ids)) + + +def _visible_knowledge_base_ids(creds: dict[str, Any]) -> list[str]: + ids: list[str] = [] + for item in _data_list(_request(creds, "GET", "/knowledge-bases")): + kb_id = str(item.get("id") or "").strip() + if kb_id: + ids.append(kb_id) + return ids + + +def _clip_passage(item: dict[str, Any]) -> dict[str, Any]: + content = str(item.get("content") or "") + clipped = content[:_MAX_CHUNK_CHARS] + return { + "knowledge_id": item.get("knowledge_id"), + "knowledge_title": item.get("knowledge_title") or item.get("knowledge_filename"), + "chunk_index": item.get("chunk_index"), + "score": item.get("score"), + "content": clipped, + "truncated": len(content) > len(clipped), + } + + +def _list_knowledge_bases(creds: dict[str, Any]) -> dict[str, Any]: + rows = _data_list(_request(creds, "GET", "/knowledge-bases")) + return { + "knowledge_bases": [ + { + "id": item.get("id"), + "name": item.get("name"), + "description": item.get("description"), + "knowledge_count": item.get("knowledge_count"), + "chunk_count": item.get("chunk_count"), + } + for item in rows + ] + } + + +def _search(creds: dict[str, Any], args: dict[str, Any]) -> dict[str, Any]: + query = str(args.get("query") or "").strip() + if not query: + raise ValueError("query is required") + ids = _configured_ids(creds, args.get("knowledge_base_ids")) + if not ids: + ids = _visible_knowledge_base_ids(creds) + if not ids: + raise ValueError("no visible WeKnora knowledge bases") + max_results = max(1, min(int(args.get("max_results") or _MAX_RESULTS), _MAX_RESULTS)) + payload = _request( + creds, + "POST", + "/knowledge-search", + params={"resource_urls": "handle"}, + body={"query": query, "knowledge_base_ids": ids}, + ) + passages = [_clip_passage(item) for item in _data_list(payload)[:max_results]] + return {"query": query, "knowledge_base_ids": ids, "passages": passages} + + +def _read_document(creds: dict[str, Any], args: dict[str, Any]) -> dict[str, Any]: + knowledge_id = str(args.get("knowledge_id") or "").strip() + if not knowledge_id: + raise ValueError("knowledge_id is required") + page = max(1, int(args.get("page") or 1)) + page_size = max(1, min(int(args.get("page_size") or 20), 50)) + escaped_id = quote(knowledge_id, safe="") + detail_payload = _request(creds, "GET", f"/knowledge/{escaped_id}") + chunks_payload = _request( + creds, + "GET", + f"/chunks/{escaped_id}", + params={"page": page, "page_size": page_size}, + ) + detail = detail_payload.get("data") + if not isinstance(detail, dict): + detail = {} + chunks = sorted( + _data_list(chunks_payload), + key=lambda item: int(item.get("chunk_index") or 0), + ) + return { + "knowledge_id": knowledge_id, + "title": detail.get("title") or detail.get("file_name"), + "description": detail.get("description"), + "summary": detail.get("summary"), + "page": chunks_payload.get("page", page), + "page_size": chunks_payload.get("page_size", page_size), + "total": chunks_payload.get("total"), + "chunks": [_clip_passage(item) for item in chunks], + } + + +def call_tool(creds: dict[str, Any], name: str, args: dict[str, Any]) -> str: + if name == "list_knowledge_bases": + result = _list_knowledge_bases(creds) + elif name == "search": + result = _search(creds, args) + elif name == "read_document": + result = _read_document(creds, args) + else: + raise ValueError(f"unknown tool: {name}") + return json.dumps(result, ensure_ascii=False) + + +def probe_credentials(creds: dict[str, Any]) -> None: + _request(creds, "GET", "/knowledge-bases") diff --git a/src/octop/infra/connectors/gateway/registry.py b/src/octop/infra/connectors/gateway/registry.py index 82456ee0..92c29f3e 100644 --- a/src/octop/infra/connectors/gateway/registry.py +++ b/src/octop/infra/connectors/gateway/registry.py @@ -16,6 +16,7 @@ tencent_news, wechat_reading, wecom_cli, + weknora, yuandian, ) @@ -41,6 +42,7 @@ def probe_credentials(self, creds: dict[str, Any]) -> None: ... "wechat-reading": wechat_reading, "feishu-cli": feishu_cli, "wecom-cli": wecom_cli, + "weknora": weknora, } diff --git a/src/octop/infra/connectors/probe.py b/src/octop/infra/connectors/probe.py index c5c05347..8f26da54 100644 --- a/src/octop/infra/connectors/probe.py +++ b/src/octop/infra/connectors/probe.py @@ -28,6 +28,34 @@ logger = logging.getLogger(__name__) +_LOCAL_WEKNORA_API_URL = "http://127.0.0.1:8080" +_LOCAL_WEKNORA_CONSOLE_URL = "http://127.0.0.1" + + +async def detect_local_weknora() -> dict[str, Any]: + """Detect a default host-side WeKnora deployment without persisting data. + + The target is deliberately fixed to loopback. This keeps the convenience + endpoint from becoming an arbitrary server-side URL fetch while matching + WeKnora's default Docker ports (UI 80, API 8080). + """ + try: + async with httpx.AsyncClient( + timeout=httpx.Timeout(2.0), + follow_redirects=False, + trust_env=False, + ) as client: + response = await client.get(f"{_LOCAL_WEKNORA_API_URL}/health") + except httpx.HTTPError: + return {"found": False} + if not response.is_success: + return {"found": False} + return { + "found": True, + "base_url": f"{_LOCAL_WEKNORA_API_URL}/api/v1", + "console_url": _LOCAL_WEKNORA_CONSOLE_URL, + } + def normalize_tools(raw: list[Any] | None) -> list[dict[str, str]]: out: list[dict[str, str]] = [] @@ -384,7 +412,7 @@ async def probe_connector( headers = dict(spec.get("headers") or {}) url = str(spec["url"]) - if is_mcp_oauth_remote(entry): + if is_mcp_oauth_remote(entry) or entry.remote_transport == "streamable_http": return await probe_streamable_http_mcp(url, headers, kind=entry.kind) try: diff --git a/tests/unit/test_connectors.py b/tests/unit/test_connectors.py index 7758caf8..ce9c26fa 100644 --- a/tests/unit/test_connectors.py +++ b/tests/unit/test_connectors.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import json from pathlib import Path import pytest @@ -170,6 +171,241 @@ def test_build_dida365_remote_spec(): assert spec["headers"]["Accept"] == "application/json, text/event-stream" +def test_weknora_catalog_and_credentials(): + from octop.infra.connectors.catalog import catalog_entry_to_dict + + entry = get_catalog_entry("weknora") + assert entry is not None + assert entry.mcp_mode == "gateway" + data = catalog_entry_to_dict(entry) + fields = {item["key"]: item for item in data["credential_fields"]} + assert fields["base_url"]["field_type"] == "url" + assert fields["api_key"]["secret"] is True + + payload = validate_create_credentials( + "weknora", + { + "base_url": "http://127.0.0.1:8080/", + "api_key": "sk-secret", + "tenant_id": "tenant-1", + "knowledge_base_ids": "kb-1, kb-2, kb-1", + }, + ) + assert payload["base_url"] == "http://127.0.0.1:8080/api/v1" + assert payload["api_key"] == "sk-secret" + assert payload["tenant_id"] == "tenant-1" + assert payload["knowledge_base_ids"] == ["kb-1", "kb-2"] + assert payload["internal_token"] + + +def test_weknora_rejects_non_https_remote_url(): + with pytest.raises(ValueError, match="non-local url must use https"): + validate_create_credentials( + "weknora", + {"base_url": "http://weknora.example.com", "api_key": "sk-secret"}, + ) + with pytest.raises(ValueError, match="query string or fragment"): + validate_create_credentials( + "weknora", + {"base_url": "https://weknora.example.com?token=secret"}, + ) + + +def test_dify_builds_streamable_http_spec(): + entry = get_catalog_entry("dify") + assert entry is not None + assert entry.remote_transport == "streamable_http" + creds = validate_create_credentials( + "dify", + {"mcp_url": "https://dify.example.com/mcp/server/server-code/mcp"}, + ) + spec = build_http_mcp_spec( + entry=entry, + instance_id="x", + creds=creds, + config=OctopConfig(), + ) + assert spec == { + "transport": "http", + "url": "https://dify.example.com/mcp/server/server-code/mcp", + "headers": {"Accept": "application/json, text/event-stream"}, + } + + +def test_dify_rejects_non_server_url(): + with pytest.raises(ValueError, match="Dify MCP Server URL"): + validate_create_credentials("dify", {"mcp_url": "https://dify.example.com/v1/chat"}) + + +def test_dify_server_code_is_redacted_from_logs(): + from octop.infra.connectors.builder import _redact_mcp_configs_for_log + + redacted = _redact_mcp_configs_for_log( + { + "dify__x": { + "transport": "http", + "url": "https://dify.example.com/mcp/server/secret-code/mcp", + } + } + ) + assert redacted["dify__x"]["url"] == "https://dify.example.com/mcp/server/***/mcp" + + +def test_custom_field_preview_redacts_secrets(): + from octop.api.routers.connectors import _credentials_preview + + weknora = _credentials_preview( + "weknora", + { + "base_url": "https://weknora.example.com/api/v1", + "api_key": "sk-secret", + "tenant_id": "tenant-1", + "knowledge_base_ids": ["kb-1"], + }, + ) + assert weknora == { + "base_url": "https://weknora.example.com/api/v1", + "api_key_configured": True, + "tenant_id": "tenant-1", + "knowledge_base_ids": ["kb-1"], + } + dify = _credentials_preview( + "dify", + {"mcp_url": "https://dify.example.com/mcp/server/secret-code/mcp"}, + ) + assert dify == {"mcp_url_configured": True} + + +def test_gateway_weknora_tools_and_search(monkeypatch: pytest.MonkeyPatch): + from octop.infra.connectors.gateway.adapters import weknora + + calls: list[tuple[str, str, dict[str, object] | None]] = [] + + def _fake_request( + _creds: dict[str, object], + method: str, + path: str, + *, + params: dict[str, object] | None = None, + body: dict[str, object] | None = None, + ) -> dict[str, object]: + calls.append((method, path, body)) + if path == "/knowledge-bases": + return {"success": True, "data": [{"id": "kb-1", "name": "Docs"}]} + assert params == {"resource_urls": "handle"} + return { + "success": True, + "data": [ + { + "knowledge_id": "doc-1", + "knowledge_title": "Guide", + "chunk_index": 2, + "score": 0.9, + "content": "x" * 1300, + } + ], + } + + monkeypatch.setattr(weknora, "_request", _fake_request) + listed = handle_mcp_request( + kind="weknora", + creds={"base_url": "http://127.0.0.1:8080/api/v1"}, + body={"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}, + ) + assert [tool["name"] for tool in listed["result"]["tools"]] == [ + "list_knowledge_bases", + "search", + "read_document", + ] + result = json.loads( + weknora.call_tool( + {"base_url": "http://127.0.0.1:8080/api/v1"}, + "search", + {"query": "install"}, + ) + ) + assert result["knowledge_base_ids"] == ["kb-1"] + assert len(result["passages"][0]["content"]) == 1200 + assert result["passages"][0]["truncated"] is True + assert calls[-1][2] == {"query": "install", "knowledge_base_ids": ["kb-1"]} + + +@pytest.mark.asyncio +async def test_dify_probe_uses_streamable_http(monkeypatch: pytest.MonkeyPatch): + from octop.infra.connectors.probe import probe_connector + + seen: dict[str, object] = {} + + async def _probe(url: str, headers: dict[str, str], *, kind: str) -> dict[str, object]: + seen.update(url=url, headers=headers, kind=kind) + return {"ok": True, "tool_count": 1, "tools": [{"name": "run", "description": ""}]} + + monkeypatch.setattr("octop.infra.connectors.probe.probe_streamable_http_mcp", _probe) + entry = get_catalog_entry("dify") + assert entry is not None + result = await probe_connector( + entry, + {"mcp_url": "https://dify.example.com/mcp/server/code/mcp"}, + instance_id="probe", + config=OctopConfig(), + ) + assert result["ok"] is True + assert seen == { + "url": "https://dify.example.com/mcp/server/code/mcp", + "headers": {"Accept": "application/json, text/event-stream"}, + "kind": "dify", + } + + +@pytest.mark.asyncio +async def test_detect_local_weknora_uses_fixed_loopback(monkeypatch: pytest.MonkeyPatch): + import httpx + + from octop.infra.connectors import probe + + seen: list[str] = [] + real_client = httpx.AsyncClient + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(str(request.url)) + return httpx.Response(200, json={"status": "ok"}) + + def client_factory(**kwargs: object) -> httpx.AsyncClient: + kwargs.pop("trust_env", None) + return real_client(transport=httpx.MockTransport(handler), **kwargs) + + monkeypatch.setattr(probe.httpx, "AsyncClient", client_factory) + result = await probe.detect_local_weknora() + + assert result == { + "found": True, + "base_url": "http://127.0.0.1:8080/api/v1", + "console_url": "http://127.0.0.1", + } + assert seen == ["http://127.0.0.1:8080/health"] + + +@pytest.mark.asyncio +async def test_detect_local_weknora_returns_not_found_on_unhealthy_host( + monkeypatch: pytest.MonkeyPatch, +): + import httpx + + from octop.infra.connectors import probe + + real_client = httpx.AsyncClient + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(503, request=request) + + def client_factory(**kwargs: object) -> httpx.AsyncClient: + kwargs.pop("trust_env", None) + return real_client(transport=httpx.MockTransport(handler), **kwargs) + + monkeypatch.setattr(probe.httpx, "AsyncClient", client_factory) + assert await probe.detect_local_weknora() == {"found": False} + + def test_gateway_tools_list(): resp = handle_mcp_request( kind="qq-mail", From 6c7824a131f1f2c3aa9a1923199164d0d78db13e Mon Sep 17 00:00:00 2001 From: jubaoliang Date: Sun, 30 Aug 2026 11:31:33 +0800 Subject: [PATCH 17/28] fix(dashboard): make chat STT follow UI locale for Chinese speech (#477) Browser STT was using navigator.language and settling on onend before results, which dropped Chinese transcripts or forced English gibberish. --- dashboard/src/hooks/useVoiceInput.ts | 141 +++++++++++++++++------- dashboard/src/locales/en.json | 1 + dashboard/src/locales/zh.json | 1 + dashboard/src/utils/localePrefs.test.ts | 9 ++ dashboard/src/utils/localePrefs.ts | 5 + 5 files changed, 118 insertions(+), 39 deletions(-) diff --git a/dashboard/src/hooks/useVoiceInput.ts b/dashboard/src/hooks/useVoiceInput.ts index e486de15..f11d2eca 100644 --- a/dashboard/src/hooks/useVoiceInput.ts +++ b/dashboard/src/hooks/useVoiceInput.ts @@ -1,23 +1,41 @@ import { useCallback, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { voiceApi, type ActiveVoice } from "../api/modules/voice"; +import { speechLocaleFromUi } from "../utils/localePrefs"; import { cachedActiveVoice, fetchActiveVoice } from "./useVoiceConfig"; import { message as antMessage } from "@/utils/antdMessage"; +interface BrowserSpeechRecognitionAlternative { + transcript?: string; +} + +interface BrowserSpeechRecognitionResult { + isFinal: boolean; + length: number; + [index: number]: BrowserSpeechRecognitionAlternative; +} + interface BrowserSpeechRecognitionResultEvent { - results: ArrayLike<{ [index: number]: { transcript?: string } }>; + resultIndex: number; + results: ArrayLike & { length: number }; +} + +interface BrowserSpeechRecognitionErrorEvent { + error?: string; } interface BrowserSpeechRecognition { lang: string; + continuous: boolean; interimResults: boolean; maxAlternatives: number; onresult: ((event: BrowserSpeechRecognitionResultEvent) => void) | null; - onerror: (() => void) | null; + onerror: ((event: BrowserSpeechRecognitionErrorEvent) => void) | null; onend: (() => void) | null; start: () => void; stop?: () => void; + abort?: () => void; } type SpeechRecognitionCtor = new () => BrowserSpeechRecognition; @@ -49,6 +67,12 @@ function pickRecorderMimeType(): string { return ""; } +/** + * Native Web Speech API transcription. + * + * Important: settle only on `onend` / error / timeout. Resolving in `onresult` + * races Chrome's `onend` and often yields empty text for Chinese utterances. + */ async function transcribeWithBrowser(language: string): Promise { const Ctor = getSpeechRecognition(); if (!Ctor) { @@ -57,7 +81,11 @@ async function transcribeWithBrowser(language: string): Promise { return new Promise((resolve, reject) => { const rec = new Ctor(); let settled = false; + let finalText = ""; + let interimText = ""; let timer = 0; + let lastError: string | undefined; + const settle = (fn: () => void) => { if (settled) return; settled = true; @@ -67,23 +95,62 @@ async function transcribeWithBrowser(language: string): Promise { rec.onend = null; fn(); }; + rec.lang = language; - rec.interimResults = false; + rec.continuous = false; + rec.interimResults = true; rec.maxAlternatives = 1; + rec.onresult = (event) => { - const text = event.results[0]?.[0]?.transcript?.trim() ?? ""; - settle(() => resolve(text)); + let finals = ""; + let interim = ""; + for (let i = event.resultIndex; i < event.results.length; i++) { + const result = event.results[i]; + const piece = result?.[0]?.transcript ?? ""; + if (result?.isFinal) finals += piece; + else interim += piece; + } + if (finals) finalText += finals; + interimText = interim; + }; + + rec.onerror = (event) => { + lastError = event.error; + // no-speech / aborted: let onend resolve with whatever we have. + if (event.error === "no-speech" || event.error === "aborted") return; + settle(() => { + if (event.error === "network") { + reject(new Error("browser STT network")); + return; + } + reject(new Error(`browser STT failed: ${event.error ?? "unknown"}`)); + }); }; - rec.onerror = () => settle(() => reject(new Error("browser STT failed"))); - rec.onend = () => settle(() => resolve("")); + + rec.onend = () => { + const text = (finalText || interimText).trim(); + settle(() => { + if (!text && lastError && lastError !== "no-speech" && lastError !== "aborted") { + reject(new Error(`browser STT failed: ${lastError}`)); + return; + } + resolve(text); + }); + }; + timer = window.setTimeout(() => { try { rec.stop?.(); } catch { // Browser implementations differ; the timeout still settles below. } - settle(() => reject(new Error("browser STT timed out"))); + settle(() => { + const text = (finalText || interimText).trim(); + if (text) resolve(text); + else reject(new Error("browser STT timed out")); + }); }, BROWSER_STT_TIMEOUT_MS); + try { rec.start(); } catch (err) { @@ -102,17 +169,19 @@ export function canRecordAudio(): boolean { /** Check whether any STT method is available. */ export function isSttAvailable(): boolean { if (canRecordAudio()) return true; // server STT via MediaRecorder - return browserSttAvailable(); // browser STT (Android Chrome only) + return browserSttAvailable(); // browser STT (Chrome / Edge) } export function useVoiceInput(onText: (text: string) => void) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const [recording, setRecording] = useState(false); const [transcribing, setTranscribing] = useState(false); const mediaRecorderRef = useRef(null); const chunksRef = useRef([]); - const language = navigator.language || "zh-CN"; + // Follow dashboard UI locale — not navigator.language (often en-US on + // machines used with Chinese UI / Chinese speech). + const language = speechLocaleFromUi(i18n.language); const stopRecording = useCallback(async () => { const recorder = mediaRecorderRef.current; @@ -136,37 +205,24 @@ export function useVoiceInput(onText: (text: string) => void) { setTranscribing(true); try { const active = await fetchActiveVoice(); - let text = ""; - if (active.stt === "browser" && browserSttAvailable()) { - text = await transcribeWithBrowser(language); - } else { - try { - const result = await voiceApi.transcribe(blob, language); - text = result.text?.trim() ?? ""; - } catch (err) { - const msg = err instanceof Error ? err.message : ""; - if (msg.includes("VOICE_BROWSER_ONLY") || msg.includes("422")) { - if (browserSttAvailable()) { - text = await transcribeWithBrowser(language); - } else { - antMessage.error(t("voice.sttProviderRequired")); - return; - } - } else { - // Server STT failed — try browser fallback if available. - if (browserSttAvailable()) { - antMessage.warning(t("voice.sttFallback")); - text = await transcribeWithBrowser(language); - } else { - throw err; - } - } - } + // SpeechRecognition cannot consume a recorded blob. If STT is still + // "browser", ask the user to configure a server provider (or use the + // click-to-talk browser path from a cold start). + if (active.stt === "browser") { + antMessage.error(t("voice.sttProviderRequired")); + return; } + const result = await voiceApi.transcribe(blob, language); + const text = result.text?.trim() ?? ""; if (text) onText(text); else antMessage.info(t("voice.sttEmpty")); - } catch { - antMessage.error(t("voice.sttFailed")); + } catch (err) { + const msg = err instanceof Error ? err.message : ""; + if (msg.includes("VOICE_BROWSER_ONLY") || msg.includes("422")) { + antMessage.error(t("voice.sttProviderRequired")); + } else { + antMessage.error(t("voice.sttFailed")); + } } finally { setTranscribing(false); } @@ -183,6 +239,13 @@ export function useVoiceInput(onText: (text: string) => void) { const text = await transcribeWithBrowser(language); if (text) onText(text); else antMessage.info(t("voice.sttEmpty")); + } catch (err) { + const msg = err instanceof Error ? err.message : ""; + if (msg.includes("network")) { + antMessage.error(t("voice.sttNetworkFailed")); + } else { + antMessage.error(t("voice.sttFailed")); + } } finally { setTranscribing(false); } diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index 83fa3e73..6bfa260f 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -2139,6 +2139,7 @@ "sttFailed": "Speech recognition failed", "sttFallback": "Server STT failed — switched to browser recognition", "sttProviderRequired": "This browser does not support native speech recognition. Configure Tencent Cloud or OpenAI STT.", + "sttNetworkFailed": "Browser speech recognition could not reach the network (often Google). Configure Tencent Cloud, Xiaomi Mimo, or OpenAI STT under Settings → Voice.", "ttsFailed": "Text-to-speech failed", "nothingToRead": "No readable prose in this message (code blocks are skipped)", "browserNoChineseVoice": "Chrome has no Chinese voice — using Edge TTS instead", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 29b6a425..dcd85c18 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -2139,6 +2139,7 @@ "sttFailed": "语音识别失败", "sttFallback": "服务端识别失败,已切换浏览器识别", "sttProviderRequired": "当前浏览器不支持原生语音识别,请配置腾讯云或 OpenAI STT", + "sttNetworkFailed": "浏览器语音识别无法联网(常依赖 Google)。请在设置 → 语音服务中配置腾讯云、小米 Mimo 或 OpenAI STT", "ttsFailed": "语音合成失败", "nothingToRead": "没有可朗读的正文(代码块等内容已跳过)", "browserNoChineseVoice": "Chrome 没有中文语音,已改用 Edge TTS 朗读", diff --git a/dashboard/src/utils/localePrefs.test.ts b/dashboard/src/utils/localePrefs.test.ts index a960faf7..3a850c46 100644 --- a/dashboard/src/utils/localePrefs.test.ts +++ b/dashboard/src/utils/localePrefs.test.ts @@ -3,6 +3,7 @@ import { detectBrowserLocale, readStoredUiLocale, resolveInitialLocale, + speechLocaleFromUi, storeUiLocale, UI_LOCALE_STORAGE_KEY, } from "./localePrefs"; @@ -40,4 +41,12 @@ describe("localePrefs", () => { localStorage.removeItem(UI_LOCALE_STORAGE_KEY); expect(resolveInitialLocale()).toBe("en"); }); + + it("speechLocaleFromUi maps UI locale to STT BCP-47 tags", () => { + expect(speechLocaleFromUi("zh")).toBe("zh-CN"); + expect(speechLocaleFromUi("zh-CN")).toBe("zh-CN"); + expect(speechLocaleFromUi("en")).toBe("en-US"); + expect(speechLocaleFromUi("en-US")).toBe("en-US"); + expect(speechLocaleFromUi(null)).toBe("zh-CN"); + }); }); diff --git a/dashboard/src/utils/localePrefs.ts b/dashboard/src/utils/localePrefs.ts index ae6eee9a..24a34f8a 100644 --- a/dashboard/src/utils/localePrefs.ts +++ b/dashboard/src/utils/localePrefs.ts @@ -56,3 +56,8 @@ export function syncDocumentLang(locale: UiLocale): void { if (typeof document === "undefined") return; document.documentElement.lang = locale === "zh" ? "zh-CN" : "en"; } + +/** BCP-47 tag for STT / SpeechRecognition from dashboard UI locale. */ +export function speechLocaleFromUi(locale: string | null | undefined): string { + return normalizeUiLocale(locale) === "zh" ? "zh-CN" : "en-US"; +} From 282aa68b0f97e676a74486ca995e4e7a8a6bba5a Mon Sep 17 00:00:00 2001 From: jubaoliang Date: Sun, 30 Aug 2026 12:17:53 +0800 Subject: [PATCH 18/28] chore: bump orcakit-harness-agent to 1.0.0 (#478) * chore: bump orcakit-harness-agent to 1.0.0 Raise the minimum harness-agent constraint and refresh lockfiles after the local sync that accompanied recent dashboard work. * fix(tests): align bwrap jail execute test with harness-agent 1.0 Non-jail scoped shells now rewrite virtual paths via _execute_on_host; patch that path and stub env mapping so CI PATH PermissionErrors do not fail. --- dashboard/package-lock.json | 18 ----------- docs/agent-backend-file-io.md | 2 +- pyproject.toml | 2 +- tests/integration/test_bwrap_jail.py | 37 +++++++++++++++------- uv.lock | 47 ++++++++++++++-------------- 5 files changed, 51 insertions(+), 55 deletions(-) diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 8d06a092..8e841ae4 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -14872,24 +14872,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://mirrors.tencent.com/npm/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://mirrors.tencent.com/npm/yargs/-/yargs-17.7.2.tgz", diff --git a/docs/agent-backend-file-io.md b/docs/agent-backend-file-io.md index e62dbe75..68cce890 100644 --- a/docs/agent-backend-file-io.md +++ b/docs/agent-backend-file-io.md @@ -234,7 +234,7 @@ create(agent) ## 12. 局部 root_dir 与 execute jail(补充) -当 agent backend 为本地 `local_shell`,且同时满足 **Linux + `virtual_mode=True` + `root_dir` 非主机 `/` + 宿主机有 `bwrap`** 时,harness 在 **构造 backend 之前** 路由到 `BubbledLocalShellBackend`,将 `execute`(含 Skill 脚本)包进 bubblewrap:工作根绑到 `/`,与文件工具虚拟路径对齐。其余情况(宿主根、非 Linux、无 bwrap、`filesystem`)走普通 `HarnessLocalShellBackend` / deepagents `local_shell`,**不再**对 execute 做虚拟路径改写。 +当 agent backend 为本地 `local_shell`,且同时满足 **Linux + `virtual_mode=True` + `root_dir` 非主机 `/` + 宿主机有 `bwrap`** 时,harness 在 **构造 backend 之前** 路由到 `BubbledLocalShellBackend`,将 `execute`(含 Skill 脚本)包进 bubblewrap:工作根绑到 `/`,与文件工具虚拟路径对齐。其余情况(宿主根、非 Linux、无 bwrap、`filesystem`)走普通 `HarnessLocalShellBackend`:无目录狱,但在 **harness-agent >= 1.0** 且 `virtual_mode` + 非宿主 `root_dir` 时,仍会把 `execute` 命令里的虚拟绝对路径改写到该 `root_dir` 下再在宿主机执行。 `scripts/install.sh`(及 desktop Linux 安装脚本)会在 **Linux** 上尽力安装 `bubblewrap`;保存局部 `root_dir` 时仪表盘也会调用 `POST /api/filesystem/ensure-bwrap` 做同样的尽力安装。macOS / 无 bwrap 时无目录狱,文件工具仍靠 deepagents `virtual_mode`;`BackendWorkspace` 读/物化路径 failback 不变:绝对路径先 virtual 映到 `root_dir` 再原始宿主机路径;相对路径先 `{root_dir}/{rel}` 再 `{workspace_dir}/{rel}`。Dashboard path I/O: use ``dashboard/src/utils/workspaceIoPath.ts`` for download/file API paths (host absolute stays ``file://…``). Dock tab identity may still ``canonicalizeDockFilePath``; diff --git a/pyproject.toml b/pyproject.toml index 7614153b..277c176d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ "apscheduler>=3.10,<4", "argon2-cffi>=23.1", "pyjwt>=2.8", - "orcakit-harness-agent[all]>=0.9.27", + "orcakit-harness-agent[all]>=1.0.0", "harness-memory>=0.9.7", "harness-gateway>=0.9.3", "cryptography>=41", diff --git a/tests/integration/test_bwrap_jail.py b/tests/integration/test_bwrap_jail.py index 232ae385..89c460d5 100644 --- a/tests/integration/test_bwrap_jail.py +++ b/tests/integration/test_bwrap_jail.py @@ -2,8 +2,9 @@ ``BubbledLocalShellBackend`` is constructed only when Linux + ``virtual_mode`` + non-host ``root_dir`` + ``bwrap`` are available (factory routes before any -backend I/O). Otherwise ``HarnessLocalShellBackend`` (deepagents local shell + -workspace ``.env`` refresh) is used — no execute path rewrite. +backend I/O). Otherwise ``HarnessLocalShellBackend`` runs on the host; with +``virtual_mode`` and a scoped ``root_dir`` it still rewrites virtual absolute +paths in ``execute`` onto that root (harness-agent >= 1.0). Cross-platform cases run on Windows, macOS, and Linux. POSIX-shell and real ``bwrap`` jail cases are skipped where the host cannot run them (see markers). @@ -115,11 +116,16 @@ def test_read_virtual_path_from_scoped_root(tmp_path: Path) -> None: assert "read-me" in read.file_data.get("content", "") -def test_execute_without_jail_does_not_rewrite_command(tmp_path: Path) -> None: - """Non-jail local shell passes the command through unchanged (no path rewrite).""" - from deepagents.backends.local_shell import LocalShellBackend +def test_execute_without_jail_rewrites_virtual_command_paths(tmp_path: Path) -> None: + """Non-jail scoped shell still maps virtual absolute paths onto ``root_dir``. - spec, _scoped, workspace = _scoped_spec(tmp_path) + harness-agent 1.0 no longer delegates ``execute`` to deepagents + ``LocalShellBackend.execute``; it rewrites then calls ``_execute_on_host``. + Env mapping is stubbed so inaccessible ``PATH`` entries (common on CI) + cannot raise ``PermissionError`` from ``Path.exists``. + """ + spec, scoped, workspace = _scoped_spec(tmp_path) + (scoped / "out").mkdir() with patch( "harness_agent.backends.bwrap_shell.resolve_bubbled_bwrap", return_value=None, @@ -127,15 +133,22 @@ def test_execute_without_jail_does_not_rewrite_command(tmp_path: Path) -> None: backend = resolve_backend(spec, workspace_dir=workspace) assert isinstance(backend, HarnessLocalShellBackend) - with patch.object( - LocalShellBackend, - "execute", - return_value=ExecuteResponse(output="ok", exit_code=0, truncated=False), - ) as base_exec: + expected = str((scoped / "out" / "mapped.txt").resolve()) + with ( + patch.object( + HarnessLocalShellBackend, + "_execute_on_host", + return_value=ExecuteResponse(output="ok", exit_code=0, truncated=False), + ) as host_exec, + patch( + "harness_agent.backends.local_shell.map_virtual_paths_in_env", + side_effect=lambda env, *_a, **_k: env, + ), + ): result = backend.execute("echo x > /out/mapped.txt") assert result.exit_code == 0 - assert base_exec.call_args.args[0] == "echo x > /out/mapped.txt" + assert host_exec.call_args.args[0] == f"echo x > {expected}" # --------------------------------------------------------------------------- diff --git a/uv.lock b/uv.lock index b0cf647a..6faa174c 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.15'", @@ -838,7 +838,7 @@ wheels = [ [[package]] name = "deepagents" -version = "0.6.12" +version = "0.7.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain" }, @@ -846,11 +846,12 @@ dependencies = [ { name = "langchain-core" }, { name = "langchain-google-genai" }, { name = "langsmith" }, + { name = "packaging" }, { name = "wcmatch" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e0/db/a6acdc72a9e90c3f07ed10de35c951734a02d4facb693bb59684ad368801/deepagents-0.6.12.tar.gz", hash = "sha256:1f281c0bc5a63132f62e2ee345c1dc593b23188da6e23016401f6879fbe54b5f", size = 211364, upload-time = "2026-06-25T17:26:52.775Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/4b/359aa09b9fb378b9cd10b69cb7bdd75847da03c110939ad01f1db715d0e4/deepagents-0.7.9.tar.gz", hash = "sha256:e62311fe73a752d36b830b2f48352aa63afbd56f47176d924065230d983b248e", size = 287549, upload-time = "2026-08-25T21:02:41.431Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/49/af7219b3c13520fee047bb807cfaefba17f8e4584c551d946773589a4f08/deepagents-0.6.12-py3-none-any.whl", hash = "sha256:28b8fa0119ca0a689e3e18e288c4634e4046062acfc87a1cb34289d3af3a1c88", size = 236120, upload-time = "2026-06-25T17:26:51.736Z" }, + { url = "https://files.pythonhosted.org/packages/56/5d/6333620e6c9f519f23d85201d9e0ad346be7134fb0039555fabb3ce38e67/deepagents-0.7.9-py3-none-any.whl", hash = "sha256:fb5b382cc98ec132a6e30e5be2cacc60888231e358368aadc12f88efdec5d847", size = 314350, upload-time = "2026-08-25T21:02:39.789Z" }, ] [[package]] @@ -1635,30 +1636,30 @@ wheels = [ [[package]] name = "langchain" -version = "1.3.15" +version = "1.3.18" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, { name = "langgraph" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6a/1c/b84579174a8e82ed79f4c3e0cd5a7f2323facc5ccd4d1b8390e7d175b663/langchain-1.3.15.tar.gz", hash = "sha256:ab4b775b9703f7e37babe0b325dbbaef25573bda60ecf79f7850bc875f252795", size = 665047, upload-time = "2026-08-11T19:10:52.455Z" } +sdist = { url = "https://files.pythonhosted.org/packages/31/e9/0e5425e522b624d7c5c0911957b467b82890b36b0099bf727951f2ee3059/langchain-1.3.18.tar.gz", hash = "sha256:74ce99294f6f2c82ee64c3df39daa6a22085ee1449a718065b3604a88d78bf4d", size = 637052, upload-time = "2026-08-27T17:33:12.702Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/09/8d/ae721f4d68ff79a17110cabc9cb39b4568b0e3f1fe0a379b926c3f81d175/langchain-1.3.15-py3-none-any.whl", hash = "sha256:c0d2d0d51ed7da249e8ab7487173872059a9dd46fb071d905957485b7334f987", size = 147001, upload-time = "2026-08-11T19:10:50.846Z" }, + { url = "https://files.pythonhosted.org/packages/f7/04/374f6014ed6959dbdab92962c2b09e4d0223ed6a82f65694870b46d2c13f/langchain-1.3.18-py3-none-any.whl", hash = "sha256:f29cbef985848e5cfff5398f3c8c1568994a3a6f2a8f4fa720df30b6e0668b9c", size = 148007, upload-time = "2026-08-27T17:33:11.23Z" }, ] [[package]] name = "langchain-anthropic" -version = "1.6.0" +version = "1.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anthropic" }, { name = "langchain-core" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ef/db/1ff8c1ceaf0bb8febe1d3b16d8b7213d47bc7113328e68dbf46ebcf5fc38/langchain_anthropic-1.6.0.tar.gz", hash = "sha256:0307ec98b24566c747e91bac4158bc4f1510bae4b45f0d25107636cc1b616328", size = 722670, upload-time = "2026-08-19T21:39:40.395Z" } +sdist = { url = "https://files.pythonhosted.org/packages/56/fc/52f6d1d6069bafb08626e204c89c49c8dd4a536eedbb94f0b7e78668594d/langchain_anthropic-1.7.0.tar.gz", hash = "sha256:d48e3c118ff8d3eea83f17b50234a2d2ff491a2375d565f212eb990e7e3856cb", size = 750068, upload-time = "2026-08-27T15:23:59.261Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/f2/c62b91a0663c668ffe5bd1cc83c90c603c4423b65e74d53f01d2b4329df5/langchain_anthropic-1.6.0-py3-none-any.whl", hash = "sha256:a85df4aa8f91a201703a4276e599ff3bcc498ade64294a85f79a96d6991f3154", size = 57151, upload-time = "2026-08-19T21:39:39.415Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ce/e4367713080bc750e1dba845409c058f196543d1a0dc99683e2ef062f581/langchain_anthropic-1.7.0-py3-none-any.whl", hash = "sha256:68b34369aa01dad0c67bc690b8c47e09d06bd30fb28f320ad19ddc71c4445dc0", size = 60475, upload-time = "2026-08-27T15:23:57.989Z" }, ] [[package]] @@ -1739,7 +1740,7 @@ wheels = [ [[package]] name = "langchain-google-genai" -version = "4.2.6" +version = "4.3.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filetype" }, @@ -1747,9 +1748,9 @@ dependencies = [ { name = "langchain-core" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4c/08/68e4f90b273d1d8dc3a7a948e6535e60baac726adbb2a8341dcf17662f54/langchain_google_genai-4.2.6.tar.gz", hash = "sha256:653dc331e691ddd79784d9ff6a4082749e0f873394c6dc782c414eb4409850eb", size = 280074, upload-time = "2026-06-26T06:18:58.807Z" } +sdist = { url = "https://files.pythonhosted.org/packages/14/2e/7410d35d5a073a8a5d4942d49dfc386e79e11c09c66f02429ffcaeb57a5c/langchain_google_genai-4.3.6.tar.gz", hash = "sha256:d123cd8007ae63fc989bd869ef3853312b912007e63c2815b9aa2f089cfa6a05", size = 301130, upload-time = "2026-08-26T23:53:56.92Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/85/3c/9a6b43bced0238f95640555e14423fe4a8268e3f4f536f67e26e79e633d4/langchain_google_genai-4.2.6-py3-none-any.whl", hash = "sha256:c40db0c2d033a5fb6db8e2cc3fb6d49c5678b89b337a64da095fb73ec9f72021", size = 70457, upload-time = "2026-06-26T06:18:57.781Z" }, + { url = "https://files.pythonhosted.org/packages/d3/d5/dd74fb715bea978e14c8ba706e228a3637951477525b182553a37a138b1a/langchain_google_genai-4.3.6-py3-none-any.whl", hash = "sha256:00b75df114a519e434b304daabcd98ea885af87a78a4b059b3063490f65feb44", size = 78524, upload-time = "2026-08-26T23:53:55.893Z" }, ] [[package]] @@ -1928,7 +1929,7 @@ wheels = [ [[package]] name = "langsmith" -version = "0.9.3" +version = "0.11.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1946,9 +1947,9 @@ dependencies = [ { name = "xxhash" }, { name = "zstandard" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7e/b7/43f80940a758ceb48714ccc10c7acb9564be4f0d4786e8ed5d46deb0be39/langsmith-0.9.3.tar.gz", hash = "sha256:868a007b3ecda002914b21212a953c77fcb1d71a8a09a94562c3b57941d3df3a", size = 4577380, upload-time = "2026-06-26T15:56:30.45Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/0a/1acb2a3ffbccbe8f8dc358778967c9d2979e8a59b67ceba6eb54474324ab/langsmith-0.11.2.tar.gz", hash = "sha256:927694c939c9fb44187e0126cf718413c45ffce2324d480438e70eb0526e1380", size = 4841592, upload-time = "2026-08-27T22:31:34.004Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/fb/dc9f70b5581371fba7b01ba0704fe859bfcd12c4dad82b42ec12bd4160b1/langsmith-0.9.3-py3-none-any.whl", hash = "sha256:aac85686868a7e61d77858b880230e510583c61ea48ac0a197e6ceff64ffb22b", size = 619927, upload-time = "2026-06-26T15:56:28.301Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/cec0c1d24dda8c67a23ad204d0167e50aee202460e9388488661d94513f5/langsmith-0.11.2-py3-none-any.whl", hash = "sha256:75258142d27dffcc5df331479704b23fc3fd812cfca0469119bb9055a842882f", size = 753928, upload-time = "2026-08-27T22:31:31.644Z" }, ] [[package]] @@ -2570,7 +2571,7 @@ requires-dist = [ { name = "mcp", specifier = ">=1.9,<2" }, { name = "mss", marker = "extra == 'desktop'", specifier = ">=9.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" }, - { name = "orcakit-harness-agent", extras = ["all"], specifier = ">=0.9.27" }, + { name = "orcakit-harness-agent", extras = ["all"], specifier = ">=1.0.0" }, { name = "pillow", specifier = ">=10.0" }, { name = "playwright", specifier = ">=1.40" }, { name = "playwright", marker = "extra == 'browser'", specifier = ">=1.40" }, @@ -2744,7 +2745,7 @@ wheels = [ [[package]] name = "orcakit-harness-agent" -version = "0.9.27" +version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "deepagents" }, @@ -2762,9 +2763,9 @@ dependencies = [ { name = "mcp" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d2/b1/bd9e23e840bf48d8cea12e0d24bae2caaecaf8e2364893466846f5f427e4/orcakit_harness_agent-0.9.27.tar.gz", hash = "sha256:40c7816f4a47ed610131c1442cc391198ca5519fcd650752803f14a85a075e54", size = 1182826, upload-time = "2026-08-26T10:50:24.475Z" } +sdist = { url = "https://files.pythonhosted.org/packages/03/78/87ae014e63908e306a1861debe8eb1a66343a97752a23f8a8eb2513c0c9e/orcakit_harness_agent-1.0.0.tar.gz", hash = "sha256:ce4632b1ab056999d17820e14ede3037a1d18e032f059e8cfe39d71dff44c701", size = 1188698, upload-time = "2026-08-29T07:51:05.528Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/16/446c4fc6c5a6f0c9f84b8c742ec325cb48b4c1e59572811cef17157811e4/orcakit_harness_agent-0.9.27-py3-none-any.whl", hash = "sha256:328898750cff1d98db3ac36ce5e8a776b47f8288955b8e2a79bdfabd52a90659", size = 1407139, upload-time = "2026-08-26T10:50:20.842Z" }, + { url = "https://files.pythonhosted.org/packages/a9/54/9d1efafe40850c6e027e1ed036011adc1989eaaa948ee4d24825160cd7ff/orcakit_harness_agent-1.0.0-py3-none-any.whl", hash = "sha256:ae59bb3c023cee62d1d8317756b54242e1244ea4cd759c248c44f68cc8a40169", size = 1414704, upload-time = "2026-08-29T07:51:01.146Z" }, ] [package.optional-dependencies] @@ -4699,14 +4700,14 @@ wheels = [ [[package]] name = "wcmatch" -version = "10.2" +version = "11.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "bracex" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/45/98/eb989c3113908e2ef46d940a53695a1ebb4be5a732c4a4f700be8f8d682b/wcmatch-10.2.tar.gz", hash = "sha256:92204839e3e9c945e1e71d7e1e4edeab2601ed50a5c51ff4f3f97ca711eeb738", size = 132499, upload-time = "2026-06-30T00:50:07.198Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/43/30e407989e313677dbb9d5f045f966549a7254834571e342eaa4b55cc67b/wcmatch-11.0.1.tar.gz", hash = "sha256:1ea2b4fa678b8ca268253798d5963935df39132d47c3e241c0a0732224005e7d", size = 144662, upload-time = "2026-08-14T15:20:40.477Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/73/aef4aaf16b8d785762e2b14cf321a9178cc84a1bf4c40f58320f499a2d65/wcmatch-10.2-py3-none-any.whl", hash = "sha256:f1a79e80ccbe296907b7eaf57d8d3bc49eab0b428d35f7d09986b5079b6e4a5d", size = 39742, upload-time = "2026-06-30T00:50:05.927Z" }, + { url = "https://files.pythonhosted.org/packages/ce/77/7a02b0f05b3ffcdbef9719ce3ee0b508d6a29b58e95299f1580055671db3/wcmatch-11.0.1-py3-none-any.whl", hash = "sha256:fd149ecddb9f0a88ea780017d6dde17c994e494e7f7303d4e3c9d6251f978f4b", size = 43449, upload-time = "2026-08-14T15:20:39.379Z" }, ] [[package]] From fbe33e49c420065fc08de166a8035484df1d8945 Mon Sep 17 00:00:00 2001 From: jubaoliang Date: Sun, 30 Aug 2026 12:24:36 +0800 Subject: [PATCH 19/28] feat(tests): add live WeChat/Feishu channel credential probes (#479) Wire ChannelManager.probe_channel live cases into CI so real iLink and Feishu app credentials are verified when LIVE_* secrets are configured. --- .github/workflows/ci.yml | 6 +++ tests/live/test_channel_probe.py | 80 ++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 tests/live/test_channel_probe.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7fc21cd..9671aa9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,4 +115,10 @@ jobs: CONNECTOR_TENCENT_LEXIANG_COMPANY_FROM: ${{ secrets.LIVE_CONNECTOR_TENCENT_LEXIANG_COMPANY_FROM }} CONNECTOR_MEITUAN_TRAVEL_TOKEN: ${{ secrets.LIVE_CONNECTOR_MEITUAN_TRAVEL_TOKEN }} CONNECTOR_YUANDIAN_TOKEN: ${{ secrets.LIVE_CONNECTOR_YUANDIAN_TOKEN }} + # Channel probes (WeChat iLink + Feishu) + WEIXIN_BOT_UIN: ${{ secrets.LIVE_WEIXIN_BOT_UIN }} + WEIXIN_TOKEN: ${{ secrets.LIVE_WEIXIN_TOKEN }} + WEIXIN_BASE_URL: ${{ secrets.LIVE_WEIXIN_BASE_URL }} + FEISHU_APP_ID: ${{ secrets.LIVE_FEISHU_APP_ID }} + FEISHU_APP_SECRET: ${{ secrets.LIVE_FEISHU_APP_SECRET }} run: make test-live diff --git a/tests/live/test_channel_probe.py b/tests/live/test_channel_probe.py new file mode 100644 index 00000000..3484185e --- /dev/null +++ b/tests/live/test_channel_probe.py @@ -0,0 +1,80 @@ +"""Live channel credential probes (WeChat iLink + Feishu). + +Exercises the same ``ChannelManager.probe_channel`` path the dashboard uses +via ``Gateway.probe_config`` / ``POST …/channels/test`` — so a green result +means the real app credentials reach the real upstream. + +Credentials come from the repo-root ``.env`` (loaded by ``tests/live/conftest.py``) +or from CI GitHub Secrets mapped in ``.github/workflows/ci.yml``. Missing vars +auto-skip that case so CI stays green when a channel is not configured. + +Run locally:: + + uv run pytest tests/live/test_channel_probe.py -m live -v +""" + +from __future__ import annotations + +import aiohttp +import pytest +from harness_gateway.channels.weixin.api import WeixinAPIClient +from harness_gateway.channels.weixin.types import WeixinAPIError +from harness_gateway.manager import ChannelManager +from tests.support.secrets import optional_env, require_env + +pytestmark = pytest.mark.live + +_DEFAULT_WEIXIN_BASE_URL = "https://ilinkai.weixin.qq.com" + + +async def _noop_processor(_msg: object) -> None: + return None + + +@pytest.mark.asyncio +async def test_feishu_probe_accepts_app_credentials() -> None: + """Feishu ``start()`` refreshes tenant_access_token — validates app id/secret.""" + app_id = require_env("FEISHU_APP_ID") + app_secret = require_env("FEISHU_APP_SECRET") + + manager = ChannelManager(processor=_noop_processor) + await manager.probe_channel( + "feishu", + {"app_id": app_id, "app_secret": app_secret}, + tenant_id="live-probe", + channel_id="live-feishu-probe", + ) + + +@pytest.mark.asyncio +async def test_weixin_ilink_token_accepted() -> None: + """WeChat iLink getUpdates with a short poll validates bot token + UIN.""" + bot_uin = require_env("WEIXIN_BOT_UIN") + token = require_env("WEIXIN_TOKEN") + base_url = optional_env("WEIXIN_BASE_URL", _DEFAULT_WEIXIN_BASE_URL) or _DEFAULT_WEIXIN_BASE_URL + + # Product probe only starts the long-poll loop; hit getUpdates once so a + # bad token fails the live test instead of silently succeeding. + async with aiohttp.ClientSession() as session: + client = WeixinAPIClient(base_url, token, session, timeout_s=20.0) + try: + await client.get_updates(sync_cursor="", timeout_ms=1000) + except WeixinAPIError as exc: + pytest.fail( + f"weixin getUpdates rejected credentials " + f"(bot_uin={bot_uin!r} base_url={base_url!r}): " + f"ret={exc.ret} errcode={exc.errcode} errmsg={exc.errmsg}" + ) + + manager = ChannelManager(processor=_noop_processor) + await manager.probe_channel( + "weixin", + { + "bot_uin": bot_uin, + "token": token, + "base_url": base_url, + "account_id": bot_uin, + }, + tenant_id="live-probe", + channel_id="live-weixin-probe", + ) From 11457e33c1fb3562f5b52fcfdfee9aa1491c4ea9 Mon Sep 17 00:00:00 2001 From: jubaoliang Date: Sun, 30 Aug 2026 20:49:14 +0800 Subject: [PATCH 20/28] feat(skills): localize skill presentation from metadata.octop (#480) * feat(skills): localize skill presentation from metadata.octop Read bilingual label/summary and icons from SKILL.md, keep slug identity for disable/chat matching, and show market icons in the chat skill picker. * chore: bump harness-agent to 1.0.1 and skip Weixin live timeouts Pin orcakit-harness-agent>=1.0.1 now that 1.0.1 is on PyPI, and treat iLink getUpdates timeouts as environment skips like other live probes. --- CHANGELOG.md | 1 + dashboard/src/locales/en.json | 8 ++ dashboard/src/locales/zh.json | 8 ++ .../Skills/components/InstalledSkillsTab.tsx | 4 - .../Agent/Skills/components/SkillCard.tsx | 19 +-- .../Skills/components/SkillDrawer.test.ts | 21 +++ .../Agent/Skills/components/SkillDrawer.tsx | 80 ++++++++++- .../Agent/Skills/components/SkillHubTab.tsx | 39 +++++- .../Skills/components/SkillPackagesTab.tsx | 16 +-- .../Agent/Skills/components/skillHubCache.ts | 13 -- .../Agent/Skills/skillDisplayNames.test.ts | 14 ++ .../pages/Agent/Skills/skillDisplayNames.ts | 30 ++-- .../pages/Chat/chatContextChips.partial.less | 11 ++ .../pages/Chat/chatInputPickers.partial.less | 7 + .../Chat/components/ChatInputPreviewBar.tsx | 4 +- .../Chat/components/SkillPickerPopover.tsx | 19 ++- .../components/UserMessageComposerTags.tsx | 4 +- .../src/pages/Chat/utils/skillChipIcon.tsx | 18 +++ .../src/pages/Chat/utils/skillChipLabel.ts | 7 - .../pages/SkillPackages/PackageSkillCard.tsx | 13 +- pyproject.toml | 2 +- src/octop/api/routers/skill_packages.py | 76 +++++++++-- src/octop/api/routers/skills.py | 128 ++++++++---------- .../builtin_skills/skill-manager/SKILL.md | 9 ++ src/octop/infra/agents/manager.py | 34 ++++- .../infra/gateway/slash/runtime_bridge.py | 7 +- src/octop/infra/skills/install.py | 20 ++- src/octop/infra/skills/presentation.py | 91 +++++++++++++ src/octop/infra/skills/skill_package_store.py | 35 ++--- src/octop/infra/skills/workspace_catalog.py | 18 ++- tests/integration/test_skills_api.py | 47 ++++++- tests/integration/test_skills_hub.py | 5 +- tests/live/test_channel_probe.py | 29 ++-- tests/support/fakes.py | 52 ++++--- tests/unit/agents/test_skill_package_store.py | 36 ++++- tests/unit/skills/test_presentation.py | 73 ++++++++++ tests/unit/test_skillhub_install_metadata.py | 22 ++- uv.lock | 10 +- 38 files changed, 764 insertions(+), 266 deletions(-) create mode 100644 dashboard/src/pages/Agent/Skills/skillDisplayNames.test.ts create mode 100644 dashboard/src/pages/Chat/utils/skillChipIcon.tsx delete mode 100644 dashboard/src/pages/Chat/utils/skillChipLabel.ts create mode 100644 src/octop/infra/skills/presentation.py create mode 100644 tests/unit/skills/test_presentation.py diff --git a/CHANGELOG.md b/CHANGELOG.md index edbef7b2..f6c3b3dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - FnOS FPK 在 Release 成功后自动构建:复用 GHCR 镜像与 Release wheel,不再重复编镜像 - 正式发版时 Auto Tag 同步触发桌面客户端打包,产物挂到同一 `v*` GitHub Release(不再对任意分支 push 跑六平台矩阵) - 修复 Auto Tag → `workflow_dispatch` 时 Docker 镜像可能只有 `:latest`、缺少 `:{version}` 的问题(显式从 tag 解析版本) +- 依赖:`orcakit-harness-agent[all]>=1.0.1`(技能 catalog 透出 `metadata.octop` 展示字段) ### 修复 diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index 6bfa260f..3c2b7806 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -1442,6 +1442,14 @@ "kindWorkspace": "Workspace", "path": "Path", "skillDescription": "Description", + "displayNameZh": "Chinese display name", + "displayNameEn": "English display name", + "displayNameZhPlaceholder": "Name shown when the interface is in Chinese", + "displayNameEnPlaceholder": "Name shown when the interface is in English", + "displaySummaryZh": "Chinese short description", + "displaySummaryEn": "English short description", + "displaySummaryZhPlaceholder": "Short card description in Chinese", + "displaySummaryEnPlaceholder": "Short card description in English", "noDescription": "No description available", "createSkill": "Create Skill", "viewSkill": "View Skill", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index dcd85c18..da8595c8 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -1441,6 +1441,14 @@ "kindWorkspace": "工作区", "path": "路径", "skillDescription": "描述", + "displayNameZh": "中文展示名称", + "displayNameEn": "英文展示名称", + "displayNameZhPlaceholder": "界面为中文时显示的名称", + "displayNameEnPlaceholder": "界面为英文时显示的名称", + "displaySummaryZh": "中文简短描述", + "displaySummaryEn": "英文简短描述", + "displaySummaryZhPlaceholder": "用于技能卡片的中文短描述", + "displaySummaryEnPlaceholder": "用于技能卡片的英文短描述", "noDescription": "暂无描述", "createSkill": "创建技能", "viewSkill": "查看技能", diff --git a/dashboard/src/pages/Agent/Skills/components/InstalledSkillsTab.tsx b/dashboard/src/pages/Agent/Skills/components/InstalledSkillsTab.tsx index 5085ad99..f666d3a5 100644 --- a/dashboard/src/pages/Agent/Skills/components/InstalledSkillsTab.tsx +++ b/dashboard/src/pages/Agent/Skills/components/InstalledSkillsTab.tsx @@ -10,7 +10,6 @@ import { EmptyState } from "../../../../components/EmptyState"; import { SkillCard } from "./SkillCard"; import { SkillDrawer, type SkillFormValues } from "./SkillDrawer"; import { SkillImportModal } from "./SkillImportModal"; -import { hubInfoBySlugFromCache } from "./skillHubCache"; import SkillsTable from "./SkillsTable"; import type { SkillDetail, SkillSpec } from "../useSkills"; import styles from "../index.module.less"; @@ -80,8 +79,6 @@ export default function InstalledSkillsTab({ const [hoverKey, setHoverKey] = useState(null); const [form] = Form.useForm(); - const hubSkillsBySlug = useMemo(() => hubInfoBySlugFromCache(), []); - const filteredSkills = useMemo( () => skills @@ -173,7 +170,6 @@ export default function InstalledSkillsTab({ void handleEdit(skill)} onMouseEnter={() => setHoverKey(skill.slug)} diff --git a/dashboard/src/pages/Agent/Skills/components/SkillCard.tsx b/dashboard/src/pages/Agent/Skills/components/SkillCard.tsx index cb34f0ec..42fba928 100644 --- a/dashboard/src/pages/Agent/Skills/components/SkillCard.tsx +++ b/dashboard/src/pages/Agent/Skills/components/SkillCard.tsx @@ -14,18 +14,8 @@ import type { SkillSpec } from "../useSkills"; import { useSkillDisplayName } from "../skillDisplayNames"; import styles from "../index.module.less"; -/** SkillHub metadata matched by slug — used to show the marketplace's - * Chinese name / description / icon for installed skills (falls back to the - * local SKILL.md values when absent). */ -export interface SkillHubInfo { - name?: string; - description_zh?: string; - iconUrl?: string | null; -} - interface SkillCardProps { skill: SkillSpec; - hubInfo?: SkillHubInfo; isHover: boolean; onClick: () => void; onMouseEnter: () => void; @@ -186,7 +176,6 @@ const renderSkillIcon = (skill: SkillSpec) => { export function SkillCard({ skill, - hubInfo, onClick, onMouseEnter, onMouseLeave, @@ -201,11 +190,9 @@ export function SkillCard({ const iconColor = DEFAULT_COLOR; const iconBg = `${iconColor}18`; // ~10% opacity tint - // Prefer the SkillHub marketplace's Chinese name / description / icon when - // this installed skill matches a hub skill by slug; fall back to SKILL.md. - const displayName = hubInfo?.name || skillDisplayName(skill); - const displayDesc = hubInfo?.description_zh || skill.description; - const hubIcon = hubInfo?.iconUrl || skill.iconUrl; + const displayName = skillDisplayName(skill); + const displayDesc = skill.description; + const hubIcon = skill.iconUrl; const handleDeleteClick = (e: React.MouseEvent) => { e.stopPropagation(); diff --git a/dashboard/src/pages/Agent/Skills/components/SkillDrawer.test.ts b/dashboard/src/pages/Agent/Skills/components/SkillDrawer.test.ts index fd695a2f..1f6e8e79 100644 --- a/dashboard/src/pages/Agent/Skills/components/SkillDrawer.test.ts +++ b/dashboard/src/pages/Agent/Skills/components/SkillDrawer.test.ts @@ -37,6 +37,27 @@ describe("SkillDrawer emoji metadata", () => { expect(md).toContain("octop:"); }); + it("writes localized presentation fields into octop metadata", () => { + const md = buildSkillMarkdown({ + name: "demo", + description: "Agent trigger description", + labelZh: "演示技能", + labelEn: "Demo Skill", + summaryZh: "完成演示任务", + summaryEn: "Complete demo tasks", + emoji: "⚙️", + metadata: [], + body: "Do things.", + }); + + expect(md).toContain("label:"); + expect(md).toContain("zh: 演示技能"); + expect(md).toContain("en: Demo Skill"); + expect(md).toContain("summary:"); + expect(md).toContain("zh: 完成演示任务"); + expect(md).toContain("en: Complete demo tasks"); + }); + it("extracts emoji from flattened metadata and keeps other keys", () => { const { emoji, metadata } = parseSkillEmojiAndMetadata([ { key: OCTOP_EMOJI_META_KEY, value: "🔧" }, diff --git a/dashboard/src/pages/Agent/Skills/components/SkillDrawer.tsx b/dashboard/src/pages/Agent/Skills/components/SkillDrawer.tsx index b49354d3..def4c391 100644 --- a/dashboard/src/pages/Agent/Skills/components/SkillDrawer.tsx +++ b/dashboard/src/pages/Agent/Skills/components/SkillDrawer.tsx @@ -33,6 +33,10 @@ export interface MetadataEntry { export interface SkillFormValues { name: string; description: string; + labelZh?: string; + labelEn?: string; + summaryZh?: string; + summaryEn?: string; /** Surfaced as ``metadata.octop.emoji`` in SKILL.md. */ emoji: string; metadata: MetadataEntry[]; @@ -43,6 +47,16 @@ export interface SkillFormValues { } export const OCTOP_EMOJI_META_KEY = "octop.emoji"; +const OCTOP_LABEL_ZH_META_KEY = "octop.label.zh"; +const OCTOP_LABEL_EN_META_KEY = "octop.label.en"; +const OCTOP_SUMMARY_ZH_META_KEY = "octop.summary.zh"; +const OCTOP_SUMMARY_EN_META_KEY = "octop.summary.en"; +const PRESENTATION_META_KEYS = new Set([ + OCTOP_LABEL_ZH_META_KEY, + OCTOP_LABEL_EN_META_KEY, + OCTOP_SUMMARY_ZH_META_KEY, + OCTOP_SUMMARY_EN_META_KEY, +]); /** * The skill name doubles as the workspace directory slug, so only @@ -135,6 +149,24 @@ function withEmojiMetadata( return [{ key: OCTOP_EMOJI_META_KEY, value }, ...rest]; } +function withPresentationMetadata( + pairs: MetadataEntry[] | undefined, + values: SkillFormValues, +): MetadataEntry[] { + const rest = (pairs ?? []).filter( + (row) => !PRESENTATION_META_KEYS.has(row.key.trim()), + ); + const presentation = [ + [OCTOP_LABEL_ZH_META_KEY, values.labelZh], + [OCTOP_LABEL_EN_META_KEY, values.labelEn], + [OCTOP_SUMMARY_ZH_META_KEY, values.summaryZh], + [OCTOP_SUMMARY_EN_META_KEY, values.summaryEn], + ] + .filter((entry): entry is [string, string] => Boolean(entry[1]?.trim())) + .map(([key, value]) => ({ key, value: value.trim() })); + return [...presentation, ...rest]; +} + function buildMetadataObject( pairs: MetadataEntry[] | undefined, ): Record { @@ -154,7 +186,10 @@ export function buildSkillMarkdown(values: SkillFormValues): string { `description: ${yamlQuote(values.description.trim())}`, ]; const meta = buildMetadataObject( - withEmojiMetadata(values.metadata, values.emoji ?? ""), + withEmojiMetadata( + withPresentationMetadata(values.metadata, values), + values.emoji ?? "", + ), ); if (Object.keys(meta).length > 0) { lines.push("metadata:"); @@ -214,11 +249,19 @@ function parseSkillFormFromDetail(detail: SkillDetail): SkillFormValues { flattenMetadata(fm.metadata), detail.emoji?.trim() || DEFAULT_SKILL_EMOJI, ); + const presentationValue = (key: string) => + metadata.find((entry) => entry.key === key)?.value ?? ""; return { name: displayName, description, + labelZh: presentationValue(OCTOP_LABEL_ZH_META_KEY), + labelEn: presentationValue(OCTOP_LABEL_EN_META_KEY), + summaryZh: presentationValue(OCTOP_SUMMARY_ZH_META_KEY), + summaryEn: presentationValue(OCTOP_SUMMARY_EN_META_KEY), emoji, - metadata, + metadata: metadata.filter( + (entry) => !PRESENTATION_META_KEYS.has(entry.key), + ), body: detail.body || "", content: detail.raw, source: detail.kind === "builtin" ? "builtin" : "workspace", @@ -490,6 +533,37 @@ export function SkillDrawer({ ); + const presentationFields = ( + <> + + + + + + + + + + + + + + ); + const emojiField = ( {nameField} {descriptionField} + {presentationFields} {emojiField} {metadataFields}
@@ -726,6 +801,7 @@ export function SkillDrawer({
{nameField} {descriptionField} + {presentationFields} {emojiField} diff --git a/dashboard/src/pages/Agent/Skills/components/SkillHubTab.tsx b/dashboard/src/pages/Agent/Skills/components/SkillHubTab.tsx index 0f3c1ad5..7982e52c 100644 --- a/dashboard/src/pages/Agent/Skills/components/SkillHubTab.tsx +++ b/dashboard/src/pages/Agent/Skills/components/SkillHubTab.tsx @@ -49,6 +49,41 @@ function skillDesc(skill: SkillHubSkill): string { return skill.description_zh || skill.description || ""; } +function firstText(...values: unknown[]): string { + for (const value of values) { + const text = String(value ?? "").trim(); + if (text) return text; + } + return ""; +} + +function localizedPair( + zh: string, + en: string, +): { zh?: string; en?: string } | undefined { + const pair: { zh?: string; en?: string } = {}; + if (zh) pair.zh = zh; + if (en && en !== zh) pair.en = en; + return Object.keys(pair).length > 0 ? pair : undefined; +} + +function hubInstallPresentation(skill: SkillHubSkill): { + label?: { zh?: string; en?: string }; + summary?: { zh?: string; en?: string }; +} { + const raw = skill as SkillHubSkill & Record; + return { + label: localizedPair( + firstText(raw.display_name_zh, raw.displayName, skill.name), + firstText(raw.display_name_en, raw.displayNameEn), + ), + summary: localizedPair( + firstText(skill.description_zh, raw.summary_zh, skill.description), + firstText(raw.description_en, raw.summary_en), + ), + }; +} + function normalizeHubSkill(raw: Record): SkillHubSkill { const slug = String(raw.slug ?? raw.name ?? ""); return { @@ -226,10 +261,12 @@ export default function SkillHubTab({ target, onInstalled }: SkillHubTabProps) { setDrawerOpen(false); setInstallingSlug(skill.slug); try { + const presentation = hubInstallPresentation(skill); const body: Record = { skill_name: skill.slug, - display_name: skill.name, icon_url: skill.iconUrl ?? null, + label: presentation.label, + summary: presentation.summary, overwrite: true, }; if (installTarget.type === "agent") { diff --git a/dashboard/src/pages/Agent/Skills/components/SkillPackagesTab.tsx b/dashboard/src/pages/Agent/Skills/components/SkillPackagesTab.tsx index b3c57332..5f6af122 100644 --- a/dashboard/src/pages/Agent/Skills/components/SkillPackagesTab.tsx +++ b/dashboard/src/pages/Agent/Skills/components/SkillPackagesTab.tsx @@ -13,7 +13,6 @@ import { PackageIcon } from "../../../SkillPackages/PackageIcon"; import { showApiError } from "../../../../utils/showApiToast"; import { supportsHostSkillPackagesFromConfig } from "../../../Experts/components/agentBackendForm"; import type { SkillSpec } from "../useSkills"; -import { hubInfoBySlugFromCache } from "./skillHubCache"; import styles from "../index.module.less"; interface SkillPackagesTabProps { @@ -26,10 +25,8 @@ interface SkillPackagesTabProps { function resolvePackageSkillIcon( packageSkill: SkillPackageSkill, installed: SkillSpec | undefined, - hubIconUrl?: string | null, ): { iconUrl?: string; emoji?: string } { - const iconUrl = - hubIconUrl || packageSkill.icon_url || installed?.iconUrl || undefined; + const iconUrl = packageSkill.icon_url || installed?.iconUrl || undefined; const emoji = packageSkill.emoji || installed?.emoji; return { iconUrl, emoji }; } @@ -77,8 +74,6 @@ export default function SkillPackagesTab({ ); const [detailLoading, setDetailLoading] = useState(false); - const hubSkillsBySlug = useMemo(() => hubInfoBySlugFromCache(), []); - useEffect(() => { let cancelled = false; setLoading(true); @@ -302,18 +297,13 @@ export default function SkillPackagesTab({
{detailPackage.skills.map((packageSkill) => { const installed = skillsBySlug.get(packageSkill.slug); - const hubInfo = hubSkillsBySlug.get(packageSkill.slug); const { iconUrl, emoji } = resolvePackageSkillIcon( packageSkill, installed, - hubInfo?.iconUrl, ); - const displayName = - hubInfo?.name || packageSkill.name || packageSkill.slug; + const displayName = packageSkill.name || packageSkill.slug; const displayDesc = - hubInfo?.description_zh || - packageSkill.description || - t("skills.noDescription"); + packageSkill.description || t("skills.noDescription"); const shadows = workspaceSlugs.has(packageSkill.slug); const canToggle = detailMounted && !!installed && !shadows; diff --git a/dashboard/src/pages/Agent/Skills/components/skillHubCache.ts b/dashboard/src/pages/Agent/Skills/components/skillHubCache.ts index e20751d3..a52790e4 100644 --- a/dashboard/src/pages/Agent/Skills/components/skillHubCache.ts +++ b/dashboard/src/pages/Agent/Skills/components/skillHubCache.ts @@ -1,4 +1,3 @@ -import type { SkillHubInfo } from "./SkillCard"; import type { SkillHubSkill } from "./SkillHubDetailDrawer"; const RANKINGS_CACHE_KEY = "octop:skillhub-rankings:v1"; @@ -32,15 +31,3 @@ export function saveRankingsCache(data: Record): void { // localStorage may be full or unavailable; ignore silently. } } - -/** Flatten rankings cache into a slug → presentation map (same as installed skills). */ -export function hubInfoBySlugFromCache(): Map { - const bySlug = new Map(); - const cached = loadRankingsCache() ?? {}; - for (const rows of Object.values(cached)) { - for (const row of rows) { - bySlug.set(row.slug, row); - } - } - return bySlug; -} diff --git a/dashboard/src/pages/Agent/Skills/skillDisplayNames.test.ts b/dashboard/src/pages/Agent/Skills/skillDisplayNames.test.ts new file mode 100644 index 00000000..c52523cb --- /dev/null +++ b/dashboard/src/pages/Agent/Skills/skillDisplayNames.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { resolveSkillDisplayName } from "./skillDisplayNames"; + +describe("resolveSkillDisplayName", () => { + it("prefers the API presentation name over the slug", () => { + expect( + resolveSkillDisplayName({ slug: "pdf", name: "PDF 阅读与编辑" }), + ).toBe("PDF 阅读与编辑"); + }); + + it("uses the slug when the API only returns the identity name", () => { + expect(resolveSkillDisplayName({ slug: "pdf", name: "pdf" })).toBe("pdf"); + }); +}); diff --git a/dashboard/src/pages/Agent/Skills/skillDisplayNames.ts b/dashboard/src/pages/Agent/Skills/skillDisplayNames.ts index ba4733f6..fa09493b 100644 --- a/dashboard/src/pages/Agent/Skills/skillDisplayNames.ts +++ b/dashboard/src/pages/Agent/Skills/skillDisplayNames.ts @@ -1,36 +1,22 @@ /** - * Localised labels for built-in agent skills shown in the dashboard. - * Labels are defined in backend ``src/octop/i18n/*.json`` (``skills.*``). + * Resolve API-provided presentation names. ``name`` is the localized label + * from the list API; ``slug`` is the stable identity. */ -import { useTranslation } from "react-i18next"; - export interface SkillLabelInput { slug?: string; name?: string; } -export function resolveSkillDisplayName( - skill: SkillLabelInput, - translate: (slug: string) => string, -): string { - const slug = skill.slug ?? skill.name ?? ""; - if (!slug) return ""; - const localized = translate(slug); - if (localized !== slug) return localized; - return skill.name || slug; +export function resolveSkillDisplayName(skill: SkillLabelInput): string { + const slug = (skill.slug ?? "").trim(); + const name = (skill.name ?? "").trim(); + if (name && slug && name !== slug) return name; + return name || slug; } export function useSkillDisplayName(): (skill: SkillLabelInput) => string { - const { t } = useTranslation(); - - const translate = (slug: string) => { - const key = `skills.${slug}`; - const translated = t(key); - return translated === key ? slug : translated; - }; - - return (skill: SkillLabelInput) => resolveSkillDisplayName(skill, translate); + return resolveSkillDisplayName; } /** Resolve by slug alone (e.g. expert template preview before agent exists). */ diff --git a/dashboard/src/pages/Chat/chatContextChips.partial.less b/dashboard/src/pages/Chat/chatContextChips.partial.less index b9d9c817..7e4f0ef5 100644 --- a/dashboard/src/pages/Chat/chatContextChips.partial.less +++ b/dashboard/src/pages/Chat/chatContextChips.partial.less @@ -48,6 +48,17 @@ height: 16px; } +.contextChipIconImg { + width: 14px; + height: 14px; + object-fit: contain; +} + +.contextChipCompact .contextChipIconImg { + width: 12px; + height: 12px; +} + .contextChipLabel { min-width: 0; flex: 1; diff --git a/dashboard/src/pages/Chat/chatInputPickers.partial.less b/dashboard/src/pages/Chat/chatInputPickers.partial.less index 74fa80cb..ac246312 100644 --- a/dashboard/src/pages/Chat/chatInputPickers.partial.less +++ b/dashboard/src/pages/Chat/chatInputPickers.partial.less @@ -514,6 +514,13 @@ font-size: 13px; font-weight: 600; line-height: 1; + overflow: hidden; +} + +.skillPickerAvatarImg { + width: 18px; + height: 18px; + object-fit: contain; } .skillPickerText { diff --git a/dashboard/src/pages/Chat/components/ChatInputPreviewBar.tsx b/dashboard/src/pages/Chat/components/ChatInputPreviewBar.tsx index 1aadf137..b63f696f 100644 --- a/dashboard/src/pages/Chat/components/ChatInputPreviewBar.tsx +++ b/dashboard/src/pages/Chat/components/ChatInputPreviewBar.tsx @@ -12,7 +12,7 @@ import { knowledgeIconForName } from "../../KnowledgeBases/knowledgeIcons"; import { inferKindFromNameAndMime } from "../utils/chatAttachments"; import { ChatMediaPlayer } from "./ChatMediaPlayer"; import ContextChip from "./ContextChip"; -import { skillChipLabel } from "../utils/skillChipLabel"; +import { skillChipIcon } from "../utils/skillChipIcon"; import { useSkillDisplayName } from "../../Agent/Skills/skillDisplayNames"; import { modelShortLabel } from "../../../utils/modelOptions"; import styles from "../index.module.less"; @@ -247,7 +247,7 @@ export default function ChatInputPreviewBar({ onSkillsChange(selectedSkills.filter((n) => n !== slug)) diff --git a/dashboard/src/pages/Chat/components/SkillPickerPopover.tsx b/dashboard/src/pages/Chat/components/SkillPickerPopover.tsx index 5554f856..bf0d3052 100644 --- a/dashboard/src/pages/Chat/components/SkillPickerPopover.tsx +++ b/dashboard/src/pages/Chat/components/SkillPickerPopover.tsx @@ -16,7 +16,20 @@ interface SkillPickerPopoverProps { onNavigateAway?: () => void; } -function skillAvatarLabel(skill: SkillSpec): string { +function SkillAvatar({ skill }: { skill: SkillSpec }) { + const iconUrl = skill.iconUrl?.trim(); + return ( + + {iconUrl ? ( + + ) : ( + skillAvatarFallback(skill) + )} + + ); +} + +function skillAvatarFallback(skill: SkillSpec): string { if (skill.emoji) return skill.emoji; const name = skill.name || skill.slug; return name.charAt(0).toUpperCase(); @@ -79,9 +92,7 @@ export default function SkillPickerPopover({ onSkillsChange(next); }} > - - {skillAvatarLabel(skill)} - + {skillDisplayName(skill)} diff --git a/dashboard/src/pages/Chat/components/UserMessageComposerTags.tsx b/dashboard/src/pages/Chat/components/UserMessageComposerTags.tsx index 57ee0a5f..4c366d37 100644 --- a/dashboard/src/pages/Chat/components/UserMessageComposerTags.tsx +++ b/dashboard/src/pages/Chat/components/UserMessageComposerTags.tsx @@ -9,7 +9,7 @@ import ExpertAgentAvatar from "./ExpertAgentAvatar"; import { ConnectorLogo } from "../../Agent/Connectors/connectorDefs"; import { knowledgeIconForName } from "../../KnowledgeBases/knowledgeIcons"; import ContextChip, { type ContextChipVariant } from "./ContextChip"; -import { skillChipLabel } from "../utils/skillChipLabel"; +import { skillChipIcon } from "../utils/skillChipIcon"; import { modelShortLabel } from "../../../utils/modelOptions"; import styles from "../index.module.less"; @@ -46,7 +46,7 @@ export default function UserMessageComposerTags({ items.push({ key: `skill-${slug}`, variant: "skill", - icon: skill ? skillChipLabel(skill) : "✦", + icon: skill ? skillChipIcon(skill) : "✦", label: skill?.name || slug, }); } diff --git a/dashboard/src/pages/Chat/utils/skillChipIcon.tsx b/dashboard/src/pages/Chat/utils/skillChipIcon.tsx new file mode 100644 index 00000000..1b098695 --- /dev/null +++ b/dashboard/src/pages/Chat/utils/skillChipIcon.tsx @@ -0,0 +1,18 @@ +import type { ReactNode } from "react"; +import type { SkillSpec } from "../../Agent/Skills/useSkills"; +import styles from "../index.module.less"; + +function skillChipFallback(skill: SkillSpec): string { + if (skill.emoji) return skill.emoji; + const name = skill.name || skill.slug; + return name.charAt(0).toUpperCase(); +} + +/** Composer / history chip icon: market image, then emoji, then initial. */ +export function skillChipIcon(skill: SkillSpec): ReactNode { + const iconUrl = skill.iconUrl?.trim(); + if (iconUrl) { + return ; + } + return skillChipFallback(skill); +} diff --git a/dashboard/src/pages/Chat/utils/skillChipLabel.ts b/dashboard/src/pages/Chat/utils/skillChipLabel.ts deleted file mode 100644 index 31ec5c6f..00000000 --- a/dashboard/src/pages/Chat/utils/skillChipLabel.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { SkillSpec } from "../../Agent/Skills/useSkills"; - -export function skillChipLabel(skill: SkillSpec): string { - if (skill.emoji) return skill.emoji; - const name = skill.name || skill.slug; - return name.charAt(0).toUpperCase(); -} diff --git a/dashboard/src/pages/SkillPackages/PackageSkillCard.tsx b/dashboard/src/pages/SkillPackages/PackageSkillCard.tsx index b9292b83..1f8e7834 100644 --- a/dashboard/src/pages/SkillPackages/PackageSkillCard.tsx +++ b/dashboard/src/pages/SkillPackages/PackageSkillCard.tsx @@ -1,9 +1,7 @@ -import { useMemo } from "react"; import { Popconfirm } from "antd"; import { FileCode2, Info, Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { SkillPackageSkill } from "../../api/types/skillPackage"; -import { hubInfoBySlugFromCache } from "../Agent/Skills/components/skillHubCache"; import skillStyles from "../Agent/Skills/index.module.less"; interface PackageSkillCardProps { @@ -22,15 +20,10 @@ export function PackageSkillCard({ onDelete, }: PackageSkillCardProps) { const { t } = useTranslation(); - const hubInfo = useMemo( - () => hubInfoBySlugFromCache().get(skill.slug), - [skill.slug], - ); - const iconUrl = hubInfo?.iconUrl || skill.icon_url || undefined; + const iconUrl = skill.icon_url || undefined; const emoji = skill.emoji; - const displayName = hubInfo?.name || skill.name; - const displayDesc = - hubInfo?.description_zh || skill.description || t("skills.noDescription"); + const displayName = skill.name; + const displayDesc = skill.description || t("skills.noDescription"); const iconBg = `${DEFAULT_COLOR}18`; return ( diff --git a/pyproject.toml b/pyproject.toml index 277c176d..63b2cf82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ "apscheduler>=3.10,<4", "argon2-cffi>=23.1", "pyjwt>=2.8", - "orcakit-harness-agent[all]>=1.0.0", + "orcakit-harness-agent[all]>=1.0.1", "harness-memory>=0.9.7", "harness-gateway>=0.9.3", "cryptography>=41", diff --git a/src/octop/api/routers/skill_packages.py b/src/octop/api/routers/skill_packages.py index 1e17dfa9..696330cf 100644 --- a/src/octop/api/routers/skill_packages.py +++ b/src/octop/api/routers/skill_packages.py @@ -28,7 +28,7 @@ from octop.infra.skills.skillhub_market import SkillHubMarketError as SkillHubDownloadError from octop.infra.users.identity import User from octop.infra.utils.frontmatter import parse_frontmatter -from octop.infra.utils.locale import resolve_request_locale +from octop.infra.utils.locale import Locale, resolve_request_locale router = APIRouter(prefix="/skill-packages") @@ -114,10 +114,17 @@ def _package_skill_exists(store: SkillPackageStore, package_id: str, slug: str) return (store.package_skills_dir(package_id) / slug / "SKILL.md").is_file() +class LocalizedSkillCopy(BaseModel): + zh: str | None = None + en: str | None = None + + class HubInstallPackageSkillBody(BaseModel): skill_name: str display_name: str | None = None icon_url: str | None = None + label: LocalizedSkillCopy | None = None + summary: LocalizedSkillCopy | None = None overwrite: bool = False @@ -148,8 +155,16 @@ def _row_public_dict(row: SkillPackageRow) -> dict[str, Any]: return payload -def _package_payload(store: SkillPackageStore, row: SkillPackageRow) -> dict[str, Any]: - return {**_row_public_dict(row), "skills": store.list_skill_summaries(row.id)} +def _package_payload( + store: SkillPackageStore, + row: SkillPackageRow, + *, + locale: Locale | None = None, +) -> dict[str, Any]: + return { + **_row_public_dict(row), + "skills": store.list_skill_summaries(row.id, locale=locale), + } def _creator_fields(server: OctopServer, created_by: str) -> dict[str, str | None]: @@ -171,9 +186,11 @@ def _package_payload_with_creator( server: OctopServer, store: SkillPackageStore, row: SkillPackageRow, + *, + locale: Locale | None = None, ) -> dict[str, Any]: return { - **_package_payload(store, row), + **_package_payload(store, row, locale=locale), **_creator_fields(server, row.created_by), } @@ -220,8 +237,13 @@ def _package_skill_or_404( store: SkillPackageStore, package_id: str, slug: str, + *, + locale: Locale | None = None, ) -> dict[str, Any]: - for skill in store.list_skill_summaries(package_id): + for skill in store.list_skill_summaries( + package_id, + locale=locale, + ): if skill["slug"] == slug: return skill raise OctopError(ErrorCode.NOT_FOUND, f"skill {slug!r} not found") @@ -342,7 +364,12 @@ async def get_skill_package( ) -> dict[str, Any]: store = _store(server) row = _package_or_404(store, package_id, locale=resolve_request_locale(request)) - return _package_payload_with_creator(server, store, row) + return _package_payload_with_creator( + server, + store, + row, + locale=resolve_request_locale(request), + ) @router.patch("/{package_id}", summary="Update global skill package metadata") @@ -373,7 +400,12 @@ async def update_skill_package( raise_skill_package_name_taken(name) raise updated = _package_or_404(store, package_id, locale=resolve_request_locale(request)) - return _package_payload_with_creator(server, store, updated) + return _package_payload_with_creator( + server, + store, + updated, + locale=resolve_request_locale(request), + ) @router.delete( @@ -402,7 +434,10 @@ async def list_package_skills( ) -> list[dict[str, Any]]: store = _store(server) _package_or_404(store, package_id, locale=resolve_request_locale(request)) - return store.list_skill_summaries(package_id) + return store.list_skill_summaries( + package_id, + locale=resolve_request_locale(request), + ) @router.get("/{package_id}/skills/{slug}", summary="Get a global package skill") @@ -415,7 +450,12 @@ async def get_package_skill( ) -> dict[str, Any]: store = _store(server) _package_or_404(store, package_id, locale=resolve_request_locale(request)) - skill = _package_skill_or_404(store, package_id, slug) + skill = _package_skill_or_404( + store, + package_id, + slug, + locale=resolve_request_locale(request), + ) raw = (store.package_skills_dir(package_id) / slug / "SKILL.md").read_text(encoding="utf-8") frontmatter, body = parse_frontmatter(raw) return {**skill, "frontmatter": frontmatter, "body": body, "raw": raw} @@ -653,8 +693,22 @@ async def hub_install_package_skill( display_name = (body.display_name or "").strip() icon_url = (body.icon_url or "").strip() + label = ( + {key: value.strip() for key, value in body.label.model_dump().items() if value} + if body.label + else {} + ) + summary = ( + {key: value.strip() for key, value in body.summary.model_dump().items() if value} + if body.summary + else {} + ) if len(display_name) > 200: raise HTTPException(status_code=400, detail="display_name is too long") + if any(len(value) > 200 for value in label.values()): + raise HTTPException(status_code=400, detail="localized skill label is too long") + if any(len(value) > 1024 for value in summary.values()): + raise HTTPException(status_code=400, detail="localized skill summary is too long") if len(icon_url) > 2048 or (icon_url and not valid_skillhub_icon_url(icon_url)): raise HTTPException(status_code=400, detail="icon_url must be an HTTP(S) URL") @@ -686,6 +740,8 @@ async def hub_install_package_skill( files=files, display_name=display_name, icon_url=icon_url, + label=label, + summary=summary, overwrite=body.overwrite, ) except SkillAlreadyExistsError as exc: @@ -703,5 +759,5 @@ async def hub_install_package_skill( "installed": True, "name": skill_name, "transport": transport, - "skill": _package_skill_or_404(store, package_id, skill_name), + "skill": _package_skill_or_404(store, package_id, skill_name, locale=locale), } diff --git a/src/octop/api/routers/skills.py b/src/octop/api/routers/skills.py index e9eaaefe..50666d0d 100644 --- a/src/octop/api/routers/skills.py +++ b/src/octop/api/routers/skills.py @@ -53,6 +53,7 @@ skills_disabled_set as _disabled_set, ) from octop.infra.errors import ErrorCode, OctopError +from octop.infra.skills.presentation import apply_skill_presentation from octop.infra.skills.skill_package_store import SkillPackageStore from octop.infra.skills.skill_packages import ( SkillPackageError, @@ -61,7 +62,7 @@ resolve_skill_package, validate_skill_slug, ) -from octop.infra.utils.locale import resolve_request_locale +from octop.infra.utils.locale import Locale, resolve_request_locale logger = logging.getLogger(__name__) @@ -140,39 +141,24 @@ def _summary_dict( *, enabled: bool, kind: str, + locale: Locale | None = None, ) -> dict[str, Any]: - out: dict[str, Any] = { - # ``slug`` is the directory name — the stable identifier used for all - # by-name operations (detail / enable / disable / delete / install - # check). ``name`` is the frontmatter display name, which may differ - # from the slug (e.g. dir "tencent-meeting-skill" with frontmatter - # name "tencent-meeting-mcp"); using ``name`` as the id 404s. - "slug": name, - "name": str(meta.get("name") or name), - "description": str(meta.get("description") or ""), - "enabled": enabled, - "kind": kind, - } - # Presentation metadata is kept separate from the stable directory slug and - # the skill's own frontmatter name. Octop-authored metadata wins, while - # upstream formats remain supported for already-installed marketplace skills. - metadata = meta.get("metadata") or {} - if isinstance(metadata, dict): - has_display_name = False - for namespace in ("octop", "lightclaw", "orca", "openclaw"): - ext = metadata.get(namespace) or {} - if not isinstance(ext, dict): - continue - display_name = str(ext.get("display_name") or "").strip() - if display_name and not has_display_name: - out["name"] = display_name - has_display_name = True - emoji = str(ext.get("emoji") or "").strip() - if emoji and "emoji" not in out: - out["emoji"] = emoji - icon_url = str(ext.get("icon_url") or "").strip() - if icon_url and "icon_url" not in out and _valid_skillhub_icon_url(icon_url): - out["icon_url"] = icon_url + out = apply_skill_presentation( + { + # ``slug`` is the directory name — the stable identifier used for all + # by-name operations (detail / enable / disable / delete / install + # check). ``name`` is the frontmatter display name, which may differ + # from the slug (e.g. dir "tencent-meeting-skill" with frontmatter + # name "tencent-meeting-mcp"); using ``name`` as the id 404s. + "slug": name, + "name": str(meta.get("name") or name), + "description": str(meta.get("description") or ""), + "enabled": enabled, + "kind": kind, + }, + meta, + locale=locale, + ) return out @@ -182,23 +168,6 @@ def _valid_skillhub_icon_url(value: str) -> bool: return valid_skillhub_icon_url(value) -def _with_skillhub_presentation_metadata( - content: str, - *, - display_name: str, - icon_url: str, -) -> str: - from octop.infra.skills.install import ( # noqa: PLC0415 - with_skillhub_presentation_metadata, - ) - - return with_skillhub_presentation_metadata( - content, - display_name=display_name, - icon_url=icon_url, - ) - - def _skill_manifest_path(name: str, kind: str, workspace: Any) -> str: root = _BUILTIN_ROOT if kind == "builtin" else _SKILLS_ROOT return f"{root}/{name}/SKILL.md" @@ -476,6 +445,7 @@ async def validate_chat_skills( @router.get("/agents/{agent_id}/skills") async def list_skills( agent_id: str, + request: Request, as_user: int | None = None, user: Any = Depends(current_user), server: Any = Depends(get_server), @@ -484,7 +454,10 @@ async def list_skills( assert server.app_runtime is not None return cast( list[dict[str, Any]], - await server.app_runtime.agent_registry.list_skill_summaries(agent_id), + await server.app_runtime.agent_registry.list_skill_summaries( + agent_id, + locale=resolve_request_locale(request), + ), ) @@ -498,6 +471,7 @@ class SkillPackageMountBody(BaseModel): ) async def list_skill_package_mounts( agent_id: str, + request: Request, as_user: int | None = None, user: Any = Depends(current_user), server: Any = Depends(get_server), @@ -510,6 +484,7 @@ async def list_skill_package_mounts( repo=server.services.skill_package_repo, root=server.paths.skill_packages_dir, ) + locale = resolve_request_locale(request) packages: list[dict[str, Any]] = [] for package_id in package_ids: package = store.repo.get(package_id) @@ -520,7 +495,7 @@ async def list_skill_package_mounts( "id": package.id, "name": package.name, "description": package.description, - "skills": store.list_skill_summaries(package_id), + "skills": store.list_skill_summaries(package_id, locale=locale), } ) return {"package_ids": package_ids, "packages": packages} @@ -548,6 +523,7 @@ async def replace_skill_package_mounts( async def get_skill( agent_id: str, name: str, + request: Request, as_user: int | None = None, user: Any = Depends(current_user), server: Any = Depends(get_server), @@ -562,7 +538,13 @@ async def get_skill( meta, body = _parse_frontmatter(manifest) disabled = _disabled_set(ctx.config) return { - **_summary_dict(name, meta, enabled=name not in disabled, kind=kind), + **_summary_dict( + name, + meta, + enabled=name not in disabled, + kind=kind, + locale=resolve_request_locale(request), + ), "frontmatter": meta, "body": body, "raw": manifest, @@ -957,28 +939,18 @@ async def disable_skill( await _persist_disabled(server, agent_id, disabled) +class LocalizedSkillCopy(BaseModel): + zh: str | None = None + en: str | None = None + + class HubInstallBody(BaseModel): skill_name: str enable: bool = True display_name: str | None = None icon_url: str | None = None - - -def _skillhub_uploads( - skill_name: str, - files: list[tuple[str, bytes]], - *, - display_name: str, - icon_url: str, -) -> list[tuple[str, bytes]]: - from octop.infra.skills.install import prepare_skillhub_package # noqa: PLC0415 - - return prepare_skillhub_package( - skill_name, - files, - display_name=display_name, - icon_url=icon_url, - ).workspace_uploads() + label: LocalizedSkillCopy | None = None + summary: LocalizedSkillCopy | None = None async def _download_skillhub_package_via_cli( @@ -1232,8 +1204,22 @@ async def hub_install_skill( ) from None display_name = (body.display_name or "").strip() icon_url = (body.icon_url or "").strip() + label = ( + {key: value.strip() for key, value in body.label.model_dump().items() if value} + if body.label + else {} + ) + summary = ( + {key: value.strip() for key, value in body.summary.model_dump().items() if value} + if body.summary + else {} + ) if len(display_name) > 200: raise HTTPException(status_code=400, detail="display_name is too long") + if any(len(value) > 200 for value in label.values()): + raise HTTPException(status_code=400, detail="localized skill label is too long") + if any(len(value) > 1024 for value in summary.values()): + raise HTTPException(status_code=400, detail="localized skill summary is too long") if len(icon_url) > 2048 or (icon_url and not valid_skillhub_icon_url(icon_url)): raise HTTPException(status_code=400, detail="icon_url must be an HTTP(S) URL") @@ -1272,6 +1258,8 @@ async def hub_install_skill( files=files, display_name=display_name, icon_url=icon_url, + label=label, + summary=summary, overwrite=True, enable=body.enable, ) diff --git a/src/octop/infra/agents/builtin_skills/skill-manager/SKILL.md b/src/octop/infra/agents/builtin_skills/skill-manager/SKILL.md index 464eb057..e7d08393 100644 --- a/src/octop/infra/agents/builtin_skills/skill-manager/SKILL.md +++ b/src/octop/infra/agents/builtin_skills/skill-manager/SKILL.md @@ -1,6 +1,15 @@ --- name: skill-manager description: 必须用于通过对话管理当前专家实例的 Skill,包括查找、检查、安装、导入、更新、编辑、列出、恢复或删除。支持 skillhub.cn/skills/... 页面 URL、SkillHub CLI、本地及上传文件、SKILL.md、ZIP/TAR、Git/GitHub 和普通下载 URL;也用于把 PDF、Markdown、文档、代码、图片等任意资料整理成新 Skill。Must use for conversational Skill management from files, URLs, Git repositories, archives, or SkillHub. +metadata: + octop: + emoji: "🧩" + label: + zh: "技能管理" + en: "Skill Manager" + summary: + zh: "在当前实例中查找、安装、导入、更新或删除技能。" + en: "Find, install, import, update, or delete skills on this instance." --- # Skill Manager diff --git a/src/octop/infra/agents/manager.py b/src/octop/infra/agents/manager.py index 08177db4..6d076d71 100644 --- a/src/octop/infra/agents/manager.py +++ b/src/octop/infra/agents/manager.py @@ -58,8 +58,10 @@ from octop.infra.connectors.service import ConnectorService from octop.infra.db.repos.audit import ACTOR_SYSTEM from octop.infra.errors import ErrorCode, OctopError +from octop.infra.skills.presentation import apply_skill_presentation, localize_skill_summary from octop.infra.skills.skill_package_store import SkillPackageStore from octop.infra.skills.workspace_catalog import list_workspace_skill_summaries +from octop.infra.utils.locale import Locale from octop.infra.utils.ulid import new_short_id if TYPE_CHECKING: @@ -1735,7 +1737,12 @@ def resolve_context_max_tokens(self, agent_id: str, *, fallback: int = 128_000) """Return the configured context cap for *agent_id* (``max_input_length``).""" return config_context_max_tokens(self.get_config(agent_id), fallback=fallback) - async def list_skill_summaries(self, agent_id: str) -> list[dict[str, Any]]: + async def list_skill_summaries( + self, + agent_id: str, + *, + locale: Locale | None = None, + ) -> list[dict[str, Any]]: """Installed skills for *agent_id* (harness catalog + package ``kind`` labels). Harness lists builtin / workspace / ``skills_dir`` entries (all non-builtin as @@ -1762,8 +1769,28 @@ async def list_skill_summaries(self, agent_id: str) -> list[dict[str, Any]]: skills_disabled=skills_disabled_set(cfg), ) ) + from harness_agent.skills import catalog as harness_skill_catalog # noqa: PLC0415 + + if getattr(harness_skill_catalog, "SKILL_PRESENTATION_METADATA_VERSION", 0) < 1: + workspace = getattr(agent, "workspace", None) + aread = getattr(workspace, "aread_text", None) + if callable(aread): + for index, row in enumerate(harness_rows): + if row.get("label") or row.get("display_name"): + continue + slug = str(row.get("slug") or "").strip() + if not slug: + continue + root = "_builtin_skills" if row.get("kind") == "builtin" else "skills" + manifest = await aread(f"{root}/{slug}/SKILL.md") + if manifest is None: + continue + meta, _body = parse_frontmatter(manifest) + harness_rows[index] = apply_skill_presentation(row, meta) package_ids = skill_package_ids_list(cfg) if not package_ids: + if locale is not None: + return [localize_skill_summary(row, locale) for row in harness_rows] return harness_rows disabled = skills_disabled_set(cfg) @@ -1810,13 +1837,16 @@ async def list_skill_summaries(self, agent_id: str) -> list[dict[str, Any]]: merged[slug] = package_row kind_order = {"builtin": 0, "package": 1, "workspace": 2} - return sorted( + rows = sorted( merged.values(), key=lambda row: ( kind_order.get(str(row.get("kind")), 99), str(row.get("slug", "")), ), ) + if locale is not None: + return [localize_skill_summary(row, locale) for row in rows] + return rows async def list_subagent_summaries(self, agent_id: str) -> list[dict[str, Any]]: """Installed subagents for *agent_id* (delegates to harness-agent catalog).""" diff --git a/src/octop/infra/gateway/slash/runtime_bridge.py b/src/octop/infra/gateway/slash/runtime_bridge.py index 78f1b4b6..5d8d13a6 100644 --- a/src/octop/infra/gateway/slash/runtime_bridge.py +++ b/src/octop/infra/gateway/slash/runtime_bridge.py @@ -41,7 +41,12 @@ def _cancel(agent_id: str, tid: str) -> None: cancel_stream = _cancel async def _list_skills() -> list[dict[str, Any]]: - return await am.list_skill_summaries(ctx.agent_id) + from octop.infra.utils.locale import normalize_locale + + return await am.list_skill_summaries( + ctx.agent_id, + locale=normalize_locale(ctx.locale), + ) list_skills = _list_skills diff --git a/src/octop/infra/skills/install.py b/src/octop/infra/skills/install.py index 7b8efd40..53c7b2a8 100644 --- a/src/octop/infra/skills/install.py +++ b/src/octop/infra/skills/install.py @@ -46,9 +46,11 @@ def with_skillhub_presentation_metadata( *, display_name: str, icon_url: str, + label: dict[str, str] | None = None, + summary: dict[str, str] | None = None, ) -> str: """Persist SkillHub presentation fields without changing the stable skill id.""" - if not display_name and not icon_url: + if not display_name and not icon_url and not label and not summary: return content meta, body = parse_frontmatter(content) @@ -58,10 +60,14 @@ def with_skillhub_presentation_metadata( octop_meta = {} if not isinstance(octop_meta, dict) else dict(octop_meta) octop_meta["source"] = "skillhub" - if display_name: - octop_meta["display_name"] = display_name if icon_url: octop_meta["icon_url"] = icon_url + if label: + octop_meta["label"] = {key: value for key, value in label.items() if value} + elif display_name: + octop_meta["display_name"] = display_name + if summary: + octop_meta["summary"] = {key: value for key, value in summary.items() if value} metadata["octop"] = octop_meta meta["metadata"] = metadata @@ -85,6 +91,8 @@ def prepare_skillhub_package( *, display_name: str = "", icon_url: str = "", + label: dict[str, str] | None = None, + summary: dict[str, str] | None = None, ) -> ResolvedSkillPackage: """Normalize SkillHub download files into a canonical package.""" safe_name = validate_skill_slug(skill_name) @@ -102,6 +110,8 @@ def prepare_skillhub_package( manifest, display_name=display_name, icon_url=icon_url, + label=label, + summary=summary, ).encode("utf-8") transformed.append((normalized, content)) return resolve_skill_package( @@ -169,6 +179,8 @@ async def install_skill_from_skillhub( files: list[tuple[str, bytes]], display_name: str = "", icon_url: str = "", + label: dict[str, str] | None = None, + summary: dict[str, str] | None = None, overwrite: bool = False, enable: bool | None = None, ) -> ResolvedSkillPackage: @@ -180,6 +192,8 @@ async def install_skill_from_skillhub( files, display_name=display_name, icon_url=icon_url, + label=label, + summary=summary, ) return await commit_skill_install( target, diff --git a/src/octop/infra/skills/presentation.py b/src/octop/infra/skills/presentation.py new file mode 100644 index 00000000..98bd707f --- /dev/null +++ b/src/octop/infra/skills/presentation.py @@ -0,0 +1,91 @@ +"""Skill presentation metadata shared by catalogs and HTTP adapters.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any +from urllib.parse import urlparse + +from octop.infra.utils.locale import Locale + +# Keep in sync with harness_agent.skills.catalog._summary_dict. +_EXTENSION_NAMESPACES = ("octop", "harness", "lightclaw", "orca", "openclaw") + + +def _text(value: object) -> str: + return str(value or "").strip() + + +def _localized_text(value: object, locale: Locale) -> str: + if not isinstance(value, Mapping): + return _text(value) + for key in (locale, "en", "zh"): + text = _text(value.get(key)) + if text: + return text + return "" + + +def _valid_icon_url(value: str) -> bool: + parsed = urlparse(value) + return parsed.scheme in {"http", "https"} and bool(parsed.netloc) + + +def apply_skill_presentation( + summary: Mapping[str, Any], + frontmatter: Mapping[str, Any] | None = None, + *, + locale: Locale | None = None, +) -> dict[str, Any]: + """Overlay optional UI metadata without changing the stable skill slug. + + Without ``locale``, ``name`` / ``description`` stay the frontmatter identity + used for disable matching and chat skill filters. ``label``, ``summary``, + and legacy ``display_name`` are copied through so a later localize pass can + fill UI copy. Compatible namespaces may still contribute emoji / icon_url. + """ + out = dict(summary) + metadata = (frontmatter or {}).get("metadata") + extensions = metadata if isinstance(metadata, Mapping) else {} + + display_name = "" + for namespace in _EXTENSION_NAMESPACES: + extension = extensions.get(namespace) + if not isinstance(extension, Mapping): + continue + if namespace == "octop": + label = extension.get("label") + short_summary = extension.get("summary") + if isinstance(label, Mapping): + out["label"] = dict(label) + if isinstance(short_summary, Mapping): + out["summary"] = dict(short_summary) + if not display_name: + display_name = _text(extension.get("display_name")) + emoji = _text(extension.get("emoji")) + if emoji and not _text(out.get("emoji")): + out["emoji"] = emoji + icon_url = _text(extension.get("icon_url")) + if icon_url and "icon_url" not in out and _valid_icon_url(icon_url): + out["icon_url"] = icon_url + if display_name: + out["display_name"] = display_name + if locale is not None: + return localize_skill_summary(out, locale) + return out + + +def localize_skill_summary(summary: Mapping[str, Any], locale: Locale) -> dict[str, Any]: + """Resolve raw localized fields already returned by an upstream catalog.""" + out = dict(summary) + label = _localized_text(out.get("label"), locale) + short_summary = _localized_text(out.get("summary"), locale) + display_name = _text(out.get("display_name")) + if label or display_name: + out["name"] = label or display_name + if short_summary: + out["description"] = short_summary + return out + + +__all__ = ["apply_skill_presentation", "localize_skill_summary"] diff --git a/src/octop/infra/skills/skill_package_store.py b/src/octop/infra/skills/skill_package_store.py index 7ae1c8f3..ec9ffac4 100644 --- a/src/octop/infra/skills/skill_package_store.py +++ b/src/octop/infra/skills/skill_package_store.py @@ -14,7 +14,7 @@ from octop.infra.db.repos.skill_packages import SkillPackageRepo, SkillPackageRow from octop.infra.errors import ErrorCode, OctopError -from octop.infra.skills.install import valid_skillhub_icon_url +from octop.infra.skills.presentation import apply_skill_presentation from octop.infra.skills.skill_packages import ( SkillPackageError, normalize_skill_files, @@ -22,6 +22,7 @@ ) from octop.infra.users.identity import User from octop.infra.utils.frontmatter import parse_frontmatter +from octop.infra.utils.locale import Locale from octop.infra.utils.ulid import new_short_id @@ -44,28 +45,6 @@ def raise_skill_package_name_taken(name: str) -> None: ) -def _apply_presentation_metadata(summary: dict[str, Any], metadata: dict[str, Any]) -> None: - """Apply display_name / emoji / icon_url from SKILL.md extension namespaces.""" - extensions = metadata.get("metadata") - if not isinstance(extensions, dict): - return - has_display_name = False - for key in ("octop", "lightclaw", "orca", "harness", "openclaw"): - extension = extensions.get(key) - if not isinstance(extension, dict): - continue - display_name = str(extension.get("display_name") or "").strip() - if display_name and not has_display_name: - summary["name"] = display_name - has_display_name = True - emoji = str(extension.get("emoji") or "").strip() - if emoji and "emoji" not in summary: - summary["emoji"] = emoji - icon_url = str(extension.get("icon_url") or "").strip() - if icon_url and "icon_url" not in summary and valid_skillhub_icon_url(icon_url): - summary["icon_url"] = icon_url - - def _allocate_package_id(repo: SkillPackageRepo) -> str: for _ in range(16): package_id = new_short_id() @@ -104,7 +83,12 @@ def create( def package_skills_dir(self, pack_id: str) -> Path: return self.root / pack_id / "skills" - def list_skill_summaries(self, pack_id: str) -> list[dict[str, Any]]: + def list_skill_summaries( + self, + pack_id: str, + *, + locale: Locale | None = None, + ) -> list[dict[str, Any]]: skills_dir = self.package_skills_dir(pack_id) if not skills_dir.is_dir(): return [] @@ -129,8 +113,7 @@ def list_skill_summaries(self, pack_id: str) -> list[dict[str, Any]]: "kind": "package", "package_id": pack_id, } - _apply_presentation_metadata(summary, metadata) - summaries.append(summary) + summaries.append(apply_skill_presentation(summary, metadata, locale=locale)) return summaries def write_skill( diff --git a/src/octop/infra/skills/workspace_catalog.py b/src/octop/infra/skills/workspace_catalog.py index 439ff2b5..fd3fa208 100644 --- a/src/octop/infra/skills/workspace_catalog.py +++ b/src/octop/infra/skills/workspace_catalog.py @@ -8,6 +8,7 @@ from typing import Any from octop.infra.agents.workspace_dir import skills_discovery_roots +from octop.infra.skills.presentation import apply_skill_presentation from octop.infra.utils.frontmatter import parse_frontmatter logger = logging.getLogger(__name__) @@ -19,13 +20,16 @@ def _summary_dict( *, enabled: bool, ) -> dict[str, Any]: - return { - "slug": slug, - "name": str(meta.get("name") or slug), - "description": str(meta.get("description") or ""), - "enabled": enabled, - "kind": "workspace", - } + return apply_skill_presentation( + { + "slug": slug, + "name": str(meta.get("name") or slug), + "description": str(meta.get("description") or ""), + "enabled": enabled, + "kind": "workspace", + }, + meta, + ) def _read_manifest(skill_dir: Path) -> str | None: diff --git a/tests/integration/test_skills_api.py b/tests/integration/test_skills_api.py index 796f7c40..f591f69d 100644 --- a/tests/integration/test_skills_api.py +++ b/tests/integration/test_skills_api.py @@ -89,6 +89,48 @@ async def test_create_then_list(env: Any) -> None: assert "file-reader" in names +async def test_list_localizes_octop_presentation_metadata(env: Any) -> None: + c, _srv, auth, aid = env + content = """--- +name: pdf-reader +description: Agent trigger description +metadata: + octop: + label: + zh: PDF 阅读 + en: PDF Reader + summary: + zh: 阅读和处理 PDF + en: Read and process PDFs + emoji: 📄 +--- +""" + created = await c.post( + f"/api/agents/{aid}/skills", + headers=auth, + json={"name": "pdf-reader", "content": content}, + ) + assert created.status_code == 201, created.text + + zh_rows = ( + await c.get( + f"/api/agents/{aid}/skills", + headers={**auth, "Accept-Language": "zh-CN"}, + ) + ).json() + en_rows = ( + await c.get( + f"/api/agents/{aid}/skills", + headers={**auth, "Accept-Language": "en-US"}, + ) + ).json() + + zh = next(row for row in zh_rows if row["slug"] == "pdf-reader") + en = next(row for row in en_rows if row["slug"] == "pdf-reader") + assert (zh["name"], zh["description"]) == ("PDF 阅读", "阅读和处理 PDF") + assert (en["name"], en["description"]) == ("PDF Reader", "Read and process PDFs") + + async def test_list_empty_when_no_workspace_skills(env: Any) -> None: c, _srv, auth, aid = env r = await c.get(f"/api/agents/{aid}/skills", headers=auth) @@ -111,9 +153,10 @@ async def test_list_includes_builtin_skills(env: Any) -> None: assert ws["enabled"] is True assert ws["emoji"] == "🔍" - manager = next(row for row in builtin if row["name"] == "skill-manager") + manager = next(row for row in builtin if row["slug"] == "skill-manager") assert manager["enabled"] is True - assert "SkillHub" in manager["description"] + assert manager["name"] in {"技能管理", "Skill Manager"} + assert manager["description"] async def test_get_builtin_skill_detail(env: Any) -> None: diff --git a/tests/integration/test_skills_hub.py b/tests/integration/test_skills_hub.py index ee8fee27..456e8bbe 100644 --- a/tests/integration/test_skills_hub.py +++ b/tests/integration/test_skills_hub.py @@ -201,6 +201,8 @@ async def unexpected_cli() -> str: "enable": True, "display_name": "中文展示名称", "icon_url": icon_url, + "label": {"zh": "中文展示名称", "en": "Friendly Name"}, + "summary": {"zh": "中文短描述", "en": "Short description"}, }, ) assert r.status_code == 201, r.text @@ -219,8 +221,9 @@ async def unexpected_cli() -> str: assert payload["frontmatter"]["name"] == "english-package-name" assert payload["frontmatter"]["metadata"]["octop"] == { "source": "skillhub", - "display_name": "中文展示名称", "icon_url": icon_url, + "label": {"zh": "中文展示名称", "en": "Friendly Name"}, + "summary": {"zh": "中文短描述", "en": "Short description"}, } diff --git a/tests/live/test_channel_probe.py b/tests/live/test_channel_probe.py index 3484185e..56067052 100644 --- a/tests/live/test_channel_probe.py +++ b/tests/live/test_channel_probe.py @@ -65,16 +65,23 @@ async def test_weixin_ilink_token_accepted() -> None: f"(bot_uin={bot_uin!r} base_url={base_url!r}): " f"ret={exc.ret} errcode={exc.errcode} errmsg={exc.errmsg}" ) + except (TimeoutError, aiohttp.ClientError) as exc: + # GitHub-hosted runners often cannot finish iLink long-poll within + # the client timeout; that is reachability, not a bad token. + pytest.skip(f"weixin getUpdates unreachable ({base_url}): {exc}") manager = ChannelManager(processor=_noop_processor) - await manager.probe_channel( - "weixin", - { - "bot_uin": bot_uin, - "token": token, - "base_url": base_url, - "account_id": bot_uin, - }, - tenant_id="live-probe", - channel_id="live-weixin-probe", - ) + try: + await manager.probe_channel( + "weixin", + { + "bot_uin": bot_uin, + "token": token, + "base_url": base_url, + "account_id": bot_uin, + }, + tenant_id="live-probe", + channel_id="live-weixin-probe", + ) + except (TimeoutError, aiohttp.ClientError) as exc: + pytest.skip(f"weixin probe_channel unreachable ({base_url}): {exc}") diff --git a/tests/support/fakes.py b/tests/support/fakes.py index baa1f339..9277d823 100644 --- a/tests/support/fakes.py +++ b/tests/support/fakes.py @@ -220,6 +220,7 @@ async def list_skill_summaries(self) -> list[dict[str, Any]]: tests, or the real disk-backed backend). Enumerate from whichever store holds them so the listed summaries match what the routers persisted. """ + from octop.infra.skills.presentation import apply_skill_presentation from octop.infra.utils.frontmatter import parse_frontmatter collected: dict[str, str] = {} @@ -274,15 +275,18 @@ async def list_skill_summaries(self) -> list[dict[str, Any]]: slug = skill_dir.name name = str(meta.get("name") or slug) is_disabled = slug in disabled or name in disabled - merged[slug] = { - "slug": slug, - "name": name, - "description": str(meta.get("description") or ""), - "kind": "package", - "package_id": package_id, - "enabled": not is_disabled, - "disabled": is_disabled, - } + merged[slug] = apply_skill_presentation( + { + "slug": slug, + "name": name, + "description": str(meta.get("description") or ""), + "kind": "package", + "package_id": package_id, + "enabled": not is_disabled, + "disabled": is_disabled, + }, + meta, + ) for path in sorted(collected): meta, _ = parse_frontmatter(collected[path]) # Slug is the skill directory name (parent of SKILL.md), matching the @@ -300,24 +304,18 @@ async def list_skill_summaries(self) -> list[dict[str, Any]]: continue name = str(meta.get("name") or slug) is_disabled = slug in disabled or name in disabled - row: dict[str, Any] = { - "slug": slug, - "name": name, - "description": str(meta.get("description") or ""), - "path": path, - "kind": kind, - "enabled": not is_disabled, - "disabled": is_disabled, - } - # Mirror harness: emoji lives under metadata.{octop,lightclaw,orca,harness}. - metadata = meta.get("metadata") or {} - if isinstance(metadata, dict): - for key in ("octop", "lightclaw", "orca", "harness"): - ext = metadata.get(key) or {} - if isinstance(ext, dict) and "emoji" in ext: - row["emoji"] = str(ext["emoji"]) - break - merged[slug] = row + merged[slug] = apply_skill_presentation( + { + "slug": slug, + "name": name, + "description": str(meta.get("description") or ""), + "path": path, + "kind": kind, + "enabled": not is_disabled, + "disabled": is_disabled, + }, + meta, + ) return sorted( merged.values(), key=lambda row: ( diff --git a/tests/unit/agents/test_skill_package_store.py b/tests/unit/agents/test_skill_package_store.py index d0ed706e..567b731e 100644 --- a/tests/unit/agents/test_skill_package_store.py +++ b/tests/unit/agents/test_skill_package_store.py @@ -65,11 +65,12 @@ def test_write_and_delete_skill_updates_disk_summaries_and_count( assert store.list_skill_summaries(row.id) == [ { "slug": "pdf", - "name": "PDF Reader", + "name": "PDF", "description": "", "path": "skills/pdf/SKILL.md", "kind": "package", "package_id": row.id, + "display_name": "PDF Reader", "emoji": "\U0001f4c4", "icon_url": "https://cdn.example.com/pdf.png", } @@ -134,6 +135,39 @@ def test_write_skill_recreates_empty_directories(store: SkillPackageStore) -> No assert list((skill_dir / "ai").iterdir()) == [] +def test_list_skill_summaries_localizes_octop_metadata(store: SkillPackageStore) -> None: + row = store.create(name="P", description="", created_by="42") + store.write_skill( + row.id, + "pdf-reader", + [ + ( + "SKILL.md", + ( + "---\n" + "name: pdf-reader\n" + "description: Agent trigger\n" + "metadata:\n" + " octop:\n" + " label:\n" + " zh: PDF 阅读\n" + " en: PDF Reader\n" + " summary:\n" + " zh: 读取 PDF\n" + " en: Read PDFs\n" + "---\n" + ).encode(), + ) + ], + ) + + zh = store.list_skill_summaries(row.id, locale="zh")[0] + en = store.list_skill_summaries(row.id, locale="en")[0] + + assert (zh["name"], zh["description"]) == ("PDF 阅读", "读取 PDF") + assert (en["name"], en["description"]) == ("PDF Reader", "Read PDFs") + + def test_skill_count_excludes_skills_hidden_from_summaries(store: SkillPackageStore) -> None: row = store.create(name="P", description="", created_by="42") skills_dir = store.package_skills_dir(row.id) diff --git a/tests/unit/skills/test_presentation.py b/tests/unit/skills/test_presentation.py new file mode 100644 index 00000000..cb753087 --- /dev/null +++ b/tests/unit/skills/test_presentation.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from octop.infra.skills.presentation import ( + apply_skill_presentation, + localize_skill_summary, +) + + +def test_apply_skill_presentation_exposes_localized_copy_and_assets() -> None: + summary = {"slug": "pdf-reader", "name": "pdf-reader", "description": "Agent trigger"} + frontmatter = { + "metadata": { + "octop": { + "label": {"zh": "PDF 阅读", "en": "PDF Reader"}, + "summary": {"zh": "读取 PDF", "en": "Read PDFs"}, + "emoji": "📄", + "icon_url": "https://cdn.example.com/pdf.png", + } + } + } + + raw = apply_skill_presentation(summary, frontmatter) + localized = apply_skill_presentation(summary, frontmatter, locale="zh") + + assert raw["name"] == "pdf-reader" + assert raw["description"] == "Agent trigger" + assert raw["label"] == {"zh": "PDF 阅读", "en": "PDF Reader"} + assert raw["summary"] == {"zh": "读取 PDF", "en": "Read PDFs"} + assert raw["emoji"] == "📄" + assert raw["icon_url"] == "https://cdn.example.com/pdf.png" + assert localized["name"] == "PDF 阅读" + assert localized["description"] == "读取 PDF" + assert localized["slug"] == "pdf-reader" + + +def test_apply_skill_presentation_keeps_identity_without_locale() -> None: + raw = apply_skill_presentation( + {"name": "pdf-reader", "description": "Agent trigger"}, + {"metadata": {"octop": {"display_name": "PDF Reader"}}}, + ) + + assert raw["name"] == "pdf-reader" + assert raw["display_name"] == "PDF Reader" + + +def test_localize_skill_summary_uses_english_then_chinese_fallback() -> None: + summary = { + "name": "stable-name", + "description": "Agent trigger", + "label": {"zh": "中文名"}, + "summary": {"zh": "中文简介"}, + } + + localized = localize_skill_summary(summary, "en") + + assert localized["name"] == "中文名" + assert localized["description"] == "中文简介" + + +def test_legacy_display_name_and_compatible_emoji_remain_supported() -> None: + localized = apply_skill_presentation( + {"name": "stable-name", "description": "Agent trigger"}, + { + "metadata": { + "octop": {"display_name": "Friendly Name"}, + "openclaw": {"emoji": "📦"}, + } + }, + locale="en", + ) + + assert localized["name"] == "Friendly Name" + assert localized["emoji"] == "📦" diff --git a/tests/unit/test_skillhub_install_metadata.py b/tests/unit/test_skillhub_install_metadata.py index 29d58469..87037a3e 100644 --- a/tests/unit/test_skillhub_install_metadata.py +++ b/tests/unit/test_skillhub_install_metadata.py @@ -3,8 +3,8 @@ from octop.api.routers.skills import ( _summary_dict, _valid_skillhub_icon_url, - _with_skillhub_presentation_metadata, ) +from octop.infra.skills.install import with_skillhub_presentation_metadata def test_summary_prefers_octop_display_metadata_and_supports_openclaw_emoji() -> None: @@ -25,7 +25,8 @@ def test_summary_prefers_octop_display_metadata_and_supports_openclaw_emoji() -> ) assert summary["slug"] == "stable-slug" - assert summary["name"] == "中文名称" + assert summary["name"] == "english-name" + assert summary["display_name"] == "中文名称" assert summary["icon_url"] == "https://cdn.example.com/icon.png" assert summary["emoji"] == "📦" @@ -35,7 +36,7 @@ def test_with_skillhub_metadata_preserves_skill_identity_and_body() -> None: "---\nname: english-name\nmetadata:\n openclaw:\n emoji: '📦'\n---\n\n# Original body\n" ) - rendered = _with_skillhub_presentation_metadata( + rendered = with_skillhub_presentation_metadata( original, display_name="中文名称", icon_url="https://cdn.example.com/icon.png", @@ -48,6 +49,21 @@ def test_with_skillhub_metadata_preserves_skill_identity_and_body() -> None: assert rendered.endswith("# Original body\n") +def test_skillhub_metadata_writes_localized_label_and_summary() -> None: + rendered = with_skillhub_presentation_metadata( + "---\nname: pdf-reader\ndescription: Read PDFs\n---\n", + display_name="PDF 阅读", + icon_url="", + label={"zh": "PDF 阅读", "en": "PDF Reader"}, + summary={"zh": "读取 PDF", "en": "Read PDFs"}, + ) + assert "zh: PDF 阅读" in rendered + assert "en: PDF Reader" in rendered + assert "zh: 读取 PDF" in rendered + assert "en: Read PDFs" in rendered + assert "display_name:" not in rendered + + def test_skillhub_icon_url_allows_only_http_urls() -> None: assert _valid_skillhub_icon_url("https://cdn.example.com/icon.png") assert _valid_skillhub_icon_url("http://cdn.example.com/icon.png") diff --git a/uv.lock b/uv.lock index 6faa174c..ebca0b11 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.15'", @@ -2571,7 +2571,7 @@ requires-dist = [ { name = "mcp", specifier = ">=1.9,<2" }, { name = "mss", marker = "extra == 'desktop'", specifier = ">=9.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" }, - { name = "orcakit-harness-agent", extras = ["all"], specifier = ">=1.0.0" }, + { name = "orcakit-harness-agent", extras = ["all"], specifier = ">=1.0.1" }, { name = "pillow", specifier = ">=10.0" }, { name = "playwright", specifier = ">=1.40" }, { name = "playwright", marker = "extra == 'browser'", specifier = ">=1.40" }, @@ -2745,7 +2745,7 @@ wheels = [ [[package]] name = "orcakit-harness-agent" -version = "1.0.0" +version = "1.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "deepagents" }, @@ -2763,9 +2763,9 @@ dependencies = [ { name = "mcp" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/03/78/87ae014e63908e306a1861debe8eb1a66343a97752a23f8a8eb2513c0c9e/orcakit_harness_agent-1.0.0.tar.gz", hash = "sha256:ce4632b1ab056999d17820e14ede3037a1d18e032f059e8cfe39d71dff44c701", size = 1188698, upload-time = "2026-08-29T07:51:05.528Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/0a/cd60b1ec78030fae248cf85f6e3f7d577edb10d7e60816eb254312f59257/orcakit_harness_agent-1.0.1.tar.gz", hash = "sha256:8fa20de7b94e8f359ec53f6153469bf2396cbb00380737e327356aab87ae1404", size = 1197195, upload-time = "2026-08-30T12:05:04.682Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/54/9d1efafe40850c6e027e1ed036011adc1989eaaa948ee4d24825160cd7ff/orcakit_harness_agent-1.0.0-py3-none-any.whl", hash = "sha256:ae59bb3c023cee62d1d8317756b54242e1244ea4cd759c248c44f68cc8a40169", size = 1414704, upload-time = "2026-08-29T07:51:01.146Z" }, + { url = "https://files.pythonhosted.org/packages/65/c2/b8ff3af0af69efcf25ab46e05f9240e64829069d7f2edec16bf4bcc84b09/orcakit_harness_agent-1.0.1-py3-none-any.whl", hash = "sha256:1a0b09d2c2a4c48d390ee4c0ff376674ddef5340eda728a1b8a13bfdea912d97", size = 1424622, upload-time = "2026-08-30T12:05:00.855Z" }, ] [package.optional-dependencies] From 7e74b1e0d47e646d27c43e0adbe6a1697c305b54 Mon Sep 17 00:00:00 2001 From: jubaoliang Date: Sun, 30 Aug 2026 21:13:27 +0800 Subject: [PATCH 21/28] fix(ui): polish expert root-dir UX, connector order, and FnOS icons (#481) * fix(ui): polish expert root-dir UX, connector order, and FnOS icons Clarify sandbox/skill-package copy, lock root_dir on edit, move WeKnora/Dify to the end of the catalog, drop their open-console buttons, and refresh FnOS icons from the PWA logo. * fix(tests): expect WeKnora/Dify at end of connector catalog Update catalog order assertion after moving those connectors behind the CLI entries. --------- Co-authored-by: jubaoliang --- dashboard/src/locales/en.json | 7 +- dashboard/src/locales/zh.json | 7 +- .../src/pages/Agent/Connectors/index.tsx | 24 ---- .../Experts/components/AgentBackendFields.tsx | 59 +++++--- .../Experts/components/RootDirSelect.tsx | 17 ++- fnos/docker/ICON.PNG | Bin 58193 -> 238751 bytes fnos/docker/ICON_256.PNG | Bin 20936 -> 43284 bytes fnos/docker/app/ui/images/icon_256.png | Bin 20936 -> 43284 bytes fnos/docker/app/ui/images/icon_64.png | Bin 3545 -> 4544 bytes fnos/native/ICON.PNG | Bin 58193 -> 238751 bytes fnos/native/ICON_256.PNG | Bin 20936 -> 43284 bytes fnos/native/app/ui/images/icon_256.png | Bin 20936 -> 43284 bytes fnos/native/app/ui/images/icon_64.png | Bin 3545 -> 4544 bytes src/octop/infra/connectors/catalog.py | 134 +++++++++--------- tests/integration/test_connectors_api.py | 6 +- 15 files changed, 133 insertions(+), 121 deletions(-) diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index 3c2b7806..810d7ec6 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -780,7 +780,7 @@ "defaultModelPlaceholder": "Select a model", "skillPackagesLabel": "Optional Skill Packages", "skillPackagesHint": "Mount packages at create time (local storage with root /).", - "skillPackagesUnsupportedHint": "Skill packages unavailable: storage root is not /. Set it to /, or use customized / built-in skills.", + "skillPackagesUnsupportedHint": "Skill packages unavailable: when the storage root is not /, the sandbox cannot reach skill-package directories, so packages cannot be used.", "skillPackagesClearFailed": "Backend settings saved, but clearing mounted skill packages failed", "skillPackagesPlaceholder": "Select skill packages", "manifestWriteFailed": "Page configuration could not be saved.", @@ -842,7 +842,8 @@ "backendRootDirPlaceholder": "Browse and select a directory", "backendRootDirDesc": "Defaults to the server user's home directory ({{home}}). You may also browse from filesystem root `/`.", "backendRootDirDescAdmin": "Defaults to the server user's home directory ({{home}}). You may also browse from filesystem root `/`.", - "backendRootDirJailHint": "When you save a non-root directory, Octop best-effort installs bubblewrap on Linux so shell/skill commands run in a directory jail. If install is unavailable (or on other platforms), execute uses the normal local shell without a jail; file tools still use the virtual filesystem.", + "backendRootDirJailHint": "When saving to a non-root directory, Octop sandboxes the AI filesystem to that directory and blocks cross-directory access.", + "backendRootDirImmutableHint": "Cannot be changed after creation.", "rootDirOutsideHomeWarning": "This path is outside your home directory ({{home}}).", "rootDirListFailed": "Could not list subdirectories. Check permissions or choose another path.", "rootDirMkdir": "New folder", @@ -1508,7 +1509,7 @@ "packagesUpdateFailed": "Failed to update mounted packages", "packageConflictHint": "The workspace skill “{{slug}}” shadows this package skill.", "mountBackendHint": "Skill packages need local storage with root /.", - "skillPackagesUnsupportedHint": "Skill packages unavailable: storage root is not /. Set it to / in expert settings, or use customized / built-in skills.", + "skillPackagesUnsupportedHint": "Skill packages unavailable: when the storage root is not /, the sandbox cannot reach skill-package directories, so packages cannot be used.", "installedSkillsDesc": "Manage installed built-in and customized skills.", "tencentSkillHubDesc": "Browse and install community skills from Tencent SkillHub.", "install": "Install", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index da8595c8..5477dfca 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -780,7 +780,7 @@ "defaultModelPlaceholder": "选择一个模型", "skillPackagesLabel": "可选技能包", "skillPackagesHint": "创建时挂载选中的技能包(需本地存储,且根目录为 /)。", - "skillPackagesUnsupportedHint": "无法使用技能包:当前存储根目录不是 /,请改为 /,或改用自定义/内置技能。", + "skillPackagesUnsupportedHint": "无法使用技能包:存储根目录不是 / 时,因沙箱限制无法访问技能包的相关文件目录,因此技能包不可用。", "skillPackagesClearFailed": "已保存后端设置,但清除已挂载技能包失败", "skillPackagesPlaceholder": "选择技能包", "patchFailed": "保存失败", @@ -843,7 +843,8 @@ "backendRootDirPlaceholder": "浏览并选择目录", "backendRootDirDesc": "默认使用当前系统用户的 home 目录({{home}})。也可从文件系统根目录 `/` 起浏览并选择。", "backendRootDirDescAdmin": "默认使用当前系统用户的 home 目录({{home}})。也可从文件系统根目录 `/` 起浏览并选择。", - "backendRootDirJailHint": "保存非根目录时,Octop 会尽量用 bubblewrap(Linux)把 shell/Skill 限制在目录狱中;安装失败或非 Linux 则退化为普通本地 shell(无目录狱)。文件工具始终走虚拟文件系统。", + "backendRootDirJailHint": "保存至非根目录时,Octop 会通过沙箱技术将 AI 文件系统限制在该目录内,禁止跨目录访问。", + "backendRootDirImmutableHint": "创建后不可更改。", "rootDirOutsideHomeWarning": "当前路径不在你的 home 目录({{home}})内。", "rootDirListFailed": "无法列出子目录,请检查权限或选择其他路径。", "rootDirMkdir": "新建文件夹", @@ -1507,7 +1508,7 @@ "packagesUpdateFailed": "更新已挂载技能包失败", "packageConflictHint": "工作区技能“{{slug}}”会覆盖此技能包中的同名技能。", "mountBackendHint": "技能包需本地存储,且根目录为 /。", - "skillPackagesUnsupportedHint": "无法使用技能包:当前存储根目录不是 /,请在专家设置中改为 /,或改用自定义/内置技能。", + "skillPackagesUnsupportedHint": "无法使用技能包:存储根目录不是 / 时,因沙箱限制无法访问技能包的相关文件目录,因此技能包不可用。", "installedSkillsDesc": "管理已安装的内置和自定义技能。", "tencentSkillHubDesc": "浏览和安装来自腾讯 SkillHub 的社区技能。", "install": "安装", diff --git a/dashboard/src/pages/Agent/Connectors/index.tsx b/dashboard/src/pages/Agent/Connectors/index.tsx index 662fabcb..2dc8258a 100644 --- a/dashboard/src/pages/Agent/Connectors/index.tsx +++ b/dashboard/src/pages/Agent/Connectors/index.tsx @@ -45,7 +45,6 @@ import { } from "./connectorDefs"; import { notifyConnectorsChanged } from "./customMcpUtils"; import { - consoleUrlFromServiceUrl, extractHttpUrl, isDifyMcpServerUrl, isGuidedConnector, @@ -313,9 +312,6 @@ function ConnectorConfigDrawer({ ); const [installingCli, setInstallingCli] = useState(false); const [detectingLocalWeKnora, setDetectingLocalWeKnora] = useState(false); - const [detectedWeKnoraConsole, setDetectedWeKnoraConsole] = useState< - string | null - >(null); const [feishuUserAuth, setFeishuUserAuth] = useState(null); const [feishuUserAuthBusy, setFeishuUserAuthBusy] = useState(false); @@ -328,8 +324,6 @@ function ConnectorConfigDrawer({ const hasStoredCredentials = Boolean(instance?.has_credentials); const mailProvider = Form.useWatch("mail_provider", form) ?? "qq"; const defaultOpen = Form.useWatch("default_open", form) === true; - const weknoraBaseUrl = String(Form.useWatch("base_url", form) ?? ""); - const difyMcpUrl = String(Form.useWatch("mcp_url", form) ?? ""); const selectedMailProvider = mailProviderById(String(mailProvider)); const draftScope = entry ? instance @@ -354,7 +348,6 @@ function ConnectorConfigDrawer({ setInstanceDetail(null); setProbeResult(null); setCliInfo(null); - setDetectedWeKnoraConsole(null); setFeishuUserAuth(null); setFeishuUserReady(false); setFeishuAuthNeedsReauth(false); @@ -803,7 +796,6 @@ function ConnectorConfigDrawer({ return; } form.setFieldValue("base_url", result.base_url); - setDetectedWeKnoraConsole(result.console_url ?? null); saveFormDraft( draftScope, form.getFieldsValue() as Record, @@ -1118,14 +1110,6 @@ function ConnectorConfigDrawer({ const manualUrl = authInfo?.manual_url ?? entry.manual_url ?? guideUrl; const authHint = authInfo?.auth_hint ?? entry.auth_hint; const guidedKind = isGuidedConnector(entry.kind) ? entry.kind : null; - const guidedServiceUrl = - guidedKind === "weknora" ? weknoraBaseUrl : difyMcpUrl; - const guidedConsoleUrl = - detectedWeKnoraConsole ?? - (guidedKind - ? consoleUrlFromServiceUrl(guidedKind, guidedServiceUrl) - : null) ?? - guideUrl; const preview = instanceDetail?.credentials_preview; const secretRequired = !hasStoredCredentials; @@ -1235,14 +1219,6 @@ function ConnectorConfigDrawer({
{guidedKind && ( <> - {guidedKind === "weknora" && ( + ); + })} + {!openEnded ? ( + + ) : null} + {interactive && hasOther ? ( +