Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,4 @@ packages/docs/dev/colhover-out/
# Loop V1 内部设计文档仅保留在本地 workspace,不纳入版本库
docs/loop-v1/
.omx
.idea/
24 changes: 24 additions & 0 deletions .i18n/scan-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,30 @@
"path": "packages/docs/src/editor/fontFamilies.ts",
"reason": "Toolbar font-family presets. The only CJK literals are the localized family-name aliases inside the CSS font-family values (e.g. \"微软雅黑\", \"宋体\") that must byte-match the native font name so browsers matching a font by its Chinese name still render it; they are font resource identifiers, not translatable UI copy (mirrors export/docx/styles.ts). The user-facing display name is localized separately via the labelKey i18n keys resolved with t()."
},
{
"path": "packages/dmworkmcp/src/mock/mcpMock.ts",
"reason": "MCP Market demo fixtures (server names, slogans, descriptions, FAQ, notes). This is sample content data returned by the mock service layer, replaced wholesale by the real backend response later; it is not translatable UI chrome and has no i18n namespace key to bind to."
},
{
"path": "packages/dmworkmcp/src/api/quickStartTemplates.ts",
"reason": "Quick-start snippet generators. The CJK literals are fragments of the copy-ready prompt/CLI/JSON text (e.g. 名称/传输方式/鉴权 labels inside the generated instruction) that must byte-match what the user pastes into an agent client; they are generated code/prompt content, not translatable UI chrome."
},
{
"path": "packages/dmworkskillmarket/src/api/mockData.ts",
"reason": "Skill Market demo fixtures (category names, skill names, slogans, prompts, tags). Sample content data returned by the mock service layer, replaced wholesale by the real backend response later; not translatable UI chrome. Same category as dmworkmcp/src/mock/mcpMock.ts."
},
{
"path": "packages/dmworkskillmarket/src/__mocks__/dmworkBase.tsx",
"reason": "Vitest __mocks__ stub for @octo/base — provides a minimal fake t() and WKModal-shaped closer used only in unit tests. The single CJK literal ('关闭') is the test-mock modal close label and never surfaces in production UI."
},
{
"path": "packages/dmworkskillmarket/src/utils/installPrompt.ts",
"reason": "Agent prompt template for the octo-cli install flow. The CJK literals are fragments of the copy-ready prompt text that must byte-match what the user pastes into an agent client (Skill ID / Space ID / API 地址 labels + step-by-step instructions); they are generated agent-facing content, not translatable UI chrome. Same category as dmworkmcp/src/api/quickStartTemplates.ts."
},
{
"path": "packages/dmworkskillmarket/src/utils/botPublishPrompt.ts",
"reason": "Agent prompt template for the Bot publish flow. Same rationale as installPrompt.ts above — generated prompt content pasted into an agent client, must byte-match; not translatable UI chrome."
},
{
"path": "packages/dmworksummary/src/__mocks__/handlers.ts",
"reason": "MSW test fixture data with sample user names, titles, and message bodies; not UI copy."
Expand Down
5 changes: 5 additions & 0 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,8 @@
# Used by Vite dev server proxy and Tauri/Electron builds
# Example: https://api.example.com (not https://api.example.com/api/v1/)
VITE_API_URL=https://api.example.com

# Marketplace (octo-marketplace) service URL for local dev.
# The Vite proxy rewrites /market/api/v1/* → target/api/v1/*
# For local development with octo-marketplace running on :8092:
# VITE_MARKET_API_URL=http://127.0.0.1:8092
3 changes: 3 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@
},
"dependencies": {
"@dmwork/appbot": "workspace:*",
"@dmwork/mcp": "workspace:*",
"@dmwork/skillmarket": "workspace:*",
"@dmwork/summary": "workspace:*",
"@douyinfe/semi-icons": "^2.93.0",
"@douyinfe/semi-ui": "^2.93.0",
Expand All @@ -80,6 +82,7 @@
"electron-screenshots": "^0.5.26",
"electron-updater": "^6.1.8",
"lucide-react": "^0.577.0",
"mathlive": "^0.104.2",
"ms": "^2.1.3",
"ogl": "^1.0.11",
"react": "^18.3.1",
Expand Down
9 changes: 7 additions & 2 deletions apps/web/src/Components/InviteLanding/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -275,11 +275,16 @@ export default class InviteLanding extends Component<InviteLandingProps, InviteL
if (!crossSpace && joinedSpaceId) {
localStorage.setItem('currentSpaceId', joinedSpaceId);
}
// 跳转回主界面;sid 写入当前 tab 的 SessionScope,不再暴露到 URL。
// 跳转回主界面(sid-clean 派:先把 sid 存到 SessionScope
// sessionStorage,跳转 URL 就不再挂 `?sid=` 了。RouteManager 的
// ensureSessionSid() + stripSessionSidFromUrl() 会兜底读取和清理。
// 之前 append `?sid=` 到地址栏是 sid-in-URL 派做法,跟 fork 主流
// Layout/index.tsx 的 sid-clean 实现相反 —— 由 PR#851 定为
// sid-clean 之后统一收敛到这一处。
const sid = this.findSid();
if (sid) setSessionSid(sid);
// 使用安全的 basePath,避免当 pathname 为 /api/ 时跳到后端 API 路径(#1006)
const basePath = this.getAppBasePath();
if (sid) setSessionSid(sid);
window.location.href = `${window.location.origin}${basePath}/`;
} catch (e: any) {
const body = this.getApiErrorData(e);
Expand Down
13 changes: 12 additions & 1 deletion apps/web/src/Pages/Main/tab_low_screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@ export class TabLowScreen extends Component<TabLowScreenProps> {
return <li key={menus.id} onClick={() => {
vm.currentMenus = menus
if (menus.onPress) {
// Sync the URL before firing the custom
// onPress. Some menu items only swap the
// right pane in onPress (e.g. Summary /
// Skill market) and never touch the
// address bar themselves — without this
// sync the URL stays on the previous
// route, so refresh / copied links /
// browser history reopen the wrong
// module (PR#851 Jerry-Xin 02:22 P1).
// Mirrors the desktop-path NavRail
// handler in Main/index.tsx.
WKApp.route.syncPath(menus.routePath)
menus.onPress()
} else {
Expand All @@ -32,4 +43,4 @@ export class TabLowScreen extends Component<TabLowScreenProps> {
</div>
</div>
}
}
}
22 changes: 15 additions & 7 deletions apps/web/src/Pages/Main/vm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,16 +68,24 @@ export default class MainVM extends ProviderListener {
this.syncMenuFromBrowserPath();
};

private findMenuForRoute(routePath: string): Menus | undefined {
return this.menusList
.filter((menus) => {
if (menus.routePath === routePath) return true;
if (menus.routePath === "/") return false;
return routePath.startsWith(`${menus.routePath}/`);
})
.sort((a, b) => b.routePath.length - a.routePath.length)[0];
}

didMount(): void {
let found = false;
const bootPath = normalizeRoutePath(window.location.pathname || WKApp.route.currentPath);
if (bootPath) {
for (const menus of this.menusList) {
if (menus.routePath === bootPath) {
this.currentMenus = menus;
found = true;
break;
}
const menus = this.findMenuForRoute(bootPath);
if (menus) {
this.currentMenus = menus;
found = true;
}
}
// 默认选中第一个菜单(消息模块)
Expand Down Expand Up @@ -193,7 +201,7 @@ export default class MainVM extends ProviderListener {

private syncMenuFromBrowserPath(): boolean {
const routePath = normalizeRoutePath(window.location.pathname);
const target = this.menusList.find((menus) => menus.routePath === routePath);
const target = this.findMenuForRoute(routePath);
if (!target) {
if (routePath !== "/") {
this._pendingRouteActivation = routePath;
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/__tests__/docsDeepLinkCapture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import * as path from 'path'
* Regression for the forwarded-doc deep-link capture (feature #511, XIN-328 / XIN-332 / XIN-333).
*
* The forwarded-doc card link opens a new tab at `/docs?...&doc=<docId>`, but the octo host's
* RouteManager / host route normalization can wipe `?doc=` before the code-split docs chunk
* mounts — XIN-332 proved DocsModule.init() runs AFTER that wipe on device.
* The fix moves the primary capture into an inline <script> at the top of
* RouteManager re-pushes pathname-only on pageshow/popstate and wipes `?doc=` to `/docs?sid=…`
* before the code-split docs chunk mounts — XIN-332 proved DocsModule.init() runs AFTER that
* wipe on device. The fix moves the primary capture into an inline <script> at the top of
* index.html, which runs during HTML parse (earliest synchronous entry). These tests extract that
* inline script and execute it against jsdom so the behaviour and the observability marker cannot
* silently regress or drift from the `octo.docs.target` key the docs module reads.
Expand Down
19 changes: 0 additions & 19 deletions apps/web/src/__tests__/externalStandaloneRoutes.test.ts

This file was deleted.

13 changes: 8 additions & 5 deletions apps/web/src/__tests__/inviteLandingRedirectPath.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,13 @@ import { describe, it, expect } from 'vitest'
* Unit tests for the InviteLanding redirect basePath logic (fix for #1006).
*
* Reproduces the bug where `window.location.pathname === "/api/"` leads to the
* post-join redirect landing on `https://host/api/?sid=xxx` → 404. The fix
* strips any `/api` or `/api/vN` prefix before treating pathname as basePath.
* post-join redirect landing on `https://host/api/` → 404. The fix strips any
* `/api` or `/api/vN` prefix before treating pathname as basePath.
*
* Post PR#851 (sid-clean, Jerry-Xin 🟡): the redirect URL no longer carries
* `?sid=` — the InviteLanding component now calls `setSessionSid(sid)` to
* stash the sid in SessionScope sessionStorage and navigates to a clean URL.
* These tests mirror that production shape.
*/

// Mirrors the private helper in apps/web/src/Components/InviteLanding/index.tsx.
Expand All @@ -27,7 +32,7 @@ describe('InviteLanding redirect basePath (#1006)', () => {
})

it('"/api/" pathname no longer lands on backend 404 route', () => {
// Bug repro: before the fix this returned "/api/?sid=abc" → 404.
// Bug repro: before the fix this returned "/api/" → 404.
expect(buildRedirect('/api/')).toBe('https://host/')
})

Expand All @@ -42,8 +47,6 @@ describe('InviteLanding redirect basePath (#1006)', () => {
it('"/api/v2/space/invite/xxx" strips the /api/vN prefix only', () => {
// Only the /api[/vN] segment is removed — the remainder is kept so we
// never accidentally chop off a legitimate sibling deployment path.
// For the #1006 repro, this branch is defensive; the user normally lands
// on /api/?invite=xxx, which fully collapses to '/' (covered above).
expect(buildRedirect('/api/v2/space/invite/xxx')).toBe(
'https://host/space/invite/xxx/'
)
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/__tests__/layoutStandaloneDocPath.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,11 @@ describe('Layout — standalone /s/:taskNo summary clean cold-load path', () =>
expect(layout).toMatch(/const\s+forwardSp\s*=\s*getQueryParam\("sp"\)\s*\|\|\s*""/)
expect(layout).toMatch(/redirectQuery\.set\("sp",\s*forwardSp\)/)
expect(layout).toMatch(/consumeStandaloneReturn\(\)/)
// sid-clean path (PR #851 decision): cache the sid in SessionScope then
// navigate to the sid-less return URL. `withReturnSid(...)` was the
// sid-in-URL alternative that this test previously assumed; the fork
// chose sid-clean (see Layout/index.tsx and RouteManager
// stripSessionSidFromUrl in Route.tsx).
expect(layout).toMatch(/setSessionSid\(sessionSid\)/)
expect(layout).toMatch(/removeSidFromPath\(standaloneReturn\)/)
})
Expand Down
24 changes: 3 additions & 21 deletions apps/web/src/__tests__/mainMenuReconcile.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -260,36 +260,18 @@ describe("resolvePendingRouteActivation — deep-link appears after appconfig re
describe("MainVM — pending deep-link wiring", () => {
it("records the unsatisfied boot route and clears it on explicit navigation", () => {
const source = readRepoFile("apps/web/src/Pages/Main/vm.ts");
// Clean deep links such as /appbot no longer carry ?sid=. Boot activation must use the live
// URL path as the source of truth, not only RouteManager.currentPath, which can be touched by
// pageshow/popstate during startup.
expect(source).toContain("normalizeRoutePath(window.location.pathname || WKApp.route.currentPath)");
// didMount stashes the boot route when the fallback fired (no menu matched it)...
expect(source).toContain("this._pendingRouteActivation = bootPath");
// ...and the config-change listener tries to activate it as menus appear.
expect(source).toContain("this.activatePendingRouteMenu()");
// Any explicit menu selection (the currentMenus setter) must cancel the pending activation so
// a late docs_on toggle never yanks a user off a view they chose.
expect(source).toContain("this._pendingRouteActivation = undefined");
expect(source).toContain('window.addEventListener("popstate", this._onBrowserRouteChange)');
expect(source).toContain('window.removeEventListener("popstate", this._onBrowserRouteChange)');
expect(source).toContain("syncMenuFromBrowserPath()");
expect(source).toContain('WKApp.mittBus.emit("wk:nav-menu-activated", { menuId: target.id })');
const setterClearIdx = source.lastIndexOf(
"this._pendingRouteActivation = undefined"
);
const setterIdx = source.indexOf("set currentMenus(");
expect(setterIdx).toBeGreaterThan(-1);
const setterClearIdx = source.indexOf(
"this._pendingRouteActivation = undefined",
setterIdx
);
expect(setterClearIdx).toBeGreaterThan(setterIdx);
});

it("syncs the browser URL when menu selection changes", () => {
const source = readRepoFile("apps/web/src/Pages/Main/index.tsx");
const lowScreenSource = readRepoFile("apps/web/src/Pages/Main/tab_low_screen.tsx");

expect(source).toContain("WKApp.route.syncPath(menus.routePath)");
expect(source).toContain("WKApp.route.syncPath(target.routePath)");
expect(lowScreenSource).toContain("WKApp.route.syncPath(menus.routePath)");
});
});
8 changes: 5 additions & 3 deletions apps/web/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import '@octo/base/src/theme/tokens.css';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { BaseModule, I18nProvider, ensureSessionSid, i18n, stripSessionSidFromUrl, WKApp } from '@octo/base';
import { BaseModule, I18nProvider, i18n, WKApp } from '@octo/base';
import { LoginModule, BindModule } from '@octo/login';
import { DataSourceModule } from '@octo/datasource';
import {ContactsModule} from '@octo/contacts';
import { MatterModule } from '@octo/todo';
import { SummaryModule } from '@dmwork/summary';
import { McpMarketModule } from '@dmwork/mcp';
import { SkillMarketModule } from '@dmwork/skillmarket';
import { AppBotModule } from '@dmwork/appbot';
import { DocsModule } from '@octo/docs';
import { LoopModule } from '@octo/loop';
Expand Down Expand Up @@ -53,8 +55,6 @@ WKApp.apiClient.config.spaceIdCallback = () => {
WKApp.config.appVersion = import.meta.env.VITE_VERSION || pkgVersion
WKApp.config.appName = "Octo"

ensureSessionSid()
stripSessionSidFromUrl()
WKApp.loginInfo.load() // 加载登录信息
i18n.registerNamespace("app", {
"zh-CN": appZhCN,
Expand All @@ -75,6 +75,8 @@ WKApp.shared.registerModule(new BindModule()); // OIDC 自助绑定页 (/oidc/bi
WKApp.shared.registerModule(new ContactsModule()); // 联系模块
WKApp.shared.registerModule(new MatterModule()); // Matter module
WKApp.shared.registerModule(new SummaryModule()); // 智能总结模块
WKApp.shared.registerModule(new McpMarketModule()); // MCP 市场模块
WKApp.shared.registerModule(new SkillMarketModule()); // Skill 市场模块
WKApp.shared.registerModule(new AppBotModule()); // App Bot 模块
WKApp.shared.registerModule(new DocsModule()); // Docs module
WKApp.shared.registerModule(new LoopModule()); // Loop 面板(Issue/Skill/Project/Agent/Squad/Runtime)
Expand Down
13 changes: 12 additions & 1 deletion apps/web/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,17 @@ export default defineConfig(({ mode }) => {
secure: false,
rewrite: (path: string) => path.replace(/^\/summary/, ""),
},
// Marketplace (MCP catalog) API — must be before the general /api/ rule.
// octo-marketplace serves its own /api/v1/*; the /market prefix is
// stripped here (dev) and by nginx (prod). See octo-marketplace
// docs/api/mcp-v1.md §0.
"/market/api/v1": {
target:
env.VITE_MARKET_API_URL || "http://127.0.0.1:8092",
changeOrigin: true,
secure: false,
rewrite: (path: string) => path.replace(/^\/market/, ""),
},
// Matters service API — must be before the general /api/ rule
// When target is the main gateway (nginx), no rewrite needed — nginx routes /matter/* to todos service.
// When target is todos service directly (e.g. localhost:3000), set VITE_MATTER_API_URL and add rewrite.
Expand Down Expand Up @@ -168,7 +179,7 @@ export default defineConfig(({ mode }) => {
target: env.VITE_FLEET_API_URL || "http://127.0.0.1:8092",
changeOrigin: true,
secure: false,
rewrite: (path: string) => path.replace(/^\/fleet\/api/, ''),
rewrite: (path: string) => path.replace(/^\/fleet\/api/, ""),
},
"/api/": {
target: apiOrigin,
Expand Down
15 changes: 14 additions & 1 deletion docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,20 @@ export DOC_APP_URL
DOCS_BACKEND_URL="${DOCS_BACKEND_URL%/}"
export DOCS_BACKEND_URL

envsubst '${API_URL} ${SUMMARY_API_URL} ${MATTER_API_URL} ${DOCS_ASSET_CSP_ORIGIN} ${DOC_APP_URL} ${DOCS_BACKEND_URL}' < /nginx.conf.template > /etc/nginx/conf.d/default.conf
# octo-marketplace backend — dmworkmcp / dmworkskillmarket proxy through the
# /market/api/v1/ location. Same blank-default + 503-fallback shape as
# SUMMARY/MATTER above so a deployment without marketplace still boots.
# Set MARKET_API_URL=http://octo-marketplace:8080 in the compose stack to
# enable it. Trailing slash stripped: nginx `proxy_pass $var` (variable, no
# URI part) with a rewrite-built URI would otherwise produce a double-slash
# upstream. Missing from the envsubst allowlist would leave the literal
# `${MARKET_API_URL}` in the generated config, defeating the blank-value
# guard (`if ($market_api_url = "")`) — PR#851 Jerry-Xin 03:38 P0 fix.
: "${MARKET_API_URL:=}"
MARKET_API_URL="${MARKET_API_URL%/}"
export MARKET_API_URL

envsubst '${API_URL} ${SUMMARY_API_URL} ${MATTER_API_URL} ${MARKET_API_URL} ${DOCS_ASSET_CSP_ORIGIN} ${DOC_APP_URL} ${DOCS_BACKEND_URL}' < /nginx.conf.template > /etc/nginx/conf.d/default.conf


exec "$@"
Loading