From 2c5205b06e3217574b9204d40f57cf4a17ac7a4f Mon Sep 17 00:00:00 2001 From: steven_li Date: Fri, 5 Jun 2026 11:46:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0MinIO=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E7=B3=BB=E7=BB=9F=E6=94=AF=E6=8C=81=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E5=A4=96=E9=83=A8=E8=BF=9E=E6=8E=A5=E5=99=A8=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加MinIO用户文件系统配置选项(BEAVER_MINIO_ROOT_USER等) - 更新外部连接器配置结构,包括BASE_URL和认证令牌设置 - 改进connector provider支持更多类型(official, feishu_bot等) - 实现Mistral模型推理模式支持reasoning_effort参数 - 增强外部连接器策略配置和运行时配置管理 - 添加connector bridge事件验证和安全保护机制 - 优化任务路由逻辑,区分simple_chat和new_task场景 - 更新初始技能工具提示配置,分离authoring admin功能 --- .env.example | 18 +- app-instance/README.md | 4 + .../beaver/engine/providers/litellm.py | 13 + .../channels/connections/external.py | 79 ++- .../backend/beaver/interfaces/web/app.py | 80 ++- .../backend/beaver/services/agent_service.py | 16 + .../builtin/intent-agent-router/SKILL.md | 9 +- app-instance/backend/beaver/tasks/router.py | 3 + .../test_external_connector_bridge_api.py | 86 +++ .../unit/test_external_sidecar_connectors.py | 142 +++++ .../unit/test_initial_skill_tool_hints.py | 25 +- .../tests/unit/test_litellm_thinking_mode.py | 84 +++ .../tests/unit/test_main_agent_router.py | 16 + .../tests/unit/test_task_mode_feedback.py | 185 +++++++ app-instance/create-instance.sh | 62 ++- app-instance/entrypoint.sh | 67 +++ .../frontend/app/(app)/files/page.tsx | 180 ++++--- .../frontend/app/(app)/marketplace/page.tsx | 69 ++- app-instance/frontend/app/(app)/mcp/page.tsx | 177 ++++--- .../notifications/[scheduledRunId]/page.tsx | 42 +- .../frontend/app/(app)/notifications/page.tsx | 23 +- .../frontend/app/(app)/outlook/page.tsx | 147 +++--- app-instance/frontend/app/(app)/page.tsx | 263 ++++++--- .../frontend/app/(app)/skills/page.tsx | 207 ++++++-- .../frontend/app/(app)/status/page.tsx | 499 ++++++++++++++---- .../app/(app)/tasks/[taskId]/page.tsx | 63 +-- .../frontend/app/(app)/tasks/page.tsx | 291 +++++++++- .../frontend/components/AppRuntimeBridge.tsx | 3 +- app-instance/frontend/components/Header.tsx | 244 ++++++--- .../frontend/components/LanguageSwitcher.tsx | 2 +- .../CurrentSessionProgressSidebar.tsx | 280 +--------- .../components/chat-workbench/MessageList.tsx | 61 ++- .../components/skills/SkillDetailView.tsx | 38 +- .../task-detail/TaskAcceptanceCard.tsx | 20 +- .../components/task-detail/TaskLiveHeader.tsx | 8 +- .../components/task-detail/TaskSideRail.tsx | 6 +- .../components/task-detail/TaskTimeline.tsx | 31 +- .../task-detail/TaskTimelineCard.tsx | 6 +- .../task-management/TaskManagementTabs.tsx | 2 +- .../frontend/components/ui/button.tsx | 6 +- .../frontend/components/ui/dialog.tsx | 4 +- app-instance/frontend/components/ui/input.tsx | 2 +- .../frontend/components/ui/select.tsx | 2 +- .../frontend/components/ui/switch.tsx | 4 +- app-instance/frontend/components/ui/tabs.tsx | 4 +- app-instance/frontend/lib/api.ts | 5 + .../lib/channel-connector-state.test.ts | 60 +++ .../frontend/lib/channel-connector-state.ts | 28 + .../frontend/lib/channel-connectors.test.ts | 13 + .../frontend/lib/session-progress.test.ts | 201 ------- app-instance/frontend/lib/session-progress.ts | 392 -------------- .../frontend/lib/task-process.test.ts | 160 ++++++ app-instance/frontend/lib/task-process.ts | 75 +++ .../frontend/lib/task-timeline-view.test.ts | 52 ++ .../frontend/lib/task-timeline-view.ts | 37 ++ .../frontend/test-results/.last-run.json | 9 + .../error-context.md | 281 ++++++++++ .../error-context.md | 208 ++++++++ .../error-context.md | 316 +++++++++++ .../error-context.md | 260 +++++++++ auth-portal/src/app/globals.css | 161 +++++- auth-portal/src/app/login/page.tsx | 35 +- auth-portal/src/test-results/.last-run.json | 4 + authz-service/.env.example | 9 + .../seed-data/backend_credentials.json | 2 +- deploy-control/.env.example | 5 + deploy-control/README.md | 7 +- deploy-control/server.py | 15 + .../tests/test_connector_instance_config.py | 58 ++ docker-compose.external-connectors.yml | 6 +- .../2026-06-04-auto-accept-on-new-topic.md | 73 +++ ...26-06-04-chat-task-timeline-consistency.md | 75 +++ .../2026-06-04-initial-multi-search-engine.md | 104 ++++ ...6-06-04-auto-accept-on-new-topic-design.md | 60 +++ ...4-chat-task-timeline-consistency-design.md | 59 +++ docs/ui-ux/README.md | 84 +++ docs/ui-ux/pages/auth-login.md | 220 ++++++++ docs/ui-ux/pages/chat-workbench.md | 346 ++++++++++++ docs/ui-ux/pages/files.md | 120 +++++ docs/ui-ux/pages/marketplace.md | 106 ++++ docs/ui-ux/pages/mcp-tools.md | 114 ++++ docs/ui-ux/pages/notifications.md | 135 +++++ docs/ui-ux/pages/outlook.md | 121 +++++ docs/ui-ux/pages/settings.md | 120 +++++ docs/ui-ux/pages/skills.md | 121 +++++ docs/ui-ux/pages/task-management.md | 144 +++++ external-connector/external_connector/app.py | 4 +- .../node/feishu_event_utils.js | 216 ++++++++ .../node/feishu_ws_receiver.js | 132 ++--- .../providers/feishu_bot.py | 190 ++++++- .../providers/weixin_ilink.py | 35 +- .../tests/node/feishu_event_utils.test.js | 189 +++++++ .../tests/test_feishu_bot_provider.py | 183 ++++++- external-connector/tests/test_sidecar_api.py | 11 + .../tests/test_weixin_ilink_provider.py | 32 ++ scripts/deploy-initial-skills.sh | 116 ++-- skills/_index/disabled.json | 6 +- skills/_index/published.json | 4 +- .../current.json | 2 +- skills/multi-search-engine/skill.json | 16 + .../versions/v0001/CHANGELOG.md | 18 + .../versions/v0001/CHANNELLOG.md | 48 ++ .../versions/v0001/SKILL.md | 156 ++++++ .../versions/v0001/_meta.json | 6 + .../versions/v0001/config.json | 85 +++ .../versions/v0001/metadata.json | 7 + .../v0001/references/advanced-search.md | 146 +++++ .../v0001/references/international-search.md | 398 ++++++++++++++ .../versions/v0001/version.json | 31 ++ skills/skills-admin/skill.json | 6 +- skills/skills-admin/versions/v0001/SKILL.md | 22 +- .../skills-admin/versions/v0001/version.json | 12 +- skills/skills-authoring-admin/current.json | 3 + skills/skills-authoring-admin/skill.json | 13 + .../versions/v0001/SKILL.md | 28 + .../versions/v0001/version.json | 22 + skills/web-operation/skill.json | 13 - skills/web-operation/versions/v0001/SKILL.md | 36 -- .../web-operation/versions/v0001/version.json | 22 - test-results/.last-run.json | 4 + 120 files changed, 8321 insertions(+), 1865 deletions(-) create mode 100644 app-instance/frontend/lib/channel-connector-state.test.ts create mode 100644 app-instance/frontend/lib/channel-connector-state.ts delete mode 100644 app-instance/frontend/lib/session-progress.test.ts delete mode 100644 app-instance/frontend/lib/session-progress.ts create mode 100644 app-instance/frontend/lib/task-process.test.ts create mode 100644 app-instance/frontend/lib/task-process.ts create mode 100644 app-instance/frontend/lib/task-timeline-view.test.ts create mode 100644 app-instance/frontend/lib/task-timeline-view.ts create mode 100644 app-instance/frontend/test-results/.last-run.json create mode 100644 app-instance/frontend/test-results/market-settings-qa-marketp-0de76-ail-file-install-flow-works/error-context.md create mode 100644 app-instance/frontend/test-results/market-settings-qa-marketp-a30a5-ngs-error-state-is-readable/error-context.md create mode 100644 app-instance/frontend/test-results/market-settings-qa-marketp-f612f-nnel-and-restart-flows-work/error-context.md create mode 100644 app-instance/frontend/test-results/market-settings-qa-marketp-f6b19-ow-or-visible-small-targets/error-context.md create mode 100644 auth-portal/src/test-results/.last-run.json create mode 100644 deploy-control/tests/test_connector_instance_config.py create mode 100644 docs/superpowers/plans/2026-06-04-auto-accept-on-new-topic.md create mode 100644 docs/superpowers/plans/2026-06-04-chat-task-timeline-consistency.md create mode 100644 docs/superpowers/plans/2026-06-04-initial-multi-search-engine.md create mode 100644 docs/superpowers/specs/2026-06-04-auto-accept-on-new-topic-design.md create mode 100644 docs/superpowers/specs/2026-06-04-chat-task-timeline-consistency-design.md create mode 100644 docs/ui-ux/README.md create mode 100644 docs/ui-ux/pages/auth-login.md create mode 100644 docs/ui-ux/pages/chat-workbench.md create mode 100644 docs/ui-ux/pages/files.md create mode 100644 docs/ui-ux/pages/marketplace.md create mode 100644 docs/ui-ux/pages/mcp-tools.md create mode 100644 docs/ui-ux/pages/notifications.md create mode 100644 docs/ui-ux/pages/outlook.md create mode 100644 docs/ui-ux/pages/settings.md create mode 100644 docs/ui-ux/pages/skills.md create mode 100644 docs/ui-ux/pages/task-management.md create mode 100644 external-connector/external_connector/node/feishu_event_utils.js create mode 100644 external-connector/tests/node/feishu_event_utils.test.js mode change 100644 => 100755 scripts/deploy-initial-skills.sh rename skills/{web-operation => multi-search-engine}/current.json (93%) create mode 100644 skills/multi-search-engine/skill.json create mode 100644 skills/multi-search-engine/versions/v0001/CHANGELOG.md create mode 100644 skills/multi-search-engine/versions/v0001/CHANNELLOG.md create mode 100644 skills/multi-search-engine/versions/v0001/SKILL.md create mode 100644 skills/multi-search-engine/versions/v0001/_meta.json create mode 100644 skills/multi-search-engine/versions/v0001/config.json create mode 100644 skills/multi-search-engine/versions/v0001/metadata.json create mode 100644 skills/multi-search-engine/versions/v0001/references/advanced-search.md create mode 100644 skills/multi-search-engine/versions/v0001/references/international-search.md create mode 100644 skills/multi-search-engine/versions/v0001/version.json create mode 100644 skills/skills-authoring-admin/current.json create mode 100644 skills/skills-authoring-admin/skill.json create mode 100644 skills/skills-authoring-admin/versions/v0001/SKILL.md create mode 100644 skills/skills-authoring-admin/versions/v0001/version.json delete mode 100644 skills/web-operation/skill.json delete mode 100644 skills/web-operation/versions/v0001/SKILL.md delete mode 100644 skills/web-operation/versions/v0001/version.json create mode 100644 test-results/.last-run.json diff --git a/.env.example b/.env.example index 00c63f2..6ac2e26 100644 --- a/.env.example +++ b/.env.example @@ -26,17 +26,27 @@ BEAVER_AUTHZ_URL=http://beaver-authz-service:19090 BEAVER_OUTLOOK_MCP_URL= BEAVER_OUTLOOK_MCP_SERVER_ID=outlook_mcp +# User file system backed by MinIO/S3. +BEAVER_MINIO_ROOT_USER= +BEAVER_MINIO_ROOT_PASSWORD= +BEAVER_USER_FILES_BUCKET=beaver-user-files +BEAVER_USER_FILES_MINIO_ENDPOINT= +BEAVER_USER_FILES_MAX_UPLOAD_BYTES=5368709120 + # Must be reachable from auth-portal and authz-service containers. BEAVER_DEPLOY_URL=http://beaver-deploy-control:8090 # External connector sidecar -EXTERNAL_CONNECTOR_TOKEN= -BEAVER_BRIDGE_TOKEN= +EXTERNAL_CONNECTOR_BASE_URL=http://external-connector:8787 +# Required for connector management API authentication. +EXTERNAL_CONNECTOR_TOKEN=change-me-connector-token +# Required for sidecar -> Beaver bridge authentication. +BEAVER_BRIDGE_TOKEN=change-me-bridge-token BEAVER_BRIDGE_BASE_URL=http://app-instance:8080 EXTERNAL_CONNECTOR_PORT=8787 CONNECTOR_PUBLIC_BASE_URL=http://localhost:8787 -# fake | vendor_cli | weixin_ilink -CONNECTOR_PROVIDER=vendor_cli +# fake | official | vendor_cli | weixin_ilink | feishu_bot +CONNECTOR_PROVIDER=official CONNECTOR_COMMAND_TIMEOUT_SECONDS=120 WEIXIN_CONNECT_COMMAND= WEIXIN_STATUS_COMMAND= diff --git a/app-instance/README.md b/app-instance/README.md index 3ec143b..6034e77 100644 --- a/app-instance/README.md +++ b/app-instance/README.md @@ -72,6 +72,8 @@ docker build -t beaver/app-instance:latest . - `--client-secret` - `--network` - `--host-bind-ip` +- `--initial-skills-dir` +- `--skip-initial-skills` - `--build` - `--replace` @@ -127,6 +129,8 @@ BEAVER_WORKSPACE=/root/.beaver/workspace 所以模型 `provider/api_key/api_base/model` 配一次即可,Web / channel 请求不需要、也不应该携带 API Key。 +`create-instance.sh` 默认会把仓库根目录的 `skills/` 非覆盖式复制到实例 workspace,并把同一个目录只读挂载到实例容器的 `/opt/app/initial-skills`。`entrypoint.sh` 每次启动都会用该目录补齐缺失的 published 初始 skills;已有 skill 目录不会被覆盖,index 只做并集追加。 + ## 当前状态 这层已经支持: diff --git a/app-instance/backend/beaver/engine/providers/litellm.py b/app-instance/backend/beaver/engine/providers/litellm.py index 74a5d3c..fab3ded 100644 --- a/app-instance/backend/beaver/engine/providers/litellm.py +++ b/app-instance/backend/beaver/engine/providers/litellm.py @@ -185,6 +185,13 @@ class LiteLLMProvider(LLMProvider): kwargs["provider"] = provider_payload def _apply_thinking_mode(self, original_model: str, resolved_model: str, kwargs: dict[str, Any], enabled: bool | None) -> None: + if self._uses_mistral_reasoning_parser(original_model, resolved_model): + if enabled is not None: + extra_body = dict(kwargs.get("extra_body") or {}) + extra_body["reasoning_effort"] = "high" if enabled else "none" + kwargs["extra_body"] = extra_body + return + extra_body = dict(kwargs.get("extra_body") or {}) chat_template_kwargs = dict(extra_body.get("chat_template_kwargs") or {}) chat_template_kwargs["enable_thinking"] = False @@ -192,6 +199,12 @@ class LiteLLMProvider(LLMProvider): extra_body["thinking"] = {"type": "disabled"} kwargs["extra_body"] = extra_body + def _uses_mistral_reasoning_parser(self, original_model: str, resolved_model: str) -> bool: + if self.provider_name != "vllm": + return False + model_names = f"{original_model} {resolved_model}".lower() + return "mistral" in model_names + async def chat( self, messages: list[dict[str, Any]], diff --git a/app-instance/backend/beaver/interfaces/channels/connections/external.py b/app-instance/backend/beaver/interfaces/channels/connections/external.py index f11ec00..2a68f15 100644 --- a/app-instance/backend/beaver/interfaces/channels/connections/external.py +++ b/app-instance/backend/beaver/interfaces/channels/connections/external.py @@ -8,6 +8,18 @@ from .models import ChannelRuntimeSpec, ValidationResult from .sidecar_client import ConnectorSidecarClient from .store import ChannelConnectionStore, CredentialStore +POLICY_CONFIG_KEYS = { + "allowFrom", + "groupAllowFrom", + "requireMentionInGroups", + "respondToMentionAll", + "dmMode", + "maxMessageChars", + "textBatchDelayMs", + "textBatchMaxMessages", + "textBatchMaxChars", +} + class ExternalConnectorBase: kind = "" @@ -33,6 +45,8 @@ class ExternalConnectorBase: owner_user_id: str | None, options: dict[str, Any], ) -> dict[str, Any]: + runtime_config = {"sidecarBaseUrl": self.sidecar_base_url} + runtime_config.update(_policy_runtime_config(options)) connection = self.connection_store.create( kind=self.kind, mode="sidecar", @@ -40,7 +54,7 @@ class ExternalConnectorBase: account_id="", owner_user_id=owner_user_id, auth_type="connector_session", - runtime_config={"sidecarBaseUrl": self.sidecar_base_url}, + runtime_config=runtime_config, capabilities=list(self.capabilities), ) connection = self.connection_store.update_status(connection.connection_id, status="pairing", last_error=None) @@ -54,7 +68,8 @@ class ExternalConnectorBase: } view = dict(await self.sidecar_client.start_session(payload)) connection.pairing_session_id = str(view.get("sessionId") or "") - self.connection_store.update(connection) + connection = self.connection_store.update(connection) + connection = self._apply_session_view(connection, view) view["connectionId"] = connection.connection_id view["channelId"] = connection.channel_id return view @@ -62,6 +77,12 @@ class ExternalConnectorBase: async def poll_session(self, session_id: str) -> dict[str, Any]: view = dict(await self.sidecar_client.get_session(session_id)) connection = self._connection_for_session(session_id) + connection = self._apply_session_view(connection, view) + view["connectionId"] = connection.connection_id + view["channelId"] = connection.channel_id + return view + + def _apply_session_view(self, connection: Any, view: dict[str, Any]) -> Any: status = str(view.get("status") or "") if status == "connected": connection.account_id = str(view.get("accountId") or connection.account_id) @@ -78,9 +99,7 @@ class ExternalConnectorBase: status="error", last_error=str(view.get("error") or status), ) - view["connectionId"] = connection.connection_id - view["channelId"] = connection.channel_id - return view + return self.connection_store.get(connection.connection_id) async def validate(self, connection_id: str) -> ValidationResult: connection = self.connection_store.get(connection_id) @@ -106,6 +125,7 @@ class ExternalConnectorBase: config={ "platformKind": self.kind, "connectionId": connection.connection_id, + **dict(connection.runtime_config), "sidecarBaseUrl": connection.runtime_config.get("sidecarBaseUrl") or self.sidecar_base_url, }, secrets_ref=None, @@ -129,3 +149,52 @@ class WeixinConnector(ExternalConnectorBase): class FeishuConnector(ExternalConnectorBase): kind = "feishu" capabilities = ["receive_text", "send_text", "receive_media", "groups"] + + +def _policy_runtime_config(options: dict[str, Any]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key in POLICY_CONFIG_KEYS: + if key not in options: + continue + value = options[key] + if key in {"allowFrom", "groupAllowFrom"}: + items = _string_list(value) + if items: + result[key] = items + continue + if key in {"maxMessageChars", "textBatchDelayMs", "textBatchMaxMessages", "textBatchMaxChars"}: + number = _positive_int(value) + if number is not None: + result[key] = number + continue + if key in {"requireMentionInGroups", "respondToMentionAll"}: + result[key] = _bool(value) + continue + text = str(value or "").strip() + if text: + result[key] = text + return result + + +def _string_list(value: Any) -> list[str]: + if isinstance(value, str): + raw_items = value.replace("\n", ",").split(",") + elif isinstance(value, list): + raw_items = value + else: + raw_items = [] + return [str(item).strip() for item in raw_items if str(item).strip()] + + +def _positive_int(value: Any) -> int | None: + try: + number = int(value) + except (TypeError, ValueError): + return None + return number if number > 0 else None + + +def _bool(value: Any) -> bool: + if isinstance(value, bool): + return value + return str(value).strip().lower() in {"1", "true", "yes", "on"} diff --git a/app-instance/backend/beaver/interfaces/web/app.py b/app-instance/backend/beaver/interfaces/web/app.py index e81179e..3ef6f11 100644 --- a/app-instance/backend/beaver/interfaces/web/app.py +++ b/app-instance/backend/beaver/interfaces/web/app.py @@ -431,6 +431,37 @@ def _connection_response_view(connection: Any) -> dict[str, Any]: return view +def _connector_session_response_view(view: dict[str, Any]) -> dict[str, Any]: + result = dict(view) + metadata = result.get("metadata") + if isinstance(metadata, dict): + result["metadata"] = { + str(key): value + for key, value in metadata.items() + if not _is_sensitive_metadata_key(str(key)) + } + return result + + +def _is_sensitive_metadata_key(key: str) -> bool: + lowered = key.lower() + return any(token in lowered for token in ("secret", "token", "password", "authorization", "credential")) + + +async def _activate_connected_channel( + request: Request, + registry: ChannelConnectorRegistry, + connection: Any, +) -> Any: + if connection.status != "connected": + return connection + runtime = get_channel_runtime(request) + config = (await registry.materialize_channel_configs()).get(connection.channel_id) + if config is not None: + await runtime.add_channel(connection.channel_id, config) + return registry.connection_store.get(connection.connection_id) + + def _normalize_connection_config(config: dict[str, Any] | None) -> dict[str, Any]: if not isinstance(config, dict): return {} @@ -441,6 +472,36 @@ def _normalize_connection_config(config: dict[str, Any] | None) -> dict[str, Any } +def _connector_bridge_guard(connection: Any, payload: WebConnectorBridgeEventRequest) -> None: + if connection.status == "revoked": + raise HTTPException(status_code=404, detail="Channel connection not found") + if connection.status not in {"connected", "running"}: + raise HTTPException(status_code=409, detail="Channel connection is not connected") + mismatches: list[str] = [] + if payload.channel_id != connection.channel_id: + mismatches.append("channelId") + if payload.kind != connection.kind: + mismatches.append("kind") + if payload.account_id != connection.account_id: + mismatches.append("accountId") + if mismatches: + raise HTTPException(status_code=403, detail=f"Bridge event does not match connection: {', '.join(mismatches)}") + content = payload.content.strip() + if not content: + raise HTTPException(status_code=400, detail="Bridge event content is required") + max_chars = _positive_int(connection.runtime_config.get("maxMessageChars"), default=20000) + if len(content) > max_chars: + raise HTTPException(status_code=413, detail=f"Bridge event content exceeds maxMessageChars ({max_chars})") + + +def _positive_int(value: Any, *, default: int) -> int: + try: + number = int(value) + except (TypeError, ValueError): + return default + return number if number > 0 else default + + def _camel_to_snake_text(value: str) -> str: result: list[str] = [] for char in value.strip(): @@ -721,8 +782,10 @@ def create_app( connection_id = _clean_text(view.get("connectionId")) connection_view = None if connection_id: - connection_view = _connection_response_view(registry.connection_store.get(connection_id)) - return WebConnectorSessionResponse(session=view, connection=connection_view) + connection = registry.connection_store.get(connection_id) + connection = await _activate_connected_channel(request, registry, connection) + connection_view = _connection_response_view(connection) + return WebConnectorSessionResponse(session=_connector_session_response_view(view), connection=connection_view) @app.get("/api/channel-connector-sessions/{session_id}", response_model=WebConnectorSessionResponse) async def get_channel_connector_session(session_id: str, request: Request) -> WebConnectorSessionResponse: @@ -739,11 +802,11 @@ def create_app( raise HTTPException(status_code=400, detail="Connector does not support sessions") view = await poll_session(session_id) connection = registry.connection_store.get(connection.connection_id) - if connection.status == "connected": - runtime = get_channel_runtime(request) - config = (await registry.materialize_channel_configs())[connection.channel_id] - await runtime.add_channel(connection.channel_id, config) - return WebConnectorSessionResponse(session=view, connection=_connection_response_view(connection)) + connection = await _activate_connected_channel(request, registry, connection) + return WebConnectorSessionResponse( + session=_connector_session_response_view(view), + connection=_connection_response_view(connection), + ) @app.post("/api/channel-connector-bridge/events", response_model=WebConnectorBridgeEventResponse) async def accept_connector_bridge_event( @@ -760,8 +823,7 @@ def create_app( connection = registry.connection_store.get(payload.connection_id) except KeyError: raise HTTPException(status_code=404, detail="Channel connection not found") - if connection.status == "revoked": - raise HTTPException(status_code=404, detail="Channel connection not found") + _connector_bridge_guard(connection, payload) store = _message_dedupe_store(_channel_connection_workspace(request)) begin = store.begin( diff --git a/app-instance/backend/beaver/services/agent_service.py b/app-instance/backend/beaver/services/agent_service.py index c2d1750..c3d87a6 100644 --- a/app-instance/backend/beaver/services/agent_service.py +++ b/app-instance/backend/beaver/services/agent_service.py @@ -604,6 +604,8 @@ class AgentService: if active_task is not None and decision.short_title and not active_task.metadata.get("short_title"): active_task.metadata["short_title"] = decision.short_title task_service.store.upsert_task(active_task) + if active_task is not None and (decision.action == "simple_chat" or decision.starts_new_task): + await self._accept_active_task_for_new_topic(active_task) if active_task is not None and decision.closes_task: task_service.close_task(active_task.task_id, reason=decision.reason) return await runner(message, **kwargs) @@ -636,6 +638,20 @@ class AgentService: ) return await self._run_task_mode(message, runner=runner, kwargs=kwargs, task=task) + async def _accept_active_task_for_new_topic(self, task: TaskRecord) -> None: + """Accept a completed active Task before routing an unrelated new topic.""" + + if task.status != "awaiting_acceptance": + return + run_id = next((item for item in reversed(task.run_ids) if item), None) + if not run_id: + return + await self.submit_acceptance( + session_id=task.session_id, + run_id=run_id, + acceptance_type="accept", + ) + def _record_revision_acceptance_for_task( self, loaded: Any, diff --git a/app-instance/backend/beaver/skills/builtin/intent-agent-router/SKILL.md b/app-instance/backend/beaver/skills/builtin/intent-agent-router/SKILL.md index 741b779..881e757 100644 --- a/app-instance/backend/beaver/skills/builtin/intent-agent-router/SKILL.md +++ b/app-instance/backend/beaver/skills/builtin/intent-agent-router/SKILL.md @@ -32,16 +32,23 @@ When there is an active task, do not force every new user message into that task - Choose `revise_task` when the user asks to change, correct, refine, expand, reformat, or redo the latest active task result. - Choose `continue_task` for neutral follow-up questions or additional next steps that still belong to the active task. -- Choose `new_task` when the user asks for clearly unrelated work. +- Choose `simple_chat` for unrelated lightweight conversation. This starts a new topic and the previous task will be accepted automatically. +- Choose `new_task` when the user asks for clearly unrelated work that needs Task capabilities. This starts a new topic and the previous task will be accepted automatically. - Choose `close_task` when the user says the task is satisfactory or finished, such as "可以了", "就这样", or "that's good". - Choose `abandon_task` when the user says to stop, cancel, or no longer do the active task. +Do not classify unrelated lightweight conversation as `revise_task` merely because +the active task is awaiting acceptance. A revision must ask to change or correct +the active task result. + Examples with an active weather task: - "再详细一点" -> `revise_task` - "加上明后天穿衣建议" -> `revise_task` - "顺便查一下深圳" -> `continue_task` - "帮我写一个采购合同" -> `new_task` +- "吃饭没" -> `simple_chat` +- "我在冰岛" -> `simple_chat` - "可以了" -> `close_task` - "不用了" -> `abandon_task` diff --git a/app-instance/backend/beaver/tasks/router.py b/app-instance/backend/beaver/tasks/router.py index 688ad54..4451fab 100644 --- a/app-instance/backend/beaver/tasks/router.py +++ b/app-instance/backend/beaver/tasks/router.py @@ -161,6 +161,9 @@ class MainAgentRouter: "Critical policy:\n" "- If there is an active Task, choose continue_task or revise_task unless the user's topic is completely unrelated " "to that Task or the user explicitly closes/abandons it.\n" + "- With an active Task, choose simple_chat for unrelated lightweight conversation and new_task for unrelated work " + "that needs Task capabilities. Either decision starts a new topic.\n" + "- An unrelated lightweight conversation must not be classified as revise_task merely because the active Task is awaiting acceptance.\n" "- Choose revise_task when the active Task is awaiting feedback or needs revision and the user asks for changes " "such as '改一下', '加上', '删除', '换成', '再详细点', '格式改成', '不要', or equivalent wording.\n" "- Choose continue_task for neutral follow-up questions or additional next steps that do not imply dissatisfaction with the previous result.\n" diff --git a/app-instance/backend/tests/unit/test_external_connector_bridge_api.py b/app-instance/backend/tests/unit/test_external_connector_bridge_api.py index 91d20c7..83c0ddd 100644 --- a/app-instance/backend/tests/unit/test_external_connector_bridge_api.py +++ b/app-instance/backend/tests/unit/test_external_connector_bridge_api.py @@ -34,6 +34,21 @@ def _connected_connection(tmp_path): return connection +def _connection_with_status(tmp_path, status: str): + state_dir = tmp_path / "state" / "channel_connections" + store = ChannelConnectionStore(state_dir / "connections.json") + connection = store.create( + kind="feishu", + mode="sidecar", + display_name="Feishu Main", + account_id="feishu:app-1", + owner_user_id=None, + auth_type="connector_session", + ) + store.update_status(connection.connection_id, status=status, last_error=None) + return connection + + def _payload(connection, *, event_id: str = "evt-1", delivery_attempt: int = 1) -> dict: return { "eventId": event_id, @@ -85,6 +100,77 @@ def test_bridge_endpoint_rejects_invalid_token(tmp_path, monkeypatch) -> None: service.close() +def test_bridge_endpoint_rejects_connection_identity_mismatch(tmp_path, monkeypatch) -> None: + app, service = _app(tmp_path, monkeypatch) + try: + with TestClient(app) as client: + connection = _connected_connection(tmp_path) + payload = _payload(connection) + payload["channelId"] = "forged-channel" + payload["kind"] = "feishu" + payload["accountId"] = "feishu:attacker" + response = client.post( + "/api/channel-connector-bridge/events", + headers={"Authorization": "Bearer bridge-token"}, + json=payload, + ) + assert response.status_code == 403 + assert "does not match connection" in response.json()["detail"] + finally: + service.close() + + +def test_bridge_endpoint_rejects_inactive_connection(tmp_path, monkeypatch) -> None: + app, service = _app(tmp_path, monkeypatch) + try: + with TestClient(app) as client: + connection = _connection_with_status(tmp_path, "pairing") + response = client.post( + "/api/channel-connector-bridge/events", + headers={"Authorization": "Bearer bridge-token"}, + json={ + **_payload(connection), + "kind": "feishu", + "accountId": "feishu:app-1", + }, + ) + assert response.status_code == 409 + assert "not connected" in response.json()["detail"] + finally: + service.close() + + +def test_bridge_endpoint_rejects_empty_or_oversized_content(tmp_path, monkeypatch) -> None: + app, service = _app(tmp_path, monkeypatch) + try: + with TestClient(app) as client: + connection = _connection_with_status(tmp_path, "connected") + blank = client.post( + "/api/channel-connector-bridge/events", + headers={"Authorization": "Bearer bridge-token"}, + json={ + **_payload(connection, event_id="blank"), + "kind": "feishu", + "accountId": "feishu:app-1", + "content": " ", + }, + ) + too_long = client.post( + "/api/channel-connector-bridge/events", + headers={"Authorization": "Bearer bridge-token"}, + json={ + **_payload(connection, event_id="too-long"), + "kind": "feishu", + "accountId": "feishu:app-1", + "content": "x" * 20001, + }, + ) + assert blank.status_code == 400 + assert too_long.status_code == 413 + finally: + service.close() + + def test_bridge_endpoint_dedupes_repeated_event(tmp_path, monkeypatch) -> None: app, service = _app(tmp_path, monkeypatch) try: diff --git a/app-instance/backend/tests/unit/test_external_sidecar_connectors.py b/app-instance/backend/tests/unit/test_external_sidecar_connectors.py index 40ed8a0..40b4248 100644 --- a/app-instance/backend/tests/unit/test_external_sidecar_connectors.py +++ b/app-instance/backend/tests/unit/test_external_sidecar_connectors.py @@ -43,6 +43,22 @@ class FakeSidecarClient: return {"ok": True} +class ImmediateConnectedSidecarClient(FakeSidecarClient): + async def start_session(self, payload: dict) -> dict: + self.started.append(payload) + session = { + "sessionId": "cs_connected", + "kind": payload["kind"], + "status": "connected", + "qrImage": None, + "accountId": f"{payload['kind']}:me", + "displayName": "Connected Account", + "metadata": {"stateRef": "state-1", "appSecret": "secret-1", "tenantAccessToken": "token-1"}, + } + self.sessions["cs_connected"] = session + return session + + def test_weixin_connector_starts_connector_session(tmp_path) -> None: async def run() -> None: connection_store = ChannelConnectionStore(tmp_path / "connections.json") @@ -67,6 +83,30 @@ def test_weixin_connector_starts_connector_session(tmp_path) -> None: asyncio.run(run()) +def test_feishu_connector_start_session_connected_updates_connection(tmp_path) -> None: + async def run() -> None: + connection_store = ChannelConnectionStore(tmp_path / "connections.json") + credential_store = CredentialStore(tmp_path / "credentials.json") + client = ImmediateConnectedSidecarClient() + connector = FeishuConnector( + connection_store=connection_store, + credential_store=credential_store, + sidecar_client=client, + sidecar_base_url="http://external-connector:8787", + ) + + view = await connector.start_session(display_name="Feishu Main", owner_user_id=None, options={}) + connection = connection_store.get(view["connectionId"]) + + assert view["status"] == "connected" + assert connection.status == "connected" + assert connection.account_id == "feishu:me" + assert connection.display_name == "Connected Account" + assert connection.credentials_ref is not None + + asyncio.run(run()) + + def test_weixin_connector_poll_connected_materializes_external_runtime(tmp_path) -> None: async def run() -> None: connection_store = ChannelConnectionStore(tmp_path / "connections.json") @@ -124,6 +164,67 @@ def test_feishu_connector_uses_feishu_kind(tmp_path) -> None: asyncio.run(run()) +def test_feishu_connector_persists_policy_options_in_runtime_config(tmp_path) -> None: + async def run() -> None: + connection_store = ChannelConnectionStore(tmp_path / "connections.json") + credential_store = CredentialStore(tmp_path / "credentials.json") + client = FakeSidecarClient() + connector = FeishuConnector( + connection_store=connection_store, + credential_store=credential_store, + sidecar_client=client, + sidecar_base_url="http://external-connector:8787", + ) + + await connector.start_session( + display_name="Feishu Main", + owner_user_id=None, + options={ + "domain": "feishu", + "requireMentionInGroups": True, + "allowFrom": ["ou_1"], + "groupAllowFrom": ["oc_1"], + "maxMessageChars": 1234, + }, + ) + connection = connection_store.list()[0] + + assert client.started[0]["options"]["requireMentionInGroups"] is True + assert connection.runtime_config["requireMentionInGroups"] is True + assert connection.runtime_config["allowFrom"] == ["ou_1"] + assert connection.runtime_config["groupAllowFrom"] == ["oc_1"] + assert connection.runtime_config["maxMessageChars"] == 1234 + + asyncio.run(run()) + + +def test_feishu_connector_materializes_policy_for_external_runtime(tmp_path) -> None: + async def run() -> None: + connection_store = ChannelConnectionStore(tmp_path / "connections.json") + credential_store = CredentialStore(tmp_path / "credentials.json") + client = ImmediateConnectedSidecarClient() + connector = FeishuConnector( + connection_store=connection_store, + credential_store=credential_store, + sidecar_client=client, + sidecar_base_url="http://external-connector:8787", + ) + + view = await connector.start_session( + display_name="Feishu Main", + owner_user_id=None, + options={"requireMentionInGroups": True, "allowFrom": ["ou_1"], "groupAllowFrom": ["oc_1"]}, + ) + spec = await connector.materialize_runtime(view["connectionId"]) + + assert spec.config["platformKind"] == "feishu" + assert spec.config["requireMentionInGroups"] is True + assert spec.config["allowFrom"] == ["ou_1"] + assert spec.config["groupAllowFrom"] == ["oc_1"] + + asyncio.run(run()) + + def test_connector_session_api_starts_and_polls_connected_session(tmp_path, monkeypatch) -> None: monkeypatch.setenv("EXTERNAL_CONNECTOR_TOKEN", "connector-token") config_path = tmp_path / "config.json" @@ -174,3 +275,44 @@ def test_connector_session_api_starts_and_polls_connected_session(tmp_path, monk assert polled.json()["connection"]["channel_id"] in app.state.channel_runtime.adapters finally: service.close() + + +def test_connector_session_api_activates_immediate_connected_session(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("EXTERNAL_CONNECTOR_TOKEN", "connector-token") + config_path = tmp_path / "config.json" + config_path.write_text( + '{"agents": {"defaults": {"workspace": "%s"}}, "providers": {}}' % str(tmp_path), + encoding="utf-8", + ) + service = AgentService(config_path=config_path) + app = create_app(service=service, manage_service_lifecycle=False) + client = ImmediateConnectedSidecarClient() + + try: + with TestClient(app) as http: + state_dir = tmp_path / "state" / "channel_connections" + connection_store = ChannelConnectionStore(state_dir / "connections.json") + credential_store = CredentialStore(state_dir / "credentials.json") + registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store) + registry.register( + FeishuConnector( + connection_store=connection_store, + credential_store=credential_store, + sidecar_client=client, + sidecar_base_url="http://external-connector:8787", + ) + ) + app.state.channel_connector_registry = registry + + started = http.post( + "/api/channel-connector-sessions", + json={"kind": "feishu", "displayName": "Feishu Main", "options": {}}, + ) + + assert started.status_code == 200 + connection = started.json()["connection"] + assert connection["status"] == "connected" + assert connection["channel_id"] in app.state.channel_runtime.adapters + assert started.json()["session"]["metadata"] == {"stateRef": "state-1"} + finally: + service.close() diff --git a/app-instance/backend/tests/unit/test_initial_skill_tool_hints.py b/app-instance/backend/tests/unit/test_initial_skill_tool_hints.py index 7118893..592f33b 100644 --- a/app-instance/backend/tests/unit/test_initial_skill_tool_hints.py +++ b/app-instance/backend/tests/unit/test_initial_skill_tool_hints.py @@ -30,10 +30,14 @@ EXPECTED_INITIAL_SKILL_TOOLS = { "mcp_outlook_mcp_calendar_find_meeting_times", "mcp_outlook_mcp_calendar_delta_sync", ], - "skills-admin": ["skills_list", "skill_manage", "skill_view"], + "skills-admin": ["skills_list", "skill_view"], "terminal-operation": ["terminal", "process", "execute_code"], "utility-tools": ["clarify", "delegate", "send_message", "spawn", "todo"], - "web-operation": ["web_fetch", "web_search"], + "multi-search-engine": ["web_fetch"], +} + +EXPECTED_NON_INITIAL_SKILL_TOOLS = { + "skills-authoring-admin": ["skill_manage"], } @@ -48,6 +52,23 @@ def test_initial_skill_tool_hints_match_runtime_tool_names() -> None: assert version["tool_hints"] == expected_tools +def test_skill_authoring_admin_is_seeded_but_not_initial() -> None: + published = json.loads((REPO_ROOT / "skills" / "_index" / "published.json").read_text(encoding="utf-8")) + disabled = json.loads((REPO_ROOT / "skills" / "_index" / "disabled.json").read_text(encoding="utf-8")) + + assert "skills-authoring-admin" not in published["items"] + assert "skills-authoring-admin" in disabled["items"] + + for skill_name, expected_tools in EXPECTED_NON_INITIAL_SKILL_TOOLS.items(): + skill_dir = REPO_ROOT / "skills" / skill_name / "versions" / "v0001" + frontmatter, _body = parse_frontmatter((skill_dir / "SKILL.md").read_text(encoding="utf-8")) + version = json.loads((skill_dir / "version.json").read_text(encoding="utf-8")) + + assert frontmatter["tools"] == expected_tools + assert version["frontmatter"]["tools"] == expected_tools + assert version["tool_hints"] == expected_tools + + def test_default_runtime_registers_skill_view_tool(tmp_path: Path) -> None: loaded = EngineLoader(workspace=tmp_path).load() try: diff --git a/app-instance/backend/tests/unit/test_litellm_thinking_mode.py b/app-instance/backend/tests/unit/test_litellm_thinking_mode.py index f3a547e..1700d87 100644 --- a/app-instance/backend/tests/unit/test_litellm_thinking_mode.py +++ b/app-instance/backend/tests/unit/test_litellm_thinking_mode.py @@ -169,6 +169,90 @@ def test_thinking_mode_is_forced_disabled_even_when_requested_enabled(monkeypatc } +def test_mistral_vllm_uses_reasoning_effort_instead_of_qwen_thinking_body( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict = {} + + class Message: + content = "ok" + reasoning_content = None + tool_calls = [] + + class Choice: + message = Message() + finish_reason = "stop" + + class Response: + choices = [Choice()] + usage = None + + async def fake_acompletion(**kwargs): + captured.update(kwargs) + return Response() + + monkeypatch.setattr("beaver.engine.providers.litellm.acompletion", fake_acompletion) + monkeypatch.setattr("beaver.engine.providers.litellm.litellm", SimpleNamespace()) + + provider = LiteLLMProvider( + api_key="EMPTY", + api_base="http://localhost:8000/v1", + default_model="mistralai/Mistral-Medium-3.5-128B", + provider_name="vllm", + ) + asyncio.run( + provider.chat( + [{"role": "user", "content": "reply ok"}], + model="mistralai/Mistral-Medium-3.5-128B", + thinking_enabled=True, + ) + ) + + assert captured["model"] == "hosted_vllm/mistralai/Mistral-Medium-3.5-128B" + assert captured["extra_body"] == {"reasoning_effort": "high"} + + +def test_mistral_vllm_omits_reasoning_body_when_thinking_mode_is_unspecified( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict = {} + + class Message: + content = "ok" + reasoning_content = None + tool_calls = [] + + class Choice: + message = Message() + finish_reason = "stop" + + class Response: + choices = [Choice()] + usage = None + + async def fake_acompletion(**kwargs): + captured.update(kwargs) + return Response() + + monkeypatch.setattr("beaver.engine.providers.litellm.acompletion", fake_acompletion) + monkeypatch.setattr("beaver.engine.providers.litellm.litellm", SimpleNamespace()) + + provider = LiteLLMProvider( + api_key="EMPTY", + api_base="http://localhost:8000/v1", + default_model="mistralai/Mistral-Medium-3.5-128B", + provider_name="vllm", + ) + asyncio.run( + provider.chat( + [{"role": "user", "content": "reply ok"}], + model="mistralai/Mistral-Medium-3.5-128B", + ) + ) + + assert "extra_body" not in captured + + def test_litellm_provider_sanitizes_tool_call_arguments(monkeypatch: pytest.MonkeyPatch) -> None: captured: dict = {} diff --git a/app-instance/backend/tests/unit/test_main_agent_router.py b/app-instance/backend/tests/unit/test_main_agent_router.py index 7100a83..cba3703 100644 --- a/app-instance/backend/tests/unit/test_main_agent_router.py +++ b/app-instance/backend/tests/unit/test_main_agent_router.py @@ -149,6 +149,22 @@ def test_router_injects_intent_skill_guidance() -> None: assert "Weather and current external data" in prompt +def test_router_prompt_treats_unrelated_lightweight_conversation_as_new_topic() -> None: + provider = RouterProvider('{"action":"simple_chat","reason":"unrelated lightweight conversation"}') + + asyncio.run( + MainAgentRouter().classify( + "吃饭没", + active_task=_task(), + provider=provider, + ) + ) + + prompt = provider.calls[0]["messages"][1]["content"] + assert "unrelated lightweight conversation" in prompt + assert "must not be classified as revise_task merely because the active Task is awaiting acceptance" in prompt + + def test_router_closes_active_task_from_llm_decision() -> None: decision = asyncio.run( MainAgentRouter().classify( diff --git a/app-instance/backend/tests/unit/test_task_mode_feedback.py b/app-instance/backend/tests/unit/test_task_mode_feedback.py index 90b59a2..d15432d 100644 --- a/app-instance/backend/tests/unit/test_task_mode_feedback.py +++ b/app-instance/backend/tests/unit/test_task_mode_feedback.py @@ -99,6 +99,191 @@ def test_task_run_records_evidence_and_waits_for_acceptance(tmp_path: Path) -> N assert "validated" not in event_types +def test_unrelated_simple_chat_auto_accepts_active_task(tmp_path: Path) -> None: + service = AgentService( + loader=EngineLoader( + workspace=tmp_path, + task_execution_planner=StubTaskExecutionPlanner(), + ) + ) + first = asyncio.run( + service.process_direct( + "recommend food in Hengqin", + session_id="web:new-topic-chat", + provider_bundle=_bundle("Food recommendations"), + ) + ) + + second = asyncio.run( + service.process_direct( + "have you eaten?", + session_id="web:new-topic-chat", + provider_bundle=_bundle("I do not eat.", route_action="simple_chat"), + ) + ) + + task_service = service.create_loop().boot().task_service + assert task_service is not None + previous = task_service.get_task(first.task_id or "") + assert previous is not None + assert previous.status == "closed" + assert previous.run_ids == [first.run_id] + assert previous.feedback[-1]["acceptance_type"] == "accept" + assert previous.metadata["final_accepted_run_id"] == first.run_id + assert second.task_id is None + + +def test_unrelated_new_task_auto_accepts_previous_task(tmp_path: Path) -> None: + service = AgentService( + loader=EngineLoader( + workspace=tmp_path, + task_execution_planner=StubTaskExecutionPlanner(), + ) + ) + first = asyncio.run( + service.process_direct( + "recommend food in Hengqin", + session_id="web:new-topic-task", + provider_bundle=_bundle("Food recommendations"), + ) + ) + + second = asyncio.run( + service.process_direct( + "check today's weather in Iceland", + session_id="web:new-topic-task", + provider_bundle=_bundle("Weather result", route_action="new_task"), + ) + ) + + task_service = service.create_loop().boot().task_service + assert task_service is not None + previous = task_service.get_task(first.task_id or "") + current = task_service.get_task(second.task_id or "") + assert previous is not None + assert current is not None + assert previous.status == "closed" + assert previous.run_ids == [first.run_id] + assert previous.feedback[-1]["acceptance_type"] == "accept" + assert current.task_id != previous.task_id + assert current.status == "awaiting_acceptance" + assert current.run_ids == [second.run_id] + + +def test_related_follow_up_continues_active_task_without_accepting_it(tmp_path: Path) -> None: + service = AgentService( + loader=EngineLoader( + workspace=tmp_path, + task_execution_planner=StubTaskExecutionPlanner(), + ) + ) + first = asyncio.run( + service.process_direct( + "recommend food in Hengqin", + session_id="web:continue-topic", + provider_bundle=_bundle("Food recommendations"), + ) + ) + + second = asyncio.run( + service.process_direct( + "include restaurants near the port", + session_id="web:continue-topic", + provider_bundle=_bundle("More recommendations", route_action="continue_task"), + ) + ) + + task_service = service.create_loop().boot().task_service + assert task_service is not None + task = task_service.get_task(first.task_id or "") + assert task is not None + assert second.task_id == first.task_id + assert task.status == "awaiting_acceptance" + assert task.run_ids == [first.run_id, second.run_id] + assert task.feedback == [] + + +def test_requested_revision_keeps_active_task_without_accepting_it(tmp_path: Path) -> None: + service = AgentService( + loader=EngineLoader( + workspace=tmp_path, + task_execution_planner=StubTaskExecutionPlanner(), + ) + ) + first = asyncio.run( + service.process_direct( + "recommend food in Hengqin", + session_id="web:revise-topic", + provider_bundle=_bundle("Food recommendations"), + ) + ) + + second = asyncio.run( + service.process_direct( + "remove expensive restaurants", + session_id="web:revise-topic", + provider_bundle=_bundle("Revised recommendations", route_action="revise_task"), + ) + ) + + task_service = service.create_loop().boot().task_service + assert task_service is not None + task = task_service.get_task(first.task_id or "") + assert task is not None + assert second.task_id == first.task_id + assert task.status == "awaiting_acceptance" + assert task.run_ids == [first.run_id, second.run_id] + assert [item["acceptance_type"] for item in task.feedback] == ["revise"] + + +def test_router_failure_fallback_does_not_auto_accept_active_task(tmp_path: Path) -> None: + service = AgentService( + loader=EngineLoader( + workspace=tmp_path, + task_execution_planner=StubTaskExecutionPlanner(), + ) + ) + first = asyncio.run( + service.process_direct( + "recommend food in Hengqin", + session_id="web:router-fallback", + provider_bundle=_bundle("Food recommendations"), + ) + ) + fallback_bundle = ProviderBundle( + main_runtime=SimpleNamespace(model="stub-model", provider_name="stub"), + main_provider=StubProvider( + [ + LLMResponse( + content="Continued response", + finish_reason="stop", + provider_name="stub", + model="stub-model", + ) + ] + ), + auxiliary_runtime=SimpleNamespace(model="stub-model", provider_name="stub"), + auxiliary_provider=StubProvider([]), + ) + + second = asyncio.run( + service.process_direct( + "continue after router failure", + session_id="web:router-fallback", + provider_bundle=fallback_bundle, + ) + ) + + task_service = service.create_loop().boot().task_service + assert task_service is not None + task = task_service.get_task(first.task_id or "") + assert task is not None + assert second.task_id == first.task_id + assert task.status == "awaiting_acceptance" + assert task.run_ids == [first.run_id, second.run_id] + assert task.feedback == [] + + def test_acceptance_closes_task_and_triggers_learning(tmp_path: Path) -> None: service = AgentService( loader=EngineLoader( diff --git a/app-instance/create-instance.sh b/app-instance/create-instance.sh index c500093..0bba41c 100755 --- a/app-instance/create-instance.sh +++ b/app-instance/create-instance.sh @@ -19,6 +19,9 @@ AUTHZ_INTERNAL_TOKEN="" AUTHZ_OUTLOOK_MCP_URL="" OUTLOOK_MCP_SERVER_ID="${OUTLOOK_MCP_SERVER_ID:-outlook_mcp}" USER_FILES_MAX_UPLOAD_BYTES="${USER_FILES_MAX_UPLOAD_BYTES:-}" +EXTERNAL_CONNECTOR_BASE_URL="${EXTERNAL_CONNECTOR_BASE_URL:-http://external-connector:8787}" +EXTERNAL_CONNECTOR_TOKEN="${EXTERNAL_CONNECTOR_TOKEN:-}" +BEAVER_BRIDGE_TOKEN="${BEAVER_BRIDGE_TOKEN:-}" BACKEND_ID="" CLIENT_ID="" CLIENT_SECRET="" @@ -40,6 +43,7 @@ REGISTRY_PATH="${REGISTRY_PATH:-$REGISTRY_PATH_DEFAULT}" NETWORK_NAME="${NETWORK_NAME:-}" HOST_BIND_IP="${HOST_BIND_IP:-127.0.0.1}" INITIAL_SKILLS_DIR="${INITIAL_SKILLS_DIR:-${SCRIPT_DIR}/../skills}" +INITIAL_SKILLS_EXCLUDE="${INITIAL_SKILLS_EXCLUDE:-officebench-mcp}" SEED_INITIAL_SKILLS=1 FORCE_BUILD=0 REPLACE=0 @@ -73,6 +77,11 @@ Optional: Default Outlook MCP server id. Default: outlook_mcp --user-files-max-upload-bytes Optional max upload size for the user file system. + --external-connector-base-url + External connector sidecar URL. Default: http://external-connector:8787 + --external-connector-token + Service token used for Beaver-to-sidecar requests. + --bridge-token Service token accepted from the connector bridge. --backend-id Pre-assigned backend id. --client-id Pre-assigned AuthZ client id. --client-secret Pre-assigned AuthZ client secret. @@ -299,6 +308,15 @@ data = { "textBatchDelaySeconds": 0.5, }, }, + "terminal-dev": { + "enabled": True, + "kind": "terminal", + "mode": "websocket", + "accountId": "local", + "displayName": "Terminal Dev", + "config": {}, + "secrets": {}, + }, }, } @@ -344,7 +362,7 @@ seed_initial_skills() { fi mkdir -p "$target_dir" - INITIAL_SKILLS_DIR="$initial_skills_dir" TARGET_DIR="$target_dir" python3 - <<'PY' + INITIAL_SKILLS_DIR="$initial_skills_dir" TARGET_DIR="$target_dir" INITIAL_SKILLS_EXCLUDE="$INITIAL_SKILLS_EXCLUDE" python3 - <<'PY' import json import shutil import os @@ -352,10 +370,13 @@ from pathlib import Path initial = Path(os.environ["INITIAL_SKILLS_DIR"]).resolve() target = Path(os.environ["TARGET_DIR"]).resolve() +excluded = {item.strip() for item in os.environ.get("INITIAL_SKILLS_EXCLUDE", "").split(",") if item.strip()} for child in sorted(initial.iterdir()): if child.name.startswith("."): continue + if child.name in excluded: + continue destination = target / child.name if destination.exists(): continue @@ -383,6 +404,8 @@ for index_name in ("published", "disabled"): merged = [] for item in [*target_items, *initial_items]: text = str(item).strip() + if text in excluded: + continue if text and text not in merged: merged.append(text) target_index.parent.mkdir(parents=True, exist_ok=True) @@ -534,6 +557,18 @@ while [[ $# -gt 0 ]]; do USER_FILES_MAX_UPLOAD_BYTES="${2:-}" shift 2 ;; + --external-connector-base-url) + EXTERNAL_CONNECTOR_BASE_URL="${2:-}" + shift 2 + ;; + --external-connector-token) + EXTERNAL_CONNECTOR_TOKEN="${2:-}" + shift 2 + ;; + --bridge-token) + BEAVER_BRIDGE_TOKEN="${2:-}" + shift 2 + ;; --backend-id) BACKEND_ID="${2:-}" shift 2 @@ -643,6 +678,16 @@ if [[ "$SKIP_PROVIDER_CONFIG" -ne 1 ]]; then esac fi +if [[ -n "$INITIAL_SKILLS_DIR" ]]; then + INITIAL_SKILLS_DIR="$(INITIAL_SKILLS_DIR="$INITIAL_SKILLS_DIR" python3 - <<'PY' +import os +from pathlib import Path + +print(Path(os.environ["INITIAL_SKILLS_DIR"]).expanduser().resolve()) +PY +)" +fi + if [[ -n "$BACKEND_ID$CLIENT_ID$CLIENT_SECRET" ]]; then [[ -n "$BACKEND_ID" && -n "$CLIENT_ID" && -n "$CLIENT_SECRET" ]] || die "backend identity requires --backend-id, --client-id and --client-secret together" fi @@ -721,14 +766,29 @@ RUN_ARGS=( -e "APP_BACKEND_PORT=18080" -e "BEAVER_ENABLE_SELF_RESTART=1" -e "BEAVER_OUTLOOK_MCP_SERVER_ID=${OUTLOOK_MCP_SERVER_ID}" + -e "EXTERNAL_CONNECTOR_BASE_URL=${EXTERNAL_CONNECTOR_BASE_URL}" --label "beaver.instance.id=${INSTANCE_ID}" --label "beaver.instance.slug=${INSTANCE_SLUG}" --label "beaver.instance.public_url=${PUBLIC_URL}" ) +if [[ "$SEED_INITIAL_SKILLS" -eq 1 && -n "$INITIAL_SKILLS_DIR" ]]; then + RUN_ARGS+=( + -v "${INITIAL_SKILLS_DIR}:/opt/app/initial-skills:ro" + -e "BEAVER_INITIAL_SKILLS_DIR=/opt/app/initial-skills" + -e "BEAVER_INITIAL_SKILLS_EXCLUDE=${INITIAL_SKILLS_EXCLUDE}" + ) +fi + if [[ -n "$USER_FILES_MAX_UPLOAD_BYTES" ]]; then RUN_ARGS+=(-e "BEAVER_USER_FILES_MAX_UPLOAD_BYTES=${USER_FILES_MAX_UPLOAD_BYTES}") fi +if [[ -n "$EXTERNAL_CONNECTOR_TOKEN" ]]; then + RUN_ARGS+=(-e "EXTERNAL_CONNECTOR_TOKEN=${EXTERNAL_CONNECTOR_TOKEN}") +fi +if [[ -n "$BEAVER_BRIDGE_TOKEN" ]]; then + RUN_ARGS+=(-e "BEAVER_BRIDGE_TOKEN=${BEAVER_BRIDGE_TOKEN}") +fi if [[ -n "$NETWORK_NAME" ]]; then RUN_ARGS+=(--network "$NETWORK_NAME") diff --git a/app-instance/entrypoint.sh b/app-instance/entrypoint.sh index ad5e876..17a31c6 100755 --- a/app-instance/entrypoint.sh +++ b/app-instance/entrypoint.sh @@ -12,6 +12,8 @@ BEAVER_CONFIG_PATH="${BEAVER_CONFIG_PATH:-$BEAVER_HOME/config.json}" BEAVER_WORKSPACE="${BEAVER_WORKSPACE:-$BEAVER_HOME/workspace}" BEAVER_AUTH_FILE="${BEAVER_AUTH_FILE:-$BEAVER_HOME/web_auth_users.json}" BEAVER_RUNTIME_ENV_FILE="${BEAVER_RUNTIME_ENV_FILE:-$BEAVER_HOME/runtime.env}" +BEAVER_INITIAL_SKILLS_DIR="${BEAVER_INITIAL_SKILLS_DIR:-/opt/app/initial-skills}" +BEAVER_INITIAL_SKILLS_EXCLUDE="${BEAVER_INITIAL_SKILLS_EXCLUDE:-officebench-mcp}" log() { printf '[app-instance] %s\n' "$*" @@ -26,6 +28,68 @@ require_file() { fi } +seed_initial_skills() { + local initial_skills_dir="$1" + local target_dir="$2" + + if [[ ! -d "$initial_skills_dir" ]]; then + return + fi + if [[ ! -f "$initial_skills_dir/_index/published.json" ]]; then + log "initial skills source has no published index, skipping: ${initial_skills_dir}" + return + fi + + mkdir -p "$target_dir" + INITIAL_SKILLS_DIR="$initial_skills_dir" TARGET_DIR="$target_dir" INITIAL_SKILLS_EXCLUDE="$BEAVER_INITIAL_SKILLS_EXCLUDE" python - <<'PY' +import json +import os +import shutil +from pathlib import Path + +initial = Path(os.environ["INITIAL_SKILLS_DIR"]).resolve() +target = Path(os.environ["TARGET_DIR"]).resolve() +excluded = {item.strip() for item in os.environ.get("INITIAL_SKILLS_EXCLUDE", "").split(",") if item.strip()} + +for child in sorted(initial.iterdir()): + if child.name.startswith(".") or child.name in excluded: + continue + destination = target / child.name + if destination.exists(): + continue + if child.is_dir(): + shutil.copytree(child, destination) + elif child.is_file(): + shutil.copy2(child, destination) + +for index_name in ("published", "disabled"): + initial_index = initial / "_index" / f"{index_name}.json" + target_index = target / "_index" / f"{index_name}.json" + if not initial_index.exists(): + continue + try: + initial_items = json.loads(initial_index.read_text(encoding="utf-8")).get("items", []) + except json.JSONDecodeError: + initial_items = [] + if target_index.exists(): + try: + target_items = json.loads(target_index.read_text(encoding="utf-8")).get("items", []) + except json.JSONDecodeError: + target_items = [] + else: + target_items = [] + merged = [] + for item in [*target_items, *initial_items]: + text = str(item).strip() + if text in excluded: + continue + if text and text not in merged: + merged.append(text) + target_index.parent.mkdir(parents=True, exist_ok=True) + target_index.write_text(json.dumps({"items": merged}, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") +PY +} + cleanup() { local status=$? @@ -54,12 +118,15 @@ if [[ -f "$BEAVER_RUNTIME_ENV_FILE" ]]; then fi require_file "$BEAVER_CONFIG_PATH" "Missing Beaver config" +seed_initial_skills "$BEAVER_INITIAL_SKILLS_DIR" "$BEAVER_WORKSPACE/skills" export BEAVER_AUTH_FILE export BEAVER_RUNTIME_ENV_FILE export BEAVER_HOME export BEAVER_CONFIG_PATH export BEAVER_WORKSPACE +export BEAVER_INITIAL_SKILLS_DIR +export BEAVER_INITIAL_SKILLS_EXCLUDE export PORT="$APP_FRONTEND_PORT" export HOSTNAME="127.0.0.1" export PYTHONFAULTHANDLER="${PYTHONFAULTHANDLER:-1}" diff --git a/app-instance/frontend/app/(app)/files/page.tsx b/app-instance/frontend/app/(app)/files/page.tsx index 70e91d3..126808e 100644 --- a/app-instance/frontend/app/(app)/files/page.tsx +++ b/app-instance/frontend/app/(app)/files/page.tsx @@ -34,6 +34,7 @@ import { Button } from '@/components/ui/button'; import { ScrollArea } from '@/components/ui/scroll-area'; import { type AppLocale, pickAppText } from '@/lib/i18n/core'; import { useAppI18n } from '@/lib/i18n/provider'; +import { containedLongTextClass, containedPreservedLongTextClass } from '@/lib/text-wrapping'; const LOAD_RETRY_DELAYS_MS = [0, 600, 1200]; @@ -126,8 +127,8 @@ export default function FilesPage() { if (selectedFile?.path === item.path) { setSelectedFile(null); } - } catch { - // ignore + } catch (err: any) { + setLoadError(err.message || pickAppText(locale, '删除失败', 'Delete failed')); } }; @@ -147,8 +148,8 @@ export default function FilesPage() { a.click(); a.remove(); URL.revokeObjectURL(a.href); - } catch { - // ignore + } catch (err: any) { + setPreviewError(err.message || pickAppText(locale, '下载失败', 'Download failed')); } }; @@ -160,13 +161,13 @@ export default function FilesPage() { setUploadProgress(0); try { for (let i = 0; i < files.length; i++) { - await uploadUserFile(files[i], currentPath || 'uploads', (pct) => { + await uploadUserFile(files[i], currentPath, (pct) => { setUploadProgress(Math.round((i / files.length) * 100 + pct / files.length)); }); } await load(); - } catch { - // ignore + } catch (err: any) { + setLoadError(err.message || pickAppText(locale, '上传失败', 'Upload failed')); } finally { setUploading(false); setUploadProgress(0); @@ -183,8 +184,8 @@ export default function FilesPage() { setShowMkdir(false); setNewDirName(''); await load(); - } catch { - // ignore + } catch (err: any) { + setLoadError(err.message || pickAppText(locale, '创建文件夹失败', 'Failed to create folder')); } }; @@ -213,16 +214,17 @@ export default function FilesPage() { }; return ( -
+
{/* Header */} -
+

