minecodex 2.0.5 → 2.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +19 -0
- package/README.md +5 -1
- package/apps/dashboard/DESIGN.md +53 -31
- package/apps/dashboard/app.js +55 -39
- package/apps/dashboard/assets/OFL.txt +93 -0
- package/apps/dashboard/assets/info.svg +15 -0
- package/apps/dashboard/assets/moon.svg +13 -0
- package/apps/dashboard/assets/refresh-cw.svg +16 -0
- package/apps/dashboard/assets/silkscreen.ttf +0 -0
- package/apps/dashboard/assets/sun.svg +21 -0
- package/apps/dashboard/assets/terrain.js +89 -0
- package/apps/dashboard/assets/triangle-alert.svg +15 -0
- package/apps/dashboard/favicon.svg +12 -17
- package/apps/dashboard/index.html +24 -18
- package/apps/dashboard/styles.css +147 -181
- package/assets/brand/README.md +19 -0
- package/assets/brand/minecodex-icon-dark.svg +9 -0
- package/assets/brand/minecodex-icon-light.svg +9 -0
- package/assets/brand/minecodex-icon.svg +8 -0
- package/assets/brand/references/lucide-LICENSE.txt +43 -0
- package/assets/readme/hero.svg +93 -0
- package/features/hide-upsell-banner/README.md +7 -5
- package/features/hide-upsell-banner/codex-feature.json +2 -2
- package/features/hide-upsell-banner/src/hide-upsell-banner.mjs +30 -12
- package/features/images/DESIGN.md +18 -3
- package/features/images/README.md +4 -3
- package/features/images/codex-feature.json +2 -2
- package/features/images/src/adapters/codex/native-image-modules.mjs +220 -0
- package/features/images/src/adapters/codex/native-image-source.mjs +113 -198
- package/features/images/src/assets/prompts-menu.svg +15 -0
- package/features/images/src/frontend/cloud-image-actions.mjs +8 -6
- package/features/images/src/frontend/local-image-source.mjs +2 -1
- package/features/images/src/library-prompt-page.mjs +10 -4
- package/features/images/src/library-prompt-source.mjs +4 -5
- package/features/model-slider/codex-feature.json +2 -2
- package/features/notes/DESIGN.md +1 -1
- package/features/notes/codex-feature.json +2 -2
- package/features/notes/src/frontend/entry.mjs +1 -1
- package/features/session-status/codex-feature.json +2 -2
- package/features/sidebar-focus/DESIGN.md +4 -4
- package/features/sidebar-focus/codex-feature.json +3 -2
- package/features/sidebar-focus/src/adapters/codex/renderer.mjs +1 -1
- package/features/sidebar-focus/src/sidebar-focus.mjs +9 -7
- package/features/sites/DESIGN.md +25 -3
- package/features/sites/README.md +6 -2
- package/features/sites/codex-feature.json +3 -2
- package/features/sites/src/adapters/codex/renderer.mjs +25 -4
- package/features/sites/src/backend/project-sites.mjs +18 -6
- package/features/sites/src/backend/sites-service.mjs +22 -10
- package/features/sites/src/domain/discovery-candidates.mjs +53 -0
- package/features/sites/src/frontend/entry.mjs +76 -38
- package/features/sites/src/frontend/styles.css +3 -3
- package/features/unread-inbox/DESIGN.md +24 -25
- package/features/unread-inbox/codex-feature.json +3 -2
- package/features/unread-inbox/src/adapters/codex/modules.mjs +52 -0
- package/features/unread-inbox/src/adapters/codex/renderer.mjs +90 -0
- package/features/unread-inbox/src/domain/notifications.mjs +21 -0
- package/features/unread-inbox/src/injection-source.mjs +5 -2
- package/features/unread-inbox/src/unread-inbox.mjs +212 -297
- package/features/usage-meter/codex-feature.json +2 -2
- package/package.json +11 -3
- package/packages/cli/native/MineCodex.app/Contents/Info.plist +4 -2
- package/packages/cli/native/MineCodex.app/Contents/MacOS/MineCodexServiceManager +0 -0
- package/packages/cli/native/MineCodex.app/Contents/Resources/MineCodex.icns +0 -0
- package/packages/cli/native/MineCodex.app/Contents/_CodeSignature/CodeResources +11 -0
- package/packages/cli/native/README.md +31 -0
- package/packages/cli/src/browser-control-panel.mjs +48 -0
- package/packages/cli/src/commands.mjs +3 -1
- package/packages/cli/src/control-server.mjs +6 -3
- package/packages/cli/src/platform.mjs +5 -0
- package/packages/runtime-host/README.md +10 -5
- package/packages/runtime-host/src/codex-injection.mjs +12 -3
- package/packages/runtime-host/src/codex-runtime.mjs +10 -0
- package/packages/ui/DESIGN.md +5 -1
- package/packages/ui/assets/arrow-up-right.svg +13 -0
- package/packages/ui/assets/lucide-LICENSE.txt +43 -0
- package/packages/ui/assets/minecodex-menu-16.svg +7 -0
- package/packages/ui/assets/minecodex-menu-20.svg +7 -0
- package/packages/ui/src/components/account-menu.mjs +88 -0
- package/packages/ui/src/components/page.mjs +2 -2
- package/packages/ui/src/foundation/layers.mjs +1 -0
- package/packages/ui/src/install.mjs +3 -0
- package/packages/ui/src/native/account-menu.mjs +21 -0
- package/packages/ui/src/native/page.mjs +17 -2
- package/packages/ui/src/native/tokens.mjs +1 -0
|
@@ -3,6 +3,7 @@ const PANEL_ID = "minecodex-library-prompt";
|
|
|
3
3
|
const zh = () => (document.documentElement.lang || navigator.language || "en").toLowerCase().startsWith("zh");
|
|
4
4
|
const label = (en, cn) => zh() ? cn : en;
|
|
5
5
|
let mounted = null;
|
|
6
|
+
let localImageSource = null;
|
|
6
7
|
const libraryApi=createLibraryApi();
|
|
7
8
|
let scheduled = false;
|
|
8
9
|
let candidate = null;
|
|
@@ -71,12 +72,13 @@ function syncMenu(record) {
|
|
|
71
72
|
item.dataset.minecodexLibraryPrompt = "menuitem";
|
|
72
73
|
const row = original.firstElementChild.cloneNode(true);
|
|
73
74
|
row.querySelector(".truncate").textContent = label("View prompts", "查看提示词");
|
|
74
|
-
//
|
|
75
|
-
const glyph =
|
|
76
|
-
|
|
75
|
+
// 图标随 Images 注入,不依赖 Prompts 导航入口是否显示。
|
|
76
|
+
const glyph = new DOMParser().parseFromString(promptGlyphSource,"image/svg+xml").documentElement;
|
|
77
|
+
for (const svg of row.querySelectorAll("svg")) {
|
|
77
78
|
const icon = glyph.cloneNode(true);
|
|
78
79
|
icon.setAttribute("width", svg.getAttribute("width"));
|
|
79
80
|
icon.setAttribute("height", svg.getAttribute("height"));
|
|
81
|
+
icon.setAttribute("class", svg.getAttribute("class") ?? "");
|
|
80
82
|
svg.replaceWith(icon);
|
|
81
83
|
}
|
|
82
84
|
item.append(row);
|
|
@@ -257,6 +259,9 @@ function localPromptRequested(){openPendingLocalPrompt();void scan();}
|
|
|
257
259
|
document.addEventListener("minecodex:image-prompt",localPromptRequested);
|
|
258
260
|
const localeObserver = new MutationObserver(() => mounted?.refreshLocale());
|
|
259
261
|
localeObserver.observe(document.documentElement, { attributes:true, attributeFilter:["lang"] });
|
|
262
|
+
localImageSource = installLocalImageSource(config, signal, createImageSourcesAdapter(config.codex), createNativeImageSource(config.codex, {
|
|
263
|
+
requestHostAction:config.requestHostAction, libraryApi, createCloudImageActions, promptGlyphSource, signal,
|
|
264
|
+
}));
|
|
260
265
|
const observer = new MutationObserver(() => {
|
|
261
266
|
if (scheduled) return;
|
|
262
267
|
scheduled = true;
|
|
@@ -270,7 +275,8 @@ function escape(event) {
|
|
|
270
275
|
closePanel(mounted, true);
|
|
271
276
|
}
|
|
272
277
|
document.addEventListener("keydown", escape, true);
|
|
273
|
-
|
|
278
|
+
let cleaned = false;
|
|
279
|
+
const cleanup = () => {if(cleaned)return;cleaned=true;document.removeEventListener("minecodex:image-prompt",localPromptRequested);candidate?.request.abort();candidate=null;observer.disconnect();localeObserver.disconnect();document.removeEventListener("keydown",escape,true);unmount();localImageSource.cleanup();};
|
|
274
280
|
signal.addEventListener("abort", cleanup, { once: true });
|
|
275
281
|
scan();
|
|
276
282
|
return cleanup;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createCloudImageActions } from "./frontend/cloud-image-actions.mjs";
|
|
2
2
|
import { createImageSourcesAdapter } from "./adapters/codex/image-sources.mjs";
|
|
3
3
|
import { createNativeImageSource } from "./adapters/codex/native-image-source.mjs";
|
|
4
|
+
import {createNativeImageModules, discoverFilePreviewComposerBindings} from "./adapters/codex/native-image-modules.mjs";
|
|
4
5
|
import { installLocalImageSource } from "./frontend/local-image-source.mjs";
|
|
5
6
|
import { createLibraryApi } from "./adapters/codex/library-api.mjs";
|
|
6
7
|
import { createLibraryViewerAdapter } from "./adapters/codex/library-viewer.mjs";
|
|
@@ -9,10 +10,8 @@ import { promptResolverSource } from "./adapters/codex/prompt-resolver-source.mj
|
|
|
9
10
|
|
|
10
11
|
export async function createPageScriptSource() {
|
|
11
12
|
const source=await readFile(new URL("./library-prompt-page.mjs",import.meta.url),"utf8");
|
|
12
|
-
const
|
|
13
|
+
const promptGlyphSource=await readFile(new URL("./assets/prompts-menu.svg",import.meta.url),"utf8");
|
|
14
|
+
const functions={createCloudImageActions,createImageSourcesAdapter,discoverFilePreviewComposerBindings,createNativeImageModules,createNativeImageSource,installLocalImageSource,createLibraryApi,createLibraryViewerAdapter};
|
|
13
15
|
const declarations=Object.entries(functions).map(([name,fn])=>`const ${name}=${fn.toString()};`).join("\n");
|
|
14
|
-
return `${declarations}
|
|
15
|
-
let localImageSource=null;
|
|
16
|
-
const promptFromConversation=${promptResolverSource()};
|
|
17
|
-
${source.replace("const observer =", "localImageSource=installLocalImageSource(config,signal,createImageSourcesAdapter(config.codex),createNativeImageSource(config.codex,{requestHostAction:config.requestHostAction,libraryApi:createLibraryApi(),createCloudImageActions}));\nconst observer =").replace("return cleanup;", "return () => { cleanup(); localImageSource.cleanup(); };")}`;
|
|
16
|
+
return `${declarations}\nconst promptGlyphSource=${JSON.stringify(promptGlyphSource)};\nconst promptFromConversation=${promptResolverSource()};\n${source}`;
|
|
18
17
|
}
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
"zh-CN": "模型滑条"
|
|
8
8
|
},
|
|
9
9
|
"description": {
|
|
10
|
-
"en": "
|
|
11
|
-
"zh-CN": "
|
|
10
|
+
"en": "Switch models and thinking levels. Keep favorites within reach.",
|
|
11
|
+
"zh-CN": "切换模型与思考强度,快速找到常用收藏。"
|
|
12
12
|
},
|
|
13
13
|
"entry": {
|
|
14
14
|
"kind": "composer-model-selector"
|
package/features/notes/DESIGN.md
CHANGED
|
@@ -54,7 +54,7 @@ Header 为 Prompts;右侧依次为排序图标、分类调节图标、常驻
|
|
|
54
54
|
|
|
55
55
|
拖拽排序使用等高 Drop Placeholder,拖影跟随指针并显式按源行圆角裁切,不留下矩形外边界;同组条目以 180ms ease-out 让位,落位淡入 140ms。放到范围外或取消恢复原位,不写入排序。遵循 prefers-reduced-motion。
|
|
56
56
|
|
|
57
|
-
页面选择通用 Page 的 `sidebar:"none"` 分支,自身不带常驻二级侧栏,保留原生 Home、Space、Scheduled sidebar
|
|
57
|
+
页面选择通用 Page 的 `sidebar:"none"` 分支,自身不带常驻二级侧栏,保留原生 Home、Space、Scheduled sidebar 的悬浮叠加与导航行为。关闭页面恢复原页面位置,不改变用户的侧栏展开偏好。原生二级入口(如 Space → Images)提交导航后,通过共享 Page 的 `onNavigate` 关闭 Prompts、编辑浮层并释放底层 inert;保留用户刚选择的目标路由。仅悬浮展开侧栏不退出 Prompts。
|
|
58
58
|
|
|
59
59
|
提示词编辑沿用原生 Project 的复合标题输入:40px 图标段位于标题前,细分隔线,共用圆角边框与 focus-within 状态。图标选择入口不再单独占一行;Title 通过 aria-label 标识。
|
|
60
60
|
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
"zh-CN": "提示词"
|
|
8
8
|
},
|
|
9
9
|
"description": {
|
|
10
|
-
"en": "Organize prompts
|
|
11
|
-
"zh-CN": "
|
|
10
|
+
"en": "Organize reusable prompts and insert them with one click.",
|
|
11
|
+
"zh-CN": "整理常用提示词,一键填入输入框。"
|
|
12
12
|
},
|
|
13
13
|
"surfaceUrl": "http://127.0.0.1:47832/",
|
|
14
14
|
"healthUrl": "http://127.0.0.1:47832/api/health",
|
|
@@ -365,7 +365,7 @@ function openPage() {
|
|
|
365
365
|
pageFocus = document.activeElement;
|
|
366
366
|
page = element("section");
|
|
367
367
|
page.setAttribute(MARKER, "page");
|
|
368
|
-
config.ui.page(page, { sidebar:"none" });
|
|
368
|
+
config.ui.page(page, { sidebar:"none", onNavigate:() => closePage(false, false) });
|
|
369
369
|
page.setAttribute("aria-label", text("Prompts", "提示词"));
|
|
370
370
|
document.body.append(page);
|
|
371
371
|
// 只隔离主内容的焦点,保留导航栏与原生窗口控件。
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
"version": "0.1.0",
|
|
5
5
|
"label": { "en": "Session Status", "zh-CN": "会话状态" },
|
|
6
6
|
"description": {
|
|
7
|
-
"en": "
|
|
8
|
-
"zh-CN": "
|
|
7
|
+
"en": "See response speed, token usage and context space.",
|
|
8
|
+
"zh-CN": "查看输出速度、Token 用量与上下文占用。"
|
|
9
9
|
},
|
|
10
10
|
"entry": { "kind": "page-script", "script": "src/session-status.mjs", "sourceFactory": "src/injection-source.mjs" },
|
|
11
11
|
"icon": {
|
|
@@ -5,13 +5,13 @@
|
|
|
5
5
|
保留当前任务所在分类与 Project,收起其他分支。再次点击恢复进入聚焦前的展开状态;聚焦期间切换任务跟随路径。用户手动调整分支时退出聚焦并保留手动布局,无撤销浮层。
|
|
6
6
|
|
|
7
7
|
- 入口:展开侧栏的 Activity 与 Search 之间,随原生侧栏一起隐藏。
|
|
8
|
-
-
|
|
9
|
-
- 控件:继承原生 Activity 的 Button 类与 28px
|
|
10
|
-
- 中英文标题:Focus current task / 聚焦当前任务;
|
|
8
|
+
- 图标:整理操作使用 Lucide list-chevrons-down-up;可恢复布局时切换为 list-chevrons-up-down。16px,24px viewBox,描边 1.575 units(实际 1.05px)。
|
|
9
|
+
- 控件:继承原生 Activity 的 Button 类与 28px 热区。两种状态均使用 secondary / ghost,以图标表达下一步动作,不显示蓝色模式选中态;aria-pressed 保留可恢复状态的可访问语义。
|
|
10
|
+
- 中英文标题:Focus current task / 聚焦当前任务;Restore layout / 恢复布局。
|
|
11
11
|
- 折叠通过原生回调执行;React 元数据仅用于读取稳定分类、项目和会话身份。
|
|
12
12
|
- 无可识别当前路径时禁用;Activity 列表展示期间暂停整理。
|
|
13
13
|
- 状态仅作用于当前窗口;卸载不额外改变用户布局。
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
显式使用中性 data-color 与 data-variant,直接复用原生语义 token 适配明暗模式,不写死颜色或背景色。Activity 开启时按钮原生 disabled,暂停聚焦操作,关闭后恢复可用性。
|
|
16
16
|
|
|
17
17
|
Tooltip 由共享组件库通过 `data-minecodex-tooltip` 管理;插件只提供随聚焦状态变化的文案,不维护独立材质、定位或显示事件。克隆入口时移除原控件的 `title` 与 `aria-describedby`。
|
|
@@ -7,14 +7,15 @@
|
|
|
7
7
|
"zh-CN": "侧栏聚焦"
|
|
8
8
|
},
|
|
9
9
|
"description": {
|
|
10
|
-
"en": "
|
|
11
|
-
"zh-CN": "
|
|
10
|
+
"en": "Fold unrelated branches to keep the current task in view.",
|
|
11
|
+
"zh-CN": "收起无关分支,专注当前任务路径。"
|
|
12
12
|
},
|
|
13
13
|
"entry": {
|
|
14
14
|
"kind": "page-script",
|
|
15
15
|
"script": "src/sidebar-focus.mjs",
|
|
16
16
|
"sourceFactory": "src/injection-source.mjs"
|
|
17
17
|
},
|
|
18
|
+
"icon": {"viewBox": "0 0 24 24", "markup": "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\" />\n <path d=\"M9 3v18\" />"},
|
|
18
19
|
"placement": {
|
|
19
20
|
"order": 80
|
|
20
21
|
}
|
|
@@ -24,5 +24,5 @@ function target(items) {
|
|
|
24
24
|
|| items.find(b => b.key.startsWith("section:") && (selected && b.element.contains(selected) || project && b.element.contains(project.element)));
|
|
25
25
|
return section ? { section, project } : null;
|
|
26
26
|
}
|
|
27
|
-
return {branches,target};
|
|
27
|
+
return {branches,target,isBranchToggle:element=>!!element.closest("[data-app-action-sidebar-section-toggle], " + projectSelector)};
|
|
28
28
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const nativeFocus = createFocusAdapter(config.codex);
|
|
2
|
-
const
|
|
2
|
+
const COLLAPSE_GLYPH = "<svg aria-hidden=\"true\"\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"16\"\n height=\"16\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"1.575\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n>\n <path d=\"M3 5h8\" />\n <path d=\"M3 12h8\" />\n <path d=\"M3 19h8\" />\n <path d=\"m15 5 3 3 3-3\" />\n <path d=\"m15 19 3-3 3 3\" />\n</svg>\n";
|
|
3
|
+
const EXPAND_GLYPH = "<svg aria-hidden=\"true\"\n xmlns=\"http://www.w3.org/2000/svg\"\n width=\"16\"\n height=\"16\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"1.575\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n>\n <path d=\"M3 5h8\" />\n <path d=\"M3 12h8\" />\n <path d=\"M3 19h8\" />\n <path d=\"m15 8 3-3 3 3\" />\n <path d=\"m15 16 3 3 3-3\" />\n</svg>\n";
|
|
3
4
|
|
|
4
5
|
|
|
5
6
|
const states = new Map();
|
|
@@ -22,7 +23,7 @@ function reconcile() {
|
|
|
22
23
|
button.setAttribute("data-minecodex-focus", "");
|
|
23
24
|
// 复用原生内层容器,由其负责图标在热区内居中。
|
|
24
25
|
const inner = bell.firstElementChild.cloneNode(false);
|
|
25
|
-
inner.innerHTML =
|
|
26
|
+
inner.innerHTML = COLLAPSE_GLYPH;
|
|
26
27
|
button.replaceChildren(inner);
|
|
27
28
|
button.removeAttribute("title");
|
|
28
29
|
button.removeAttribute("aria-describedby");
|
|
@@ -38,16 +39,17 @@ function reconcile() {
|
|
|
38
39
|
}
|
|
39
40
|
anchor.after(button);
|
|
40
41
|
}
|
|
41
|
-
const label = active ? text("
|
|
42
|
+
const label = active ? text("Restore layout", "恢复布局") : text("Focus current task", "聚焦当前任务");
|
|
42
43
|
if (button.getAttribute("aria-label") !== label) button.setAttribute("aria-label", label);
|
|
43
44
|
button.setAttribute("data-minecodex-tooltip", label);
|
|
44
45
|
const pressed = String(active);
|
|
45
46
|
if (button.getAttribute("aria-pressed") !== pressed) {
|
|
46
47
|
button.setAttribute("aria-pressed", pressed);
|
|
48
|
+
button.firstElementChild.innerHTML = active ? EXPAND_GLYPH : COLLAPSE_GLYPH;
|
|
47
49
|
}
|
|
48
|
-
//
|
|
49
|
-
button.setAttribute("data-color",
|
|
50
|
-
button.setAttribute("data-variant",
|
|
50
|
+
// 图标表达下一步操作;保持中性按钮,不显示持续模式的选中底色。
|
|
51
|
+
button.setAttribute("data-color", "secondary");
|
|
52
|
+
button.setAttribute("data-variant", "ghost");
|
|
51
53
|
const items = nativeFocus.branches(), path = nativeFocus.target(items);
|
|
52
54
|
const activityActive = bell.getAttribute("aria-pressed") === "true";
|
|
53
55
|
button.disabled = activityActive || (!active && !restoring && !path);
|
|
@@ -74,7 +76,7 @@ function schedule() {
|
|
|
74
76
|
}
|
|
75
77
|
document.addEventListener("click", event => {
|
|
76
78
|
if (!active || !event.isTrusted) return;
|
|
77
|
-
if (event.target
|
|
79
|
+
if (nativeFocus.isBranchToggle(event.target)) {
|
|
78
80
|
active = false; states.clear(); schedule();
|
|
79
81
|
}
|
|
80
82
|
}, { capture:true, signal });
|
package/features/sites/DESIGN.md
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
## 用户路径
|
|
8
8
|
|
|
9
|
-
Sites 标题工具区增加次要操作 Add site,保留官方
|
|
9
|
+
Sites 标题工具区增加次要操作 Add site,保留官方 New。添加弹窗默认展示 Codex 本地项目中的网页入口候选(含未启动网站),不显示重复的项目分组标题,项目名称仅在候选次级文本呈现;候选使用 checkbox 多选,点击整行切换勾选;已收录项不再出现在候选列表中。两个模式底部统一提供 Cancel / Add,项目模式未选择时禁用 Add,点击后才批量收录;取消不保存选择。候选不自动收录;扫描不启动服务。打开已收录的未启动站点时,使用支持的启动方式按需启动。弹窗使用「Project / Custom」分段按钮切换;切换保留已填写的网址;项目模式自动扫描并直接展示候选,不显示搜索框、说明行或刷新按钮。「自定义网址」只提供网址和名称,默认作为外部网址收录,在无项目聊天中打开;不显示开发来源及说明。网址与名称均必填;名称不能仅含空白且最多160字符,网址须为不含嵌入凭证的有效 HTTP(S) 地址。不符合时禁用 Add,输入变化后实时更新。项目关联自动来自扫描,不在编辑弹窗暴露。
|
|
10
10
|
|
|
11
|
-
通过 MineCodex
|
|
11
|
+
通过 MineCodex 添加的条目直接追加在 Space → Sites 的原生列表/网格中,保留官方条目,共用原生搜索与列表/网格切换,不提供额外筛选 tabs。标题下只显示主机地址与端口;访问列用图标 + Local address / External site,沿用原生 16px 图标、主文字色、text-sm 与 gap-1.5。Local 和 External 同时展示,访问列区分地址类型。操作列复用原生外部箭头,在默认浏览器打开;Project 条目菜单只提供移除,Custom URL 可编辑网址与名称,沿用添加校验。移除只移除入口。
|
|
12
12
|
|
|
13
13
|
## 原生 token 与组件
|
|
14
14
|
|
|
@@ -17,7 +17,7 @@ Sites 标题工具区增加次要操作 Add site,保留官方 Create。添加
|
|
|
17
17
|
- 按钮:复用原生 `_Button` 与 `_ButtonInner` 类以及 data-color / data-variant / data-size 状态接口。Add site 为 secondary outline;确认沿用 primary solid。
|
|
18
18
|
- 弹窗:选择通用 Dialog 的 opaque 分支(Sites Share),最大宽560px,不加独立操作区分隔线;当前通过 `config.ui` 接入。通用容器、遮罩、标题、Close及焦点契约遵循根规范。
|
|
19
19
|
- 分段按钮:使用原生 segmented-control 的统一底板、等宽选项及选中块,复用 gap/gutter/background/font-weight/thumb-background/thumb-shadow/option-highlight token;控件尺寸为 control-size-md,支持方向键切换与选中状态语义。
|
|
20
|
-
- 图标:从原生 Sites、
|
|
20
|
+
- 图标:从原生 Space 下的 Sites、New、行菜单节点克隆 SVG;不重画。扫描结果和添加后的条目异步获取真实网页截图,本地缓存;完成后替换占位。没有截图时保留原生 Sites 图标,不阻塞选择和添加。
|
|
21
21
|
|
|
22
22
|
## 打开与布局
|
|
23
23
|
|
|
@@ -58,3 +58,25 @@ Custom 地址分类:localhost/回环、IPv4私有网段及链路本地、IPv6
|
|
|
58
58
|
|
|
59
59
|
新版 Sites 位于 `/space/sites`;注入与离开页面的布局判断使用该路由,移除旧 `/sites` 判断。
|
|
60
60
|
标题区通过 New 的语义定位创建入口;按钮容器复用原生 lg secondary Button,不再假设 New 提供 data-variant。
|
|
61
|
+
|
|
62
|
+
2026-10-02 隔离原生页面验证:新版已移除 Site ownership 筛选组;注入不依赖该组,也不隐藏官方条目。仅在原生列表为空且存在匹配的本地站点时隐藏原生空状态,保留头部与工具区;搜索无匹配或卸载时恢复。空状态定位排除本插件节点。
|
|
63
|
+
|
|
64
|
+
添加候选行使用 flex 横排:96×60 预览、可收缩的标题/项目名、16px checkbox,间距12px;表单 label 的块布局不应用于候选行。New 的 SVG 带桌面隐藏类,克隆为 Add site 图标时移除该显示限制。
|
|
65
|
+
|
|
66
|
+
## 添加候选筛选
|
|
67
|
+
|
|
68
|
+
Project 模式保持单一完整列表,按标题首字母排序(忽略大小写、数字自然顺序),同名按项目名与路径稳定排序;不设推荐、数量上限、筛选 tabs 或版本展开。
|
|
69
|
+
|
|
70
|
+
同一项目、相同归一目录的迭代仅展示一个入口:识别目录段或后缀中的 v/ver/version/rev/revision 数字版本,以及 final/latest/copy/backup/old 副本标记,也识别 name-1.2.0 形式的发布目录。明确版本号最高者优先;无版本号或版本相同时按入口文件修改时间选取。修改时间只用于内部选择,不作为运行或访问时间。不同目录和项目不因标题相同而合并。此规则作用于当前已支持的站点入口,不额外枚举任意 HTML 文件。
|
|
71
|
+
|
|
72
|
+
排除明确自测/证据目录(test/tests、__tests__、fixtures、test-results、playwright-report、coverage、agent-tests/checks/notes/reports、self-test)及 docs/research、internal-notes、implementation-notes;明确自测报告/实现说明标题同样排除。带 SKILL.md 的技能目录下 demo/example/examples/preview 属于技能说明示例,同样排除;普通项目的 docs、demo、prototype 保留。历史记录仅补充当前磁盘入口或在线服务,失效历史记录从发现缓存清除;不删除用户文件或已保存站点。
|
|
73
|
+
|
|
74
|
+
已添加的 entryId、相同 URL 或同一迭代组不再呈现为候选;后端仍保留完整有效入口供已保存站点启动与解析,不自动迁移用户之前保存的版本。全部已添加时显示“暂无可添加的项目网页”。批量添加部分成功时,成功项从候选中移除,失败项保留选择。
|
|
75
|
+
|
|
76
|
+
## 浏览器快捷添加
|
|
77
|
+
|
|
78
|
+
Codex 26.930.21537 隔离窗口验证:浏览器 panel 的 More(Browser options)菜单在 Downloads 下方插入 Add to Sites,复用原生菜单行样式;保持 History 等原生动作及 Escape 行为。方向键可经过注入项,Enter/Space 可激活,卸载移除入口。
|
|
79
|
+
|
|
80
|
+
通过菜单所属浏览器的原生状态读取实时 URL、标题及本地 cwd,不读取地址栏草稿,也不使用其他选中标签的缓存。仅支持无嵌入凭证的 HTTP(S) 页面。点击后预填添加弹窗,沿用 Project / Custom 分段选择与网址、名称校验。已确认本地项目归属的本地地址默认 Project;其余默认 Custom。有本地项目上下文时可切换 Project,无归属时禁用该项。远程浏览器路径不映射为本机项目。
|
|
81
|
+
|
|
82
|
+
保存 Project 使用已确认项目身份,Custom 使用独立网址身份;复用既有重复检测。保存或取消后仍留在浏览器,不跳转 Sites,不发送聊天消息。原生 browserState、菜单动作及关闭协议仅由 Sites Adapter 解释,后端负责校验 URL 和本机项目目录归属。
|
package/features/sites/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# Local & Self-hosted Sites
|
|
2
2
|
|
|
3
|
-
Extends the native Sites page. **Add site** discovers website entries from local Codex project files, including stopped sites, and supplements them with running HTML services. Select candidates with checkboxes and confirm with Add, or use Custom URL to enter an HTTP/HTTPS URL and name. Both modes provide Cancel / Add; Add stays disabled until the selection or required fields are valid.
|
|
3
|
+
Extends the native Sites page. **Add site** discovers website entries from local Codex project files, including stopped sites, and supplements them with running HTML services. Candidates form one alphabetical list: recognized iterations show only the latest, already-added sites are omitted, and clear agent test/report artifacts and invalid discovery history are excluded. Select candidates with checkboxes and confirm with Add, or use Custom URL to enter an HTTP/HTTPS URL and name. Both modes provide Cancel / Add; Add stays disabled until the selection or required fields are valid.
|
|
4
|
+
|
|
5
|
+
**Browser shortcut:** open the browser panel’s More menu and choose **Add to Sites** below Downloads. URL and name are prefilled from that browser tab. Choose Project (when local project context is available) or Custom and confirm; the browser stays open. Local addresses with a confirmed project default to Project; other pages default to Custom. Only HTTP(S) URLs without embedded credentials are accepted.
|
|
4
6
|
|
|
5
7
|
- Project: opens a native new draft in that project.
|
|
6
8
|
- Projectless chat: reopens the original conversation.
|
|
@@ -22,6 +24,8 @@ Discovery never starts services. Opening a saved stopped site can start a suppor
|
|
|
22
24
|
|
|
23
25
|
## Native UI boundary
|
|
24
26
|
|
|
25
|
-
The renderer owns `data-mcx-sites` elements
|
|
27
|
+
The renderer owns `data-mcx-sites` elements. Saved local sites join the native Space → Sites list/grid and search without additional filter tabs. Native empty states are suppressed only while matching local results are displayed and restored on cleanup. It uses native Sites layout classes, current semantic theme tokens, and the existing Prompts dialog/menu patterns. The route-state and resize adapters depend on native renderer internals and must be verified against app updates. Icon sources: native Sites controls, existing Prompts resources, and the Lucide monitor icon; Lucide license is included in `vendor/`.
|
|
26
28
|
|
|
27
29
|
弹窗与浮层统一调用 UI 组件库提供的 `config.ui`(由 Runtime Host 注入),共享主题材质、标题、Close 和表单操作区;插件只维护内容及交互。
|
|
30
|
+
|
|
31
|
+
Iteration selection uses explicit numeric version directory names/suffixes (v/ver/version/rev/revision), then entry-file modification time for equal or unnumbered copies (final/latest/copy/backup/old). Titles alone never merge independent sites. This applies to discovered entries, not arbitrary HTML files. Semver release directories such as name-1.2.0 are also recognized. Demo/example/preview folders owned by a SKILL.md are treated as agent skill documentation; ordinary project documentation sites, demos and prototypes remain eligible. Saved entries keep their existing target; discovery filtering does not rewrite the library. Invalid history is pruned only when no current file entry or live service supports it.
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
"zh-CN": "本地与自托管站点"
|
|
8
8
|
},
|
|
9
9
|
"description": {
|
|
10
|
-
"en": "
|
|
11
|
-
"zh-CN": "
|
|
10
|
+
"en": "Find project web services and open your saved sites.",
|
|
11
|
+
"zh-CN": "发现项目网页服务,收藏并打开常用站点。"
|
|
12
12
|
},
|
|
13
13
|
"entry": {
|
|
14
14
|
"kind": "page-script",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"hostActions": [
|
|
23
23
|
"sites"
|
|
24
24
|
],
|
|
25
|
+
"icon": {"viewBox": "0 0 24 24", "markup": "<circle cx=\"12\" cy=\"12\" r=\"10\" />\n <path d=\"M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20\" />\n <path d=\"M2 12h20\" />"},
|
|
25
26
|
"placement": {
|
|
26
27
|
"order": 90
|
|
27
28
|
}
|
|
@@ -31,7 +31,26 @@ function pageInfo() {
|
|
|
31
31
|
const shared = native ? !!native.shared : [...page.querySelectorAll("button[aria-pressed=true]")].some(b => /Shared with you|与你共享|與你共用/.test(b.textContent));
|
|
32
32
|
const create = [...page.firstElementChild.querySelectorAll("button")].find(b => /^(New|新建|新增)$/.test(b.textContent.trim()) && !b.hasAttribute(owned));
|
|
33
33
|
codex.report("sites-page",true);
|
|
34
|
-
return { page, input, content, list, grid, shared, create };
|
|
34
|
+
return { page, input, content, list, grid, shared, create, empty: list ? null : [...content.children].findLast(e => !e.hasAttribute(owned)) };
|
|
35
|
+
}
|
|
36
|
+
function browserMenu() {
|
|
37
|
+
for (const menu of document.querySelectorAll('[role="menu"]')) {
|
|
38
|
+
if (!visible(menu)) continue;
|
|
39
|
+
const actions = nativeProps(menu, p => typeof p.onOpenDownloads === "function" && typeof p.onOpenChange === "function");
|
|
40
|
+
const page = nativeProps(menu, p => p.browserState && p.browserTabId);
|
|
41
|
+
if (!actions || !page || page.isVisible === false) continue;
|
|
42
|
+
const downloads = [...menu.querySelectorAll('[role="menuitem"]')].find(e => /^(Downloads|下载|下載)$/.test(e.textContent.trim()));
|
|
43
|
+
if (!downloads) continue;
|
|
44
|
+
const host = nativeProps(menu, p => p.browserTabId === page.browserTabId && "browserHostDisplayName" in p);
|
|
45
|
+
let url;
|
|
46
|
+
try { url = new URL(page.browserState.url); } catch { continue; }
|
|
47
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) continue;
|
|
48
|
+
return { menu, downloads, tabId: page.browserTabId,
|
|
49
|
+
page: { url: url.href, title: page.browserState.title || url.hostname,
|
|
50
|
+
cwd: host?.browserHostDisplayName === "Local" ? page.cwd : null },
|
|
51
|
+
close: () => actions.onOpenChange(false) };
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
35
54
|
}
|
|
36
55
|
function fullViewControl(enter) {
|
|
37
56
|
const pattern = enter ? /^(Enter full view|进入完整视图|进入全屏视图|進入完整檢視|進入全螢幕檢視)$/ : /^(Exit full view|退出完整视图|退出全屏视图|退出完整檢視|退出全螢幕檢視)$/;
|
|
@@ -67,10 +86,12 @@ function templates(page) {
|
|
|
67
86
|
const create = page.create;
|
|
68
87
|
if (create) {
|
|
69
88
|
const template = page.page.querySelector('button[data-size="lg"][data-color="secondary"]');
|
|
70
|
-
nativeButton = template.className; nativeInner = template.firstElementChild
|
|
89
|
+
if (template) { nativeButton = template.className; nativeInner = template.firstElementChild?.className || ""; }
|
|
71
90
|
nativePlus = create.querySelector("svg")?.cloneNode(true);
|
|
91
|
+
// New 的图标在桌面端隐藏,克隆后只保留图标自身样式。
|
|
92
|
+
nativePlus?.classList.remove("hidden", "browser:max-md:block");
|
|
72
93
|
}
|
|
73
|
-
nativeSiteIcon ||= document.
|
|
94
|
+
nativeSiteIcon ||= [...document.querySelectorAll('button, a')].find(e => /^(Sites|站点|網站)$/.test(e.textContent.trim()) && !e.closest(`[${owned}]`))?.querySelector("svg")?.cloneNode(true);
|
|
74
95
|
const row = page.list?.querySelector("[data-appgen-row], article");
|
|
75
96
|
nativeExternal ||= row?.querySelector('button[aria-label*="new tab"]')?.cloneNode(true);
|
|
76
97
|
nativeActionsClass ||= row?.lastElementChild?.className;
|
|
@@ -79,5 +100,5 @@ function templates(page) {
|
|
|
79
100
|
return {nativeButton,nativeInner,nativePlus,nativeSiteIcon,nativeMore,nativeExternal,nativeGlobe,nativeActionsClass};
|
|
80
101
|
}
|
|
81
102
|
|
|
82
|
-
return {templates,pageInfo,fullViewControl,open,path:()=>router()?.state?.location?.pathname};
|
|
103
|
+
return {templates,pageInfo,browserMenu,fullViewControl,open,path:()=>router()?.state?.location?.pathname};
|
|
83
104
|
}
|
|
@@ -1,14 +1,24 @@
|
|
|
1
|
-
import { readFile, readdir, realpath } from "node:fs/promises";
|
|
1
|
+
import { readFile, readdir, realpath, stat } from "node:fs/promises";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { isAuxiliarySite } from "../domain/discovery-candidates.mjs";
|
|
4
5
|
|
|
5
6
|
const ignored = new Set(["node_modules", "dist", "build", "out", "vendor", "target", "coverage"]);
|
|
6
7
|
export const entryKey = (projectId, directory) => createHash("sha256").update(`${projectId}\0${directory}`).digest("hex");
|
|
8
|
+
export async function isSkillExample(directory, root) {
|
|
9
|
+
let current = directory;
|
|
10
|
+
while (current && current !== root && current !== path.dirname(current)) {
|
|
11
|
+
if (/^(?:demo|examples?|preview)$/i.test(path.basename(current))
|
|
12
|
+
&& await stat(path.join(path.dirname(current), "SKILL.md")).then(s => s.isFile(), () => false)) return true;
|
|
13
|
+
current = path.dirname(current);
|
|
14
|
+
}
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
7
17
|
export async function findProjectSites(projects) {
|
|
8
18
|
const found = [], visited = new Set();
|
|
9
19
|
let remaining = 4000;
|
|
10
|
-
async function walk(directory, project, depth) {
|
|
11
|
-
if (--remaining < 0 || depth > 4) return;
|
|
20
|
+
async function walk(directory, project, root, depth) {
|
|
21
|
+
if (--remaining < 0 || depth > 4 || isAuxiliarySite(path.relative(root, directory))) return;
|
|
12
22
|
const canonical = await realpath(directory).catch(() => null);
|
|
13
23
|
if (!canonical || visited.has(canonical)) return; visited.add(canonical);
|
|
14
24
|
let entries; try { entries = await readdir(canonical, { withFileTypes: true }); } catch { return; }
|
|
@@ -23,12 +33,14 @@ export async function findProjectSites(projects) {
|
|
|
23
33
|
if (selected || staticEntry) {
|
|
24
34
|
let title = pkg?.name || path.basename(canonical);
|
|
25
35
|
if (staticEntry) { const html = await readFile(path.join(canonical, "index.html"), "utf8").catch(() => ""); title = html.match(/<title[^>]*>([^<]*)<\/title>/i)?.[1]?.trim() || title; }
|
|
26
|
-
|
|
36
|
+
if (isAuxiliarySite(path.relative(root, canonical), title)) return;
|
|
37
|
+
const modifiedAt = (await stat(path.join(canonical, staticEntry ? "index.html" : "package.json"))).mtimeMs;
|
|
38
|
+
found.push({ modifiedAt, entryId: entryKey(project.id, canonical), directory: canonical, projectId: project.id, projectName: project.name, title, framework: selected?.framework || "static", script: selected?.script || null, command: selected?.command || null, url: null, status: "stopped" });
|
|
27
39
|
}
|
|
28
|
-
for (const entry of entries) if (entry.isDirectory() && !entry.name.startsWith(".") && !ignored.has(entry.name)) await walk(path.join(canonical, entry.name), project, depth + 1);
|
|
40
|
+
for (const entry of entries) if (entry.isDirectory() && !entry.name.startsWith(".") && !ignored.has(entry.name) && !(entries.some(e => e.name === "SKILL.md") && /^(?:demo|examples?|preview)$/i.test(entry.name))) await walk(path.join(canonical, entry.name), project, root, depth + 1);
|
|
29
41
|
}
|
|
30
42
|
// 深层项目先归属,避免被父项目重复收录。
|
|
31
43
|
const roots = projects.flatMap(p => p.rootPaths.map(root => ({ root, project: p }))).sort((a,b) => b.root.length - a.root.length);
|
|
32
|
-
for (const { root, project } of roots) await walk(root, project, 0);
|
|
44
|
+
for (const { root, project } of roots) await walk(root, project, root, 0);
|
|
33
45
|
return found;
|
|
34
46
|
}
|
|
@@ -6,7 +6,8 @@ import os from "node:os";
|
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { CodexCatalog } from "../adapters/codex/catalog.mjs";
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import { isAuxiliarySite, selectDiscoveryCandidates } from "../domain/discovery-candidates.mjs";
|
|
10
|
+
import { findProjectSites, entryKey, isSkillExample } from "./project-sites.mjs";
|
|
10
11
|
import { SiteLauncher } from "./site-launcher.mjs";
|
|
11
12
|
import { SitePreviews } from "./sites-preview.mjs";
|
|
12
13
|
|
|
@@ -45,17 +46,19 @@ export class SitesService {
|
|
|
45
46
|
const attributed = await Promise.all(listeners.map(async e => {
|
|
46
47
|
const cwd = e.cwd ? await realpath(e.cwd).catch(() => e.cwd) : null;
|
|
47
48
|
const project = projectForDirectory(canonical, cwd) || (process.platform === "win32" ? canonical.filter(p => p.rootPaths.some(r => String(e.command).toLowerCase().includes((r + path.sep).toLowerCase()))).sort((a,b) => b.rootPaths[0].length - a.rootPaths[0].length)[0] : null);
|
|
48
|
-
|
|
49
|
+
const root = project?.rootPaths.find(r => projectForDirectory([{ rootPaths: [r] }], cwd)) || project?.rootPaths[0];
|
|
50
|
+
const auxiliary = root && (isAuxiliarySite(path.relative(root, cwd || root)) || cwd && await isSkillExample(cwd, root));
|
|
51
|
+
return { ...e, cwd, project, auxiliary };
|
|
49
52
|
}));
|
|
50
53
|
// 模拟器桥接首页也会执行 simctl;须在 HTTP 探测前排除,避免安装授权弹窗。
|
|
51
54
|
// 无法读取完整命令时不主动探测,仍允许用户手动添加网址。
|
|
52
|
-
const pending = attributed.filter(e => e.project && e.command && !/(?:^|[\s/\\])(?:serve-sim|swiftui-preview-browser)(?:[.\s/\\]|$)/i.test(e.command));
|
|
55
|
+
const pending = attributed.filter(e => e.project && e.command && !e.auxiliary && !/(?:^|[\s/\\])(?:serve-sim|swiftui-preview-browser)(?:[.\s/\\]|$)/i.test(e.command));
|
|
53
56
|
await Promise.all(Array.from({ length: Math.min(6, pending.length) }, async () => {
|
|
54
57
|
while (pending.length && Date.now() < deadline) {
|
|
55
58
|
const entry = pending.shift();
|
|
56
59
|
if ([...this.launcher.running.values()].some(r => r.url === entry.url)) continue;
|
|
57
60
|
if (seen.has(entry.url)) continue; seen.add(entry.url);
|
|
58
|
-
try { const page = await this.probe(entry.url, { timeoutMs: Math.min(1400, Math.max(1, deadline - Date.now())) }); if (page) candidates.push({ title: page.title, url: entry.url, projectId: entry.project.id, projectName: entry.project.name, directory: entry.cwd, status: "running" }); } catch { /* 未就绪或非 HTTP 服务不列为网页。 */ }
|
|
61
|
+
try { const page = await this.probe(entry.url, { timeoutMs: Math.min(1400, Math.max(1, deadline - Date.now())) }); if (page && !isAuxiliarySite("", page.title)) candidates.push({ title: page.title, url: entry.url, projectId: entry.project.id, projectName: entry.project.name, directory: entry.cwd, status: "running" }); } catch { /* 未就绪或非 HTTP 服务不列为网页。 */ }
|
|
59
62
|
}
|
|
60
63
|
}));
|
|
61
64
|
const historyFile = path.join(path.dirname(this.file), "project-history.json");
|
|
@@ -64,24 +67,33 @@ export class SitesService {
|
|
|
64
67
|
for (const candidate of candidates) {
|
|
65
68
|
const definition = definitions.filter(d => d.projectId === candidate.projectId && candidate.directory && projectForDirectory([{ rootPaths: [d.directory] }], candidate.directory)).sort((a,b) => b.directory.length - a.directory.length)[0];
|
|
66
69
|
candidate.entryId = definition?.entryId || entryKey(candidate.projectId, candidate.directory || candidate.url);
|
|
67
|
-
if (definition) { Object.assign(candidate, { framework: definition.framework, script: definition.script, command: definition.command }); definitions.splice(definitions.indexOf(definition), 1); }
|
|
70
|
+
if (definition) { Object.assign(candidate, { directory: definition.directory, modifiedAt: definition.modifiedAt, framework: definition.framework, script: definition.script, command: definition.command }); definitions.splice(definitions.indexOf(definition), 1); }
|
|
68
71
|
history[candidate.entryId] = { ...history[candidate.entryId], projectId:candidate.projectId,projectName:candidate.projectName,directory:candidate.directory, lastSeenAt: Date.now(), url: candidate.url, title: candidate.title };
|
|
69
72
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
+
// 历史只补充当前入口的运行信息,不再独立生成离线候选。
|
|
74
|
+
const validIds = new Set([...candidates, ...definitions].map(c => c.entryId));
|
|
75
|
+
for (const id of Object.keys(history)) if (!validIds.has(id)) delete history[id];
|
|
73
76
|
for (const definition of definitions) {
|
|
74
77
|
const running = this.launcher.running.get(definition.entryId);
|
|
75
|
-
candidates.push({ ...
|
|
78
|
+
candidates.push({ ...history[definition.entryId], ...definition, url: running?.url || history[definition.entryId]?.url || null, status: running ? "running" : "stopped" });
|
|
76
79
|
}
|
|
77
80
|
for (const candidate of candidates) Object.assign(candidate, { lastSeenAt: history[candidate.entryId]?.lastSeenAt, lastStartedAt: history[candidate.entryId]?.lastStartedAt });
|
|
78
81
|
await mkdir(path.dirname(this.file), { recursive: true, mode: 0o700 });
|
|
79
82
|
await writeFile(historyFile, JSON.stringify(history), { mode: 0o600 });
|
|
80
83
|
this.entries = new Map(candidates.map(c => [c.entryId, c])); this.scannedAt = Date.now();
|
|
81
84
|
this.discoveredUrls = new Set(candidates.filter(c => c.status === "running").map(c => c.url));
|
|
82
|
-
return { candidates: candidates
|
|
85
|
+
return { candidates: selectDiscoveryCandidates(candidates, await this.read()), scope: "local-projects" };
|
|
83
86
|
}
|
|
84
87
|
async action(payload = {}) {
|
|
88
|
+
if (payload.operation === "prepare-browser-site") {
|
|
89
|
+
const site = normalizeSite({ url: payload.url, title: payload.title, origin: { kind: "external" } });
|
|
90
|
+
const { projects } = await this.catalog();
|
|
91
|
+
const cwd = payload.cwd ? await realpath(payload.cwd).catch(() => null) : null;
|
|
92
|
+
const canonical = await Promise.all(projects.map(async p => ({ ...p, rootPaths: await Promise.all(p.rootPaths.map(r => realpath(r).catch(() => r))) })));
|
|
93
|
+
const project = cwd ? projectForDirectory(canonical, cwd) : null;
|
|
94
|
+
return { site, project: project ? { id: project.id, name: project.name } : null,
|
|
95
|
+
preferProject: !!project && isLocalAddress(new URL(site.url).hostname) };
|
|
96
|
+
}
|
|
85
97
|
if (payload.operation === "start") {
|
|
86
98
|
const site = (await this.read()).find(s => s.id === payload.id);
|
|
87
99
|
if (!site || site.origin.kind !== "project") throw new Error("Project site is unavailable.");
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
const auxiliary = /^(?:__tests__|__fixtures__|tests?|fixtures?|test-results|playwright-report|coverage|agent[-_]?(?:tests?|checks?|notes|reports)|self[-_]?tests?|(?:.*[-_])?(?:smoke|self)[-_]tests?)$/i;
|
|
2
|
+
export function isAuxiliarySite(relativePath, title = "") {
|
|
3
|
+
const parts = relativePath.split(/[\\/]/);
|
|
4
|
+
return parts.some(part => auxiliary.test(part))
|
|
5
|
+
|| /(?:^|\/)(?:docs\/research|internal-notes|implementation-notes)(?:\/|$)/i.test(parts.join("/"))
|
|
6
|
+
|| /(?:^|\b)(?:(?:agent|自动化|代理)\s*[-::]?\s*)?(?:self[- ]?test|smoke test|test report|implementation notes|自测报告|测试报告|实现说明)(?:\b|\s|[-::]|$)/i.test(title);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function candidateFamily(candidate) {
|
|
10
|
+
const directory = candidate.directory;
|
|
11
|
+
if (!directory) return { key: `${candidate.projectId}\0${candidate.entryId || candidate.url}`, version: [] };
|
|
12
|
+
const parts = directory.split(/[\\/]/), version = [];
|
|
13
|
+
const normalized = parts.map(part => {
|
|
14
|
+
const match = part.match(/^(?:(.*?)[-_ .])?(?:v|ver|version|rev|revision)[-_ .]?(\d+(?:[._]\d+)*)$/i);
|
|
15
|
+
const release = part.match(/^(.*?)[-_ ](\d+\.\d+(?:\.\d+)*)$/);
|
|
16
|
+
if (release) { version.push(...release[2].split(".").map(Number)); return release[1]; }
|
|
17
|
+
if (match) { version.push(...match[2].split(/[._]/).map(Number)); return (match[1] || "").replace(/[-_ .]+$/, ""); }
|
|
18
|
+
if (/^(?:final|latest|copy|backup|old)$/i.test(part)) return "";
|
|
19
|
+
return part.replace(/[-_ ](?:final|latest|copy|backup|old)$/i, "");
|
|
20
|
+
}).filter(Boolean).join("/");
|
|
21
|
+
return { key: `${candidate.projectId}\0${normalized}`, version };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function newer(a, b) {
|
|
25
|
+
const av = candidateFamily(a).version, bv = candidateFamily(b).version;
|
|
26
|
+
// 明确版本号不受复制文件或格式化导致的时间变化影响。
|
|
27
|
+
if (av.length || bv.length) {
|
|
28
|
+
for (let i = 0; i < Math.max(av.length, bv.length); i++) {
|
|
29
|
+
const difference = (av[i] || 0) - (bv[i] || 0);
|
|
30
|
+
if (difference) return difference;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return (a.modifiedAt || 0) - (b.modifiedAt || 0)
|
|
34
|
+
|| Number(a.status === "running") - Number(b.status === "running")
|
|
35
|
+
|| (a.directory || a.url || "").localeCompare(b.directory || b.url || "", "en", { numeric: true });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function selectDiscoveryCandidates(candidates, saved) {
|
|
39
|
+
const families = new Map();
|
|
40
|
+
for (const candidate of candidates) {
|
|
41
|
+
const key = candidateFamily(candidate).key;
|
|
42
|
+
const previous = families.get(key);
|
|
43
|
+
if (!previous || newer(candidate, previous) > 0) families.set(key, candidate);
|
|
44
|
+
}
|
|
45
|
+
const savedFamilies = new Set(saved.filter(s => s.origin?.kind === "project" && s.directory)
|
|
46
|
+
.map(s => candidateFamily({ ...s, projectId: s.origin.projectId }).key));
|
|
47
|
+
return [...families].filter(([key, candidate]) => !savedFamilies.has(key) && !saved.some(s =>
|
|
48
|
+
s.entryId === candidate.entryId || candidate.url && s.url === candidate.url
|
|
49
|
+
)).map(([, candidate]) => candidate).sort((a, b) =>
|
|
50
|
+
a.title.localeCompare(b.title, "en", { sensitivity: "base", numeric: true })
|
|
51
|
+
|| a.projectName.localeCompare(b.projectName, "en", { sensitivity: "base", numeric: true })
|
|
52
|
+
|| (a.directory || a.url).localeCompare(b.directory || b.url));
|
|
53
|
+
}
|