Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Loading