From 2743c2b7e01a586dfd8b960756f01eb127518f00 Mon Sep 17 00:00:00 2001 From: huanling1 Date: Wed, 12 Aug 2026 11:06:03 +0800 Subject: [PATCH 1/2] feat: sites support secondary development, custom domain prefix --- backend/app/api/endpoints/sites.py | 47 ++++ backend/app/schemas/site.py | 19 +- .../app/services/site_application_types.py | 6 +- backend/app/services/sites.py | 39 ++- backend/tests/api/test_sites_api.py | 39 ++- .../developer-guide/wework-codex-plugins.md | 2 +- .../developer-guide/wework-codex-plugins.md | 2 +- wework/e2e/desktop/modules/desktop-server.mjs | 2 +- .../e2e/utils/mock-sites-upstream-server.mjs | 46 +++- wework/src/App.plugins.test.tsx | 4 +- wework/src/api/sites.test.ts | 35 ++- wework/src/api/sites.ts | 20 +- .../src/components/chat/AssistantMarkdown.tsx | 6 +- .../src/components/chat/MessageList.test.tsx | 25 ++ wework/src/components/chat/MessageList.tsx | 2 +- .../ComposerProseMirrorEditor.test.tsx | 12 + .../components/chat/composer/composerLinks.ts | 5 +- .../src/components/sites/ApplicationRows.tsx | 67 +++-- .../src/components/sites/EditSiteDialog.tsx | 131 ++++++++++ .../src/components/sites/SiteActionsMenu.tsx | 72 ++++- .../components/sites/SitesWorkspace.test.tsx | 83 +++++- .../src/components/sites/SitesWorkspace.tsx | 47 ++++ .../sites/applicationTypeDefinitions.test.tsx | 2 +- .../sites/applicationTypeDefinitions.tsx | 2 +- .../src/features/plugins/pluginTrial.test.ts | 12 + wework/src/features/plugins/pluginTrial.ts | 16 ++ wework/src/i18n/locales/en/sites.json | 10 + wework/src/i18n/locales/zh-CN/sites.json | 10 + wework/src/lib/link-preview.test.ts | 11 + wework/src/lib/link-preview.ts | 35 ++- wework/src/pages/SitesPage.tsx | 246 ++++++++++++------ 31 files changed, 915 insertions(+), 140 deletions(-) create mode 100644 wework/src/components/sites/EditSiteDialog.tsx diff --git a/backend/app/api/endpoints/sites.py b/backend/app/api/endpoints/sites.py index dc4a45a4263..a31be8122be 100644 --- a/backend/app/api/endpoints/sites.py +++ b/backend/app/api/endpoints/sites.py @@ -14,6 +14,7 @@ ApplicationTypeListResponse, SiteAppType, SiteListResponse, + SiteMetadataUpdateRequest, SiteNetworkUpdateRequest, SiteResponse, SiteUpdateRequest, @@ -160,6 +161,52 @@ async def update_site( _raise_sites_error(error) +@router.patch("/{siteid}", response_model=SiteResponse) +async def update_site_metadata( + siteid: str, + request: SiteMetadataUpdateRequest, + current_user: User = Depends(security.get_current_user), +) -> SiteResponse: + """Update editable metadata for an owned site project.""" + request_fields = request.model_fields_set + title = request.title if "title" in request_fields else request.name + title = title.strip() if title is not None else None + if ("title" in request_fields or "name" in request_fields) and not title: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "validation_error", + "message": "Site title is required", + }, + ) + custom_domain_prefix_set = "custom_domain_prefix" in request_fields + custom_domain_prefix = request.custom_domain_prefix + if isinstance(custom_domain_prefix, str): + custom_domain_prefix = custom_domain_prefix.strip() or None + if title is None and not custom_domain_prefix_set: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "validation_error", + "message": "No editable site fields were provided", + }, + ) + try: + return await sites_service.update_site_metadata( + siteid, + username=current_user.user_name, + title=title, + custom_domain_prefix=custom_domain_prefix, + custom_domain_prefix_set=custom_domain_prefix_set, + ) + except ( + SitesNotAvailableError, + SitesUpstreamUnavailableError, + SitesUpstreamResponseError, + ) as error: + _raise_sites_error(error) + + @router.delete("/{siteid}", status_code=status.HTTP_204_NO_CONTENT) async def delete_site( siteid: str, diff --git a/backend/app/schemas/site.py b/backend/app/schemas/site.py index 13d6173bcad..4133625b05e 100644 --- a/backend/app/schemas/site.py +++ b/backend/app/schemas/site.py @@ -18,7 +18,13 @@ ] SiteNetwork = Literal["inner", "outer"] SiteAppType = Literal["web", "miniapp", "site", "mini_program"] -ApplicationCapability = Literal["create", "publish", "delete", "open_experience"] +ApplicationCapability = Literal[ + "create", + "publish", + "edit", + "delete", + "open_experience", +] class SiteResponse(BaseModel): @@ -26,10 +32,12 @@ class SiteResponse(BaseModel): app_type: Literal["web"] = "web" siteid: str + project_id: str taskid: str username: str name: str slug: str + custom_domain_prefix: str | None = None network: SiteNetwork internal_url: AnyHttpUrl external_url: AnyHttpUrl | None = None @@ -46,6 +54,7 @@ class MiniProgramResponse(BaseModel): app_type: Literal["miniapp"] = "miniapp" siteid: str + project_id: str taskid: str username: str name: str @@ -109,3 +118,11 @@ class SiteUpdateRequest(BaseModel): sitename: str | None = Field(default=None, min_length=1, max_length=255) name: str | None = Field(default=None, min_length=1, max_length=255) + + +class SiteMetadataUpdateRequest(BaseModel): + """Request to update editable site project metadata.""" + + title: str | None = Field(default=None, min_length=1, max_length=255) + name: str | None = Field(default=None, min_length=1, max_length=255) + custom_domain_prefix: str | None = Field(default=None, min_length=4, max_length=63) diff --git a/backend/app/services/site_application_types.py b/backend/app/services/site_application_types.py index 1360214e4d7..f1db50a8618 100644 --- a/backend/app/services/site_application_types.py +++ b/backend/app/services/site_application_types.py @@ -110,6 +110,7 @@ class SiteApplicationHandler(ApplicationTypeHandler): capabilities: tuple[ApplicationCapability, ...] = ( "create", "publish", + "edit", "delete", ) create_plugin_name = BUILTIN_SITES_PLUGIN_NAME @@ -128,10 +129,12 @@ def parse(self, payload: Any, *, username: str) -> SiteResponse: site = { "app_type": self.app_type, "siteid": project_id, + "project_id": project_id, "taskid": project_id, "username": username, "name": payload.get("title"), - "slug": project_id, + "slug": payload.get("slug") or project_id, + "custom_domain_prefix": payload.get("custom_domain_prefix"), "network": network, "internal_url": url, "external_url": url if network == "outer" else None, @@ -173,6 +176,7 @@ def parse(self, payload: Any, *, username: str) -> MiniProgramResponse: mini_program = { "app_type": self.app_type, "siteid": project_id, + "project_id": project_id, "taskid": project_id, "username": username, "name": payload.get("title"), diff --git a/backend/app/services/sites.py b/backend/app/services/sites.py index e46697157d2..1e4a258bd19 100644 --- a/backend/app/services/sites.py +++ b/backend/app/services/sites.py @@ -5,6 +5,7 @@ """Gateway for the external Sites project API.""" from typing import Any +from uuid import uuid4 import httpx @@ -61,10 +62,15 @@ async def _request( *, params: dict[str, Any] | None = None, json: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, ) -> Any: - headers = {} + request_headers = {} if settings.SITES_API_TOKEN.strip(): - headers["Authorization"] = f"Bearer {settings.SITES_API_TOKEN.strip()}" + request_headers["Authorization"] = ( + f"Bearer {settings.SITES_API_TOKEN.strip()}" + ) + if headers: + request_headers.update(headers) try: async with httpx.AsyncClient(timeout=self._timeout_seconds) as client: response = await client.request( @@ -72,7 +78,7 @@ async def _request( f"{self._base_url()}{path}", params=params, json=json, - headers=headers or None, + headers=request_headers or None, ) except SitesNotAvailableError: raise @@ -103,6 +109,8 @@ def _response_detail(response: httpx.Response) -> Any: if isinstance(payload, dict) and "detail" in payload: return payload["detail"] + if isinstance(payload, dict) and isinstance(payload.get("error"), dict): + return payload["error"] return payload async def list_sites( @@ -168,6 +176,31 @@ async def update_site_name( ) return self._parse_site_project(payload, username=username) + async def update_site_metadata( + self, + siteid: str, + *, + username: str, + title: str | None = None, + custom_domain_prefix: str | None = None, + custom_domain_prefix_set: bool = False, + ) -> SiteResponse: + json_payload: dict[str, Any] = {} + if title is not None: + json_payload["title"] = title + if custom_domain_prefix_set: + json_payload["custom_domain_prefix"] = custom_domain_prefix + payload = await self._request( + "PATCH", + f"/api/v1/projects/{siteid}", + json=json_payload, + headers={ + "Idempotency-Key": f"project-update-{uuid4().hex}", + "X-Wegent-Username": username, + }, + ) + return self._parse_site_project(payload, username=username) + async def _list_platform_sites( self, *, diff --git a/backend/tests/api/test_sites_api.py b/backend/tests/api/test_sites_api.py index 7e6ab994d28..2a2e81e12fd 100644 --- a/backend/tests/api/test_sites_api.py +++ b/backend/tests/api/test_sites_api.py @@ -78,7 +78,7 @@ def test_list_site_app_types_returns_ordered_capabilities( "app_type": "web", "enabled": True, "order": 10, - "capabilities": ["create", "publish", "delete"], + "capabilities": ["create", "publish", "edit", "delete"], "create": { "plugin_name": "wegent-sites", "marketplace_name": "wegent", @@ -142,10 +142,12 @@ def test_list_sites_searches_platform_projects_with_authenticated_username( assert body["items"][0] == { "app_type": "web", "siteid": "prj_01K0A0BCDEFGHJKMNPQRSTVWXY", + "project_id": "prj_01K0A0BCDEFGHJKMNPQRSTVWXY", "taskid": "prj_01K0A0BCDEFGHJKMNPQRSTVWXY", "username": "testuser", "name": "Product site", "slug": "prj_01K0A0BCDEFGHJKMNPQRSTVWXY", + "custom_domain_prefix": None, "network": "inner", "internal_url": "https://product.inner.test/", "external_url": None, @@ -219,6 +221,7 @@ def test_list_sites_returns_mini_programs_from_the_shared_endpoint( { "app_type": "miniapp", "siteid": "mini_01K0A0BCDEFGHJKMNPQRSTVWXY", + "project_id": "mini_01K0A0BCDEFGHJKMNPQRSTVWXY", "taskid": "mini_01K0A0BCDEFGHJKMNPQRSTVWXY", "username": "testuser", "name": "Campaign assistant", @@ -479,6 +482,40 @@ def test_update_site_name_proxies_platform_name_update( } +def test_update_site_metadata_proxies_platform_patch( + test_client: TestClient, + test_token: str, + monkeypatch: pytest.MonkeyPatch, + httpx_mock: HTTPXMock, +) -> None: + monkeypatch.setattr(settings, "SITES_API_BASE_URL", SITES_API_BASE_URL) + monkeypatch.setattr(settings, "SITES_API_TOKEN", "platform-token") + httpx_mock.add_response( + method="PATCH", + url=f"{SITES_API_BASE_URL}/api/v1/projects/prj_01K0A0BCDEFGHJKMNPQRSTVWXY", + json=_project(title="Docs Site", custom_domain_prefix="docs"), + ) + + response = test_client.patch( + "/api/sites/prj_01K0A0BCDEFGHJKMNPQRSTVWXY", + json={"title": " Docs Site ", "custom_domain_prefix": "docs"}, + headers=_authorization(test_token), + ) + + assert response.status_code == 200 + body = response.json() + assert body["name"] == "Docs Site" + assert body["custom_domain_prefix"] == "docs" + request = httpx_mock.get_requests()[0] + assert request.headers["authorization"] == "Bearer platform-token" + assert request.headers["x-wegent-username"] == "testuser" + assert request.headers["idempotency-key"].startswith("project-update-") + assert _json_body(request) == { + "title": "Docs Site", + "custom_domain_prefix": "docs", + } + + def test_delete_site_removes_owned_platform_project( test_client: TestClient, test_token: str, diff --git a/docs/en/wework/developer-guide/wework-codex-plugins.md b/docs/en/wework/developer-guide/wework-codex-plugins.md index f95c47905e1..50916e3e343 100644 --- a/docs/en/wework/developer-guide/wework-codex-plugins.md +++ b/docs/en/wework/developer-guide/wework-codex-plugins.md @@ -44,7 +44,7 @@ Plugin installation is user-scoped, while CLI credentials are device-scoped. Ins Built-in application plugin identity is defined by the Backend built-in plugin registry. The current registry contains only `wegent-sites` and `weibo-miniapp-h5-develop-agent`; both use `visibility=workspace`, so their canonical marketplace name is `wegent`. `public` remains a valid visibility for ordinary plugins. Only when the built-in installation path finds one of these two system-owned `user_id=0` marketplace rows still stored as `visibility=public` does Backend treat it as a legacy row and normalize it to `workspace` before installing. This prevents the same built-in plugin from appearing as `plugin://...@wework` in old data and `plugin://...@wegent` in the current application create flow. -The Applications page reads its lists through `GET /api/sites`. Sites and Mini Programs share this endpoint and pass `app_type=web` and `app_type=miniapp`, respectively. Omitting the parameter defaults to Sites for backward compatibility. The response `app_type` discriminates the fields for each application type. The page also calls `GET /api/sites/app-types` to discover the types enabled by the current Backend, their display order, and capabilities such as `create`, `publish`, `delete`, and `open_experience`. Wework only shows types that are both enabled by the server and represented by a local Definition, and hides operations that are not supported by the advertised capabilities. +The Applications page reads its lists through `GET /api/sites`. Sites and Mini Programs share this endpoint and pass `app_type=web` and `app_type=miniapp`, respectively. Omitting the parameter defaults to Sites for backward compatibility. The response `app_type` discriminates the fields for each application type. The page also calls `GET /api/sites/app-types` to discover the types enabled by the current Backend, their display order, and capabilities such as `create`, `publish`, `edit`, `delete`, and `open_experience`. Wework only shows types that are both enabled by the server and represented by a local Definition, and hides operations that are not supported by the advertised capabilities. When Wework is connected to Wegent cloud, it calls `POST /api/users/me/wegent-runtime-token` to issue the token that local application Skills use for Backend runtime APIs, then writes it to the local Codex shell environment as `WEGENT_RUNTIME_AUTH_TOKEN`. Wework refreshes this token before the returned `expires_in`. `AUTH_TOKEN` remains the existing per-task bearer token, and `WEGENT_AUTH_TOKEN` remains reserved for executor device connections; these credentials must not be used interchangeably. diff --git a/docs/zh/wework/developer-guide/wework-codex-plugins.md b/docs/zh/wework/developer-guide/wework-codex-plugins.md index f6a1b04c01f..eb0400e511b 100644 --- a/docs/zh/wework/developer-guide/wework-codex-plugins.md +++ b/docs/zh/wework/developer-guide/wework-codex-plugins.md @@ -44,7 +44,7 @@ Codex 插件运行配置位于“设置 → 集成 → 插件”,当前提供 内置应用插件的身份以 Backend 内置插件注册表为准。当前注册表只包含 `wegent-sites` 和 `weibo-miniapp-h5-develop-agent`,二者都使用 `visibility=workspace`,因此规范市场名是 `wegent`。`public` 仍是普通插件的合法可见性;只有在内置插件安装路径中,系统所有者 `user_id=0` 下的这两个内置插件市场行仍保存为 `visibility=public` 时,才会被视为历史遗留行并在安装前规范化为 `workspace`。这样可以避免同一个内置插件在旧数据中以 `plugin://...@wework`、在当前应用创建流程中以 `plugin://...@wegent` 出现两套身份。 -应用页通过 `GET /api/sites` 读取列表。站点和小程序共用该接口,并分别传入 `app_type=web` 和 `app_type=miniapp`;省略参数时默认返回站点,兼容已有调用。响应中的 `app_type` 是区分两类应用字段的判别值。页面还会调用 `GET /api/sites/app-types` 获取当前 Backend 启用的类型、展示顺序和 `create`、`publish`、`delete`、`open_experience` 等能力;Wework 只显示本地已有 Definition 且服务端已启用的类型,并按能力隐藏不支持的操作。 +应用页通过 `GET /api/sites` 读取列表。站点和小程序共用该接口,并分别传入 `app_type=web` 和 `app_type=miniapp`;省略参数时默认返回站点,兼容已有调用。响应中的 `app_type` 是区分两类应用字段的判别值。页面还会调用 `GET /api/sites/app-types` 获取当前 Backend 启用的类型、展示顺序和 `create`、`publish`、`edit`、`delete`、`open_experience` 等能力;Wework 只显示本地已有 Definition 且服务端已启用的类型,并按能力隐藏不支持的操作。 连接 Wegent 云端时,Wework 会调用 `POST /api/users/me/wegent-runtime-token` 获取本地应用 Skill 访问 Backend runtime API 的 token,并把它作为 `WEGENT_RUNTIME_AUTH_TOKEN` 写入本机 Codex shell 环境配置;该 token 会按响应中的 `expires_in` 提前刷新。`AUTH_TOKEN` 仍表示单次任务的原有 bearer token,`WEGENT_AUTH_TOKEN` 仍保留给 executor 设备连接使用,三者不能混用。 diff --git a/wework/e2e/desktop/modules/desktop-server.mjs b/wework/e2e/desktop/modules/desktop-server.mjs index 14eaa486c96..5df7aa90851 100644 --- a/wework/e2e/desktop/modules/desktop-server.mjs +++ b/wework/e2e/desktop/modules/desktop-server.mjs @@ -898,7 +898,7 @@ class DesktopE2EServer { app_type: 'web', enabled: true, order: 10, - capabilities: ['create', 'publish', 'delete'], + capabilities: ['create', 'publish', 'edit', 'delete'], create: { plugin_name: 'wegent-sites', marketplace_name: 'wegent', diff --git a/wework/e2e/utils/mock-sites-upstream-server.mjs b/wework/e2e/utils/mock-sites-upstream-server.mjs index 866c37069da..00a9fc1111b 100644 --- a/wework/e2e/utils/mock-sites-upstream-server.mjs +++ b/wework/e2e/utils/mock-sites-upstream-server.mjs @@ -43,8 +43,8 @@ seedProjects() function corsHeaders(extra = {}) { return { 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type,Authorization', + 'Access-Control-Allow-Methods': 'GET,POST,PATCH,OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type,Authorization,Idempotency-Key,X-Wegent-Username', ...extra, } } @@ -168,6 +168,43 @@ function updateProjectName(req, res, body) { writeJson(res, 200, project) } +function updateProjectMetadata(req, res, body) { + if (!requireAuth(req, res)) { + return + } + + const projectId = new URL(req.url || '/', `http://127.0.0.1:${PORT}`).pathname.split('/').pop() + if (!req.headers['idempotency-key'] || !req.headers['x-wegent-username']) { + writeJson(res, 400, { + error: { + code: 'VALIDATION_ERROR', + message: 'Missing project metadata headers', + }, + }) + return + } + if (!projectId || !projects.has(projectId)) { + writeJson(res, 404, { + error: { + code: 'NOT_FOUND', + message: 'Project not found', + }, + }) + return + } + + const payload = parseJsonBody(body) + const project = projects.get(projectId) + if (typeof payload?.title === 'string' && payload.title.trim()) { + project.title = payload.title.trim() + } + if (Object.hasOwn(payload || {}, 'custom_domain_prefix')) { + project.custom_domain_prefix = payload.custom_domain_prefix || null + } + + writeJson(res, 200, project) +} + function deleteProject(req, res, body) { if (!requireAuth(req, res)) { return @@ -238,6 +275,11 @@ const server = http.createServer((req, res) => { return } + if (req.url?.startsWith('/api/v1/projects/') && req.method === 'PATCH') { + updateProjectMetadata(req, res, body) + return + } + if (req.url === '/api/v1/projects/del' && req.method === 'POST') { deleteProject(req, res, body) return diff --git a/wework/src/App.plugins.test.tsx b/wework/src/App.plugins.test.tsx index 20d660182f8..21823a9a74b 100644 --- a/wework/src/App.plugins.test.tsx +++ b/wework/src/App.plugins.test.tsx @@ -448,7 +448,7 @@ function applicationTypesResponse() { app_type: 'web', enabled: true, order: 10, - capabilities: ['create', 'publish', 'delete'], + capabilities: ['create', 'publish', 'edit', 'delete'], create: { plugin_name: 'wegent-sites', marketplace_name: 'wegent', @@ -1114,6 +1114,7 @@ describe('App plugins route', () => { input: '[$站点](plugin://wegent-sites@wegent) Build an internal website and validate it locally', pluginName: '站点', + openInNewChat: true, } ) }) @@ -1177,6 +1178,7 @@ describe('App plugins route', () => { input: '[$微博小程序开发助手](plugin://weibo-miniapp-h5-develop-agent@wegent) 创建并发布一个小程序', pluginName: '微博小程序开发助手', + openInNewChat: true, } ) }) diff --git a/wework/src/api/sites.test.ts b/wework/src/api/sites.test.ts index 2a690682165..525ebc814a6 100644 --- a/wework/src/api/sites.test.ts +++ b/wework/src/api/sites.test.ts @@ -32,7 +32,7 @@ describe('createSitesApi', () => { app_type: 'web', enabled: true, order: 10, - capabilities: ['create', 'publish', 'delete'], + capabilities: ['create', 'publish', 'edit', 'delete'], create: { plugin_name: 'wegent-sites', marketplace_name: 'wegent', @@ -49,7 +49,7 @@ describe('createSitesApi', () => { app_type: 'web', enabled: true, order: 10, - capabilities: ['create', 'publish', 'delete'], + capabilities: ['create', 'publish', 'edit', 'delete'], create: { plugin_name: 'wegent-sites', marketplace_name: 'wegent', @@ -161,6 +161,37 @@ describe('createSitesApi', () => { }) }) + test('updates editable site metadata through Wegent Backend', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + siteid: 'site/1', + name: 'Docs Site', + custom_domain_prefix: 'docs', + }), + }) + + const api = createSitesApi('/api/') + const site = await api.updateSite('site/1', { + title: 'Docs Site', + customDomainPrefix: 'docs', + }) + + expect(site.name).toBe('Docs Site') + expect(fetchMock).toHaveBeenCalledWith('/api/sites/site%2F1', { + method: 'PATCH', + body: JSON.stringify({ + title: 'Docs Site', + custom_domain_prefix: 'docs', + }), + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer wegent-secret', + }, + }) + }) + test('deletes a site using its encoded unique site id', async () => { fetchMock.mockResolvedValueOnce({ ok: true, status: 204 }) diff --git a/wework/src/api/sites.ts b/wework/src/api/sites.ts index 21d7f45f122..a40865ab4cb 100644 --- a/wework/src/api/sites.ts +++ b/wework/src/api/sites.ts @@ -3,15 +3,17 @@ import { ApiError, createHttpClient } from './http' export type SitePublishStatus = 'unpublished' | 'publishing' | 'published' | 'failed' | 'scanning' export type SiteAppType = 'web' | 'miniapp' export type SiteNetwork = 'inner' | 'outer' -export type ApplicationCapability = 'create' | 'publish' | 'delete' | 'open_experience' +export type ApplicationCapability = 'create' | 'publish' | 'edit' | 'delete' | 'open_experience' export interface Site { app_type: 'web' siteid: string + project_id?: string | null taskid: string username: string name: string slug: string + custom_domain_prefix?: string | null network?: SiteNetwork internal_url: string external_url: string | null @@ -26,6 +28,7 @@ export interface Site { export interface MiniProgram { app_type: 'miniapp' siteid: string + project_id?: string | null taskid: string username: string name: string @@ -72,11 +75,17 @@ export interface ListSitesInput { limit: number } +export interface UpdateSiteInput { + title?: string + customDomainPrefix?: string | null +} + export interface SitesApi { listApplicationTypes(): Promise listSites(input: ListSitesInput): Promise publishSite(siteid: string): Promise updateSiteNetwork(siteid: string, network: SiteNetwork): Promise + updateSite(siteid: string, input: UpdateSiteInput): Promise deleteSite(siteid: string): Promise } @@ -114,6 +123,14 @@ export function createSitesApi(baseUrl: string, options: SitesApiOptions = {}): updateSiteNetwork(siteid, network) { return client.put(`/sites/${encodeURIComponent(siteid)}/network`, { network }) }, + updateSite(siteid, input) { + return client.patch(`/sites/${encodeURIComponent(siteid)}`, { + ...(input.title !== undefined ? { title: input.title } : {}), + ...(input.customDomainPrefix !== undefined + ? { custom_domain_prefix: input.customDomainPrefix } + : {}), + }) + }, deleteSite(siteid) { return client.delete(`/sites/${encodeURIComponent(siteid)}`) }, @@ -129,6 +146,7 @@ export function createUnavailableSitesApi(): SitesApi { listSites: unavailable, publishSite: unavailable, updateSiteNetwork: unavailable, + updateSite: unavailable, deleteSite: unavailable, } } diff --git a/wework/src/components/chat/AssistantMarkdown.tsx b/wework/src/components/chat/AssistantMarkdown.tsx index 7d87e4cff10..ef2996af60f 100644 --- a/wework/src/components/chat/AssistantMarkdown.tsx +++ b/wework/src/components/chat/AssistantMarkdown.tsx @@ -180,7 +180,11 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ a: ({ href, children }: { href?: string; children?: ReactNode }) => { const text = reactNodeToText(children) const isComposerLink = - href && /^https?:\/\//.test(href) && text && text !== href && getRecognizedLink(href) + href && + /^[a-z][a-z0-9+.-]*:\/\//i.test(href) && + text && + text !== href && + getRecognizedLink(href) if (isComposerLink && !href?.startsWith(WEWORK_MARKDOWN_FILE_LINK_PREFIX)) { return } diff --git a/wework/src/components/chat/MessageList.test.tsx b/wework/src/components/chat/MessageList.test.tsx index df9786f8bfd..7b1b6d6837d 100644 --- a/wework/src/components/chat/MessageList.test.tsx +++ b/wework/src/components/chat/MessageList.test.tsx @@ -4053,6 +4053,31 @@ describe('MessageList', () => { ) }) + test('renders sent Wegent Sites project links like composer chips', () => { + render( + + ) + + const message = screen.getByTestId('message-user') + const chip = within(message).getByTestId('composer-link-chip') + expect(chip).toHaveTextContent('产品发布页') + expect(chip).toHaveAttribute('data-composer-link-url', 'wegent-sites-project://prj_product') + expect(chip).toHaveAttribute('data-composer-link-provider', 'wegent-sites-project') + expect(chip.querySelector('img')).toHaveAttribute('src', '/plugin-icons/wework.svg') + expect(message).toHaveTextContent('产品发布页 请说出你要做的改动') + expect(message).not.toHaveTextContent('wegent-sites-project://prj_product') + }) + test('renders GitHub link chips in the edit form and opens the link edit popover', async () => { render( { ) }) +test('renders Wegent Sites project markdown links as inline link chips', () => { + const value = '[产品发布页](wegent-sites-project://prj_product) 请说出你要做的改动' + renderEditor(value) + + const chip = screen.getByTestId('composer-link-chip') + expect(chip).toHaveAttribute('data-composer-link-url', 'wegent-sites-project://prj_product') + expect(chip).toHaveAttribute('data-composer-link-provider', 'wegent-sites-project') + expect(chip).toHaveTextContent('产品发布页') + expect(chip.querySelector('img')).toHaveAttribute('src', '/plugin-icons/wework.svg') + expect(serializeComposerDocument(createComposerDocument(value))).toBe(value) +}) + test('keeps unrecognized URLs as plain text', () => { renderEditor('Visit https://example.com/page') diff --git a/wework/src/components/chat/composer/composerLinks.ts b/wework/src/components/chat/composer/composerLinks.ts index df55ce1f299..5be38748e1a 100644 --- a/wework/src/components/chat/composer/composerLinks.ts +++ b/wework/src/components/chat/composer/composerLinks.ts @@ -12,7 +12,10 @@ export interface ParsedComposerLink extends ComposerLinkPayload { end: number } -const MARKDOWN_LINK_REGEX = new RegExp('\\[!?([^\\]]*)\\]\\((https?:\\/\\/[^\\s)\\]]+)\\)', 'gi') +const MARKDOWN_LINK_REGEX = new RegExp( + '\\[!?([^\\]]*)\\]\\(([a-z][a-z0-9+.-]*:\\/\\/[^\\s)\\]]+)\\)', + 'gi' +) const BARE_URL_REGEX = new RegExp('https?:\\/\\/[^\\s)\\]}]+', 'gi') function recognizedToParsed( diff --git a/wework/src/components/sites/ApplicationRows.tsx b/wework/src/components/sites/ApplicationRows.tsx index 51a4b5b2fff..c4af221d7a4 100644 --- a/wework/src/components/sites/ApplicationRows.tsx +++ b/wework/src/components/sites/ApplicationRows.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { AlertCircle, Check, + Code2, Copy, ExternalLink, Globe2, @@ -22,7 +23,10 @@ export interface ApplicationRowContext { capabilities: ReadonlySet publishingIds: ReadonlySet deletingSiteId: string | null + continuingSiteId?: string | null onPublish: (site: Site) => void + onContinueDevelopment: (site: Site) => void + onEdit: (site: Site) => void onDelete: (site: Site) => void } @@ -61,10 +65,21 @@ export function SiteApplicationRow({ const { t } = useTranslation('sites') const publishing = context.publishingIds.has(site.siteid) const deleting = context.deletingSiteId === site.siteid + const continuing = context.continuingSiteId === site.siteid const network = getSiteNetwork(site) const isPublishing = publishing || site.publish_status === 'publishing' const isFailed = site.publish_status === 'failed' const isSecurityChecking = site.publish_status === 'scanning' + const publishLabel = isPublishing + ? t('publishing', '发布中') + : isSecurityChecking + ? t('security_checking', '安全检查中') + : isFailed + ? t('retry_publish', '重试发布') + : network === 'outer' + ? t('publish_inner', '发布到内网') + : t('publish', '发布到外网') + const PublishIcon = isPublishing || isSecurityChecking ? Loader2 : Upload const openUrl = (url: string) => { void openExternalUrl(url).catch(error => { @@ -110,34 +125,34 @@ export function SiteApplicationRow({ ) : null} - {context.capabilities.has('publish') ? ( - - ) : null} - {context.capabilities.has('delete') ? ( + + {context.capabilities.has('publish') || + context.capabilities.has('edit') || + context.capabilities.has('delete') ? ( ) : null} diff --git a/wework/src/components/sites/EditSiteDialog.tsx b/wework/src/components/sites/EditSiteDialog.tsx new file mode 100644 index 00000000000..88f3bde5a1a --- /dev/null +++ b/wework/src/components/sites/EditSiteDialog.tsx @@ -0,0 +1,131 @@ +import { FormEvent, useEffect, useRef, useState } from 'react' +import { Loader2, Pencil } from 'lucide-react' +import type { Site } from '@/api/sites' +import { useTranslation } from '@/hooks/useTranslation' + +interface EditSiteDialogProps { + site: Site + loading: boolean + error: string | null + onCancel: () => void + onConfirm: (input: { title: string; customDomainPrefix: string | null }) => void +} + +export function EditSiteDialog({ site, loading, error, onCancel, onConfirm }: EditSiteDialogProps) { + const { t } = useTranslation('sites') + const titleInputRef = useRef(null) + const [title, setTitle] = useState(site.name) + const [customDomainPrefix, setCustomDomainPrefix] = useState(site.custom_domain_prefix ?? '') + const trimmedTitle = title.trim() + const normalizedPrefix = customDomainPrefix.trim().toLowerCase() + const currentPrefix = site.custom_domain_prefix ?? '' + const hasChanges = trimmedTitle !== site.name || normalizedPrefix !== currentPrefix + const canSave = Boolean(trimmedTitle) && hasChanges && !loading + + useEffect(() => { + titleInputRef.current?.focus() + titleInputRef.current?.select() + }, []) + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape' && !loading) onCancel() + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [loading, onCancel]) + + const submit = (event: FormEvent) => { + event.preventDefault() + if (!canSave) return + onConfirm({ + title: trimmedTitle, + customDomainPrefix: normalizedPrefix || null, + }) + } + + return ( +
{ + if (!loading && event.target === event.currentTarget) onCancel() + }} + > +
event.stopPropagation()} + onSubmit={submit} + > +
+
+
+
+

+ {t('edit_title', '编辑站点')} +

+
+ + +
+ {error && ( +

+ {error} +

+ )} +
+
+
+ + +
+
+
+ ) +} diff --git a/wework/src/components/sites/SiteActionsMenu.tsx b/wework/src/components/sites/SiteActionsMenu.tsx index d77616b95f6..60ce03dc2ba 100644 --- a/wework/src/components/sites/SiteActionsMenu.tsx +++ b/wework/src/components/sites/SiteActionsMenu.tsx @@ -1,4 +1,5 @@ -import { Trash2 } from 'lucide-react' +import { Pencil, Trash2, Upload } from 'lucide-react' +import type { LucideIcon } from 'lucide-react' import type { Site } from '@/api/sites' import { ActionMenu } from '@/components/common/ActionMenu' import { useTranslation } from '@/hooks/useTranslation' @@ -6,11 +7,67 @@ import { useTranslation } from '@/hooks/useTranslation' interface SiteActionsMenuProps { site: Site disabled: boolean + canPublish: boolean + publishDisabled: boolean + publishLabel: string + publishIcon?: LucideIcon + canEdit: boolean + canDelete: boolean + onPublish: (site: Site) => void + onEdit: (site: Site) => void onDelete: (site: Site) => void } -export function SiteActionsMenu({ site, disabled, onDelete }: SiteActionsMenuProps) { +export function SiteActionsMenu({ + site, + disabled, + canPublish, + publishDisabled, + publishLabel, + publishIcon: PublishIcon = Upload, + canEdit, + canDelete, + onPublish, + onEdit, + onDelete, +}: SiteActionsMenuProps) { const { t } = useTranslation('sites') + const items = [ + ...(canEdit + ? [ + { + label: t('edit_site', '编辑站点'), + icon: Pencil, + testId: `site-edit-menu-item-${site.siteid}`, + disabled, + onSelect: () => onEdit(site), + }, + ] + : []), + ...(canPublish + ? [ + { + label: publishLabel, + icon: PublishIcon, + testId: `site-publish-${site.siteid}`, + disabled: disabled || publishDisabled, + onSelect: () => onPublish(site), + }, + ] + : []), + ...(canDelete + ? [ + { + label: t('delete_site', '删除站点'), + icon: Trash2, + testId: `site-delete-menu-item-${site.siteid}`, + danger: true, + disabled, + onSelect: () => onDelete(site), + }, + ] + : []), + ] return ( onDelete(site), - }, - ]} + items={items} /> ) } diff --git a/wework/src/components/sites/SitesWorkspace.test.tsx b/wework/src/components/sites/SitesWorkspace.test.tsx index c2c51aaddd2..dc68a736a49 100644 --- a/wework/src/components/sites/SitesWorkspace.test.tsx +++ b/wework/src/components/sites/SitesWorkspace.test.tsx @@ -19,10 +19,12 @@ vi.mock('@/lib/external-links', () => ({ const unpublishedSite: Site = { app_type: 'web', siteid: 'site-1', + project_id: 'prj-product', taskid: 'task-1', username: 'alice', name: '产品发布页', slug: 'product', + custom_domain_prefix: 'product', network: 'inner', internal_url: 'http://sites.internal/product', external_url: null, @@ -56,7 +58,7 @@ function createApi(items: SiteListItem[] = [unpublishedSite]): SitesApi { app_type: 'web', enabled: true, order: 10, - capabilities: ['create', 'publish', 'delete'], + capabilities: ['create', 'publish', 'edit', 'delete'], create: { plugin_name: 'wegent-sites', marketplace_name: 'wegent', @@ -92,6 +94,16 @@ function createApi(items: SiteListItem[] = [unpublishedSite]): SitesApi { publish_status: 'published', external_url: 'https://product.example.site', }), + updateSite: vi + .fn() + .mockImplementation( + (_siteid: string, input: { title?: string; customDomainPrefix?: string | null }) => + Promise.resolve({ + ...unpublishedSite, + name: input.title ?? unpublishedSite.name, + custom_domain_prefix: input.customDomainPrefix, + }) + ), deleteSite: vi.fn().mockResolvedValue(undefined), } } @@ -165,12 +177,14 @@ describe('SitesWorkspace', () => { render() await screen.findByText('产品发布页') expect(screen.getByTestId('site-network-site-1')).toHaveTextContent('内网') + await userEvent.click(screen.getByTestId('site-more-site-1')) expect(screen.getByTestId('site-publish-site-1')).toHaveTextContent('发布到外网') await userEvent.click(screen.getByTestId('site-publish-site-1')) await waitFor(() => expect(api.updateSiteNetwork).toHaveBeenCalledWith('site-1', 'outer')) expect(screen.getByTestId('site-network-site-1')).toHaveTextContent('外网') + await userEvent.click(screen.getByTestId('site-more-site-1')) expect(screen.getByTestId('site-publish-site-1')).toHaveTextContent('发布到内网') vi.mocked(api.updateSiteNetwork).mockResolvedValueOnce({ @@ -204,6 +218,7 @@ describe('SitesWorkspace', () => { render() await screen.findByText('产品发布页') + await userEvent.click(screen.getByTestId('site-more-site-1')) await userEvent.click(screen.getByTestId('site-publish-site-1')) await waitFor(() => expect(api.updateSiteNetwork).toHaveBeenCalledWith('site-1', 'outer')) @@ -211,6 +226,7 @@ describe('SitesWorkspace', () => { expect(screen.getByTestId('site-network-site-1')).not.toHaveTextContent( 'Outer network exposure security audit has been requeued' ) + await userEvent.click(screen.getByTestId('site-more-site-1')) expect(screen.getByTestId('site-publish-site-1')).toHaveTextContent('安全检查中') expect(screen.getByTestId('site-publish-site-1')).toBeDisabled() }) @@ -431,6 +447,71 @@ describe('SitesWorkspace', () => { expect(api.deleteSite).not.toHaveBeenCalled() }) + test('continues development from a site row action', async () => { + const api = createApi() + const onContinueDevelopment = vi.fn() + render( + + ) + await screen.findByText('产品发布页') + + await userEvent.click(screen.getByTestId('site-continue-development-site-1')) + + expect(onContinueDevelopment).toHaveBeenCalledWith( + unpublishedSite, + expect.objectContaining({ pluginName: 'wegent-sites', marketplaceName: 'wegent' }) + ) + }) + + test('edits a site title and custom domain prefix from the row menu', async () => { + const api = createApi() + render() + await screen.findByText('产品发布页') + + await userEvent.click(screen.getByTestId('site-more-site-1')) + await userEvent.click(screen.getByTestId('site-edit-menu-item-site-1')) + + expect(screen.getByTestId('site-edit-dialog')).toBeInTheDocument() + expect(screen.getByTestId('site-edit-title-input')).toHaveValue('产品发布页') + expect(screen.getByTestId('site-edit-domain-prefix-input')).toHaveValue('product') + + await userEvent.clear(screen.getByTestId('site-edit-title-input')) + await userEvent.type(screen.getByTestId('site-edit-title-input'), 'Docs Site') + await userEvent.clear(screen.getByTestId('site-edit-domain-prefix-input')) + await userEvent.type(screen.getByTestId('site-edit-domain-prefix-input'), 'Docs') + await userEvent.click(screen.getByTestId('site-edit-save-button')) + + await waitFor(() => + expect(api.updateSite).toHaveBeenCalledWith('site-1', { + title: 'Docs Site', + customDomainPrefix: 'docs', + }) + ) + expect(await screen.findByText('Docs Site')).toBeInTheDocument() + expect(screen.queryByTestId('site-edit-dialog')).not.toBeInTheDocument() + }) + + test('keeps the edit dialog open when metadata saving fails', async () => { + const api = createApi() + vi.mocked(api.updateSite).mockRejectedValueOnce( + new Error('custom_domain_prefix is already in use') + ) + render() + await screen.findByText('产品发布页') + + await userEvent.click(screen.getByTestId('site-more-site-1')) + await userEvent.click(screen.getByTestId('site-edit-menu-item-site-1')) + await userEvent.clear(screen.getByTestId('site-edit-domain-prefix-input')) + await userEvent.type(screen.getByTestId('site-edit-domain-prefix-input'), 'taken') + await userEvent.click(screen.getByTestId('site-edit-save-button')) + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'custom_domain_prefix is already in use' + ) + expect(screen.getByTestId('site-edit-dialog')).toBeInTheDocument() + expect(screen.getByTestId('site-row-site-1')).toHaveTextContent('产品发布页') + }) + test('removes only the confirmed site after the API succeeds', async () => { const api = createApi() render() diff --git a/wework/src/components/sites/SitesWorkspace.tsx b/wework/src/components/sites/SitesWorkspace.tsx index b402d3da0df..823b2c8416c 100644 --- a/wework/src/components/sites/SitesWorkspace.tsx +++ b/wework/src/components/sites/SitesWorkspace.tsx @@ -13,12 +13,15 @@ import { getApplicationTypeDefinition, } from './applicationTypeDefinitions' import { DeleteSiteDialog } from './DeleteSiteDialog' +import { EditSiteDialog } from './EditSiteDialog' import { useApplicationTypeDefinitions } from './useApplicationTypeDefinitions' interface SitesWorkspaceProps { api: SitesApi onCreate: (appType: SiteAppType, create: ApplicationCreateStrategy) => void | Promise + onContinueDevelopment?: (site: Site, create: ApplicationCreateStrategy) => void | Promise creatingType?: SiteAppType | null + continuingSiteId?: string | null pageSize?: number sidebarCollapsed?: boolean topBarLeftActions?: ReactNode @@ -198,7 +201,9 @@ function SitesCreateNotice({ message }: { message: string }) { export function SitesWorkspace({ api, onCreate, + onContinueDevelopment, creatingType = null, + continuingSiteId = null, pageSize = 20, sidebarCollapsed = false, topBarLeftActions, @@ -215,6 +220,9 @@ export function SitesWorkspace({ const [pendingDeleteSite, setPendingDeleteSite] = useState(null) const [deletingSiteId, setDeletingSiteId] = useState(null) const [deleteError, setDeleteError] = useState(null) + const [pendingEditSite, setPendingEditSite] = useState(null) + const [editingSiteId, setEditingSiteId] = useState(null) + const [editError, setEditError] = useState(null) useEffect(() => { const timeout = window.setTimeout(() => setDebouncedQuery(query.trim()), 180) @@ -337,6 +345,25 @@ export function SitesWorkspace({ } } + const editSite = async (input: { title: string; customDomainPrefix: string | null }) => { + if (!pendingEditSite || editingSiteId) return + setEditingSiteId(pendingEditSite.siteid) + setEditError(null) + try { + const updated = await api.updateSite(pendingEditSite.siteid, input) + collection.setItems(current => + current.map(item => (item.siteid === pendingEditSite.siteid ? updated : item)) + ) + setPendingEditSite(null) + track('feature_action_completed', { action: 'edit', domain: 'site' }) + } catch (error) { + setEditError(errorMessage(error, t('edit_failed', '站点保存失败'))) + track('operation_failed', { operation: 'site_action' }) + } finally { + setEditingSiteId(null) + } + } + const createItems = applicationTypes .filter(item => item.capabilities.has('create')) .map(({ definition }) => ({ @@ -530,7 +557,14 @@ export function SitesWorkspace({ capabilities: activeApplicationType.capabilities, publishingIds, deletingSiteId, + continuingSiteId, onPublish: publish, + onContinueDevelopment: siteToContinue => + onContinueDevelopment?.(siteToContinue, activeDefinition.create), + onEdit: siteToEdit => { + setEditError(null) + setPendingEditSite(siteToEdit) + }, onDelete: siteToDelete => { setDeleteError(null) setPendingDeleteSite(siteToDelete) @@ -577,6 +611,19 @@ export function SitesWorkspace({ onConfirm={() => void deleteSite()} /> )} + {pendingEditSite && ( + { + if (editingSiteId) return + setEditError(null) + setPendingEditSite(null) + }} + onConfirm={input => void editSite(input)} + /> + )} ) } diff --git a/wework/src/components/sites/applicationTypeDefinitions.test.tsx b/wework/src/components/sites/applicationTypeDefinitions.test.tsx index 0583b8a4341..5bcff527511 100644 --- a/wework/src/components/sites/applicationTypeDefinitions.test.tsx +++ b/wework/src/components/sites/applicationTypeDefinitions.test.tsx @@ -16,7 +16,7 @@ describe('application type definitions', () => { test('does not expose create capability before cloud descriptors or cached descriptors load', () => { expect(defaultResolvedApplicationTypes().map(item => [...item.capabilities])).toEqual([ - ['publish', 'delete'], + ['publish', 'edit', 'delete'], ['open_experience'], ]) }) diff --git a/wework/src/components/sites/applicationTypeDefinitions.tsx b/wework/src/components/sites/applicationTypeDefinitions.tsx index d4f922b562d..d8d05a77acb 100644 --- a/wework/src/components/sites/applicationTypeDefinitions.tsx +++ b/wework/src/components/sites/applicationTypeDefinitions.tsx @@ -43,7 +43,7 @@ export interface ResolvedApplicationTypeDefinition { const siteDefinition: ApplicationTypeDefinition = { appType: 'web', icon: Globe2, - capabilities: ['create', 'publish', 'delete'], + capabilities: ['create', 'publish', 'edit', 'delete'], tab: { key: 'site_tab', fallback: '站点' }, search: { key: 'search', fallback: '搜索站点' }, loading: { key: 'loading', fallback: '正在加载站点' }, diff --git a/wework/src/features/plugins/pluginTrial.test.ts b/wework/src/features/plugins/pluginTrial.test.ts index 76c0c58d4b6..b60c571b603 100644 --- a/wework/src/features/plugins/pluginTrial.test.ts +++ b/wework/src/features/plugins/pluginTrial.test.ts @@ -10,6 +10,7 @@ import { getPluginUseCount30d, pluginTrialInput, pluginTrialTemplates, + queuePluginInputTrial, queuePluginPromptTrial, queuePluginReferenceTrial, queuePluginTrial, @@ -192,6 +193,17 @@ describe('plugin trial state', () => { expect(consumePluginTrialInput()).toBeNull() }) + test('queues a custom plugin trial input for a fresh chat', () => { + const input = '[产品发布页](wegent-sites-project://prj_product) 请说出你要做的改动' + + expect(queuePluginInputTrial(pluginWithSkill(), input, { openInNewChat: true })).toBe(true) + + const trial = consumePluginTrial() + expect(trial?.input).toBe(input) + expect(trial?.pluginName).toBe('Documents') + expect(trial?.openInNewChat).toBe(true) + }) + test('queues a canonical plugin reference without an installed plugin record', () => { expect( queuePluginReferenceTrial({ diff --git a/wework/src/features/plugins/pluginTrial.ts b/wework/src/features/plugins/pluginTrial.ts index e461680fa31..707777c8f8d 100644 --- a/wework/src/features/plugins/pluginTrial.ts +++ b/wework/src/features/plugins/pluginTrial.ts @@ -264,6 +264,22 @@ export function queuePluginPromptTrial( }) } +export function queuePluginInputTrial( + plugin: InstalledPlugin, + input: string, + { openInNewChat = false, prompt }: PluginTrialOptions = {} +): boolean { + const pluginName = plugin.spec.displayName || plugin.spec.source.pluginKey + const normalizedInput = input.trim() + if (!pluginName || !normalizedInput) return false + return queuePendingPluginTrial({ + input: normalizedInput, + pluginName, + templates: pluginTrialTemplates(plugin, prompt ?? normalizedInput), + openInNewChat, + }) +} + export function queuePluginReferenceTrial({ pluginName, marketplaceName, diff --git a/wework/src/i18n/locales/en/sites.json b/wework/src/i18n/locales/en/sites.json index ad6400c3c36..c110f2f0b29 100644 --- a/wework/src/i18n/locales/en/sites.json +++ b/wework/src/i18n/locales/en/sites.json @@ -67,6 +67,16 @@ "plugin_install_failed": "Failed to install the application plugin. Try again.", "open_plugins": "View plugins", "more_actions": "More actions", + "continue_development": "Continue development", + "continue_development_prompt_suffix": "describe the changes you want", + "edit_site": "Edit site", + "edit_title": "Edit site", + "edit_site_title_label": "Title", + "edit_domain_prefix_label": "Custom domain prefix", + "edit_domain_prefix_placeholder": "Only lowercase letters, numbers, and hyphens", + "save": "Save", + "saving": "Saving", + "edit_failed": "Failed to save site", "delete_site": "Delete site", "delete_title": "Delete site?", "delete_description": "This removes the site registration and public URL for “{{name}}”, but does not delete the local directory.", diff --git a/wework/src/i18n/locales/zh-CN/sites.json b/wework/src/i18n/locales/zh-CN/sites.json index 1e3deccc41c..42627962310 100644 --- a/wework/src/i18n/locales/zh-CN/sites.json +++ b/wework/src/i18n/locales/zh-CN/sites.json @@ -67,6 +67,16 @@ "plugin_install_failed": "应用插件安装失败,请重试", "open_plugins": "查看插件", "more_actions": "更多操作", + "continue_development": "继续开发", + "continue_development_prompt_suffix": "请说出你要做的改动", + "edit_site": "编辑站点", + "edit_title": "编辑站点", + "edit_site_title_label": "标题", + "edit_domain_prefix_label": "自定义域名前缀", + "edit_domain_prefix_placeholder": "只允许小写字母、数字、短横线", + "save": "保存", + "saving": "保存中", + "edit_failed": "站点保存失败", "delete_site": "删除站点", "delete_title": "删除站点?", "delete_description": "将删除“{{name}}”的站点登记和公网入口,但不会删除本地目录。", diff --git a/wework/src/lib/link-preview.test.ts b/wework/src/lib/link-preview.test.ts index 740ddf105ff..7fa302fa895 100644 --- a/wework/src/lib/link-preview.test.ts +++ b/wework/src/lib/link-preview.test.ts @@ -67,6 +67,17 @@ describe('recognized links', () => { expect(link?.isAbbreviated).toBe(true) }) + test('recognizes Wegent Sites project links', () => { + const link = getRecognizedLink('wegent-sites-project://prj_01K0') + expect(link).toMatchObject({ + url: 'wegent-sites-project://prj_01K0', + label: 'prj_01K0', + provider: 'wegent-sites-project', + iconUrl: '/plugin-icons/wework.svg', + isAbbreviated: true, + }) + }) + test('returns undefined for unsupported URLs', () => { expect(getRecognizedLink('https://example.com/page')).toBeUndefined() }) diff --git a/wework/src/lib/link-preview.ts b/wework/src/lib/link-preview.ts index 658de40afde..bddc74f8a92 100644 --- a/wework/src/lib/link-preview.ts +++ b/wework/src/lib/link-preview.ts @@ -103,7 +103,40 @@ class GitHubRecognizer implements LinkRecognizer { } } -const recognizers: readonly LinkRecognizer[] = [new GitHubRecognizer()] +class WegentSitesProjectRecognizer implements LinkRecognizer { + readonly provider = 'wegent-sites-project' + + match(url: string): RecognizedLink | undefined { + let parsed: URL + try { + parsed = new URL(url) + } catch { + return undefined + } + if (parsed.protocol !== 'wegent-sites-project:') return undefined + let projectId: string + try { + projectId = decodeURIComponent(parsed.hostname || parsed.pathname.replace(/^\/+/, '')) + } catch { + return undefined + } + if (!projectId.trim()) return undefined + + return { + url, + label: projectId, + iconUrl: '/plugin-icons/wework.svg', + provider: this.provider, + fullUrl: url, + isAbbreviated: true, + } + } +} + +const recognizers: readonly LinkRecognizer[] = [ + new GitHubRecognizer(), + new WegentSitesProjectRecognizer(), +] export function getRecognizedLink(url: string): RecognizedLink | undefined { for (const recognizer of recognizers) { diff --git a/wework/src/pages/SitesPage.tsx b/wework/src/pages/SitesPage.tsx index eac075c9e38..244e91e5306 100644 --- a/wework/src/pages/SitesPage.tsx +++ b/wework/src/pages/SitesPage.tsx @@ -3,7 +3,7 @@ import { Menu } from 'lucide-react' import { ApiError, createHttpClient } from '@/api/http' import { createPluginApi } from '@/api/plugins' import { createSitesApi, createUnavailableSitesApi } from '@/api/sites' -import type { SiteAppType } from '@/api/sites' +import type { Site, SiteAppType } from '@/api/sites' import { DesktopSidebar } from '@/components/layout/DesktopSidebar' import { DesktopCollapsedSidebarToggle } from '@/components/layout/DesktopCollapsedSidebarToggle' import { DesktopWindowControls } from '@/components/layout/DesktopWindowControls' @@ -22,7 +22,11 @@ import { useWorkbench } from '@/features/workbench/useWorkbench' import { useIsMobile } from '@/hooks/useIsMobile' import { useTranslation } from '@/hooks/useTranslation' import { managedMarketplaceName } from '@/features/plugins/pluginMarketplaceIdentity' -import { notifyLocalPluginSkillsChanged, queuePluginTrial } from '@/features/plugins/pluginTrial' +import { + notifyLocalPluginSkillsChanged, + queuePluginInputTrial, + queuePluginTrial, +} from '@/features/plugins/pluginTrial' import { getPreferredStandaloneDeviceId } from '@/lib/device-selection' import { buildRuntimeTaskRoute, navigateTo } from '@/lib/navigation' import { isTauriRuntime } from '@/lib/runtime-environment' @@ -36,6 +40,34 @@ import type { class ApplicationPluginSyncConfirmationError extends Error {} +interface PreparedApplicationPlugin { + plugin: InstalledPlugin + pluginName: string + marketplaceName: string +} + +function sanitizeMarkdownLinkLabel(value: string): string { + return value + .replace(/[\r\n]+/g, ' ') + .replace(/\[/g, ' ') + .replace(/]/g, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +function buildSiteContinueDevelopmentInput( + site: Pick, + suffix: string +): string | null { + const projectId = String(site.project_id || site.siteid || '').trim() + if (!projectId) return null + const label = sanitizeMarkdownLinkLabel(site.name) || projectId + const normalizedSuffix = suffix.trim() + return `[${label}](wegent-sites-project://${encodeURIComponent(projectId)})${ + normalizedSuffix ? ` ${normalizedSuffix}` : '' + }` +} + function installedPluginId(plugin: InstalledPlugin): string | null { const labels = plugin.metadata.labels && typeof plugin.metadata.labels === 'object' @@ -189,6 +221,7 @@ export function SitesPage() { const [createError, setCreateError] = useState(null) const [createNotice, setCreateNotice] = useState(null) const [creatingType, setCreatingType] = useState(null) + const [continuingSiteId, setContinuingSiteId] = useState(null) const { sidebarCollapsed, setSidebarCollapsed } = useDesktopSidebarCollapsed() const { state, @@ -291,6 +324,92 @@ export function SitesPage() { startNewProjectChat(projectId) } + const prepareApplicationPlugin = async ( + createStrategy: ApplicationCreateStrategy + ): Promise => { + if (!createStrategy.pluginName || !createStrategy.marketplaceName) { + setCreateError( + t('plugin_create_configuration_missing', '应用创建插件配置尚未同步,请刷新后重试') + ) + return null + } + if (!pluginApi) { + setCreateError(t('plugin_cloud_unavailable', '连接云端后才能使用应用创建插件')) + return null + } + const targetDeviceId = getPreferredStandaloneDeviceId( + state.devices, + state.standaloneDeviceId ?? state.user?.preferences?.default_execution_target + ) + if (!targetDeviceId) { + setCreateError(t('plugin_device_unavailable', '请选择一个在线且版本兼容的设备后再创建应用')) + return null + } + + const installedPlugins = await pluginApi.listInstalledPlugins(targetDeviceId).catch(error => { + console.warn('[Wework Applications] failed to inspect local plugin installation', error) + return { items: [] } + }) + const locallyInstalledPlugin = findInstalledApplicationPlugin( + installedPlugins.items, + createStrategy.pluginName, + createStrategy.marketplaceName, + targetDeviceId + ) + const plugin = locallyInstalledPlugin + if (!plugin) { + setCreateNotice(t('plugin_installing', '正在安装应用插件,完成后将进入会话...')) + } + const prepared = plugin + ? { plugin, sync: null } + : await pluginApi.ensureBuiltinPluginInstalled(createStrategy.pluginName, { + deviceId: targetDeviceId, + }) + const syncConfirmed = + locallyInstalledPlugin !== null || + isPluginInstalledOnDevice(prepared.plugin, targetDeviceId) || + isApplicationPluginSyncConfirmed( + prepared.sync, + prepared.plugin, + createStrategy.pluginName, + targetDeviceId + ) + if (!syncConfirmed) { + throw new ApplicationPluginSyncConfirmationError( + 'The Backend did not confirm application plugin synchronization to the target device' + ) + } + return { + plugin: prepared.plugin, + pluginName: createStrategy.pluginName, + marketplaceName: createStrategy.marketplaceName, + } + } + + const handleApplicationPluginError = (error: unknown) => { + console.error('[Wework Applications] cloud plugin preparation failed', error) + if (error instanceof ApiError && error.status === 404) { + setCreateError( + t( + 'plugin_backend_upgrade_required', + '云端 Backend 尚未支持对应的应用插件,请先部署最新 Backend' + ) + ) + } else if (error instanceof ApiError && error.status === 503) { + setCreateError( + t('plugin_not_published', '云端市场尚未发布对应的应用插件,请检查内置插件打包配置') + ) + } else if (error instanceof ApiError && error.status === 409) { + setCreateError(t('plugin_device_unavailable', '目标设备当前离线,请连接设备后重试')) + } else if (error instanceof ApiError && error.status === 502) { + setCreateError(t('plugin_device_sync_failed', '应用插件未能同步到目标设备,请检查设备后重试')) + } else if (error instanceof ApplicationPluginSyncConfirmationError) { + setCreateError(t('plugin_device_sync_failed', '应用插件未能同步到目标设备,请检查设备后重试')) + } else { + setCreateError(t('plugin_install_failed', '应用插件安装失败,请重试')) + } + } + const handleCreate = async (appType: SiteAppType, createStrategy: ApplicationCreateStrategy) => { if (creatingType) return setCreatingType(appType) @@ -301,63 +420,14 @@ export function SitesPage() { setCreateError(t('unsupported_application_type', '当前版本不支持该应用类型')) return } - if (!createStrategy.pluginName || !createStrategy.marketplaceName) { - setCreateError( - t('plugin_create_configuration_missing', '应用创建插件配置尚未同步,请刷新后重试') - ) - return - } - if (!pluginApi) { - setCreateError(t('plugin_cloud_unavailable', '连接云端后才能使用应用创建插件')) - return - } - const targetDeviceId = getPreferredStandaloneDeviceId( - state.devices, - state.standaloneDeviceId ?? state.user?.preferences?.default_execution_target - ) - if (!targetDeviceId) { - setCreateError(t('plugin_device_unavailable', '请选择一个在线且版本兼容的设备后再创建应用')) - return - } - - const installedPlugins = await pluginApi.listInstalledPlugins(targetDeviceId).catch(error => { - console.warn('[Wework Applications] failed to inspect local plugin installation', error) - return { items: [] } - }) - const locallyInstalledPlugin = findInstalledApplicationPlugin( - installedPlugins.items, - createStrategy.pluginName, - createStrategy.marketplaceName, - targetDeviceId - ) - const plugin = locallyInstalledPlugin - if (!plugin) { - setCreateNotice(t('plugin_installing', '正在安装应用插件,完成后将进入会话...')) - } - const prepared = plugin - ? { plugin, sync: null } - : await pluginApi.ensureBuiltinPluginInstalled(createStrategy.pluginName, { - deviceId: targetDeviceId, - }) - const syncConfirmed = - locallyInstalledPlugin !== null || - isPluginInstalledOnDevice(prepared.plugin, targetDeviceId) || - isApplicationPluginSyncConfirmed( - prepared.sync, - prepared.plugin, - createStrategy.pluginName, - targetDeviceId - ) - if (!syncConfirmed) { - throw new ApplicationPluginSyncConfirmationError( - 'The Backend did not confirm application plugin synchronization to the target device' - ) - } + const prepared = await prepareApplicationPlugin(createStrategy) + if (!prepared) return const queued = queuePluginTrial(prepared.plugin, { + openInNewChat: true, reference: { - pluginName: createStrategy.pluginName, - marketplaceName: createStrategy.marketplaceName, - displayName: applicationPluginReferenceName(prepared.plugin, createStrategy.pluginName), + pluginName: prepared.pluginName, + marketplaceName: prepared.marketplaceName, + displayName: applicationPluginReferenceName(prepared.plugin, prepared.pluginName), }, }) if (!queued) { @@ -369,34 +439,48 @@ export function SitesPage() { setCreateNotice(null) navigateTo('/') } catch (error) { - console.error('[Wework Applications] cloud plugin preparation failed', error) - if (error instanceof ApiError && error.status === 404) { - setCreateError( - t( - 'plugin_backend_upgrade_required', - '云端 Backend 尚未支持对应的应用插件,请先部署最新 Backend' - ) - ) - } else if (error instanceof ApiError && error.status === 503) { - setCreateError( - t('plugin_not_published', '云端市场尚未发布对应的应用插件,请检查内置插件打包配置') - ) - } else if (error instanceof ApiError && error.status === 409) { - setCreateError(t('plugin_device_unavailable', '目标设备当前离线,请连接设备后重试')) - } else if (error instanceof ApiError && error.status === 502) { - setCreateError( - t('plugin_device_sync_failed', '应用插件未能同步到目标设备,请检查设备后重试') - ) - } else if (error instanceof ApplicationPluginSyncConfirmationError) { + handleApplicationPluginError(error) + } finally { + setCreateNotice(null) + setCreatingType(null) + } + } + + const handleContinueDevelopment = async ( + site: Site, + createStrategy: ApplicationCreateStrategy + ) => { + if (continuingSiteId) return + setContinuingSiteId(site.siteid) + setCreateError(null) + try { + const prepared = await prepareApplicationPlugin(createStrategy) + if (!prepared) return + const suffix = t('continue_development_prompt_suffix', '请说出你要做的改动') + const input = buildSiteContinueDevelopmentInput(site, suffix) + if (!input) { setCreateError( - t('plugin_device_sync_failed', '应用插件未能同步到目标设备,请检查设备后重试') + t('plugin_create_configuration_missing', '应用创建插件配置尚未同步,请刷新后重试') ) - } else { - setCreateError(t('plugin_install_failed', '应用插件安装失败,请重试')) + return } + const queued = queuePluginInputTrial(prepared.plugin, input, { + openInNewChat: true, + prompt: `${site.name} ${suffix}`, + }) + if (!queued) { + throw new Error('The installed application plugin cannot be referenced in chat') + } + + notifyLocalPluginSkillsChanged() + setCreateError(null) + setCreateNotice(null) + navigateTo('/') + } catch (error) { + handleApplicationPluginError(error) } finally { setCreateNotice(null) - setCreatingType(null) + setContinuingSiteId(null) } } @@ -531,7 +615,9 @@ export function SitesPage() { navigateTo('/plugins')} From bcfd526fefe6c56a982d013fb0fea9671d13f448 Mon Sep 17 00:00:00 2001 From: huanling1 Date: Wed, 12 Aug 2026 16:42:16 +0800 Subject: [PATCH 2/2] fix(wework): align site editing with current types --- wework/src/components/sites/EditSiteDialog.tsx | 3 ++- wework/src/telemetry/events.ts | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/wework/src/components/sites/EditSiteDialog.tsx b/wework/src/components/sites/EditSiteDialog.tsx index 88f3bde5a1a..f2abb20646b 100644 --- a/wework/src/components/sites/EditSiteDialog.tsx +++ b/wework/src/components/sites/EditSiteDialog.tsx @@ -1,4 +1,5 @@ -import { FormEvent, useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' +import type { FormEvent } from 'react' import { Loader2, Pencil } from 'lucide-react' import type { Site } from '@/api/sites' import { useTranslation } from '@/hooks/useTranslation' diff --git a/wework/src/telemetry/events.ts b/wework/src/telemetry/events.ts index e7567df1022..7d55a734a1e 100644 --- a/wework/src/telemetry/events.ts +++ b/wework/src/telemetry/events.ts @@ -181,6 +181,7 @@ export interface AnalyticsEventMap { | 'open' | 'move' | 'update' + | 'edit' | 'rename' | 'save_grouping' | 'member_invite' @@ -476,6 +477,7 @@ export const ANALYTICS_EVENT_VALUE_CONSTRAINTS: { 'open', 'move', 'update', + 'edit', 'rename', 'save_grouping', 'member_invite',