feat(workspace): add React shell integration
This commit is contained in:
@@ -75,6 +75,14 @@ directly remains supported through the legacy compatibility pages and
|
||||
`service-worker-loader.js` for source-level development and tests; the React
|
||||
Popup and Prompt Template Library are generated by the WXT build entrypoints.
|
||||
|
||||
The generated Workspace page also mounts a React shell for the top toolbar,
|
||||
provider selection, feature actions, and layout controls. The shell delegates
|
||||
Provider panels, message transport, Task Runtime, Compare, Session, Prompt,
|
||||
Template, and Handoff behavior to the existing Workspace runtime through a
|
||||
small event/DOM compatibility bridge. This keeps the first migration slice
|
||||
reversible: direct source-checkout loading still uses the legacy toolbar until
|
||||
the remaining Workspace features have been migrated and browser-validated.
|
||||
|
||||
For a local browser smoke against the built artifact, use:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { defineUnlistedScript } from "wxt/utils/define-unlisted-script";
|
||||
import "../shared/provider-catalog.js";
|
||||
import "../shared/storage-contract.js";
|
||||
import { WorkspaceShell } from "../ui/workspace/workspace-shell";
|
||||
|
||||
export default defineUnlistedScript(() => {
|
||||
const root = document.getElementById("workspaceReactRoot");
|
||||
if (root) createRoot(root).render(<WorkspaceShell />);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ProviderDescriptor, ProviderId } from "../../../contracts/provider";
|
||||
import { Badge } from "../../components/badge";
|
||||
import { Button } from "../../components/button";
|
||||
|
||||
export function ProviderStrip({
|
||||
providers,
|
||||
selected,
|
||||
onToggle
|
||||
}: {
|
||||
providers: readonly ProviderDescriptor[];
|
||||
selected: readonly ProviderId[];
|
||||
onToggle: (providerId: ProviderId) => void;
|
||||
}) {
|
||||
const selectedSet = new Set(selected);
|
||||
return (
|
||||
<div className="workspace-provider-strip" aria-label="Workspace 模型选择">
|
||||
{providers.map((provider) => {
|
||||
const active = selectedSet.has(provider.id);
|
||||
return (
|
||||
<Button
|
||||
key={provider.id}
|
||||
size="sm"
|
||||
variant={active ? "secondary" : "ghost"}
|
||||
className="workspace-provider-button"
|
||||
aria-pressed={active}
|
||||
onClick={() => onToggle(provider.id)}
|
||||
>
|
||||
{provider.name}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
<Badge>{selected.length} 个模型</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Button } from "../../components/button";
|
||||
|
||||
export type WorkspaceAction = "session" | "prompt" | "template" | "compare";
|
||||
|
||||
const actions: Array<{ id: WorkspaceAction; label: string }> = [
|
||||
{ id: "session", label: "Sessions" },
|
||||
{ id: "prompt", label: "Prompts" },
|
||||
{ id: "template", label: "Templates" },
|
||||
{ id: "compare", label: "Compare" }
|
||||
];
|
||||
|
||||
export function WorkspaceActions({ onAction }: { onAction: (action: WorkspaceAction) => void }) {
|
||||
return (
|
||||
<div className="workspace-actions" aria-label="Workspace 功能">
|
||||
{actions.map((action) => (
|
||||
<Button key={action.id} size="sm" variant="outline" onClick={() => onAction(action.id)}>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ProviderDescriptor, ProviderId } from "../../contracts/provider";
|
||||
import { Button } from "../components/button";
|
||||
import { ProviderStrip } from "./features/provider-strip";
|
||||
import { WorkspaceActions, type WorkspaceAction } from "./features/workspace-actions";
|
||||
|
||||
const providers = globalThis.AIParallelProviderCatalog;
|
||||
const storage = globalThis.AIParallelStorageContract.createLocalStorage();
|
||||
const legacyActionIds: Record<WorkspaceAction, string> = {
|
||||
session: "sessionBtn",
|
||||
prompt: "promptLibraryBtn",
|
||||
template: "templateLibraryBtn",
|
||||
compare: "compareBtn"
|
||||
};
|
||||
const layouts = ["auto", "1", "2", "3"] as const;
|
||||
type WorkspaceLayout = (typeof layouts)[number];
|
||||
|
||||
function isProviderId(value: unknown): value is ProviderId {
|
||||
return typeof value === "string" && providers.some((provider) => provider.id === value);
|
||||
}
|
||||
|
||||
function readProviderSelection(value: unknown, fallback: readonly ProviderDescriptor[]) {
|
||||
if (!Array.isArray(value)) return fallback.filter((provider) => provider.default).map((provider) => provider.id);
|
||||
return value.filter(isProviderId);
|
||||
}
|
||||
|
||||
function emitSelection(providerIds: readonly ProviderId[]) {
|
||||
window.dispatchEvent(new CustomEvent("ai-parallel:workspace-set-selection", {
|
||||
detail: { providerIds: [...providerIds] }
|
||||
}));
|
||||
}
|
||||
|
||||
function triggerLegacyAction(action: WorkspaceAction) {
|
||||
document.getElementById(legacyActionIds[action])?.click();
|
||||
}
|
||||
|
||||
export function WorkspaceShell() {
|
||||
const [selected, setSelected] = useState<ProviderId[]>([]);
|
||||
const [layout, setLayout] = useState<WorkspaceLayout>("auto");
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
storage.get(["selectedProviders", "workspaceLayout"]).then((data) => {
|
||||
if (!active) return;
|
||||
setSelected(readProviderSelection(data.selectedProviders, providers));
|
||||
setLayout(layouts.includes(data.workspaceLayout as WorkspaceLayout) ? data.workspaceLayout as WorkspaceLayout : "auto");
|
||||
}).catch(() => {});
|
||||
|
||||
const handleWorkspaceState = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ selectedProviders?: unknown; workspaceLayout?: unknown }>).detail;
|
||||
if (detail?.selectedProviders) setSelected(readProviderSelection(detail.selectedProviders, providers));
|
||||
if (layouts.includes(detail?.workspaceLayout as WorkspaceLayout)) setLayout(detail.workspaceLayout as WorkspaceLayout);
|
||||
};
|
||||
window.addEventListener("ai-parallel:workspace-state", handleWorkspaceState);
|
||||
return () => {
|
||||
active = false;
|
||||
window.removeEventListener("ai-parallel:workspace-state", handleWorkspaceState);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function toggleProvider(providerId: ProviderId) {
|
||||
const next = selected.includes(providerId)
|
||||
? selected.filter((id) => id !== providerId)
|
||||
: [...selected, providerId];
|
||||
setSelected(next);
|
||||
emitSelection(next);
|
||||
}
|
||||
|
||||
function changeLayout(next: WorkspaceLayout) {
|
||||
setLayout(next);
|
||||
document.querySelector<HTMLButtonElement>(`.layout-switch button[data-layout="${next}"]`)?.click();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="workspace-react-toolbar">
|
||||
<div className="workspace-react-brand">
|
||||
<span className="workspace-react-mark">AP</span>
|
||||
<span>AI Parallel</span>
|
||||
<span className="workspace-react-version">React Shell</span>
|
||||
</div>
|
||||
<ProviderStrip providers={providers} selected={selected} onToggle={toggleProvider} />
|
||||
<WorkspaceActions onAction={triggerLegacyAction} />
|
||||
<div className="workspace-layout-switch" aria-label="布局">
|
||||
{layouts.map((value) => (
|
||||
<Button key={value} size="sm" variant={layout === value ? "secondary" : "ghost"} aria-pressed={layout === value} onClick={() => changeLayout(value)}>
|
||||
{value === "auto" ? "Auto" : value}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
body[data-react-workspace="true"] .workspace-legacy-toolbar { display: none; }
|
||||
body[data-react-workspace="true"] #workspaceReactRoot { display: block; grid-column: 1 / -1; min-width: 0; }
|
||||
#workspaceReactRoot { display: none; }
|
||||
.workspace-react-toolbar { align-items: center; display: grid; gap: 12px; grid-template-columns: auto minmax(0, 1fr) auto auto; min-height: 48px; padding: 0 14px; }
|
||||
.workspace-react-brand { align-items: center; display: flex; font-size: 13px; font-weight: 700; gap: 8px; white-space: nowrap; }
|
||||
.workspace-react-mark { align-items: center; background: linear-gradient(135deg, #7c6cff, #a274ff); border-radius: 7px; color: white; display: inline-flex; font-size: 9px; height: 24px; justify-content: center; letter-spacing: -.04em; width: 24px; }
|
||||
.workspace-react-version { border: 1px solid var(--ui-border); border-radius: 999px; color: var(--ui-muted); font-size: 9px; padding: 3px 6px; text-transform: uppercase; }
|
||||
.workspace-provider-strip, .workspace-actions, .workspace-layout-switch { align-items: center; display: flex; gap: 5px; min-width: 0; }
|
||||
.workspace-provider-strip { overflow-x: auto; scrollbar-width: none; }
|
||||
.workspace-provider-strip::-webkit-scrollbar { display: none; }
|
||||
.workspace-provider-button { flex: 0 0 auto; white-space: nowrap; }
|
||||
.workspace-provider-strip .ui-badge { flex: 0 0 auto; }
|
||||
.workspace-actions { justify-content: flex-end; }
|
||||
.workspace-layout-switch { background: #0d0f13; border: 1px solid var(--ui-border); border-radius: 8px; gap: 2px; padding: 2px; }
|
||||
.workspace-layout-switch .ui-button { min-width: 30px; }
|
||||
@media (max-width: 1100px) {
|
||||
.workspace-react-toolbar { grid-template-columns: auto minmax(0, 1fr) auto; }
|
||||
.workspace-actions { grid-column: 2 / -1; grid-row: 2; justify-content: flex-start; }
|
||||
}
|
||||
@@ -5,17 +5,19 @@
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>AI Parallel</title>
|
||||
<link rel="stylesheet" href="workspace.css" />
|
||||
<link rel="stylesheet" href="../workspace-shell.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<header class="toolbar">
|
||||
<div class="brand">
|
||||
<div id="workspaceReactRoot"></div>
|
||||
<div class="brand workspace-legacy-toolbar">
|
||||
<span class="brand-mark">AP</span>
|
||||
<span>AI Parallel</span>
|
||||
<span class="version">Live Web</span>
|
||||
</div>
|
||||
<div class="toolbar-center" id="providerBar"></div>
|
||||
<div class="toolbar-actions">
|
||||
<div class="toolbar-center workspace-legacy-toolbar" id="providerBar"></div>
|
||||
<div class="toolbar-actions workspace-legacy-toolbar">
|
||||
<button id="sessionBtn" type="button" class="compare-trigger">Sessions</button>
|
||||
<button id="promptLibraryBtn" type="button" class="compare-trigger">Prompts</button>
|
||||
<button id="templateLibraryBtn" type="button" class="compare-trigger">Templates</button>
|
||||
|
||||
@@ -114,6 +114,12 @@ function updateMeta() {
|
||||
sendBtn.disabled = selected.size === 0 || !promptInput.value.trim();
|
||||
}
|
||||
|
||||
function notifyWorkspaceShell() {
|
||||
window.dispatchEvent(new CustomEvent("ai-parallel:workspace-state", {
|
||||
detail: { selectedProviders: [...selected], workspaceLayout: currentLayout }
|
||||
}));
|
||||
}
|
||||
|
||||
function setPanelState(providerId, state, { ready } = {}) {
|
||||
const panel = panels.get(providerId);
|
||||
if (!panel) return;
|
||||
@@ -154,6 +160,7 @@ function renderProviderBar() {
|
||||
});
|
||||
providerBar.append(button);
|
||||
}
|
||||
notifyWorkspaceShell();
|
||||
}
|
||||
|
||||
function postToFrame(providerId, payload) {
|
||||
@@ -1674,6 +1681,22 @@ insertTemplateBtn.addEventListener("click", () => applyTemplateForm(false).catch
|
||||
runTemplateBtn.addEventListener("click", () => applyTemplateForm(true).catch((error) => showTemplateFormError(error instanceof Error ? error.message : String(error))));
|
||||
templateForm.addEventListener("submit", (event) => event.preventDefault());
|
||||
templateFormDialog.addEventListener("cancel", () => closeTemplateForm());
|
||||
|
||||
window.addEventListener("ai-parallel:workspace-set-selection", (event) => {
|
||||
const detail = event instanceof CustomEvent ? event.detail : null;
|
||||
if (!Array.isArray(detail?.providerIds)) return;
|
||||
const providerIds = detail.providerIds.filter((id) => providerById(id));
|
||||
selected = new Set(providerIds);
|
||||
for (const providerId of PROVIDERS.map((provider) => provider.id)) {
|
||||
if (!selected.has(providerId)) responseBundles.delete(providerId);
|
||||
}
|
||||
storage.set({ selectedProviders: providerIds }).catch(() => {});
|
||||
renderProviderBar();
|
||||
renderPanels();
|
||||
renderResponses();
|
||||
updateMeta();
|
||||
});
|
||||
|
||||
compareBtn.addEventListener("click", openCompareDrawer);
|
||||
closeCompareBtn.addEventListener("click", closeCompareDrawer);
|
||||
copyMarkdownBtn.addEventListener("click", () => copyText(buildComparisonMarkdown(), "Markdown 已复制"));
|
||||
@@ -1697,6 +1720,7 @@ document.querySelectorAll(".layout-switch button").forEach((button) => {
|
||||
panelGrid.dataset.layout = currentLayout;
|
||||
document.querySelectorAll(".layout-switch button").forEach((item) => item.classList.toggle("active", item === button));
|
||||
await storage.set({ workspaceLayout: currentLayout });
|
||||
notifyWorkspaceShell();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1724,6 +1748,7 @@ async function init() {
|
||||
});
|
||||
autosizeComposer();
|
||||
updateMeta();
|
||||
notifyWorkspaceShell();
|
||||
if (pendingLaunch) runPendingLaunch(pendingLaunch).catch((error) => showError(error instanceof Error ? error.message : String(error)));
|
||||
setInterval(() => {
|
||||
for (const id of selected) {
|
||||
|
||||
@@ -14,6 +14,8 @@ const files = [
|
||||
"workspace/workspace.css",
|
||||
"workspace/workspace.js",
|
||||
"workspace/context-utils.js",
|
||||
"workspace-shell.css",
|
||||
"ui/theme.css",
|
||||
"shared/agent-execution-controller.js",
|
||||
"shared/prompt-template-catalog.js",
|
||||
"shared/prompt-template-utils.js",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = resolve(fileURLToPath(new URL(".", import.meta.url)));
|
||||
@@ -77,20 +77,27 @@ for (const relativePath of referencedFiles) {
|
||||
}
|
||||
}
|
||||
|
||||
for (const page of ["popup.html", "templates.html"]) {
|
||||
for (const page of ["popup.html", "templates.html", "workspace/index.html"]) {
|
||||
const pagePath = resolve(buildRoot, page);
|
||||
if (!existsSync(pagePath)) throw new Error(`Generated extension page is missing: ${page}`);
|
||||
const html = readFileSync(pagePath, "utf8");
|
||||
if (/https?:\/\//i.test(html)) throw new Error(`Generated ${page} contains a remote asset URL`);
|
||||
for (const [, reference] of html.matchAll(/(?:src|href)="([^"]+)"/g)) {
|
||||
const relativePath = reference.replace(/^\/+/, "");
|
||||
if (!relativePath || relativePath.startsWith("#")) continue;
|
||||
if (!existsSync(resolve(buildRoot, relativePath))) {
|
||||
if (!reference || reference.startsWith("#")) continue;
|
||||
const referencedPath = reference.startsWith("/")
|
||||
? resolve(buildRoot, reference.slice(1))
|
||||
: resolve(dirname(pagePath), reference);
|
||||
if (!existsSync(referencedPath)) {
|
||||
throw new Error(`Generated ${page} references missing asset: ${reference}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const generatedWorkspace = readFileSync(resolve(buildRoot, "workspace/index.html"), "utf8");
|
||||
if (!generatedWorkspace.includes('data-react-workspace="true"') || !generatedWorkspace.includes("../workspace-shell.js")) {
|
||||
throw new Error("Generated Workspace page is missing the React shell integration");
|
||||
}
|
||||
|
||||
for (const legacyRuntimeFile of ["service-worker.js", "content/frame-bridge.js", "content/providers/core.js"]) {
|
||||
if (existsSync(resolve(buildRoot, legacyRuntimeFile))) {
|
||||
throw new Error(`Generated extension still ships a legacy runtime asset: ${legacyRuntimeFile}`);
|
||||
|
||||
@@ -47,3 +47,24 @@ test("React template library covers schema validation, import/export, editing, a
|
||||
assert.match(source, /编辑模板定义/);
|
||||
assert.match(source, /createRoot\(document\.getElementById\("root"\)!\)/);
|
||||
});
|
||||
|
||||
test("Workspace React shell owns the generated toolbar and delegates legacy runtime actions", () => {
|
||||
const shell = read("ui", "workspace", "workspace-shell.tsx");
|
||||
const entrypoint = read("entrypoints", "workspace-shell.tsx");
|
||||
const workspaceHtml = read("workspace", "index.html");
|
||||
const legacyWorkspace = read("workspace", "workspace.js");
|
||||
|
||||
assert.match(shell, /ProviderStrip/);
|
||||
assert.match(shell, /WorkspaceActions/);
|
||||
assert.match(shell, /ai-parallel:workspace-set-selection/);
|
||||
assert.match(shell, /sessionBtn/);
|
||||
assert.match(shell, /promptLibraryBtn/);
|
||||
assert.match(shell, /templateLibraryBtn/);
|
||||
assert.match(shell, /compareBtn/);
|
||||
assert.doesNotMatch(shell, /content\/providers/);
|
||||
assert.match(entrypoint, /defineUnlistedScript/);
|
||||
assert.match(entrypoint, /workspaceReactRoot/);
|
||||
assert.match(workspaceHtml, /id="workspaceReactRoot"/);
|
||||
assert.match(legacyWorkspace, /ai-parallel:workspace-state/);
|
||||
assert.match(legacyWorkspace, /ai-parallel:workspace-set-selection/);
|
||||
});
|
||||
|
||||
@@ -53,6 +53,22 @@ export default defineConfig({
|
||||
content_security_policy: legacyManifest.content_security_policy,
|
||||
}),
|
||||
hooks: {
|
||||
"build:publicAssets": (_wxt, files) => {
|
||||
const workspaceFileIndex = files.findIndex((file) => file.relativeDest === "workspace/index.html");
|
||||
const workspaceFile = files[workspaceFileIndex];
|
||||
if (!workspaceFile || !("absoluteSrc" in workspaceFile)) return;
|
||||
const source = readFileSync(workspaceFile.absoluteSrc, "utf8");
|
||||
files[workspaceFileIndex] = {
|
||||
relativeDest: workspaceFile.relativeDest,
|
||||
contents: source
|
||||
.replace(
|
||||
"</head>",
|
||||
' <link rel="stylesheet" href="../ui/theme.css" />\n</head>'
|
||||
)
|
||||
.replace("<body>", '<body data-react-workspace="true">')
|
||||
.replace("</body>", ' <script src="../workspace-shell.js"></script>\n</body>')
|
||||
};
|
||||
},
|
||||
"build:manifestGenerated": (_wxt, manifest) => {
|
||||
// Background and content scripts are generated from explicit WXT
|
||||
// entrypoints. Keep DNR data sourced from the single legacy manifest
|
||||
|
||||
Reference in New Issue
Block a user