已合并
fix: 打通用户面的同源代理并补齐联合认证配置 #463
fix: 打通用户面的同源代理并补齐联合认证配置 #463
已合并
Wal1et创建于 24 天前
11 个文件变更+1134-253
@@ -41,6 +41,14 @@ IDENTITY_REFRESH_TTL=604800
41IDENTITY_SEED_ADMIN=true41IDENTITY_SEED_ADMIN=true
42IDENTITY_SEED_USER1=true42IDENTITY_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
98MANAGER_WEB_PORT=5273106MANAGER_WEB_PORT=5273
99MANAGER_WEB_PROXY_TARGET=http://127.0.0.1:8765107MANAGER_WEB_PROXY_TARGET=http://127.0.0.1:8765
100MANAGER_WEB_IDP_TARGET=http://127.0.0.1:8770108MANAGER_WEB_IDP_TARGET=http://127.0.0.1:8770
101-MANAGER_WEB_USER_SERVER_TARGET=http://127.0.0.1:5174109+MANAGER_WEB_USER_WEB_TARGET=http://127.0.0.1:5173
102-MANAGER_WEB_GATEWAY_SSE=http://127.0.0.1:19001/web/invoke110+MANAGER_WEB_GATEWAY_HTTP_TARGET=http://127.0.0.1:19002
111+MANAGER_WEB_GATEWAY_WS_TARGET=http://127.0.0.1:19000
103MANAGER_WEB_LOG_LEVEL=info112MANAGER_WEB_LOG_LEVEL=info
@@ -113,7 +113,7 @@ npm run dev
113Vite 开发服务器已配置代理,`/api``8765``/idp``8770`(会自动去掉 `/idp` 前缀)。113Vite 开发服务器已配置代理,`/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```bash185```bash
186-# 构建前端(完整集成还需构建 user_web)186+# 构建 Manager 前端
187-cd applications/access/user_web && npm install && npm run build187+cd applications/manager/manager_web
188-cd ../../manager/manager_web && npm install && npm run build188+npm install
189+npm run build
189cd ../../..190cd ../../..
190 191 
191# 启动后端(需先激活虚拟环境)192# 启动后端(需先激活虚拟环境)
@@ -196,7 +197,9 @@ manager-server
196manager-web \197manager-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/dist200+ --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 iframe208- `/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 Server211+- `/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
246MANAGER_WEB_PORT=5273252MANAGER_WEB_PORT=5273
247MANAGER_WEB_PROXY_TARGET=http://127.0.0.1:8765253MANAGER_WEB_PROXY_TARGET=http://127.0.0.1:8765
248MANAGER_WEB_IDP_TARGET=http://127.0.0.1:8770254MANAGER_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 @@
3from __future__ import annotations3from __future__ import annotations
4 4 
5import argparse5import argparse
6+import asyncio
6import logging7import logging
7from collections.abc import AsyncIterator8from collections.abc import AsyncIterator
9+from contextlib import suppress
8from pathlib import Path10from pathlib import Path
9 11 
10import httpx12import httpx
11import uvicorn13import uvicorn
12-from fastapi import FastAPI, Request, Response14+import websockets
15+from fastapi import FastAPI, Request, Response, WebSocket, WebSocketDisconnect
13from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse16from 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 
19def _manager_web_dist() -> Path:26def _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- 
27def _coerce_backend_url(raw: str) -> str:30def _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 url34 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+ 
34async def _relay(52async def _relay(
35- request: Request, upstream_url: str, tag: str, *, timeout: float = 30.053+ 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_HEADERS87 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_HEADERS103 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", exc170+ 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 None177 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+ 
148def create_manager_web_app(265def 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 @application.get("/chat")279 @application.get("/chat")
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- tail295+ _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 @application.api_route(302 @application.api_route(
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- @application.post("/web/invoke")
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_error309 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+ @application.api_route(
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+ @application.api_route(
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+ @application.websocket("/ws")
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+ @application.websocket("/ws/{tail:path}")
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 @application.api_route(362 @application.api_route(
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+ @application.api_route(
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 @application.api_route(391 @application.api_route(
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 exc483 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 
@@ -19,18 +19,23 @@ Claw Manager 管理面 Web 前端。技术栈与 `jiuwenclaw/web` 完全对齐
19## 统一入口启动19## 统一入口启动
20 20 
21```bash21```bash
22-cd applications/access/user_web && npm install && npm run build22+cd applications/manager/manager_web
23-cd ../../manager/manager_web && npm install && npm run build23+npm install
24+npm run build
24cd ../../..25cd ../../..
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/dist29+ --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 
315273 是唯一外部入口:`/auth``/user``/manager` 由 Manager SPA 提供,345273 是唯一外部入口:`/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。 */
17function agentRuntimeId(agent: AgentTemplate): string {26function 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 // 切组织后重置选中的 agent273 // 切组织后重置选中的 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 <div358 <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 → Agent455+ // (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 <iframe608 <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);
70let refreshToken: string | null = localStorage.getItem(REFRESH_KEY);70let refreshToken: string | null = localStorage.getItem(REFRESH_KEY);
71let unauthorizedHandler: (() => void) | null = null;71let 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+ 
73export function setTokens(access: string | null, refresh?: string | null): void {84export 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 注入集群 DNS36 // 本地开发默认 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
22RUN npm install22RUN npm install
23RUN npm run build23RUN 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- 
28WORKDIR /app/agent-runtime25WORKDIR /app/agent-runtime
29ENV PATH=$PATH:/home/app/.local/bin26ENV PATH=$PATH:/home/app/.local/bin
30ENV MANAGER_WEB_HOST=0.0.0.027ENV MANAGER_WEB_HOST=0.0.0.0