已合并
fix: 打通用户面的同源代理并补齐联合认证配置 #463
Wal1et创建于 24 天前
fix: 打通用户面的同源代理并补齐联合认证配置 #463
已合并
共 11 个文件变更+1134-253
| @@ -41,6 +41,14 @@ IDENTITY_REFRESH_TTL=604800 | |||
| 41 | IDENTITY_SEED_ADMIN=true | 41 | IDENTITY_SEED_ADMIN=true |
| 42 | IDENTITY_SEED_USER1=true | 42 | IDENTITY_SEED_USER1=true |
| 43 | 43 | ||
| 44 | +# 联合认证 Demo Provider(仅用于本地/集成链路验证,默认关闭) | ||
| 45 | +# 统一从 Manager Web 的 /idp 同源入口访问时,PUBLIC_PATH_PREFIX 保持 /idp。 | ||
| 46 | +IDENTITY_FEDERATION_DEMO_ENABLED=false | ||
| 47 | +IDENTITY_FEDERATION_PUBLIC_PATH_PREFIX=/idp | ||
| 48 | +IDENTITY_FEDERATION_REQUEST_TTL=300 | ||
| 49 | +IDENTITY_FEDERATION_CODE_TTL=60 | ||
| 50 | +IDENTITY_FEDERATION_DEMO_ADMIN_GROUP=enterprise-admins | ||
| 51 | + | ||
| 44 | # ============================================================================= | 52 | # ============================================================================= |
| 45 | # 管理 API manager-server(REST 8765) | 53 | # 管理 API manager-server(REST 8765) |
| 46 | # ============================================================================= | 54 | # ============================================================================= |
| @@ -98,6 +106,7 @@ MANAGER_WEB_HOST=127.0.0.1 | |||
| 98 | MANAGER_WEB_PORT=5273 | 106 | MANAGER_WEB_PORT=5273 |
| 99 | MANAGER_WEB_PROXY_TARGET=http://127.0.0.1:8765 | 107 | MANAGER_WEB_PROXY_TARGET=http://127.0.0.1:8765 |
| 100 | MANAGER_WEB_IDP_TARGET=http://127.0.0.1:8770 | 108 | MANAGER_WEB_IDP_TARGET=http://127.0.0.1:8770 |
| 101 | -MANAGER_WEB_USER_SERVER_TARGET=http://127.0.0.1:5174 | 109 | +MANAGER_WEB_USER_WEB_TARGET=http://127.0.0.1:5173 |
| 102 | -MANAGER_WEB_GATEWAY_SSE=http://127.0.0.1:19001/web/invoke | 110 | +MANAGER_WEB_GATEWAY_HTTP_TARGET=http://127.0.0.1:19002 |
| 111 | +MANAGER_WEB_GATEWAY_WS_TARGET=http://127.0.0.1:19000 | ||
| 103 | MANAGER_WEB_LOG_LEVEL=info | 112 | MANAGER_WEB_LOG_LEVEL=info |
| @@ -113,7 +113,7 @@ npm run dev | |||
| 113 | Vite 开发服务器已配置代理,`/api` → `8765`、`/idp` → `8770`(会自动去掉 `/idp` 前缀)。 | 113 | Vite 开发服务器已配置代理,`/api` → `8765`、`/idp` → `8770`(会自动去掉 `/idp` 前缀)。 |
| 114 | 114 | ||
| 115 | > 若登录报 `/idp/v1/auth/token` 404,请确认 `identity-center` 已在 `8770` 端口运行,并重启 `npm run dev` 使代理配置生效。 | 115 | > 若登录报 `/idp/v1/auth/token` 404,请确认 `identity-center` 已在 `8770` 端口运行,并重启 `npm run dev` 使代理配置生效。 |
| 116 | -`/web/invoke`、`/file-api` 依赖 Gateway / User Server,未启动时聊天相关功能不可用,管理面主体功能可正常使用。 | 116 | +`/chat`、`/ws`、`/gateway-api`、`/file-api` 和 `/share-api` 依赖 User Web / Gateway,未启动时用户面功能不可用,管理面主体功能可正常使用。 |
| 117 | 117 | ||
| 118 | ### 联合认证本地联调 | 118 | ### 联合认证本地联调 |
| 119 | 119 | ||
| @@ -180,12 +180,13 @@ Manager 权限守卫。 | |||
| 180 | 180 | ||
| 181 | ## 生产 / 集成模式(统一入口) | 181 | ## 生产 / 集成模式(统一入口) |
| 182 | 182 | ||
| 183 | -构建静态资源后,由 `manager-web` 提供单一 HTTP 入口(默认 `5273`),统一转发 `/api`、`/idp` 等请求。 | 183 | +构建静态资源后,由 `manager-web` 提供单一 HTTP 入口(默认 `5273`)。User Web 保持独立进程,通过 `/chat/` 在同源 iframe 中呈现。 |
| 184 | 184 | ||
| 185 | ```bash | 185 | ```bash |
| 186 | -# 构建前端(完整集成还需构建 user_web) | 186 | +# 构建 Manager 前端 |
| 187 | -cd applications/access/user_web && npm install && npm run build | 187 | +cd applications/manager/manager_web |
| 188 | -cd ../../manager/manager_web && npm install && npm run build | 188 | +npm install |
| 189 | +npm run build | ||
| 189 | cd ../../.. | 190 | cd ../../.. |
| 190 | 191 | ||
| 191 | # 启动后端(需先激活虚拟环境) | 192 | # 启动后端(需先激活虚拟环境) |
| @@ -196,7 +197,9 @@ manager-server | |||
| 196 | manager-web \ | 197 | manager-web \ |
| 197 | --host 127.0.0.1 --port 5273 \ | 198 | --host 127.0.0.1 --port 5273 \ |
| 198 | --dist applications/manager/manager_web/dist \ | 199 | --dist applications/manager/manager_web/dist \ |
| 199 | - --chat-dist applications/access/user_web/dist | 200 | + --user-web-target http://127.0.0.1:5173 \ |
| 201 | + --gateway-ws-target http://127.0.0.1:19000 \ | ||
| 202 | + --gateway-http-target http://127.0.0.1:19002 | ||
| 200 | ``` | 203 | ``` |
| 201 | 204 | ||
| 202 | `5273` 为外部唯一入口: | 205 | `5273` 为外部唯一入口: |
| @@ -205,7 +208,10 @@ manager-web \ | |||
| 205 | - `/chat/` — 同源 User Web iframe | 208 | - `/chat/` — 同源 User Web iframe |
| 206 | - `/api` — 转发至 Manager API(`8765`) | 209 | - `/api` — 转发至 Manager API(`8765`) |
| 207 | - `/idp` — 转发至身份中心(`8770`) | 210 | - `/idp` — 转发至身份中心(`8770`) |
| 208 | -- `/web/invoke`、`/file-api` — 转发至 Gateway / User Server | 211 | +- `/ws` — 转发至 Gateway WebSocket(默认 `19000`) |
| 212 | +- `/gateway-api`、`/file-api`、`/share-api` — 转发至 Gateway Web HTTP(默认 `19002`) | ||
| 213 | + | ||
| 214 | +Gateway HTTP 使用独立的 `/gateway-api` 前缀,避免与 Manager API 的 `/api/v1/*` 发生路由冲突。 | ||
| 209 | 215 | ||
| 210 | 也可一键启动 API + 已构建的 Web(需先 `npm run build`): | 216 | 也可一键启动 API + 已构建的 Web(需先 `npm run build`): |
| 211 | 217 | ||
| @@ -246,6 +252,9 @@ MANAGER_WEB_HOST=127.0.0.1 | |||
| 246 | MANAGER_WEB_PORT=5273 | 252 | MANAGER_WEB_PORT=5273 |
| 247 | MANAGER_WEB_PROXY_TARGET=http://127.0.0.1:8765 | 253 | MANAGER_WEB_PROXY_TARGET=http://127.0.0.1:8765 |
| 248 | MANAGER_WEB_IDP_TARGET=http://127.0.0.1:8770 | 254 | MANAGER_WEB_IDP_TARGET=http://127.0.0.1:8770 |
| 255 | +MANAGER_WEB_USER_WEB_TARGET=http://127.0.0.1:5173 | ||
| 256 | +MANAGER_WEB_GATEWAY_HTTP_TARGET=http://127.0.0.1:19002 | ||
| 257 | +MANAGER_WEB_GATEWAY_WS_TARGET=http://127.0.0.1:19000 | ||
| 249 | ``` | 258 | ``` |
| 250 | 259 | ||
| 251 | 完整变量列表见 `applications/manager/.env.example`。 | 260 | 完整变量列表见 `applications/manager/.env.example`。 |
| @@ -0,0 +1,41 @@ | |||
| 1 | +"""Federation settings exposed through the Manager environment file.""" | ||
| 2 | + | ||
| 3 | +from pathlib import Path | ||
| 4 | + | ||
| 5 | +from identity_center.infrastructure.config import Settings | ||
| 6 | + | ||
| 7 | + | ||
| 8 | +def test_manager_env_example_exposes_safe_federation_defaults(): | ||
| 9 | + manager_dir = Path(__file__).resolve().parents[3] | ||
| 10 | + | ||
| 11 | + settings = Settings(_env_file=manager_dir / ".env.example") | ||
| 12 | + | ||
| 13 | + assert settings.federation_demo_enabled is False | ||
| 14 | + assert settings.federation_public_path_prefix == "/idp" | ||
| 15 | + assert settings.federation_request_ttl_seconds == 300 | ||
| 16 | + assert settings.federation_code_ttl_seconds == 60 | ||
| 17 | + assert settings.federation_demo_admin_group == "enterprise-admins" | ||
| 18 | + | ||
| 19 | + | ||
| 20 | +def test_federation_settings_accept_explicit_environment_values(tmp_path: Path): | ||
| 21 | + env_file = tmp_path / ".env" | ||
| 22 | + env_file.write_text( | ||
| 23 | + "\n".join( | ||
| 24 | + ( | ||
| 25 | + "IDENTITY_FEDERATION_DEMO_ENABLED=true", | ||
| 26 | + "IDENTITY_FEDERATION_PUBLIC_PATH_PREFIX=/idp", | ||
| 27 | + "IDENTITY_FEDERATION_REQUEST_TTL=420", | ||
| 28 | + "IDENTITY_FEDERATION_CODE_TTL=90", | ||
| 29 | + "IDENTITY_FEDERATION_DEMO_ADMIN_GROUP=verified-admins", | ||
| 30 | + ) | ||
| 31 | + ), | ||
| 32 | + encoding="utf-8", | ||
| 33 | + ) | ||
| 34 | + | ||
| 35 | + settings = Settings(_env_file=env_file) | ||
| 36 | + | ||
| 37 | + assert settings.federation_demo_enabled is True | ||
| 38 | + assert settings.federation_public_path_prefix == "/idp" | ||
| 39 | + assert settings.federation_request_ttl_seconds == 420 | ||
| 40 | + assert settings.federation_code_ttl_seconds == 90 | ||
| 41 | + assert settings.federation_demo_admin_group == "verified-admins" | ||
| @@ -89,13 +89,17 @@ class Settings(BaseSettings): | |||
| 89 | default="http://127.0.0.1:8770", | 89 | default="http://127.0.0.1:8770", |
| 90 | validation_alias="MANAGER_WEB_IDP_TARGET", | 90 | validation_alias="MANAGER_WEB_IDP_TARGET", |
| 91 | ) | 91 | ) |
| 92 | - manager_web_user_server_target: str = Field( | 92 | + manager_web_user_web_target: str = Field( |
| 93 | - default="http://127.0.0.1:5174", | 93 | + default="http://127.0.0.1:5173", |
| 94 | - validation_alias="MANAGER_WEB_USER_SERVER_TARGET", | 94 | + validation_alias="MANAGER_WEB_USER_WEB_TARGET", |
| 95 | ) | 95 | ) |
| 96 | - manager_web_gateway_sse: str = Field( | 96 | + manager_web_gateway_http_target: str = Field( |
| 97 | - default="http://127.0.0.1:19001/web/invoke", | 97 | + default="http://127.0.0.1:19002", |
| 98 | - validation_alias="MANAGER_WEB_GATEWAY_SSE", | 98 | + validation_alias="MANAGER_WEB_GATEWAY_HTTP_TARGET", |
| 99 | + ) | ||
| 100 | + manager_web_gateway_ws_target: str = Field( | ||
| 101 | + default="http://127.0.0.1:19000", | ||
| 102 | + validation_alias="MANAGER_WEB_GATEWAY_WS_TARGET", | ||
| 99 | ) | 103 | ) |
| 100 | manager_web_log_level: str = Field(default="info", validation_alias="MANAGER_WEB_LOG_LEVEL") | 104 | manager_web_log_level: str = Field(default="info", validation_alias="MANAGER_WEB_LOG_LEVEL") |
| 101 | 105 | ||
| @@ -3,27 +3,30 @@ | |||
| 3 | from __future__ import annotations | 3 | from __future__ import annotations |
| 4 | 4 | ||
| 5 | import argparse | 5 | import argparse |
| 6 | +import asyncio | ||
| 6 | import logging | 7 | import logging |
| 7 | from collections.abc import AsyncIterator | 8 | from collections.abc import AsyncIterator |
| 9 | +from contextlib import suppress | ||
| 8 | from pathlib import Path | 10 | from pathlib import Path |
| 9 | 11 | ||
| 10 | import httpx | 12 | import httpx |
| 11 | import uvicorn | 13 | import uvicorn |
| 12 | -from fastapi import FastAPI, Request, Response | 14 | +import websockets |
| 15 | +from fastapi import FastAPI, Request, Response, WebSocket, WebSocketDisconnect | ||
| 13 | from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse | 16 | from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse |
| 17 | +from websockets.exceptions import ConnectionClosed | ||
| 14 | 18 | ||
| 15 | _SKIP_REQ_HEADERS = frozenset({"host", "content-length", "transfer-encoding", "connection"}) | 19 | _SKIP_REQ_HEADERS = frozenset({"host", "content-length", "transfer-encoding", "connection"}) |
| 16 | -_SKIP_RESP_HEADERS = frozenset({"content-encoding", "content-length", "transfer-encoding", "connection"}) | 20 | +_SKIP_RESP_HEADERS = frozenset( |
| 21 | + {"content-encoding", "content-length", "transfer-encoding", "connection"} | ||
| 22 | +) | ||
| 23 | +_ACCESS_COOKIE = "openjiuwen_access_token" | ||
| 17 | 24 | ||
| 18 | 25 | ||
| 19 | def _manager_web_dist() -> Path: | 26 | def _manager_web_dist() -> Path: |
| 20 | return Path(__file__).resolve().parents[2].parent / "manager_web" / "dist" | 27 | return Path(__file__).resolve().parents[2].parent / "manager_web" / "dist" |
| 21 | 28 | ||
| 22 | 29 | ||
| 23 | -def _user_web_dist() -> Path: | ||
| 24 | - return Path(__file__).resolve().parents[4] / "access" / "user_web" / "dist" | ||
| 25 | - | ||
| 26 | - | ||
| 27 | def _coerce_backend_url(raw: str) -> str: | 30 | def _coerce_backend_url(raw: str) -> str: |
| 28 | url = raw.strip().rstrip("/") | 31 | url = raw.strip().rstrip("/") |
| 29 | if not url.startswith(("http://", "https://")): | 32 | if not url.startswith(("http://", "https://")): |
| @@ -31,8 +34,28 @@ def _coerce_backend_url(raw: str) -> str: | |||
| 31 | return url | 34 | return url |
| 32 | 35 | ||
| 33 | 36 | ||
| 37 | +def _coerce_ws_url(raw: str) -> str: | ||
| 38 | + url = raw.strip().rstrip("/") | ||
| 39 | + if url.startswith("http://"): | ||
| 40 | + return f"ws://{url.removeprefix('http://')}" | ||
| 41 | + if url.startswith("https://"): | ||
| 42 | + return f"wss://{url.removeprefix('https://')}" | ||
| 43 | + if not url.startswith(("ws://", "wss://")): | ||
| 44 | + raise ValueError(f"websocket backend url must be ws/wss/http/https: {raw}") | ||
| 45 | + return url | ||
| 46 | + | ||
| 47 | + | ||
| 48 | +def _join_url(base_url: str, path: str) -> str: | ||
| 49 | + return f"{base_url.rstrip('/')}/{path.lstrip('/')}" | ||
| 50 | + | ||
| 51 | + | ||
| 34 | async def _relay( | 52 | async def _relay( |
| 35 | - request: Request, upstream_url: str, tag: str, *, timeout: float = 30.0 | 53 | + request: Request, |
| 54 | + upstream_url: str, | ||
| 55 | + tag: str, | ||
| 56 | + *, | ||
| 57 | + timeout: float = 30.0, | ||
| 58 | + follow_redirects: bool = False, | ||
| 36 | ) -> Response: | 59 | ) -> Response: |
| 37 | """反向代理一个请求到 upstream_url(本机目标,trust_env=False 不读环境代理)。""" | 60 | """反向代理一个请求到 upstream_url(本机目标,trust_env=False 不读环境代理)。""" |
| 38 | if request.url.query: | 61 | if request.url.query: |
| @@ -44,9 +67,16 @@ async def _relay( | |||
| 44 | } | 67 | } |
| 45 | payload = await request.body() | 68 | payload = await request.body() |
| 46 | try: | 69 | try: |
| 47 | - async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client: | 70 | + async with httpx.AsyncClient( |
| 71 | + timeout=timeout, | ||
| 72 | + trust_env=False, | ||
| 73 | + follow_redirects=follow_redirects, | ||
| 74 | + ) as client: | ||
| 48 | upstream = await client.request( | 75 | upstream = await client.request( |
| 49 | - request.method, upstream_url, content=payload, headers=outbound_headers, | 76 | + request.method, |
| 77 | + upstream_url, | ||
| 78 | + content=payload, | ||
| 79 | + headers=outbound_headers, | ||
| 50 | ) | 80 | ) |
| 51 | except httpx.HTTPError as exc: | 81 | except httpx.HTTPError as exc: |
| 52 | logging.getLogger("jiuwenclaw-manager-web").error("%s relay failed: %s", tag, exc) | 82 | logging.getLogger("jiuwenclaw-manager-web").error("%s relay failed: %s", tag, exc) |
| @@ -57,7 +87,9 @@ async def _relay( | |||
| 57 | if name.lower() not in _SKIP_RESP_HEADERS | 87 | if name.lower() not in _SKIP_RESP_HEADERS |
| 58 | } | 88 | } |
| 59 | return Response( | 89 | return Response( |
| 60 | - content=upstream.content, status_code=upstream.status_code, headers=response_headers, | 90 | + content=upstream.content, |
| 91 | + status_code=upstream.status_code, | ||
| 92 | + headers=response_headers, | ||
| 61 | ) | 93 | ) |
| 62 | 94 | ||
| 63 | 95 | ||
| @@ -70,9 +102,7 @@ async def _relay_stream(request: Request, upstream_url: str, tag: str) -> Respon | |||
| 70 | for name, value in request.headers.items() | 102 | for name, value in request.headers.items() |
| 71 | if name.lower() not in _SKIP_REQ_HEADERS | 103 | if name.lower() not in _SKIP_REQ_HEADERS |
| 72 | } | 104 | } |
| 73 | - client = httpx.AsyncClient( | 105 | + client = httpx.AsyncClient(timeout=httpx.Timeout(None, connect=10.0), trust_env=False) |
| 74 | - timeout=httpx.Timeout(None, connect=10.0), trust_env=False | ||
| 75 | - ) | ||
| 76 | try: | 106 | try: |
| 77 | upstream = await client.send( | 107 | upstream = await client.send( |
| 78 | client.build_request( | 108 | client.build_request( |
| @@ -85,9 +115,7 @@ async def _relay_stream(request: Request, upstream_url: str, tag: str) -> Respon | |||
| 85 | ) | 115 | ) |
| 86 | except httpx.HTTPError as exc: | 116 | except httpx.HTTPError as exc: |
| 87 | await client.aclose() | 117 | await client.aclose() |
| 88 | - logging.getLogger("jiuwenclaw-manager-web").error( | 118 | + logging.getLogger("jiuwenclaw-manager-web").error("%s relay failed: %s", tag, exc) |
| 89 | - "%s relay failed: %s", tag, exc | ||
| 90 | - ) | ||
| 91 | return Response(content=f"{tag} relay failed".encode(), status_code=502) | 119 | return Response(content=f"{tag} relay failed".encode(), status_code=502) |
| 92 | 120 | ||
| 93 | response_headers = { | 121 | response_headers = { |
| @@ -112,15 +140,25 @@ async def _relay_stream(request: Request, upstream_url: str, tag: str) -> Respon | |||
| 112 | ) | 140 | ) |
| 113 | 141 | ||
| 114 | 142 | ||
| 115 | -async def _authorize_web_request(request: Request, idp_url: str) -> Response | None: | 143 | +def _request_authorization( |
| 116 | - """通过身份中心校验聊天请求携带的 Bearer token。""" | 144 | + headers: object, |
| 117 | - authorization = request.headers.get("authorization", "").strip() | 145 | + cookies: object, |
| 146 | +) -> str: | ||
| 147 | + header_get = getattr(headers, "get", None) | ||
| 148 | + authorization = str(header_get("authorization") or "").strip() if header_get else "" | ||
| 149 | + if authorization: | ||
| 150 | + return authorization | ||
| 151 | + cookie_get = getattr(cookies, "get", None) | ||
| 152 | + access_token = str(cookie_get(_ACCESS_COOKIE) or "").strip() if cookie_get else "" | ||
| 153 | + return f"Bearer {access_token}" if access_token else "" | ||
| 154 | + | ||
| 155 | + | ||
| 156 | +async def _validate_identity( | ||
| 157 | + authorization: str, | ||
| 158 | + idp_url: str, | ||
| 159 | +) -> tuple[int, bytes, str] | None: | ||
| 118 | if not authorization.lower().startswith("bearer "): | 160 | if not authorization.lower().startswith("bearer "): |
| 119 | - return Response( | 161 | + return 401, b'{"detail":"Not authenticated"}', "application/json" |
| 120 | - content=b'{"detail":"Not authenticated"}', | ||
| 121 | - status_code=401, | ||
| 122 | - media_type="application/json", | ||
| 123 | - ) | ||
| 124 | try: | 162 | try: |
| 125 | async with httpx.AsyncClient(timeout=10.0, trust_env=False) as client: | 163 | async with httpx.AsyncClient(timeout=10.0, trust_env=False) as client: |
| 126 | upstream = await client.get( | 164 | upstream = await client.get( |
| @@ -128,78 +166,198 @@ async def _authorize_web_request(request: Request, idp_url: str) -> Response | N | |||
| 128 | headers={"authorization": authorization}, | 166 | headers={"authorization": authorization}, |
| 129 | ) | 167 | ) |
| 130 | except httpx.HTTPError as exc: | 168 | except httpx.HTTPError as exc: |
| 131 | - logging.getLogger("jiuwenclaw-manager-web").error( | 169 | + logging.getLogger("jiuwenclaw-manager-web").error("identity validation failed: %s", exc) |
| 132 | - "identity validation failed: %s", exc | 170 | + return 502, b'{"detail":"identity service unavailable"}', "application/json" |
| 133 | - ) | ||
| 134 | - return Response( | ||
| 135 | - content=b'{"detail":"identity service unavailable"}', | ||
| 136 | - status_code=502, | ||
| 137 | - media_type="application/json", | ||
| 138 | - ) | ||
| 139 | if upstream.status_code != 200: | 171 | if upstream.status_code != 200: |
| 140 | - return Response( | 172 | + return ( |
| 141 | - content=upstream.content, | 173 | + upstream.status_code, |
| 142 | - status_code=upstream.status_code, | 174 | + upstream.content, |
| 143 | - media_type=upstream.headers.get("content-type", "application/json"), | 175 | + upstream.headers.get("content-type", "application/json"), |
| 144 | ) | 176 | ) |
| 145 | return None | 177 | return None |
| 146 | 178 | ||
| 147 | 179 | ||
| 180 | +async def _authorize_web_request(request: Request, idp_url: str) -> Response | None: | ||
| 181 | + """通过身份中心校验统一用户入口请求。""" | ||
| 182 | + error = await _validate_identity( | ||
| 183 | + _request_authorization(request.headers, request.cookies), | ||
| 184 | + idp_url, | ||
| 185 | + ) | ||
| 186 | + if error is None: | ||
| 187 | + return None | ||
| 188 | + status_code, content, media_type = error | ||
| 189 | + return Response(content=content, status_code=status_code, media_type=media_type) | ||
| 190 | + | ||
| 191 | + | ||
| 192 | +async def _relay_websocket( | ||
| 193 | + client_ws: WebSocket, | ||
| 194 | + upstream_url: str, | ||
| 195 | + idp_url: str, | ||
| 196 | +) -> None: | ||
| 197 | + """校验统一登录后,在 Manager Web 与 Gateway 之间双向转发 WebSocket。""" | ||
| 198 | + auth_error = await _validate_identity( | ||
| 199 | + _request_authorization(client_ws.headers, client_ws.cookies), | ||
| 200 | + idp_url, | ||
| 201 | + ) | ||
| 202 | + if auth_error is not None: | ||
| 203 | + await client_ws.close(code=4401) | ||
| 204 | + return | ||
| 205 | + | ||
| 206 | + if client_ws.url.query: | ||
| 207 | + upstream_url = f"{upstream_url}?{client_ws.url.query}" | ||
| 208 | + | ||
| 209 | + accepted = False | ||
| 210 | + try: | ||
| 211 | + async with websockets.connect( | ||
| 212 | + upstream_url, | ||
| 213 | + origin=client_ws.headers.get("origin"), | ||
| 214 | + open_timeout=10, | ||
| 215 | + ping_interval=20, | ||
| 216 | + ping_timeout=20, | ||
| 217 | + max_size=None, | ||
| 218 | + ) as upstream_ws: | ||
| 219 | + await client_ws.accept() | ||
| 220 | + accepted = True | ||
| 221 | + | ||
| 222 | + async def client_to_gateway() -> None: | ||
| 223 | + while True: | ||
| 224 | + message = await client_ws.receive() | ||
| 225 | + if message["type"] == "websocket.disconnect": | ||
| 226 | + return | ||
| 227 | + if message.get("text") is not None: | ||
| 228 | + await upstream_ws.send(message["text"]) | ||
| 229 | + elif message.get("bytes") is not None: | ||
| 230 | + await upstream_ws.send(message["bytes"]) | ||
| 231 | + | ||
| 232 | + async def gateway_to_client() -> None: | ||
| 233 | + async for message in upstream_ws: | ||
| 234 | + if isinstance(message, bytes): | ||
| 235 | + await client_ws.send_bytes(message) | ||
| 236 | + else: | ||
| 237 | + await client_ws.send_text(message) | ||
| 238 | + | ||
| 239 | + tasks = { | ||
| 240 | + asyncio.create_task(client_to_gateway()), | ||
| 241 | + asyncio.create_task(gateway_to_client()), | ||
| 242 | + } | ||
| 243 | + done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) | ||
| 244 | + for task in pending: | ||
| 245 | + task.cancel() | ||
| 246 | + for task in pending: | ||
| 247 | + with suppress(asyncio.CancelledError): | ||
| 248 | + await task | ||
| 249 | + for task in done: | ||
| 250 | + task.result() | ||
| 251 | + except WebSocketDisconnect: | ||
| 252 | + return | ||
| 253 | + except ConnectionClosed: | ||
| 254 | + return | ||
| 255 | + except Exception as exc: # noqa: BLE001 | ||
| 256 | + logging.getLogger("jiuwenclaw-manager-web").error("gateway-ws relay failed: %s", exc) | ||
| 257 | + if accepted: | ||
| 258 | + with suppress(RuntimeError): | ||
| 259 | + await client_ws.close(code=1011) | ||
| 260 | + else: | ||
| 261 | + with suppress(RuntimeError): | ||
| 262 | + await client_ws.close(code=1013) | ||
| 263 | + | ||
| 264 | + | ||
| 148 | def create_manager_web_app( | 265 | def create_manager_web_app( |
| 149 | - dist_root: Path, backend_url: str, idp_url: str, | 266 | + dist_root: Path, |
| 150 | - *, chat_dist_root: Path | None = None, user_server_url: str = "", | 267 | + backend_url: str, |
| 151 | - gateway_sse: str = "", | 268 | + idp_url: str, |
| 269 | + *, | ||
| 270 | + user_web_url: str = "", | ||
| 271 | + gateway_http_url: str = "", | ||
| 272 | + gateway_ws_url: str = "", | ||
| 152 | ) -> FastAPI: | 273 | ) -> FastAPI: |
| 153 | application = FastAPI(title="jiuwenclaw-manager-web", docs_url=None, redoc_url=None) | 274 | application = FastAPI(title="jiuwenclaw-manager-web", docs_url=None, redoc_url=None) |
| 154 | 275 | ||
| 155 | - # User Web 是统一外壳中的同源 iframe 静态应用,构建 base 固定为 /chat/。 | 276 | + # User Web 由独立服务提供,Manager Web 只保留同源 /chat 入口。 |
| 156 | - # 这里直接发 dist,避免代理 Vite HTML 后其 /src、/@vite/client 落到 Manager SPA。 | 277 | + if user_web_url: |
| 157 | - if chat_dist_root is not None: | ||
| 158 | - chat_root = chat_dist_root.resolve() | ||
| 159 | - chat_index = chat_root / "index.html" | ||
| 160 | 278 | ||
| 161 | 279 | ||
| 162 | async def chat_slash() -> Response: | 280 | async def chat_slash() -> Response: |
| 163 | return RedirectResponse("/chat/", status_code=307) | 281 | return RedirectResponse("/chat/", status_code=307) |
| 164 | 282 | ||
| 165 | - @application.get("/chat/{tail:path}") | 283 | + @application.api_route( |
| 284 | + "/chat/{tail:path}", | ||
| 285 | + methods=["GET", "HEAD", "OPTIONS"], | ||
| 286 | + ) | ||
| 166 | async def chat_static(request: Request, tail: str) -> Response: | 287 | async def chat_static(request: Request, tail: str) -> Response: |
| 167 | if ( | 288 | if ( |
| 168 | tail in ("", "index.html") | 289 | tail in ("", "index.html") |
| 169 | and request.headers.get("sec-fetch-dest", "").lower() == "document" | 290 | and request.headers.get("sec-fetch-dest", "").lower() == "document" |
| 170 | ): | 291 | ): |
| 171 | return RedirectResponse("/auth", status_code=302) | 292 | return RedirectResponse("/auth", status_code=302) |
| 172 | - candidate = (chat_root / tail).resolve() | 293 | + return await _relay( |
| 173 | - if ( | 294 | + request, |
| 174 | - tail | 295 | + _join_url(user_web_url, tail), |
| 175 | - and candidate.is_relative_to(chat_root) | 296 | + "user-web", |
| 176 | - and candidate.is_file() | 297 | + timeout=300.0, |
| 177 | - ): | 298 | + ) |
| 178 | - return FileResponse(candidate) | 299 | + |
| 179 | - return FileResponse(chat_index) | 300 | + if gateway_http_url: |
| 180 | 301 | ||
| 181 | - if user_server_url: | ||
| 182 | - # iframe 的文件接口仍由 User Server 提供,统一入口只做同源转发。 | ||
| 183 | 302 | ||
| 184 | "/file-api/{tail:path}", | 303 | "/file-api/{tail:path}", |
| 185 | methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD"], | 304 | methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD"], |
| 186 | ) | 305 | ) |
| 187 | async def relay_file_api(request: Request, tail: str) -> Response: | 306 | async def relay_file_api(request: Request, tail: str) -> Response: |
| 188 | - return await _relay( | ||
| 189 | - request, | ||
| 190 | - f"{user_server_url}/file-api/{tail}", | ||
| 191 | - "file-api", | ||
| 192 | - timeout=300.0, | ||
| 193 | - ) | ||
| 194 | - | ||
| 195 | - # chat iframe 内的 user_web 使用同源 HTTP POST + SSE。 | ||
| 196 | - if gateway_sse: | ||
| 197 | - | ||
| 198 | - async def relay_gateway_sse(request: Request) -> Response: | ||
| 199 | auth_error = await _authorize_web_request(request, idp_url) | 307 | auth_error = await _authorize_web_request(request, idp_url) |
| 200 | if auth_error is not None: | 308 | if auth_error is not None: |
| 201 | return auth_error | 309 | return auth_error |
| 202 | - return await _relay_stream(request, gateway_sse, "gateway-sse") | 310 | + return await _relay( |
| 311 | + request, _join_url(gateway_http_url, f"file-api/{tail}"), "file-api", timeout=300.0 | ||
| 312 | + ) | ||
| 313 | + | ||
| 314 | + | ||
| 315 | + "/share-api/{tail:path}", | ||
| 316 | + methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD"], | ||
| 317 | + ) | ||
| 318 | + async def relay_share_api(request: Request, tail: str) -> Response: | ||
| 319 | + auth_error = await _authorize_web_request(request, idp_url) | ||
| 320 | + if auth_error is not None: | ||
| 321 | + return auth_error | ||
| 322 | + return await _relay( | ||
| 323 | + request, | ||
| 324 | + _join_url(gateway_http_url, f"share-api/{tail}"), | ||
| 325 | + "share-api", | ||
| 326 | + timeout=300.0, | ||
| 327 | + ) | ||
| 328 | + | ||
| 329 | + # 独立前缀避免与 Manager Server 的 /api/v1/* 冲突。 | ||
| 330 | + | ||
| 331 | + "/gateway-api/{tail:path}", | ||
| 332 | + methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"], | ||
| 333 | + ) | ||
| 334 | + async def relay_gateway_http(request: Request, tail: str) -> Response: | ||
| 335 | + auth_error = await _authorize_web_request(request, idp_url) | ||
| 336 | + if auth_error is not None: | ||
| 337 | + return auth_error | ||
| 338 | + return await _relay_stream( | ||
| 339 | + request, | ||
| 340 | + _join_url(gateway_http_url, f"api/{tail}"), | ||
| 341 | + "gateway-http", | ||
| 342 | + ) | ||
| 343 | + | ||
| 344 | + if gateway_ws_url: | ||
| 345 | + | ||
| 346 | + | ||
| 347 | + async def relay_gateway_ws_root(websocket: WebSocket) -> None: | ||
| 348 | + await _relay_websocket( | ||
| 349 | + websocket, | ||
| 350 | + _join_url(gateway_ws_url, "ws"), | ||
| 351 | + idp_url, | ||
| 352 | + ) | ||
| 353 | + | ||
| 354 | + | ||
| 355 | + async def relay_gateway_ws(websocket: WebSocket, tail: str) -> None: | ||
| 356 | + await _relay_websocket( | ||
| 357 | + websocket, | ||
| 358 | + _join_url(gateway_ws_url, f"ws/{tail}"), | ||
| 359 | + idp_url, | ||
| 360 | + ) | ||
| 203 | 361 | ||
| 204 | 362 | ||
| 205 | "/api/{tail:path}", | 363 | "/api/{tail:path}", |
| @@ -207,7 +365,28 @@ def create_manager_web_app( | |||
| 207 | ) | 365 | ) |
| 208 | async def relay_manager_api(request: Request, tail: str) -> Response: | 366 | async def relay_manager_api(request: Request, tail: str) -> Response: |
| 209 | # 平台管理 API → 本机 Manager API(8765)。 | 367 | # 平台管理 API → 本机 Manager API(8765)。 |
| 210 | - return await _relay(request, f"{backend_url}/api/{tail}", "api") | 368 | + # Manager Server 的部分集合路由以斜杠结尾,FastAPI 会对无斜杠请求 |
| 369 | + # 返回指向集群内部域名的 307。代理端完成该规范化跳转,避免内部地址 | ||
| 370 | + # 暴露给浏览器后触发 Failed to fetch。 | ||
| 371 | + return await _relay( | ||
| 372 | + request, | ||
| 373 | + f"{backend_url}/api/{tail}", | ||
| 374 | + "api", | ||
| 375 | + follow_redirects=True, | ||
| 376 | + ) | ||
| 377 | + | ||
| 378 | + | ||
| 379 | + "/manager-api/{tail:path}", | ||
| 380 | + methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"], | ||
| 381 | + ) | ||
| 382 | + async def relay_embedded_user_manager_api(request: Request, tail: str) -> Response: | ||
| 383 | + """Relay the embedded User Web's explicit Manager API namespace.""" | ||
| 384 | + return await _relay( | ||
| 385 | + request, | ||
| 386 | + f"{backend_url}/api/{tail}", | ||
| 387 | + "manager-api", | ||
| 388 | + follow_redirects=True, | ||
| 389 | + ) | ||
| 211 | 390 | ||
| 212 | 391 | ||
| 213 | "/idp/{tail:path}", | 392 | "/idp/{tail:path}", |
| @@ -270,16 +449,20 @@ def main() -> None: | |||
| 270 | default=settings.manager_web_idp_target, | 449 | default=settings.manager_web_idp_target, |
| 271 | help="Identity service base URL for /idp relay.", | 450 | help="Identity service base URL for /idp relay.", |
| 272 | ) | 451 | ) |
| 273 | - parser.add_argument("--chat-dist", default=str(_user_web_dist())) | ||
| 274 | parser.add_argument( | 452 | parser.add_argument( |
| 275 | - "--user-server-target", | 453 | + "--user-web-target", |
| 276 | - default=settings.manager_web_user_server_target, | 454 | + default=settings.manager_web_user_web_target, |
| 277 | - help="User Server base URL for /file-api relay.", | 455 | + help="User Web base URL for the same-origin /chat relay.", |
| 278 | ) | 456 | ) |
| 279 | parser.add_argument( | 457 | parser.add_argument( |
| 280 | - "--gateway-sse", | 458 | + "--gateway-http-target", |
| 281 | - default=settings.manager_web_gateway_sse, | 459 | + default=settings.manager_web_gateway_http_target, |
| 282 | - help="Gateway HTTP/SSE URL for /web/invoke relay.", | 460 | + help="Gateway Web HTTP base URL for /gateway-api and file/share relays.", |
| 461 | + ) | ||
| 462 | + parser.add_argument( | ||
| 463 | + "--gateway-ws-target", | ||
| 464 | + default=settings.manager_web_gateway_ws_target, | ||
| 465 | + help="Gateway WebSocket base URL for /ws relay.", | ||
| 283 | ) | 466 | ) |
| 284 | parser.add_argument( | 467 | parser.add_argument( |
| 285 | "--log-level", | 468 | "--log-level", |
| @@ -290,14 +473,12 @@ def main() -> None: | |||
| 290 | dist_root = Path(args.dist).expanduser().resolve() | 473 | dist_root = Path(args.dist).expanduser().resolve() |
| 291 | if not dist_root.is_dir(): | 474 | if not dist_root.is_dir(): |
| 292 | raise SystemExit(f"dist directory not found: {dist_root}") | 475 | raise SystemExit(f"dist directory not found: {dist_root}") |
| 293 | - chat_dist_root = Path(args.chat_dist).expanduser().resolve() | ||
| 294 | - if not chat_dist_root.is_dir(): | ||
| 295 | - raise SystemExit(f"chat dist directory not found: {chat_dist_root}") | ||
| 296 | - | ||
| 297 | try: | 476 | try: |
| 298 | backend_url = _coerce_backend_url(args.proxy_target) | 477 | backend_url = _coerce_backend_url(args.proxy_target) |
| 299 | idp_url = _coerce_backend_url(args.idp_target) | 478 | idp_url = _coerce_backend_url(args.idp_target) |
| 300 | - user_server_url = _coerce_backend_url(args.user_server_target) | 479 | + user_web_url = _coerce_backend_url(args.user_web_target) |
| 480 | + gateway_http_url = _coerce_backend_url(args.gateway_http_target) | ||
| 481 | + gateway_ws_url = _coerce_ws_url(args.gateway_ws_target) | ||
| 301 | except ValueError as exc: | 482 | except ValueError as exc: |
| 302 | raise SystemExit(str(exc)) from exc | 483 | raise SystemExit(str(exc)) from exc |
| 303 | 484 | ||
| @@ -306,15 +487,17 @@ def main() -> None: | |||
| 306 | log.info("http://%s:%s", args.host, args.port) | 487 | log.info("http://%s:%s", args.host, args.port) |
| 307 | log.info("/api relay -> %s", backend_url) | 488 | log.info("/api relay -> %s", backend_url) |
| 308 | log.info("/idp relay -> %s", idp_url) | 489 | log.info("/idp relay -> %s", idp_url) |
| 309 | - log.info("/chat static -> %s", chat_dist_root) | 490 | + log.info("/chat relay -> %s", user_web_url) |
| 310 | - log.info("/file-api relay -> %s", user_server_url) | 491 | + log.info("/gateway-api,/file-api,/share-api relay -> %s", gateway_http_url) |
| 311 | - log.info("/web/invoke relay -> %s", args.gateway_sse) | 492 | + log.info("/ws relay -> %s", gateway_ws_url) |
| 312 | 493 | ||
| 313 | app = create_manager_web_app( | 494 | app = create_manager_web_app( |
| 314 | - dist_root, backend_url, idp_url, | 495 | + dist_root, |
| 315 | - chat_dist_root=chat_dist_root, | 496 | + backend_url, |
| 316 | - user_server_url=user_server_url, | 497 | + idp_url, |
| 317 | - gateway_sse=args.gateway_sse, | 498 | + user_web_url=user_web_url, |
| 499 | + gateway_http_url=gateway_http_url, | ||
| 500 | + gateway_ws_url=gateway_ws_url, | ||
| 318 | ) | 501 | ) |
| 319 | uvicorn.run(app, host=args.host, port=args.port, log_level=args.log_level.lower()) | 502 | uvicorn.run(app, host=args.host, port=args.port, log_level=args.log_level.lower()) |
| 320 | 503 | ||
Mapplications/manager/manager_server/tests/unit_tests/test_manager_web_sse_proxy.py+257-47文件内容审核中,请稍后刷新重试
| @@ -19,18 +19,23 @@ Claw Manager 管理面 Web 前端。技术栈与 `jiuwenclaw/web` 完全对齐 | |||
| 19 | ## 统一入口启动 | 19 | ## 统一入口启动 |
| 20 | 20 | ||
| 21 | ```bash | 21 | ```bash |
| 22 | -cd applications/access/user_web && npm install && npm run build | 22 | +cd applications/manager/manager_web |
| 23 | -cd ../../manager/manager_web && npm install && npm run build | 23 | +npm install |
| 24 | +npm run build | ||
| 24 | cd ../../.. | 25 | cd ../../.. |
| 25 | .venv/bin/manager-web \ | 26 | .venv/bin/manager-web \ |
| 26 | --host 127.0.0.1 --port 5273 \ | 27 | --host 127.0.0.1 --port 5273 \ |
| 27 | --dist applications/manager/manager_web/dist \ | 28 | --dist applications/manager/manager_web/dist \ |
| 28 | - --chat-dist applications/access/user_web/dist | 29 | + --user-web-target http://127.0.0.1:5173 \ |
| 30 | + --gateway-ws-target http://127.0.0.1:19000 \ | ||
| 31 | + --gateway-http-target http://127.0.0.1:19002 | ||
| 29 | ``` | 32 | ``` |
| 30 | 33 | ||
| 31 | 5273 是唯一外部入口:`/auth`、`/user`、`/manager` 由 Manager SPA 提供, | 34 | 5273 是唯一外部入口:`/auth`、`/user`、`/manager` 由 Manager SPA 提供, |
| 32 | -`/chat/` 是同源 User Web iframe。登录和退出均由外层统一管理,不存在第二套 | 35 | +`/chat/` 反向代理独立 User Web 并作为同源 iframe。登录和退出均由外层统一管理。 |
| 33 | -User Web 登录态。`/api`、`/idp`、`/web/invoke` 和 `/file-api` 由统一入口转发。 | 36 | +`/api` 转发 Manager Server,`/idp` 转发 Identity Center,`/ws` 转发 |
| 37 | +Gateway WebSocket,`/gateway-api`、`/file-api`、`/share-api` 转发 Gateway Web HTTP。 | ||
| 38 | +独立的 `/gateway-api` 前缀用于避免与 Manager `/api/v1/*` 冲突。 | ||
| 34 | 39 | ||
| 35 | `npm run dev` 仍可用于单独调试 Manager 前端;集成验证应使用上面的统一入口。 | 40 | `npm run dev` 仍可用于单独调试 Manager 前端;集成验证应使用上面的统一入口。 |
| 36 | 41 | ||
| @@ -1,17 +1,26 @@ | |||
| 1 | -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; | 1 | +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 2 | -import { useTranslation } from 'react-i18next'; | 2 | +import { useTranslation } from "react-i18next"; |
| 3 | -import { LanguageSwitcher } from '../../components/LanguageSwitcher'; | 3 | +import { LanguageSwitcher } from "../../components/LanguageSwitcher"; |
| 4 | -import { ThemeToggle } from '../../components/ThemeToggle'; | 4 | +import { ThemeToggle } from "../../components/ThemeToggle"; |
| 5 | -import { useAuth } from '../../auth/AuthContext'; | 5 | +import { useAuth } from "../../auth/AuthContext"; |
| 6 | -import { useAsync } from '../../hooks/useAsync'; | 6 | +import { useAsync } from "../../hooks/useAsync"; |
| 7 | -import { AgentTemplate, Org, UserConsoleApi, UserGateway } from '../../services/api'; | 7 | +import { |
| 8 | -import { getProductName } from '../../utils/env'; | 8 | + AgentTemplate, |
| 9 | + Org, | ||
| 10 | + UserConsoleApi, | ||
| 11 | + UserGateway, | ||
| 12 | +} from "../../services/api"; | ||
| 13 | +import { getProductName } from "../../utils/env"; | ||
| 9 | 14 | ||
| 10 | -// 内嵌聊天(web_enterprise)的基址:默认同源 /chat(webui nginx 以 base=/chat/ 同源提供 dist, | 15 | +// 内嵌 User Web 的基址。默认通过 Manager Web 的同源 /chat 反向代理加载。 |
| 11 | -// 不再指向 :5173,因而不会"localhost 拒绝访问";其 SSE/file-api 走根路径,由 webui 反代到 web 后端)。 | 16 | +const CHAT_BASE = |
| 12 | -const CHAT_BASE = (import.meta.env.VITE_CHAT_BASE_URL as string | undefined) || '/chat'; | 17 | + (import.meta.env.VITE_CHAT_BASE_URL as string | undefined) || "/chat"; |
| 13 | -const AUTH_EXPIRED_MESSAGE = 'jiuwenswarm:auth-expired'; | 18 | +const AUTH_EXPIRED_MESSAGE = "jiuwenswarm:auth-expired"; |
| 14 | -const USER_CONTEXT_KEY = 'jiuwenswarm:user-context'; | 19 | +const USER_CONTEXT_KEY = "jiuwenswarm:user-context"; |
| 20 | +const CONTEXT_READY_MESSAGE = "jiuwenswarm:enterprise-context-ready"; | ||
| 21 | +const CONTEXT_SNAPSHOT_MESSAGE = "jiuwenswarm:enterprise-context-snapshot"; | ||
| 22 | +const CONTEXT_CHANGE_MESSAGE = "jiuwenswarm:enterprise-context-change"; | ||
| 23 | +const CONTEXT_LOGOUT_MESSAGE = "jiuwenswarm:enterprise-context-logout"; | ||
| 15 | 24 | ||
| 16 | /** 用户控制台里实例化 Agent 的运行时标识;回退 template_id。 */ | 25 | /** 用户控制台里实例化 Agent 的运行时标识;回退 template_id。 */ |
| 17 | function agentRuntimeId(agent: AgentTemplate): string { | 26 | function agentRuntimeId(agent: AgentTemplate): string { |
| @@ -22,18 +31,49 @@ export function UserConsole() { | |||
| 22 | const { t } = useTranslation(); | 31 | const { t } = useTranslation(); |
| 23 | const { user, logout } = useAuth(); | 32 | const { user, logout } = useAuth(); |
| 24 | const { data: orgsData } = useAsync(() => UserConsoleApi.orgs(), []); | 33 | const { data: orgsData } = useAsync(() => UserConsoleApi.orgs(), []); |
| 25 | - const { data: gatewaysData, loading: gatewaysLoading } = useAsync(() => UserConsoleApi.gateways(), []); | 34 | + const { data: gatewaysData, loading: gatewaysLoading } = useAsync( |
| 35 | + () => UserConsoleApi.gateways(), | ||
| 36 | + [], | ||
| 37 | + ); | ||
| 26 | const [orgId, setOrgId] = useState<string | null>(null); | 38 | const [orgId, setOrgId] = useState<string | null>(null); |
| 27 | - const [gatewayId, setGatewayId] = useState<string | null>(() => sessionStorage.getItem('jw_user_gateway_id')); | 39 | + const [gatewayId, setGatewayId] = useState<string | null>(() => |
| 28 | - const saveGateway = useCallback((gateway: UserGateway) => { | 40 | + sessionStorage.getItem("jw_user_gateway_id"), |
| 29 | - setGatewayId(gateway.jiuwenclaw_id); | 41 | + ); |
| 30 | - sessionStorage.setItem('jw_user_gateway_id', gateway.jiuwenclaw_id); | 42 | + const saveGateway = useCallback( |
| 31 | - sessionStorage.setItem(USER_CONTEXT_KEY, JSON.stringify({ user_id: user?.user_id ?? '', gateway_id: gateway.jiuwenclaw_id, gateway_endpoint: gateway.gateway_endpoint })); | 43 | + (gateway: UserGateway) => { |
| 32 | - }, [user?.user_id]); | 44 | + setGatewayId(gateway.jiuwenclaw_id); |
| 45 | + sessionStorage.setItem("jw_user_gateway_id", gateway.jiuwenclaw_id); | ||
| 46 | + sessionStorage.setItem( | ||
| 47 | + USER_CONTEXT_KEY, | ||
| 48 | + JSON.stringify({ | ||
| 49 | + user_id: user?.user_id ?? "", | ||
| 50 | + gateway_id: gateway.jiuwenclaw_id, | ||
| 51 | + gateway_endpoint: gateway.gateway_endpoint, | ||
| 52 | + }), | ||
| 53 | + ); | ||
| 54 | + }, | ||
| 55 | + [user?.user_id], | ||
| 56 | + ); | ||
| 33 | const gateways = gatewaysData?.gateways ?? []; | 57 | const gateways = gatewaysData?.gateways ?? []; |
| 34 | const orgs = orgsData?.orgs ?? []; | 58 | const orgs = orgsData?.orgs ?? []; |
| 35 | const currentOrg = orgs.find((o) => o.group_id === orgId) ?? null; | 59 | const currentOrg = orgs.find((o) => o.group_id === orgId) ?? null; |
| 36 | - const currentGateway = gateways.find((g) => g.jiuwenclaw_id === gatewayId) ?? null; | 60 | + const currentGateway = |
| 61 | + gateways.find((g) => g.jiuwenclaw_id === gatewayId) ?? null; | ||
| 62 | + const selectOrg = useCallback( | ||
| 63 | + (id: string) => { | ||
| 64 | + if (orgs.some((org) => org.group_id === id)) setOrgId(id); | ||
| 65 | + }, | ||
| 66 | + [orgs], | ||
| 67 | + ); | ||
| 68 | + const selectGateway = useCallback( | ||
| 69 | + (id: string) => { | ||
| 70 | + const gateway = gateways.find((item) => item.jiuwenclaw_id === id); | ||
| 71 | + if (!gateway) return; | ||
| 72 | + saveGateway(gateway); | ||
| 73 | + setOrgId(null); | ||
| 74 | + }, | ||
| 75 | + [gateways, saveGateway], | ||
| 76 | + ); | ||
| 37 | useEffect(() => { | 77 | useEffect(() => { |
| 38 | if (!gatewayId && gateways.length === 1) { | 78 | if (!gatewayId && gateways.length === 1) { |
| 39 | setGatewayId(gateways[0].jiuwenclaw_id); | 79 | setGatewayId(gateways[0].jiuwenclaw_id); |
| @@ -47,110 +87,258 @@ export function UserConsole() { | |||
| 47 | if (event.origin !== window.location.origin) return; | 87 | if (event.origin !== window.location.origin) return; |
| 48 | if (event.data?.type === AUTH_EXPIRED_MESSAGE) void logout(); | 88 | if (event.data?.type === AUTH_EXPIRED_MESSAGE) void logout(); |
| 49 | }; | 89 | }; |
| 50 | - window.addEventListener('message', handleMessage); | 90 | + window.addEventListener("message", handleMessage); |
| 51 | - return () => window.removeEventListener('message', handleMessage); | 91 | + return () => window.removeEventListener("message", handleMessage); |
| 52 | }, [logout]); | 92 | }, [logout]); |
| 53 | 93 | ||
| 54 | return ( | 94 | return ( |
| 55 | // 自带高度链:100vh 的纵向 flex,topbar 固定高、body 占满剩余(min-height:0 关键) | 95 | // 自带高度链:100vh 的纵向 flex,topbar 固定高、body 占满剩余(min-height:0 关键) |
| 56 | - <div style={{ display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden' }}> | 96 | + <div |
| 97 | + style={{ | ||
| 98 | + display: "flex", | ||
| 99 | + flexDirection: "column", | ||
| 100 | + height: "100vh", | ||
| 101 | + overflow: "hidden", | ||
| 102 | + }} | ||
| 103 | + > | ||
| 57 | <header className="topbar" style={{ flexShrink: 0 }}> | 104 | <header className="topbar" style={{ flexShrink: 0 }}> |
| 58 | <div className="brand"> | 105 | <div className="brand"> |
| 59 | - <img src="/logo.png" alt={getProductName()} className="brand-logo-img" /> | 106 | + <img |
| 107 | + src="/logo.png" | ||
| 108 | + alt={getProductName()} | ||
| 109 | + className="brand-logo-img" | ||
| 110 | + /> | ||
| 60 | <div className="brand-text"> | 111 | <div className="brand-text"> |
| 61 | - <span className="brand-title">{t('brand.title')}</span> | 112 | + <span className="brand-title">{t("brand.title")}</span> |
| 62 | - <span className="brand-sub">{t('userConsole.brandSub')}</span> | 113 | + <span className="brand-sub">{t("userConsole.brandSub")}</span> |
| 63 | </div> | 114 | </div> |
| 64 | </div> | 115 | </div> |
| 65 | <div className="flex items-center gap-3"> | 116 | <div className="flex items-center gap-3"> |
| 66 | <LanguageSwitcher /> | 117 | <LanguageSwitcher /> |
| 67 | <ThemeToggle /> | 118 | <ThemeToggle /> |
| 68 | <span className="text-sm text-muted">{user?.display_name}</span> | 119 | <span className="text-sm text-muted">{user?.display_name}</span> |
| 69 | - <button className="btn" onClick={() => void logout()}>{t('auth.logout')}</button> | 120 | + <button className="btn" onClick={() => void logout()}> |
| 121 | + {t("auth.logout")} | ||
| 122 | + </button> | ||
| 70 | </div> | 123 | </div> |
| 71 | </header> | 124 | </header> |
| 72 | 125 | ||
| 73 | - <div style={{ flex: 1, minHeight: 0, display: 'flex' }}> | 126 | + <div style={{ flex: 1, minHeight: 0, display: "flex" }}> |
| 74 | {!currentGateway ? ( | 127 | {!currentGateway ? ( |
| 75 | - <GatewayPicker gateways={gateways} loading={gatewaysLoading} onPick={(id) => { const gateway = gateways.find((item) => item.jiuwenclaw_id === id); if (gateway) saveGateway(gateway); }} /> | 128 | + <GatewayPicker |
| 129 | + gateways={gateways} | ||
| 130 | + loading={gatewaysLoading} | ||
| 131 | + onPick={(id) => { | ||
| 132 | + const gateway = gateways.find( | ||
| 133 | + (item) => item.jiuwenclaw_id === id, | ||
| 134 | + ); | ||
| 135 | + if (gateway) saveGateway(gateway); | ||
| 136 | + }} | ||
| 137 | + /> | ||
| 76 | ) : !currentOrg ? ( | 138 | ) : !currentOrg ? ( |
| 77 | <OrgPicker orgs={orgs} onPick={setOrgId} /> | 139 | <OrgPicker orgs={orgs} onPick={setOrgId} /> |
| 78 | ) : ( | 140 | ) : ( |
| 79 | - <OrgWorkspace org={currentOrg} gateway={currentGateway} userId={user?.user_id ?? ''} onSwitchOrg={() => setOrgId(null)} onSwitchGateway={() => { setGatewayId(null); sessionStorage.removeItem('jw_user_gateway_id'); sessionStorage.removeItem(USER_CONTEXT_KEY); setOrgId(null); }} /> | 141 | + <OrgWorkspace |
| 142 | + org={currentOrg} | ||
| 143 | + orgs={orgs} | ||
| 144 | + gateway={currentGateway} | ||
| 145 | + gateways={gateways} | ||
| 146 | + userId={user?.user_id ?? ""} | ||
| 147 | + userDisplayName={user?.display_name ?? user?.user_id ?? ""} | ||
| 148 | + onOrgChange={selectOrg} | ||
| 149 | + onGatewayChange={selectGateway} | ||
| 150 | + onLogout={() => void logout()} | ||
| 151 | + onSwitchOrg={() => setOrgId(null)} | ||
| 152 | + onSwitchGateway={() => { | ||
| 153 | + setGatewayId(null); | ||
| 154 | + sessionStorage.removeItem("jw_user_gateway_id"); | ||
| 155 | + sessionStorage.removeItem(USER_CONTEXT_KEY); | ||
| 156 | + setOrgId(null); | ||
| 157 | + }} | ||
| 158 | + /> | ||
| 80 | )} | 159 | )} |
| 81 | </div> | 160 | </div> |
| 82 | </div> | 161 | </div> |
| 83 | ); | 162 | ); |
| 84 | } | 163 | } |
| 85 | 164 | ||
| 86 | -function GatewayPicker({ gateways, loading, onPick }: { gateways: UserGateway[]; loading: boolean; onPick: (id: string) => void }) { | 165 | +function GatewayPicker({ |
| 87 | - return <div style={{ flex: 1, overflow: 'auto', padding: '20px 24px' }}> | 166 | + gateways, |
| 88 | - <h2 className="card-title mb-1">选择组网</h2> | 167 | + loading, |
| 89 | - <p className="text-sm text-muted mb-3">登录用户只能看到管理面授权的组网。</p> | 168 | + onPick, |
| 90 | - {loading ? <div className="text-muted">加载中...</div> : gateways.length === 0 ? <div className="text-muted">暂无可用组网</div> : | 169 | +}: { |
| 91 | - <div style={{ display: 'flex', flexWrap: 'wrap', gap: 12 }}>{gateways.map((g) => | 170 | + gateways: UserGateway[]; |
| 92 | - <button key={g.jiuwenclaw_id} className="card" style={{ width: 260, textAlign: 'left', cursor: 'pointer' }} onClick={() => onPick(g.jiuwenclaw_id)}> | 171 | + loading: boolean; |
| 93 | - <div className="card-title">{g.jiuwenclaw_name}</div><div className="text-xs text-muted mono">{g.jiuwenclaw_id}</div> | 172 | + onPick: (id: string) => void; |
| 94 | - <div className="text-xs text-muted">{g.status} · {g.gateway_endpoint || 'endpoint 未注册'}</div> | 173 | +}) { |
| 95 | - </button>)}</div>} | 174 | + return ( |
| 96 | - </div>; | 175 | + <div style={{ flex: 1, overflow: "auto", padding: "20px 24px" }}> |
| 176 | + <h2 className="card-title mb-1">选择组网</h2> | ||
| 177 | + <p className="text-sm text-muted mb-3"> | ||
| 178 | + 登录用户只能看到管理面授权的组网。 | ||
| 179 | + </p> | ||
| 180 | + {loading ? ( | ||
| 181 | + <div className="text-muted">加载中...</div> | ||
| 182 | + ) : gateways.length === 0 ? ( | ||
| 183 | + <div className="text-muted">暂无可用组网</div> | ||
| 184 | + ) : ( | ||
| 185 | + <div style={{ display: "flex", flexWrap: "wrap", gap: 12 }}> | ||
| 186 | + {gateways.map((g) => ( | ||
| 187 | + <button | ||
| 188 | + key={g.jiuwenclaw_id} | ||
| 189 | + className="card" | ||
| 190 | + style={{ width: 260, textAlign: "left", cursor: "pointer" }} | ||
| 191 | + onClick={() => onPick(g.jiuwenclaw_id)} | ||
| 192 | + > | ||
| 193 | + <div className="card-title">{g.jiuwenclaw_name}</div> | ||
| 194 | + <div className="text-xs text-muted mono">{g.jiuwenclaw_id}</div> | ||
| 195 | + <div className="text-xs text-muted"> | ||
| 196 | + {g.status} · {g.gateway_endpoint || "endpoint 未注册"} | ||
| 197 | + </div> | ||
| 198 | + </button> | ||
| 199 | + ))} | ||
| 200 | + </div> | ||
| 201 | + )} | ||
| 202 | + </div> | ||
| 203 | + ); | ||
| 97 | } | 204 | } |
| 98 | 205 | ||
| 99 | -function OrgPicker({ orgs, onPick }: { orgs: Org[]; onPick: (gid: string) => void }) { | 206 | +function OrgPicker({ |
| 207 | + orgs, | ||
| 208 | + onPick, | ||
| 209 | +}: { | ||
| 210 | + orgs: Org[]; | ||
| 211 | + onPick: (gid: string) => void; | ||
| 212 | +}) { | ||
| 100 | const { t } = useTranslation(); | 213 | const { t } = useTranslation(); |
| 101 | return ( | 214 | return ( |
| 102 | - <div style={{ flex: 1, overflow: 'auto', padding: '20px 24px' }}> | 215 | + <div style={{ flex: 1, overflow: "auto", padding: "20px 24px" }}> |
| 103 | - <h2 className="card-title mb-1">{t('userConsole.myOrgs')}</h2> | 216 | + <h2 className="card-title mb-1">{t("userConsole.myOrgs")}</h2> |
| 104 | - <p className="text-sm text-muted mb-3">{t('userConsole.pickOrgFirst')}</p> | 217 | + <p className="text-sm text-muted mb-3">{t("userConsole.pickOrgFirst")}</p> |
| 105 | - <div style={{ display: 'flex', flexWrap: 'wrap', gap: 12 }}> | 218 | + <div style={{ display: "flex", flexWrap: "wrap", gap: 12 }}> |
| 106 | {orgs.map((o) => ( | 219 | {orgs.map((o) => ( |
| 107 | - <button key={o.group_id} className="card" style={{ width: 200, textAlign: 'left', cursor: 'pointer' }} onClick={() => onPick(o.group_id)}> | 220 | + <button |
| 221 | + key={o.group_id} | ||
| 222 | + className="card" | ||
| 223 | + style={{ width: 200, textAlign: "left", cursor: "pointer" }} | ||
| 224 | + onClick={() => onPick(o.group_id)} | ||
| 225 | + > | ||
| 108 | <div className="card-title">{o.name}</div> | 226 | <div className="card-title">{o.name}</div> |
| 109 | <div className="text-xs text-muted mono">{o.group_id}</div> | 227 | <div className="text-xs text-muted mono">{o.group_id}</div> |
| 110 | </button> | 228 | </button> |
| 111 | ))} | 229 | ))} |
| 112 | - {orgs.length === 0 && <div className="text-muted">{t('userConsole.noOrgs')}</div>} | 230 | + {orgs.length === 0 && ( |
| 231 | + <div className="text-muted">{t("userConsole.noOrgs")}</div> | ||
| 232 | + )} | ||
| 113 | </div> | 233 | </div> |
| 114 | </div> | 234 | </div> |
| 115 | ); | 235 | ); |
| 116 | } | 236 | } |
| 117 | 237 | ||
| 118 | -function OrgWorkspace({ org, gateway, userId, onSwitchOrg, onSwitchGateway }: { org: Org; gateway: UserGateway; userId: string; onSwitchOrg: () => void; onSwitchGateway: () => void }) { | 238 | +function OrgWorkspace({ |
| 239 | + org, | ||
| 240 | + orgs, | ||
| 241 | + gateway, | ||
| 242 | + gateways, | ||
| 243 | + userId, | ||
| 244 | + userDisplayName, | ||
| 245 | + onOrgChange, | ||
| 246 | + onGatewayChange, | ||
| 247 | + onLogout, | ||
| 248 | + onSwitchOrg, | ||
| 249 | + onSwitchGateway, | ||
| 250 | +}: { | ||
| 251 | + org: Org; | ||
| 252 | + orgs: Org[]; | ||
| 253 | + gateway: UserGateway; | ||
| 254 | + gateways: UserGateway[]; | ||
| 255 | + userId: string; | ||
| 256 | + userDisplayName: string; | ||
| 257 | + onOrgChange: (id: string) => void; | ||
| 258 | + onGatewayChange: (id: string) => void; | ||
| 259 | + onLogout: () => void; | ||
| 260 | + onSwitchOrg: () => void; | ||
| 261 | + onSwitchGateway: () => void; | ||
| 262 | +}) { | ||
| 119 | const { t } = useTranslation(); | 263 | const { t } = useTranslation(); |
| 120 | - const { data: agentsData, loading } = useAsync(() => UserConsoleApi.agents(org.group_id, gateway.jiuwenclaw_id), [org.group_id, gateway.jiuwenclaw_id]); | 264 | + const { data: agentsData, loading } = useAsync( |
| 265 | + () => UserConsoleApi.agents(org.group_id, gateway.jiuwenclaw_id), | ||
| 266 | + [org.group_id, gateway.jiuwenclaw_id], | ||
| 267 | + ); | ||
| 121 | const [agentId, setAgentId] = useState<string | null>(null); | 268 | const [agentId, setAgentId] = useState<string | null>(null); |
| 122 | const agents = agentsData?.agents ?? []; | 269 | const agents = agentsData?.agents ?? []; |
| 123 | - const currentAgent = agents.find((a) => agentRuntimeId(a) === agentId) ?? null; | 270 | + const currentAgent = |
| 271 | + agents.find((a) => agentRuntimeId(a) === agentId) ?? null; | ||
| 124 | 272 | ||
| 125 | // 切组织后重置选中的 agent | 273 | // 切组织后重置选中的 agent |
| 126 | - useEffect(() => { setAgentId(null); }, [org.group_id]); | 274 | + useEffect(() => { |
| 275 | + setAgentId(null); | ||
| 276 | + }, [org.group_id]); | ||
| 127 | 277 | ||
| 128 | return ( | 278 | return ( |
| 129 | - <div style={{ display: 'flex', flex: 1, minHeight: 0 }}> | 279 | + <div style={{ display: "flex", flex: 1, minHeight: 0 }}> |
| 130 | {/* 左:组织信息 + agent 列表 */} | 280 | {/* 左:组织信息 + agent 列表 */} |
| 131 | - <aside style={{ width: 240, borderRight: '1px solid var(--border, #e5e7eb)', padding: 12, overflowY: 'auto', flexShrink: 0 }}> | 281 | + <aside |
| 282 | + style={{ | ||
| 283 | + width: 240, | ||
| 284 | + borderRight: "1px solid var(--border, #e5e7eb)", | ||
| 285 | + padding: 12, | ||
| 286 | + overflowY: "auto", | ||
| 287 | + flexShrink: 0, | ||
| 288 | + }} | ||
| 289 | + > | ||
| 132 | <div className="flex items-center justify-between mb-2"> | 290 | <div className="flex items-center justify-between mb-2"> |
| 133 | - <div className="card-title" style={{ fontSize: 14 }}>{org.name}</div> | 291 | + <div className="card-title" style={{ fontSize: 14 }}> |
| 134 | - <button className="btn ghost sm" onClick={onSwitchOrg}>{t('userConsole.switchOrg')}</button><button className="btn ghost sm" onClick={onSwitchGateway}>切换组网</button> | 292 | + {org.name} |
| 293 | + </div> | ||
| 294 | + <button className="btn ghost sm" onClick={onSwitchOrg}> | ||
| 295 | + {t("userConsole.switchOrg")} | ||
| 296 | + </button> | ||
| 297 | + <button className="btn ghost sm" onClick={onSwitchGateway}> | ||
| 298 | + 切换组网 | ||
| 299 | + </button> | ||
| 300 | + </div> | ||
| 301 | + <div className="nav-group-title nav-group-title--uppercase"> | ||
| 302 | + {t("userConsole.availableBots")} | ||
| 135 | </div> | 303 | </div> |
| 136 | - <div className="nav-group-title nav-group-title--uppercase">{t('userConsole.availableBots')}</div> | ||
| 137 | <div className="space-y-1"> | 304 | <div className="space-y-1"> |
| 138 | {agents.map((a) => ( | 305 | {agents.map((a) => ( |
| 139 | - <button key={agentRuntimeId(a)} className={`nav-item ${agentId === agentRuntimeId(a) ? 'active' : ''}`} onClick={() => setAgentId(agentRuntimeId(a))}> | 306 | + <button |
| 307 | + key={agentRuntimeId(a)} | ||
| 308 | + className={`nav-item ${agentId === agentRuntimeId(a) ? "active" : ""}`} | ||
| 309 | + onClick={() => setAgentId(agentRuntimeId(a))} | ||
| 310 | + > | ||
| 140 | {a.template_name} | 311 | {a.template_name} |
| 141 | </button> | 312 | </button> |
| 142 | ))} | 313 | ))} |
| 143 | - {!loading && agents.length === 0 && <div className="text-xs text-muted" style={{ padding: 8 }}>{t('userConsole.noBots')}</div>} | 314 | + {!loading && agents.length === 0 && ( |
| 315 | + <div className="text-xs text-muted" style={{ padding: 8 }}> | ||
| 316 | + {t("userConsole.noBots")} | ||
| 317 | + </div> | ||
| 318 | + )} | ||
| 144 | </div> | 319 | </div> |
| 145 | </aside> | 320 | </aside> |
| 146 | 321 | ||
| 147 | {/* 右:选中 agent 的工作区 */} | 322 | {/* 右:选中 agent 的工作区 */} |
| 148 | - <section style={{ flex: 1, minWidth: 0, minHeight: 0, display: 'flex' }}> | 323 | + <section style={{ flex: 1, minWidth: 0, minHeight: 0, display: "flex" }}> |
| 149 | {currentAgent ? ( | 324 | {currentAgent ? ( |
| 150 | - <AgentWorkspace agent={currentAgent} userId={userId} groupId={org.group_id} gatewayId={gateway.jiuwenclaw_id} /> | 325 | + <AgentWorkspace |
| 326 | + agent={currentAgent} | ||
| 327 | + agents={agents} | ||
| 328 | + org={org} | ||
| 329 | + orgs={orgs} | ||
| 330 | + gateway={gateway} | ||
| 331 | + gateways={gateways} | ||
| 332 | + userId={userId} | ||
| 333 | + userDisplayName={userDisplayName} | ||
| 334 | + onAgentChange={setAgentId} | ||
| 335 | + onOrgChange={onOrgChange} | ||
| 336 | + onGatewayChange={onGatewayChange} | ||
| 337 | + onLogout={onLogout} | ||
| 338 | + /> | ||
| 151 | ) : ( | 339 | ) : ( |
| 152 | <div className="flex items-center justify-center" style={{ flex: 1 }}> | 340 | <div className="flex items-center justify-center" style={{ flex: 1 }}> |
| 153 | - <div className="text-muted">{t('userConsole.pickBot')}</div> | 341 | + <div className="text-muted">{t("userConsole.pickBot")}</div> |
| 154 | </div> | 342 | </div> |
| 155 | )} | 343 | )} |
| 156 | </section> | 344 | </section> |
| @@ -169,30 +357,94 @@ function ChatLoading() { | |||
| 169 | return ( | 357 | return ( |
| 170 | <div | 358 | <div |
| 171 | style={{ | 359 | style={{ |
| 172 | - position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', | 360 | + position: "absolute", |
| 173 | - alignItems: 'center', justifyContent: 'center', gap: 14, | 361 | + inset: 0, |
| 174 | - background: 'var(--bg-content, var(--bg, #fff))', | 362 | + display: "flex", |
| 363 | + flexDirection: "column", | ||
| 364 | + alignItems: "center", | ||
| 365 | + justifyContent: "center", | ||
| 366 | + gap: 14, | ||
| 367 | + background: "var(--bg-content, var(--bg, #fff))", | ||
| 175 | }} | 368 | }} |
| 176 | > | 369 | > |
| 177 | <svg width="64" height="64" viewBox="0 0 50 50" aria-hidden="true"> | 370 | <svg width="64" height="64" viewBox="0 0 50 50" aria-hidden="true"> |
| 178 | - <circle cx="25" cy="25" r="20" fill="none" stroke="var(--border, #e5e7eb)" strokeWidth="4" /> | 371 | + <circle |
| 179 | - <path d="M25 5 a20 20 0 0 1 20 20" fill="none" stroke="var(--accent, #6366f1)" strokeWidth="4" strokeLinecap="round"> | 372 | + cx="25" |
| 180 | - <animateTransform attributeName="transform" type="rotate" from="0 25 25" to="360 25 25" dur="0.9s" repeatCount="indefinite" /> | 373 | + cy="25" |
| 374 | + r="20" | ||
| 375 | + fill="none" | ||
| 376 | + stroke="var(--border, #e5e7eb)" | ||
| 377 | + strokeWidth="4" | ||
| 378 | + /> | ||
| 379 | + <path | ||
| 380 | + d="M25 5 a20 20 0 0 1 20 20" | ||
| 381 | + fill="none" | ||
| 382 | + stroke="var(--accent, #6366f1)" | ||
| 383 | + strokeWidth="4" | ||
| 384 | + strokeLinecap="round" | ||
| 385 | + > | ||
| 386 | + <animateTransform | ||
| 387 | + attributeName="transform" | ||
| 388 | + type="rotate" | ||
| 389 | + from="0 25 25" | ||
| 390 | + to="360 25 25" | ||
| 391 | + dur="0.9s" | ||
| 392 | + repeatCount="indefinite" | ||
| 393 | + /> | ||
| 181 | </path> | 394 | </path> |
| 182 | </svg> | 395 | </svg> |
| 183 | - <div className="text-sm text-muted">{t('userConsole.chatLoading')}</div> | 396 | + <div className="text-sm text-muted">{t("userConsole.chatLoading")}</div> |
| 184 | - {slow && <div className="text-xs text-muted" style={{ maxWidth: 320, textAlign: 'center' }}>{t('userConsole.chatLoadingSlow')}</div>} | 397 | + {slow && ( |
| 398 | + <div | ||
| 399 | + className="text-xs text-muted" | ||
| 400 | + style={{ maxWidth: 320, textAlign: "center" }} | ||
| 401 | + > | ||
| 402 | + {t("userConsole.chatLoadingSlow")} | ||
| 403 | + </div> | ||
| 404 | + )} | ||
| 185 | </div> | 405 | </div> |
| 186 | ); | 406 | ); |
| 187 | } | 407 | } |
| 188 | 408 | ||
| 189 | -function AgentWorkspace({ agent, userId, groupId, gatewayId }: { agent: AgentTemplate; userId: string; groupId: string; gatewayId: string }) { | 409 | +function AgentWorkspace({ |
| 410 | + agent, | ||
| 411 | + agents, | ||
| 412 | + org, | ||
| 413 | + orgs, | ||
| 414 | + gateway, | ||
| 415 | + gateways, | ||
| 416 | + userId, | ||
| 417 | + userDisplayName, | ||
| 418 | + onAgentChange, | ||
| 419 | + onOrgChange, | ||
| 420 | + onGatewayChange, | ||
| 421 | + onLogout, | ||
| 422 | +}: { | ||
| 423 | + agent: AgentTemplate; | ||
| 424 | + agents: AgentTemplate[]; | ||
| 425 | + org: Org; | ||
| 426 | + orgs: Org[]; | ||
| 427 | + gateway: UserGateway; | ||
| 428 | + gateways: UserGateway[]; | ||
| 429 | + userId: string; | ||
| 430 | + userDisplayName: string; | ||
| 431 | + onAgentChange: (id: string) => void; | ||
| 432 | + onOrgChange: (id: string) => void; | ||
| 433 | + onGatewayChange: (id: string) => void; | ||
| 434 | + onLogout: () => void; | ||
| 435 | +}) { | ||
| 190 | const { t, i18n } = useTranslation(); | 436 | const { t, i18n } = useTranslation(); |
| 191 | // 内嵌 iframe(web_enterprise)与父窗口可能不同源,语言状态独立 → 用 postMessage 同步语言 | 437 | // 内嵌 iframe(web_enterprise)与父窗口可能不同源,语言状态独立 → 用 postMessage 同步语言 |
| 192 | const iframeRef = useRef<HTMLIFrameElement | null>(null); | 438 | const iframeRef = useRef<HTMLIFrameElement | null>(null); |
| 193 | const postLang = useCallback( | 439 | const postLang = useCallback( |
| 194 | (el: HTMLIFrameElement | null) => | 440 | (el: HTMLIFrameElement | null) => |
| 195 | - el?.contentWindow?.postMessage({ type: 'jw-set-lang', lang: i18n.language.startsWith('en') ? 'en' : 'zh' }, '*'), | 441 | + el?.contentWindow?.postMessage( |
| 442 | + { | ||
| 443 | + type: "jw-set-lang", | ||
| 444 | + lang: i18n.language.startsWith("en") ? "en" : "zh", | ||
| 445 | + }, | ||
| 446 | + "*", | ||
| 447 | + ), | ||
| 196 | [i18n.language], | 448 | [i18n.language], |
| 197 | ); | 449 | ); |
| 198 | // 语言切换时,通知已加载的内嵌用户面 | 450 | // 语言切换时,通知已加载的内嵌用户面 |
| @@ -200,15 +452,128 @@ function AgentWorkspace({ agent, userId, groupId, gatewayId }: { agent: AgentTem | |||
| 200 | postLang(iframeRef.current); | 452 | postLang(iframeRef.current); |
| 201 | }, [postLang]); | 453 | }, [postLang]); |
| 202 | 454 | ||
| 203 | - // (user_id, group_id, bot_id) 经 query 注入 user_web → extSettings 读取 → HTTP/SSE 透传 → Agent | 455 | + // (user_id, group_id, bot_id) 经 query 注入 User Web,再通过构建时选定的 WS 或 HTTP/SSE 通道传给 Gateway。 |
| 204 | // bot_id 传实例化 resource_id(运行时路由字段名未改) | 456 | // bot_id 传实例化 resource_id(运行时路由字段名未改) |
| 205 | const runtimeId = agentRuntimeId(agent); | 457 | const runtimeId = agentRuntimeId(agent); |
| 206 | const baseQuery = useMemo( | 458 | const baseQuery = useMemo( |
| 207 | - () => new URLSearchParams({ user_id: userId, group_id: groupId, bot_id: runtimeId, gateway_id: gatewayId }).toString(), | 459 | + () => |
| 208 | - [userId, groupId, runtimeId, gatewayId], | 460 | + new URLSearchParams({ |
| 461 | + user_id: userId, | ||
| 462 | + group_id: org.group_id, | ||
| 463 | + bot_id: runtimeId, | ||
| 464 | + gateway_id: gateway.jiuwenclaw_id, | ||
| 465 | + }).toString(), | ||
| 466 | + [userId, org.group_id, runtimeId, gateway.jiuwenclaw_id], | ||
| 209 | ); | 467 | ); |
| 210 | const chatUrl = `${CHAT_BASE}/?${baseQuery}`; | 468 | const chatUrl = `${CHAT_BASE}/?${baseQuery}`; |
| 469 | + const chatOrigin = useMemo( | ||
| 470 | + () => new URL(chatUrl, window.location.href).origin, | ||
| 471 | + [chatUrl], | ||
| 472 | + ); | ||
| 211 | const [loaded, setLoaded] = useState(false); | 473 | const [loaded, setLoaded] = useState(false); |
| 474 | + const contextSnapshot = useMemo( | ||
| 475 | + () => ({ | ||
| 476 | + user: { user_id: userId, display_name: userDisplayName }, | ||
| 477 | + org: { group_id: org.group_id, name: org.name }, | ||
| 478 | + orgs: orgs.map((item) => ({ group_id: item.group_id, name: item.name })), | ||
| 479 | + gateway: { | ||
| 480 | + jiuwenclaw_id: gateway.jiuwenclaw_id, | ||
| 481 | + jiuwenclaw_name: gateway.jiuwenclaw_name, | ||
| 482 | + gateway_endpoint: gateway.gateway_endpoint, | ||
| 483 | + }, | ||
| 484 | + gateways: gateways.map((item) => ({ | ||
| 485 | + jiuwenclaw_id: item.jiuwenclaw_id, | ||
| 486 | + jiuwenclaw_name: item.jiuwenclaw_name, | ||
| 487 | + gateway_endpoint: item.gateway_endpoint, | ||
| 488 | + })), | ||
| 489 | + agents: agents.map((item) => ({ | ||
| 490 | + template_id: item.template_id, | ||
| 491 | + template_name: item.template_name, | ||
| 492 | + resource_id: item.resource_id, | ||
| 493 | + })), | ||
| 494 | + selectedBot: runtimeId, | ||
| 495 | + }), | ||
| 496 | + [ | ||
| 497 | + agent, | ||
| 498 | + agents, | ||
| 499 | + gateway, | ||
| 500 | + gateways, | ||
| 501 | + org, | ||
| 502 | + orgs, | ||
| 503 | + runtimeId, | ||
| 504 | + userDisplayName, | ||
| 505 | + userId, | ||
| 506 | + ], | ||
| 507 | + ); | ||
| 508 | + const postContext = useCallback( | ||
| 509 | + (target?: WindowProxy | null) => { | ||
| 510 | + const receiver = target ?? iframeRef.current?.contentWindow; | ||
| 511 | + receiver?.postMessage( | ||
| 512 | + { type: CONTEXT_SNAPSHOT_MESSAGE, context: contextSnapshot }, | ||
| 513 | + chatOrigin, | ||
| 514 | + ); | ||
| 515 | + }, | ||
| 516 | + [chatOrigin, contextSnapshot], | ||
| 517 | + ); | ||
| 518 | + | ||
| 519 | + useEffect(() => { | ||
| 520 | + const handleContextMessage = (event: MessageEvent) => { | ||
| 521 | + if ( | ||
| 522 | + event.source !== iframeRef.current?.contentWindow || | ||
| 523 | + event.origin !== chatOrigin | ||
| 524 | + ) | ||
| 525 | + return; | ||
| 526 | + if (!event.data || typeof event.data !== "object") return; | ||
| 527 | + if (event.data.type === CONTEXT_READY_MESSAGE) { | ||
| 528 | + postContext(iframeRef.current?.contentWindow); | ||
| 529 | + return; | ||
| 530 | + } | ||
| 531 | + if (event.data.type === CONTEXT_LOGOUT_MESSAGE) { | ||
| 532 | + onLogout(); | ||
| 533 | + return; | ||
| 534 | + } | ||
| 535 | + if ( | ||
| 536 | + event.data.type !== CONTEXT_CHANGE_MESSAGE || | ||
| 537 | + typeof event.data.field !== "string" || | ||
| 538 | + typeof event.data.value !== "string" | ||
| 539 | + ) { | ||
| 540 | + return; | ||
| 541 | + } | ||
| 542 | + const value = event.data.value; | ||
| 543 | + if ( | ||
| 544 | + event.data.field === "group_id" && | ||
| 545 | + orgs.some((item) => item.group_id === value) | ||
| 546 | + ) { | ||
| 547 | + onOrgChange(value); | ||
| 548 | + } else if ( | ||
| 549 | + event.data.field === "gateway_id" && | ||
| 550 | + gateways.some((item) => item.jiuwenclaw_id === value) | ||
| 551 | + ) { | ||
| 552 | + onGatewayChange(value); | ||
| 553 | + } else if ( | ||
| 554 | + event.data.field === "bot_id" && | ||
| 555 | + agents.some((item) => agentRuntimeId(item) === value) | ||
| 556 | + ) { | ||
| 557 | + onAgentChange(value); | ||
| 558 | + } | ||
| 559 | + }; | ||
| 560 | + window.addEventListener("message", handleContextMessage); | ||
| 561 | + return () => window.removeEventListener("message", handleContextMessage); | ||
| 562 | + }, [ | ||
| 563 | + agents, | ||
| 564 | + chatOrigin, | ||
| 565 | + gateways, | ||
| 566 | + onAgentChange, | ||
| 567 | + onGatewayChange, | ||
| 568 | + onLogout, | ||
| 569 | + onOrgChange, | ||
| 570 | + orgs, | ||
| 571 | + postContext, | ||
| 572 | + ]); | ||
| 573 | + | ||
| 574 | + useEffect(() => { | ||
| 575 | + postContext(); | ||
| 576 | + }, [postContext]); | ||
| 212 | 577 | ||
| 213 | // 切换 Agent 或组织后,新的 iframe 重新进入加载态。 | 578 | // 切换 Agent 或组织后,新的 iframe 重新进入加载态。 |
| 214 | useEffect(() => { | 579 | useEffect(() => { |
| @@ -216,20 +581,47 @@ function AgentWorkspace({ agent, userId, groupId, gatewayId }: { agent: AgentTem | |||
| 216 | }, [baseQuery]); | 581 | }, [baseQuery]); |
| 217 | 582 | ||
| 218 | return ( | 583 | return ( |
| 219 | - <div style={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}> | 584 | + <div |
| 220 | - <div style={{ display: 'flex', gap: 4, padding: '8px 12px', borderBottom: '1px solid var(--border, #e5e7eb)', flexShrink: 0 }}> | 585 | + style={{ |
| 221 | - <span className="btn sm primary">{t('userConsole.tabChat')}</span> | 586 | + display: "flex", |
| 587 | + flexDirection: "column", | ||
| 588 | + flex: 1, | ||
| 589 | + minHeight: 0, | ||
| 590 | + }} | ||
| 591 | + > | ||
| 592 | + <div | ||
| 593 | + style={{ | ||
| 594 | + display: "flex", | ||
| 595 | + gap: 4, | ||
| 596 | + padding: "8px 12px", | ||
| 597 | + borderBottom: "1px solid var(--border, #e5e7eb)", | ||
| 598 | + flexShrink: 0, | ||
| 599 | + }} | ||
| 600 | + > | ||
| 601 | + <span className="btn sm primary">{t("userConsole.tabChat")}</span> | ||
| 222 | <div style={{ flex: 1 }} /> | 602 | <div style={{ flex: 1 }} /> |
| 223 | - <span className="text-xs text-muted" style={{ alignSelf: 'center' }}>agent: <span className="mono">{runtimeId}</span></span> | 603 | + <span className="text-xs text-muted" style={{ alignSelf: "center" }}> |
| 604 | + agent: <span className="mono">{runtimeId}</span> | ||
| 605 | + </span> | ||
| 224 | </div> | 606 | </div> |
| 225 | - <div style={{ flex: 1, minHeight: 0, position: 'relative' }}> | 607 | + <div style={{ flex: 1, minHeight: 0, position: "relative" }}> |
| 226 | <iframe | 608 | <iframe |
| 227 | key={baseQuery} | 609 | key={baseQuery} |
| 228 | ref={iframeRef} | 610 | ref={iframeRef} |
| 229 | src={chatUrl} | 611 | src={chatUrl} |
| 230 | title="chat" | 612 | title="chat" |
| 231 | - onLoad={(e) => { setLoaded(true); postLang(e.currentTarget); }} | 613 | + onLoad={(e) => { |
| 232 | - style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', border: 0 }} | 614 | + setLoaded(true); |
| 615 | + postLang(e.currentTarget); | ||
| 616 | + postContext(e.currentTarget.contentWindow); | ||
| 617 | + }} | ||
| 618 | + style={{ | ||
| 619 | + position: "absolute", | ||
| 620 | + inset: 0, | ||
| 621 | + width: "100%", | ||
| 622 | + height: "100%", | ||
| 623 | + border: 0, | ||
| 624 | + }} | ||
| 233 | /> | 625 | /> |
| 234 | {!loaded && <ChatLoading />} | 626 | {!loaded && <ChatLoading />} |
| 235 | </div> | 627 | </div> |
| @@ -70,10 +70,22 @@ let accessToken: string | null = localStorage.getItem(ACCESS_KEY); | |||
| 70 | let refreshToken: string | null = localStorage.getItem(REFRESH_KEY); | 70 | let refreshToken: string | null = localStorage.getItem(REFRESH_KEY); |
| 71 | let unauthorizedHandler: (() => void) | null = null; | 71 | let unauthorizedHandler: (() => void) | null = null; |
| 72 | 72 | ||
| 73 | +function syncAccessCookie(access: string | null): void { | ||
| 74 | + const secure = window.location.protocol === 'https:' ? '; Secure' : ''; | ||
| 75 | + if (access) { | ||
| 76 | + document.cookie = `${ACCESS_KEY}=${encodeURIComponent(access)}; Path=/; SameSite=Strict${secure}`; | ||
| 77 | + } else { | ||
| 78 | + document.cookie = `${ACCESS_KEY}=; Path=/; Max-Age=0; SameSite=Strict${secure}`; | ||
| 79 | + } | ||
| 80 | +} | ||
| 81 | + | ||
| 82 | +syncAccessCookie(accessToken); | ||
| 83 | + | ||
| 73 | export function setTokens(access: string | null, refresh?: string | null): void { | 84 | export function setTokens(access: string | null, refresh?: string | null): void { |
| 74 | accessToken = access; | 85 | accessToken = access; |
| 75 | if (access) localStorage.setItem(ACCESS_KEY, access); | 86 | if (access) localStorage.setItem(ACCESS_KEY, access); |
| 76 | else localStorage.removeItem(ACCESS_KEY); | 87 | else localStorage.removeItem(ACCESS_KEY); |
| 88 | + syncAccessCookie(access); | ||
| 77 | if (refresh !== undefined) { | 89 | if (refresh !== undefined) { |
| 78 | refreshToken = refresh; | 90 | refreshToken = refresh; |
| 79 | if (refresh) localStorage.setItem(REFRESH_KEY, refresh); | 91 | if (refresh) localStorage.setItem(REFRESH_KEY, refresh); |
| @@ -7,6 +7,13 @@ export default defineConfig(({ mode }) => { | |||
| 7 | const env = loadEnv(mode, envDir, '') | 7 | const env = loadEnv(mode, envDir, '') |
| 8 | const allowLocalProvision = | 8 | const allowLocalProvision = |
| 9 | env.VITE_MANAGER_ALLOW_LOCAL_PROVISION ?? env.MANAGER_ALLOW_LOCAL_PROVISION ?? 'false' | 9 | env.VITE_MANAGER_ALLOW_LOCAL_PROVISION ?? env.MANAGER_ALLOW_LOCAL_PROVISION ?? 'false' |
| 10 | + const managerTarget = process.env.MANAGER_WEB_PROXY_TARGET || 'http://127.0.0.1:8765' | ||
| 11 | + const idpTarget = process.env.MANAGER_WEB_IDP_TARGET || 'http://127.0.0.1:8770' | ||
| 12 | + const userWebTarget = process.env.MANAGER_WEB_USER_WEB_TARGET || 'http://127.0.0.1:5173' | ||
| 13 | + const gatewayHttpTarget = | ||
| 14 | + process.env.MANAGER_WEB_GATEWAY_HTTP_TARGET || 'http://127.0.0.1:19002' | ||
| 15 | + const gatewayWsTarget = | ||
| 16 | + process.env.MANAGER_WEB_GATEWAY_WS_TARGET || 'http://127.0.0.1:19000' | ||
| 10 | 17 | ||
| 11 | return { | 18 | return { |
| 12 | envDir, | 19 | envDir, |
| @@ -22,28 +29,40 @@ export default defineConfig(({ mode }) => { | |||
| 22 | server: { | 29 | server: { |
| 23 | port: 5273, | 30 | port: 5273, |
| 24 | strictPort: true, | 31 | strictPort: true, |
| 25 | - // 与生产入口同一套路由前缀:/api→管理API、/idp→认证中心、 | 32 | + // 与生产入口同一套路由前缀:/api→管理 API、/idp→认证中心、 |
| 26 | - // /web/invoke→Gateway HTTP/SSE、/file-api→web 后端。 | 33 | + // /chat→User Web、/ws→Gateway WebSocket、/gateway-api 及文件路由→Gateway Web HTTP。 |
| 27 | // 三段 SPA 路由(/auth /user /manager)由 vite 开发服务器自动回退 index.html。 | 34 | // 三段 SPA 路由(/auth /user /manager)由 vite 开发服务器自动回退 index.html。 |
| 28 | // proxy target 统一读 MANAGER_WEB_*(与生产 manager-web Python 入口同一套 env,避免两套变量) | 35 | // proxy target 统一读 MANAGER_WEB_*(与生产 manager-web Python 入口同一套 env,避免两套变量) |
| 29 | // 本地开发默认 127.0.0.1;容器内由 manager-web.template.yaml 注入集群 DNS | 36 | // 本地开发默认 127.0.0.1;容器内由 manager-web.template.yaml 注入集群 DNS |
| 30 | proxy: { | 37 | proxy: { |
| 31 | - '/api': { target: process.env.MANAGER_WEB_PROXY_TARGET || 'http://127.0.0.1:8765', changeOrigin: true }, | 38 | + '/gateway-api': { |
| 39 | + target: gatewayHttpTarget, | ||
| 40 | + changeOrigin: true, | ||
| 41 | + timeout: 0, | ||
| 42 | + proxyTimeout: 0, | ||
| 43 | + rewrite: (requestPath) => requestPath.replace(/^\/gateway-api/, '/api'), | ||
| 44 | + }, | ||
| 45 | + '/file-api': { target: gatewayHttpTarget, changeOrigin: true }, | ||
| 46 | + '/share-api': { target: gatewayHttpTarget, changeOrigin: true }, | ||
| 47 | + '/ws': { target: gatewayWsTarget, ws: true, changeOrigin: true }, | ||
| 48 | + '/chat': { | ||
| 49 | + target: userWebTarget, | ||
| 50 | + ws: true, | ||
| 51 | + changeOrigin: true, | ||
| 52 | + rewrite: (requestPath) => requestPath.replace(/^\/chat/, '') || '/', | ||
| 53 | + }, | ||
| 54 | + '/manager-api': { | ||
| 55 | + target: managerTarget, | ||
| 56 | + changeOrigin: true, | ||
| 57 | + rewrite: (requestPath) => requestPath.replace(/^\/manager-api/, '/api'), | ||
| 58 | + }, | ||
| 59 | + '/api': { target: managerTarget, changeOrigin: true }, | ||
| 32 | '/idp': { | 60 | '/idp': { |
| 33 | - target: process.env.MANAGER_WEB_IDP_TARGET || 'http://127.0.0.1:8770', | 61 | + target: idpTarget, |
| 34 | changeOrigin: true, | 62 | changeOrigin: true, |
| 35 | // 去掉 /idp 前缀,与 manager-web 生产反代行为一致 | 63 | // 去掉 /idp 前缀,与 manager-web 生产反代行为一致 |
| 36 | rewrite: (path) => path.replace(/^\/idp/, ''), | 64 | rewrite: (path) => path.replace(/^\/idp/, ''), |
| 37 | }, | 65 | }, |
| 38 | - '/web/invoke': { | ||
| 39 | - target: process.env.MANAGER_WEB_GATEWAY_SSE || 'http://127.0.0.1:19001/web/invoke', | ||
| 40 | - changeOrigin: true, | ||
| 41 | - timeout: 0, | ||
| 42 | - proxyTimeout: 0, | ||
| 43 | - // MANAGER_WEB_GATEWAY_SSE 含 /web/invoke 路径,去掉前缀避免路径翻倍 | ||
| 44 | - rewrite: (path) => path.replace(/^\/web\/invoke/, ''), | ||
| 45 | - }, | ||
| 46 | - '/file-api': { target: process.env.MANAGER_WEB_USER_SERVER_TARGET || 'http://127.0.0.1:19000', changeOrigin: true }, | ||
| 47 | }, | 66 | }, |
| 48 | }, | 67 | }, |
| 49 | } | 68 | } |
| @@ -22,9 +22,6 @@ WORKDIR /app/agent-runtime/applications/manager/manager_web | |||
| 22 | RUN npm install | 22 | RUN npm install |
| 23 | RUN npm run build | 23 | RUN npm run build |
| 24 | 24 | ||
| 25 | -# 本仓暂无 user_web:放空目录占位,满足 manager-web 对 --chat-dist 的存在性检查 | ||
| 26 | -RUN mkdir -p /app/agent-runtime/applications/access/user_web/dist | ||
| 27 | - | ||
| 28 | WORKDIR /app/agent-runtime | 25 | WORKDIR /app/agent-runtime |
| 29 | ENV PATH=$PATH:/home/app/.local/bin | 26 | ENV PATH=$PATH:/home/app/.local/bin |
| 30 | ENV MANAGER_WEB_HOST=0.0.0.0 | 27 | ENV MANAGER_WEB_HOST=0.0.0.0 |