{pickAppText(locale, '文件管理', 'Files')}

-
+
)} -
+
{/* File list */} -
+
{loading && items.length === 0 ? (
@@ -340,7 +356,7 @@ export default function FilesPage() {

{pickAppText(locale, '加载失败', 'Failed to load')}

{loadError}

- @@ -349,90 +365,80 @@ export default function FilesPage() {

{pickAppText(locale, '空文件夹', 'Empty folder')}

-

{pickAppText(locale, '点击上方"上传"或"新建文件夹"按钮开始使用', 'Use "Upload" or "New folder" above to get started')}

+

{pickAppText(locale, '点击上方"上传"或"新建文件夹"按钮开始使用', 'Use "Upload" or "New folder" above to get started')}

) : ( - +
{items.map((item) => ( -
-
+
+
{item.name}
+

+ {item.type === 'file' && formatSize(item.size)} + {item.modified && ( + <> + {item.type === 'file' && ' · '} + {formatDate(item.modified)} + + )} +

+
+ + +
{item.type === 'file' && ( - { event.stopPropagation(); void handleDownload(item); }} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - event.stopPropagation(); - void handleDownload(item); - } - }} + aria-label={`${pickAppText(locale, '下载', 'Download')} ${item.name}`} title={pickAppText(locale, '下载', 'Download')} > - + )} - { event.stopPropagation(); void handleDelete(item); }} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - event.stopPropagation(); - void handleDelete(item); - } - }} + aria-label={`${pickAppText(locale, '删除', 'Delete')} ${item.name}`} title={pickAppText(locale, '删除', 'Delete')} > - +
- +
))}
@@ -471,7 +477,7 @@ function FilePreviewPanel({ locale: AppLocale; }) { return ( -
+
{loading ? (
@@ -485,16 +491,16 @@ function FilePreviewPanel({
) : (
-
+
-

{file.name}

-

+

{file.name}

+

{formatSize(file.size)} · {formatDate(file.modified)} · {file.content_type} {file.is_truncated ? ` · ${pickAppText(locale, '仅预览前 1MB', 'Showing first 1MB')}` : ''}

{downloadUrl && ( -
) : isMarkdown(file) ? ( -
+
*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_*]:min-w-0 ${containedLongTextClass}`}> {file.content || ''}
) : ( -
+            
               {file.content || ''}
             
)} diff --git a/app-instance/frontend/app/(app)/marketplace/page.tsx b/app-instance/frontend/app/(app)/marketplace/page.tsx index 2ef4949..32ec326 100644 --- a/app-instance/frontend/app/(app)/marketplace/page.tsx +++ b/app-instance/frontend/app/(app)/marketplace/page.tsx @@ -168,10 +168,18 @@ export default function MarketplacePage() { const totalPages = useMemo(() => Math.max(1, Math.ceil(total / 12)), [total]); return ( -
-
+
+
+
+

+ {t('市场', 'Marketplace')} +

+

+ {t('搜索、查看并安装 SkillHub 技能。', 'Search, inspect, and install SkillHub skills.')} +

+
{ event.preventDefault(); setPage(0); @@ -187,7 +195,7 @@ export default function MarketplacePage() { className="h-14 rounded-2xl pl-12 text-base" />
- @@ -195,9 +203,9 @@ export default function MarketplacePage() { {error && ( - - - {error} + + + {error} )} @@ -206,6 +214,7 @@ export default function MarketplacePage() {
@@ -283,7 +292,7 @@ export default function MarketplacePage() { {label} ))} - {t('筛选:', 'Filter:')} + {t('筛选:', 'Filter:')} ))}
)} -
+
diff --git a/app-instance/frontend/app/(app)/mcp/page.tsx b/app-instance/frontend/app/(app)/mcp/page.tsx index 16da65b..2ad24a9 100644 --- a/app-instance/frontend/app/(app)/mcp/page.tsx +++ b/app-instance/frontend/app/(app)/mcp/page.tsx @@ -247,6 +247,9 @@ export default function MCPPage() { }; const handleDelete = async (serverId: string) => { + if (!window.confirm(t('确定删除这个 MCP 服务吗?此操作不可撤销。', 'Delete this MCP server? This action cannot be undone.'))) { + return; + } try { await deleteMcpServer(serverId); setSelectedServerId((current) => (current === serverId ? null : current)); @@ -312,9 +315,9 @@ export default function MCPPage() { } return ( -
-
-
+
+
+

{t('工具', 'Tools')} @@ -323,8 +326,8 @@ export default function MCPPage() { {t('本地工具和在线工具都通过 MCP Server 暴露;本地工具按类别由真实 stdio MCP 子进程承载。', 'Local and online tools are both exposed through MCP servers. Local tool categories run as real stdio MCP subprocesses.')}

-
- @@ -333,12 +336,12 @@ export default function MCPPage() { if (!open) resetForm(); }}> - - + {editingId ? t('编辑 MCP 服务', 'Edit MCP server') : t('新增 MCP 服务', 'Add MCP server')} @@ -346,11 +349,11 @@ export default function MCPPage() {
- setForm((s) => ({ ...s, id: e.target.value }))} required disabled={!!editingId} /> + setForm((s) => ({ ...s, id: e.target.value }))} required disabled={!!editingId} />
- setForm((s) => ({ ...s, tool_timeout: e.target.value }))} /> + setForm((s) => ({ ...s, tool_timeout: e.target.value }))} />
{t('MCP Server 地址', 'MCP server URL')} setForm((s) => ({ ...s, url: e.target.value }))} placeholder="http://localhost:3001/mcp" @@ -403,7 +407,7 @@ export default function MCPPage() { id="auth_mode" value={form.auth_mode} onChange={(e) => setForm((s) => ({ ...s, auth_mode: e.target.value }))} - className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" + className="flex h-11 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" > @@ -452,6 +456,7 @@ export default function MCPPage() { setForm((s) => ({ ...s, command: e.target.value }))} placeholder="npx" @@ -462,6 +467,7 @@ export default function MCPPage() { setForm((s) => ({ ...s, args: e.target.value }))} placeholder="-y @modelcontextprotocol/server-github" @@ -470,11 +476,11 @@ export default function MCPPage() {
-
- - @@ -488,7 +494,7 @@ export default function MCPPage() { {error && ( -
+
{error}
@@ -500,9 +506,9 @@ export default function MCPPage() { setToolTab(value as 'local' | 'online'); setSelectedServerId(null); }} className="space-y-4"> - - {t('本地工具', 'Local tools')} - {t('在线工具', 'Online tools')} + + {t('本地工具', 'Local tools')} + {t('在线工具', 'Online tools')} @@ -511,78 +517,73 @@ export default function MCPPage() { {visibleServers.map((server) => ( setSelectedServerId(server.id)} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - setSelectedServerId(server.id); - } - }} className={cn( - 'cursor-pointer transition-colors', + 'min-w-0 transition-colors', selectedServerId === server.id && 'border-primary bg-primary/5 shadow-sm' )} > - -
-
- {server.name} -

{server.id}

+
setSelectedServerId(server.id)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setSelectedServerId(server.id); + } + }} + className="min-h-11 cursor-pointer rounded-t-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" + > + +
+
+ {server.name} +

{server.id}

+
+
+ {transportLabel(server.transport, locale)} + {server.category || (server.kind === 'local' ? 'local' : 'online')} + {server.managed && {t('内置', 'Built-in')}} + + {serverStatusLabel(server.status, locale)} + +
-
- {transportLabel(server.transport, locale)} - {server.category || (server.kind === 'local' ? 'local' : 'online')} - {server.managed && {t('内置', 'Built-in')}} - - {serverStatusLabel(server.status, locale)} - -
-
- - - {server.url &&
URL: {server.url}
} - {server.command &&
{t('命令:', 'Command:')} {server.command} {(server.args || []).join(' ')}
} - {server.auth_mode && server.auth_mode !== 'none' &&
{t('鉴权:', 'Auth:')} {server.auth_mode}
} - {(server.auth_audience || server.auth_mode === 'oauth_backend_token') && ( -
Audience: {server.auth_audience || resolveAuthAudience(server.id)}
- )} - {(server.auth_scopes || []).length > 0 &&
Scopes: {(server.auth_scopes || []).join(', ')}
} - {server.auth_mode === 'oauth_backend_token' && (!server.auth_scopes || server.auth_scopes.length === 0) && ( -
Scopes: {t('由 AuthZ 动态决定', 'Derived from AuthZ')}
- )} -
- {t(`${discoveredToolCount(server.id, tools, server.tool_count)} 个工具`, `${discoveredToolCount(server.id, tools, server.tool_count)} tools`)} - {selectedServerId === server.id ? t('已选中', 'Selected') : t('点击查看工具', 'Click to view tools')} - {server.last_error && {server.last_error}} -
-
- {!server.managed && ( - + + + {server.url &&
URL: {server.url}
} + {server.command &&
{t('命令:', 'Command:')} {server.command} {(server.args || []).join(' ')}
} + {server.auth_mode && server.auth_mode !== 'none' &&
{t('鉴权:', 'Auth:')} {server.auth_mode}
} + {(server.auth_audience || server.auth_mode === 'oauth_backend_token') && ( +
Audience: {server.auth_audience || resolveAuthAudience(server.id)}
)} -
+ + {!server.managed && ( + - {!server.managed && ( - - )} -
+ )} + + {!server.managed && ( + + )} ))} @@ -595,9 +596,9 @@ export default function MCPPage() { )}
- + - + {selectedServer ? t(`${selectedServer.name} 的工具`, `${selectedServer.name} tools`) : t('工具详情', 'Tool details')} @@ -613,12 +614,12 @@ export default function MCPPage() { )} {selectedToolGroup && (
-
{selectedToolGroup.server_id}
+
{selectedToolGroup.server_id}
{selectedToolGroup.tools.map((tool) => ( -
-
{String(tool.tool_name || tool.name)}
-
+
+
{String(tool.tool_name || tool.name)}
+
{String(tool.description || '—')}
diff --git a/app-instance/frontend/app/(app)/notifications/[scheduledRunId]/page.tsx b/app-instance/frontend/app/(app)/notifications/[scheduledRunId]/page.tsx index 708bf56..e2b104a 100644 --- a/app-instance/frontend/app/(app)/notifications/[scheduledRunId]/page.tsx +++ b/app-instance/frontend/app/(app)/notifications/[scheduledRunId]/page.tsx @@ -9,6 +9,7 @@ import { getNotification, sendMessage } from '@/lib/api'; import type { ChatMessage, NotificationDetail } from '@/types'; import { pickAppText } from '@/lib/i18n/core'; import { useAppI18n } from '@/lib/i18n/provider'; +import { containedLongTextClass } from '@/lib/text-wrapping'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { ChatWorkbench } from '@/components/chat-workbench/ChatWorkbench'; @@ -27,6 +28,7 @@ export default function NotificationDetailPage() { const [submitting, setSubmitting] = useState(false); const messagesEndRef = useRef(null); const viewportRef = useRef(null); + const replyTextareaId = `notification-reply-${scheduledRunId}`; const load = React.useCallback(async () => { setLoading(true); @@ -94,8 +96,8 @@ export default function NotificationDetailPage() { if (!detail) { return ( -
- +
+ {pickAppText(locale, '返回通知', 'Back to notifications')} @@ -106,29 +108,29 @@ export default function NotificationDetailPage() { return (
-
+
- + {pickAppText(locale, '通知列表', 'Notifications')}
-

{detail.title || detail.job_name}

- {detail.status} - {detail.engaged && {pickAppText(locale, '已接入 Task', 'Task linked')}} +

{detail.title || detail.job_name}

+ {detail.status} + {detail.engaged && {pickAppText(locale, '已接入 Task', 'Task linked')}}

{pickAppText(locale, '生成时间', 'Generated')}: {formatTime(detail.started_at)}

-
- {detail.task_id && ( - )} @@ -136,7 +138,7 @@ export default function NotificationDetailPage() {
- {error &&
{error}
} + {error &&
{error}
}
-
+
{detail.engaged && ( - + {pickAppText(locale, '这条通知已经接入 Task', 'This notification is linked to a Task')} @@ -184,7 +190,13 @@ export default function NotificationDetailPage() {
{intent && (
+