feat(ui): add React popup and template library
This commit is contained in:
@@ -65,13 +65,15 @@ pnpm package:extension
|
||||
```
|
||||
|
||||
The generated extension is written to `dist/chrome-mv3`. The explicit WXT
|
||||
entrypoints in `entrypoints/background.ts` and `entrypoints/content.ts` bundle
|
||||
the Service Worker and Provider Bridge dependencies in a deterministic order.
|
||||
entrypoints in `entrypoints/background.ts`, `entrypoints/content.ts`, and the
|
||||
React pages under `entrypoints/` bundle the Service Worker, Provider Bridge,
|
||||
Popup, and Prompt Template Library dependencies locally in a deterministic order.
|
||||
The staging script keeps only public UI/shared assets, while the build verifier
|
||||
checks manifest semantics, permissions, referenced files, and the generated
|
||||
Provider/Bridge initialization order. Loading `apps/browser-extension`
|
||||
directly remains supported through `service-worker-loader.js` for source-level
|
||||
development and tests.
|
||||
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.
|
||||
|
||||
For a local browser smoke against the built artifact, use:
|
||||
|
||||
|
||||
+10
@@ -45,7 +45,17 @@ declare global {
|
||||
readonly PACKAGE_KIND: "ai-parallel.prompt-template-package";
|
||||
readonly SCHEMA_VERSION: 1;
|
||||
clone<T>(value: T): T;
|
||||
inferSchemaFromValue(value: unknown, title?: string): import("./json-schema").JsonSchema;
|
||||
normalizeTemplate(raw: unknown, options?: { source?: string }): PromptTemplate;
|
||||
parseTemplateImport(text: string, options?: { source?: string }): {
|
||||
ok: boolean;
|
||||
templates: PromptTemplate[];
|
||||
errors: Array<{ path: string; message: string }>;
|
||||
warnings: Array<{ path: string; message: string }>;
|
||||
};
|
||||
renderPromptTemplate(template: PromptTemplate, values: Record<string, unknown>, options?: { appendOutputSchema?: boolean }):
|
||||
| { ok: true; prompt: string }
|
||||
| { ok: false; errors: Array<{ path: string; message: string }> };
|
||||
validateTemplateDefinition(template: PromptTemplate): TemplateValidationResult;
|
||||
toPackage(templates: PromptTemplate[]): PromptTemplatePackage;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>AI Parallel Launcher</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { browser } from "wxt/browser";
|
||||
import type { ProviderId } from "../../contracts/provider";
|
||||
import "../../shared/provider-catalog.js";
|
||||
import "../../shared/contract-runtime.js";
|
||||
import "../../shared/storage-contract.js";
|
||||
import { Badge } from "../../ui/components/badge";
|
||||
import { Button } from "../../ui/components/button";
|
||||
import { Card, CardContent, CardHeader } from "../../ui/components/card";
|
||||
import { Input } from "../../ui/components/input";
|
||||
import { Textarea } from "../../ui/components/textarea";
|
||||
import "../../ui/theme.css";
|
||||
import "./popup.css";
|
||||
|
||||
const providers = globalThis.AIParallelProviderCatalog;
|
||||
const contractRuntime = globalThis.AIParallelContractRuntime;
|
||||
const storage = globalThis.AIParallelStorageContract.createLocalStorage();
|
||||
|
||||
function extensionUrl(path: string) {
|
||||
const runtime = browser.runtime as typeof browser.runtime & { getURL: (value: string) => string };
|
||||
return runtime.getURL(path);
|
||||
}
|
||||
|
||||
function providerIdsFrom(value: unknown): ProviderId[] | null {
|
||||
if (!Array.isArray(value)) return null;
|
||||
const known = new Set(providers.map((provider) => provider.id));
|
||||
return value.filter((id): id is ProviderId => typeof id === "string" && known.has(id as ProviderId));
|
||||
}
|
||||
|
||||
function PopupApp() {
|
||||
const [selected, setSelected] = useState<ProviderId[]>([]);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const allSelected = selected.length === providers.length;
|
||||
const selectedSet = useMemo(() => new Set(selected), [selected]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
storage.get(["selectedProviders", "draftPrompt"]).then((data) => {
|
||||
if (!active) return;
|
||||
setSelected(providerIdsFrom(data.selectedProviders) || providers.filter((provider) => provider.default).map((provider) => provider.id));
|
||||
setDraft(typeof data.draftPrompt === "string" ? data.draftPrompt : "");
|
||||
setHydrated(true);
|
||||
}).catch((reason) => {
|
||||
if (!active) return;
|
||||
setError(reason instanceof Error ? reason.message : String(reason));
|
||||
setHydrated(true);
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) return;
|
||||
storage.set({ selectedProviders: selected }).catch((reason) => setError(reason instanceof Error ? reason.message : String(reason)));
|
||||
}, [hydrated, selected]);
|
||||
|
||||
function toggleProvider(providerId: ProviderId) {
|
||||
setSelected((current) => current.includes(providerId)
|
||||
? current.filter((id) => id !== providerId)
|
||||
: [...current, providerId]);
|
||||
setError("");
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
setSelected(allSelected ? [] : providers.map((provider) => provider.id));
|
||||
setError("");
|
||||
}
|
||||
|
||||
async function launch() {
|
||||
const prompt = draft.trim();
|
||||
if (!prompt) return setError("请输入 Prompt");
|
||||
if (!selected.length) return setError("至少选择一个模型");
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await storage.set({
|
||||
draftPrompt: draft,
|
||||
selectedProviders: selected,
|
||||
pendingLaunch: { prompt, providerIds: selected, queuedAt: new Date().toISOString() }
|
||||
});
|
||||
const request = { type: "OPEN_WORKSPACE" } as const;
|
||||
if (!contractRuntime.isServiceWorkerRequest(request)) throw new Error("Invalid workspace request");
|
||||
const response = await browser.runtime.sendMessage(request) as { ok?: boolean; error?: string };
|
||||
if (!contractRuntime.isServiceWorkerResponse(response)) throw new Error("Invalid workspace response");
|
||||
if (!response.ok) throw new Error(response.error || "启动失败");
|
||||
window.close();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : String(reason));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function openTemplateLibrary() {
|
||||
try {
|
||||
await browser.tabs.create({ url: extensionUrl("templates.html") });
|
||||
window.close();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : String(reason));
|
||||
}
|
||||
}
|
||||
|
||||
function clearDraft() {
|
||||
setDraft("");
|
||||
setError("");
|
||||
storage.set({ draftPrompt: "" }).catch((reason) => setError(reason instanceof Error ? reason.message : String(reason)));
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="popup-app">
|
||||
<header className="popup-header">
|
||||
<div>
|
||||
<div className="popup-eyebrow">AI PARALLEL LAUNCHER</div>
|
||||
<h1>一次提问,多模型并行</h1>
|
||||
<p>使用已登录的模型网页会话,在 Workspace 中并行执行。</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={clearDraft} aria-label="清空输入">清空</Button>
|
||||
</header>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="popup-section-head">
|
||||
<span>模型</span>
|
||||
<Button variant="ghost" size="sm" onClick={toggleAll}>{allSelected ? "取消全选" : "全选"}</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="provider-grid" aria-label="模型选择">
|
||||
{providers.map((provider) => {
|
||||
const checked = selectedSet.has(provider.id);
|
||||
return (
|
||||
<button
|
||||
key={provider.id}
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
className={`provider-option${checked ? " is-selected" : ""}`}
|
||||
onClick={() => toggleProvider(provider.id)}
|
||||
>
|
||||
<span className="provider-mark" aria-hidden="true">{checked ? "✓" : ""}</span>
|
||||
<span>{provider.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<section className="popup-composer" aria-label="Prompt 输入">
|
||||
<Textarea
|
||||
value={draft}
|
||||
rows={8}
|
||||
autoFocus
|
||||
placeholder="输入你想同时发送给多个模型的 Prompt…"
|
||||
onChange={(event) => {
|
||||
setDraft(event.target.value);
|
||||
setError("");
|
||||
storage.set({ draftPrompt: event.target.value }).catch(() => {});
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
void launch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="composer-meta">
|
||||
<span>{draft.length} 字符</span>
|
||||
<span>Ctrl / ⌘ + Enter 发送</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error && <div className="popup-error" role="alert">{error}</div>}
|
||||
|
||||
<Button className="send-button" size="lg" disabled={busy || !hydrated} onClick={() => void launch()}>
|
||||
{busy ? "正在打开…" : "并行发送"}
|
||||
<Badge>{selected.length}</Badge>
|
||||
</Button>
|
||||
|
||||
<Button className="templates-button" variant="outline" onClick={() => void openTemplateLibrary()}>
|
||||
打开 Prompt Template Library
|
||||
</Button>
|
||||
|
||||
<footer>Prompt 仅保存在浏览器扩展本地存储中,不写入 URL。</footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<PopupApp />);
|
||||
@@ -0,0 +1,51 @@
|
||||
body {
|
||||
background:
|
||||
radial-gradient(circle at 18% 0%, rgba(79, 70, 229, .14), transparent 34%),
|
||||
var(--ui-background);
|
||||
color: var(--ui-text);
|
||||
margin: 0;
|
||||
min-height: 620px;
|
||||
width: 560px;
|
||||
}
|
||||
|
||||
.popup-app { padding: 20px; }
|
||||
.popup-header {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.popup-eyebrow { color: var(--ui-muted); font-size: 10px; font-weight: 750; letter-spacing: .16em; margin-bottom: 5px; }
|
||||
h1 { font-size: 22px; letter-spacing: -.03em; margin: 0; }
|
||||
.popup-header p { color: var(--ui-muted); font-size: 12px; line-height: 1.45; margin: 7px 0 0; max-width: 370px; }
|
||||
.popup-section-head { align-items: center; display: flex; font-size: 12px; font-weight: 650; justify-content: space-between; }
|
||||
.provider-grid { display: grid; gap: 8px; grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.provider-option {
|
||||
align-items: center;
|
||||
background: var(--ui-surface);
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 10px;
|
||||
color: #d4d4d8;
|
||||
display: flex;
|
||||
font-size: 12px;
|
||||
gap: 8px;
|
||||
min-height: 42px;
|
||||
overflow: hidden;
|
||||
padding: 9px 10px;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.provider-option:hover { border-color: var(--ui-border-strong); }
|
||||
.provider-option.is-selected { background: rgba(79, 70, 229, .14); border-color: #615fff; color: #fff; }
|
||||
.provider-mark { align-items: center; border: 1px solid #454751; border-radius: 5px; display: inline-flex; flex: 0 0 auto; height: 17px; justify-content: center; width: 17px; }
|
||||
.provider-option.is-selected .provider-mark { background: var(--ui-accent); border-color: var(--ui-accent); }
|
||||
.popup-composer { margin: 12px 0; }
|
||||
.popup-composer .ui-textarea { min-height: 180px; max-height: 360px; }
|
||||
.composer-meta { color: #71717a; display: flex; font-size: 11px; justify-content: space-between; margin: 7px 2px 0; }
|
||||
.send-button { justify-content: center; width: 100%; }
|
||||
.send-button .ui-badge { background: #d4d4d8; border: 0; color: #111217; }
|
||||
.templates-button { justify-content: center; margin-top: 10px; width: 100%; }
|
||||
.popup-error { background: rgba(127, 29, 29, .18); border: 1px solid #7f1d1d; border-radius: 9px; color: #fecaca; font-size: 12px; margin: -2px 0 10px; padding: 9px 11px; }
|
||||
footer { color: #5f616a; font-size: 10px; margin-top: 11px; text-align: center; }
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>AI Parallel Prompt Templates</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="../ui/template-library/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -27,7 +27,8 @@
|
||||
"service_worker": "service-worker-loader.js"
|
||||
},
|
||||
"action": {
|
||||
"default_title": "Open AI Parallel Workspace",
|
||||
"default_title": "AI Parallel Launcher",
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
"16": "icons/icon16.png",
|
||||
"32": "icons/icon32.png",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type HTMLAttributes } from "react";
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
export function Badge({ className, ...props }: HTMLAttributes<HTMLSpanElement>) {
|
||||
return <span className={cn("ui-badge", className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { forwardRef, type ButtonHTMLAttributes } from "react";
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
type ButtonVariant = "primary" | "secondary" | "outline" | "ghost" | "danger";
|
||||
type ButtonSize = "sm" | "md" | "lg";
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
}
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
{ className, variant = "primary", size = "md", type = "button", ...props },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={cn("ui-button", `ui-button-${variant}`, `ui-button-${size}`, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { type HTMLAttributes } from "react";
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <section className={cn("ui-card", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("ui-card-header", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("ui-card-content", className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { forwardRef, type InputHTMLAttributes } from "react";
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(function Input(
|
||||
{ className, ...props },
|
||||
ref
|
||||
) {
|
||||
return <input ref={ref} className={cn("ui-input", className)} {...props} />;
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { forwardRef, type TextareaHTMLAttributes } from "react";
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaHTMLAttributes<HTMLTextAreaElement>>(function Textarea(
|
||||
{ className, ...props },
|
||||
ref
|
||||
) {
|
||||
return <textarea ref={ref} className={cn("ui-textarea", className)} {...props} />;
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export function cn(...values: Array<string | false | null | undefined>) {
|
||||
return values.filter(Boolean).join(" ");
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { browser } from "wxt/browser";
|
||||
import type { JsonSchema } from "../../contracts/json-schema";
|
||||
import type { PromptTemplate, PromptTemplateCategory } from "../../contracts/template";
|
||||
import "../../shared/contract-runtime.js";
|
||||
import "../../shared/prompt-template-catalog.js";
|
||||
import "../../shared/prompt-template-utils.js";
|
||||
import "../../shared/storage-contract.js";
|
||||
import { Badge } from "../components/badge";
|
||||
import { Button } from "../components/button";
|
||||
import { Card, CardContent, CardHeader } from "../components/card";
|
||||
import { Input } from "../components/input";
|
||||
import { Textarea } from "../components/textarea";
|
||||
import "../theme.css";
|
||||
import "./templates.css";
|
||||
|
||||
const catalog = globalThis.AIParallelPromptTemplateCatalog;
|
||||
const utils = globalThis.AIParallelPromptTemplateUtils;
|
||||
const contractRuntime = globalThis.AIParallelContractRuntime;
|
||||
const storage = globalThis.AIParallelStorageContract.createLocalStorage();
|
||||
type FieldValues = Record<string, unknown>;
|
||||
|
||||
function schemaType(schema: JsonSchema) {
|
||||
return Array.isArray(schema.type) ? schema.type[0] : schema.type;
|
||||
}
|
||||
|
||||
function initialValues(template: PromptTemplate): FieldValues {
|
||||
return Object.fromEntries(Object.entries(template.inputSchema.properties || {}).map(([name, schema]) => {
|
||||
if (schema.default !== undefined) return [name, utils.clone(schema.default)];
|
||||
if (schemaType(schema) === "boolean") return [name, false];
|
||||
if (schemaType(schema) === "array") return [name, []];
|
||||
return [name, ""];
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeUserTemplates(value: unknown) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map((entry) => {
|
||||
try {
|
||||
const template = utils.normalizeTemplate(entry, { source: "user" });
|
||||
return utils.validateTemplateDefinition(template).ok ? template : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}).filter((template): template is PromptTemplate => Boolean(template));
|
||||
}
|
||||
|
||||
function categoryName(categoryId: string, categories: readonly PromptTemplateCategory[]) {
|
||||
return categories.find((category) => category.id === categoryId)?.name || categoryId;
|
||||
}
|
||||
|
||||
function newTemplateId(prefix = "custom.template") {
|
||||
const suffix = typeof crypto?.randomUUID === "function"
|
||||
? crypto.randomUUID().replace(/[^a-z0-9-]/gi, "")
|
||||
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
return `${prefix}.${suffix}`.slice(0, 96);
|
||||
}
|
||||
|
||||
function downloadJson(value: unknown, filename: string) {
|
||||
const blob = new Blob([JSON.stringify(value, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function fieldValue(schema: JsonSchema, raw: string, checked?: boolean): unknown {
|
||||
const type = schemaType(schema);
|
||||
if (type === "boolean") return Boolean(checked);
|
||||
if (type === "integer") return raw === "" ? "" : Number.parseInt(raw, 10);
|
||||
if (type === "number") return raw === "" ? "" : Number(raw);
|
||||
return raw;
|
||||
}
|
||||
|
||||
function TemplateForm({
|
||||
template,
|
||||
values,
|
||||
onChange,
|
||||
onClose,
|
||||
onApply
|
||||
}: {
|
||||
template: PromptTemplate;
|
||||
values: FieldValues;
|
||||
onChange: (name: string, value: unknown) => void;
|
||||
onClose: () => void;
|
||||
onApply: () => void;
|
||||
}) {
|
||||
const preview = utils.renderPromptTemplate(template, values);
|
||||
const properties = Object.entries(template.inputSchema.properties || {});
|
||||
const required = new Set(template.inputSchema.required || []);
|
||||
|
||||
return (
|
||||
<div className="template-modal-backdrop" role="presentation" onMouseDown={onClose}>
|
||||
<section className="template-modal" role="dialog" aria-modal="true" aria-labelledby="template-form-title" onMouseDown={(event) => event.stopPropagation()}>
|
||||
<div className="template-modal-header">
|
||||
<div>
|
||||
<div className="template-eyebrow">USE TEMPLATE</div>
|
||||
<h2 id="template-form-title">{template.name}</h2>
|
||||
<p>{template.description}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" autoFocus onClick={onClose} aria-label="关闭模板表单">关闭</Button>
|
||||
</div>
|
||||
<form onSubmit={(event) => { event.preventDefault(); onApply(); }}>
|
||||
<div className="template-form-fields">
|
||||
{properties.map(([name, schema]) => {
|
||||
const type = schemaType(schema);
|
||||
const value = values[name];
|
||||
const label = schema.title || name;
|
||||
if (type === "boolean") {
|
||||
return (
|
||||
<label className="template-checkbox" key={name}>
|
||||
<input type="checkbox" checked={Boolean(value)} onChange={(event) => onChange(name, fieldValue(schema, "", event.target.checked))} />
|
||||
<span>{label}{required.has(name) ? " *" : ""}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
const isLong = type === "string" && (name.toLowerCase().includes("text") || name.toLowerCase().includes("code") || name.toLowerCase().includes("context") || (schema.maxLength || 0) > 240);
|
||||
const control = schema.enum?.length ? (
|
||||
<select value={String(value ?? "")} required={required.has(name)} onChange={(event) => onChange(name, fieldValue(schema, event.target.value))}>
|
||||
<option value="">请选择…</option>
|
||||
{schema.enum.map((option) => <option key={String(option)} value={String(option)}>{String(option)}</option>)}
|
||||
</select>
|
||||
) : isLong ? (
|
||||
<Textarea value={String(value ?? "")} required={required.has(name)} placeholder={schema.description || label} onChange={(event) => onChange(name, fieldValue(schema, event.target.value))} />
|
||||
) : (
|
||||
<Input
|
||||
type={type === "integer" || type === "number" ? "number" : "text"}
|
||||
min={schema.minimum}
|
||||
max={schema.maximum}
|
||||
step={type === "integer" ? 1 : "any"}
|
||||
value={String(value ?? "")}
|
||||
required={required.has(name)}
|
||||
placeholder={schema.description || label}
|
||||
onChange={(event) => onChange(name, fieldValue(schema, event.target.value))}
|
||||
/>
|
||||
);
|
||||
return <label className="template-field" key={name}><span>{label}{required.has(name) ? " *" : ""}</span>{control}</label>;
|
||||
})}
|
||||
</div>
|
||||
<details className="schema-details">
|
||||
<summary>查看输出 Schema</summary>
|
||||
<pre>{template.output.mode === "json" ? JSON.stringify(template.output.schema, null, 2) : "文本输出,不附加 JSON Schema"}</pre>
|
||||
</details>
|
||||
<div className="template-preview">
|
||||
<div className="template-preview-title">Prompt 预览</div>
|
||||
<pre>{preview.ok ? preview.prompt : preview.errors.map((error) => `${error.path}: ${error.message}`).join("\n")}</pre>
|
||||
</div>
|
||||
<div className="template-modal-actions">
|
||||
<Button variant="outline" onClick={onClose}>取消</Button>
|
||||
<Button type="submit">写入 Workspace</Button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateLibraryApp() {
|
||||
const [userTemplates, setUserTemplates] = useState<PromptTemplate[]>([]);
|
||||
const [category, setCategory] = useState("all");
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeTemplate, setActiveTemplate] = useState<PromptTemplate | null>(null);
|
||||
const [templateValues, setTemplateValues] = useState<FieldValues>({});
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editorText, setEditorText] = useState("");
|
||||
const [status, setStatus] = useState("模板、输入 Schema 和输出 Schema 均保存在当前浏览器扩展中");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const known = new Set(catalog.categories.map((item) => item.id));
|
||||
const customCategories = userTemplates
|
||||
.filter((template) => !known.has(template.categoryId))
|
||||
.map((template) => ({ id: template.categoryId, name: template.categoryId, description: "自定义分类" }));
|
||||
return [...catalog.categories, ...customCategories];
|
||||
}, [userTemplates]);
|
||||
const templates = useMemo(() => [...catalog.templates, ...userTemplates], [userTemplates]);
|
||||
const filteredTemplates = useMemo(() => {
|
||||
const normalized = query.trim().toLocaleLowerCase();
|
||||
return templates.filter((template) => {
|
||||
if (category !== "all" && template.categoryId !== category) return false;
|
||||
if (!normalized) return true;
|
||||
return [template.name, template.description, template.categoryId, ...(template.tags || [])]
|
||||
.join(" ").toLocaleLowerCase().includes(normalized);
|
||||
});
|
||||
}, [category, query, templates]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
storage.get(["promptTemplatesV1"]).then((data) => {
|
||||
if (active) setUserTemplates(normalizeUserTemplates(data.promptTemplatesV1));
|
||||
}).catch((reason) => {
|
||||
if (active) setError(reason instanceof Error ? reason.message : String(reason));
|
||||
}).finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, []);
|
||||
|
||||
async function persist(next: PromptTemplate[]) {
|
||||
setUserTemplates(next);
|
||||
await storage.set({ promptTemplatesV1: next });
|
||||
}
|
||||
|
||||
async function importText(text: string) {
|
||||
const result = utils.parseTemplateImport(text, { source: "file" });
|
||||
if (!result.ok) {
|
||||
setError(`导入失败:${result.errors.map((item) => `${item.path} ${item.message}`).join(";")}`);
|
||||
return;
|
||||
}
|
||||
const imported = result.templates.map((template) => {
|
||||
const copy = utils.clone(template);
|
||||
copy.id = templates.some((item) => item.id === copy.id) ? newTemplateId("custom.imported") : copy.id;
|
||||
copy.metadata = { ...(copy.metadata || {}), source: "user", importedAt: new Date().toISOString() };
|
||||
return copy;
|
||||
});
|
||||
await persist([...imported, ...userTemplates]);
|
||||
setError("");
|
||||
setStatus(`已导入 ${imported.length} 个模板${result.warnings.length ? " · 有可选警告" : ""}`);
|
||||
}
|
||||
|
||||
function openUseForm(template: PromptTemplate) {
|
||||
setActiveTemplate(template);
|
||||
setTemplateValues(initialValues(template));
|
||||
setError("");
|
||||
}
|
||||
|
||||
async function applyTemplate() {
|
||||
if (!activeTemplate) return;
|
||||
const result = utils.renderPromptTemplate(activeTemplate, templateValues);
|
||||
if (!result.ok) {
|
||||
setError(result.errors.map((item) => `${item.path} ${item.message}`).join(";"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await storage.set({ draftPrompt: result.prompt });
|
||||
const request = { type: "OPEN_WORKSPACE" } as const;
|
||||
if (!contractRuntime.isServiceWorkerRequest(request)) throw new Error("Invalid workspace request");
|
||||
const response = await browser.runtime.sendMessage(request) as { ok?: boolean; error?: string };
|
||||
if (!contractRuntime.isServiceWorkerResponse(response) || !response.ok) throw new Error(response?.error || "Workspace 打开失败");
|
||||
setStatus(`${activeTemplate.name} 已写入 Workspace`);
|
||||
setActiveTemplate(null);
|
||||
setError("");
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : String(reason));
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTemplate(template: PromptTemplate) {
|
||||
if (template.metadata?.source !== "user") return;
|
||||
await persist(userTemplates.filter((entry) => entry.id !== template.id));
|
||||
setStatus(`${template.name} 已删除`);
|
||||
}
|
||||
|
||||
async function duplicateTemplate(template: PromptTemplate) {
|
||||
const copy = utils.clone(template);
|
||||
copy.id = newTemplateId("custom.copy");
|
||||
copy.name = `${template.name} 副本`;
|
||||
copy.metadata = { ...(copy.metadata || {}), source: "user", copiedFrom: template.id };
|
||||
await persist([copy, ...userTemplates]);
|
||||
setStatus(`${copy.name} 已创建`);
|
||||
}
|
||||
|
||||
function openEditor(template: PromptTemplate) {
|
||||
setEditingId(template.id);
|
||||
setEditorText(JSON.stringify(template, null, 2));
|
||||
setError("");
|
||||
}
|
||||
|
||||
async function saveEditor() {
|
||||
if (!editingId) return;
|
||||
const result = utils.parseTemplateImport(editorText, { source: "user" });
|
||||
if (!result.ok || !result.templates[0]) {
|
||||
setError(`保存失败:${result.errors.map((item) => `${item.path} ${item.message}`).join(";")}`);
|
||||
return;
|
||||
}
|
||||
const updated = utils.clone(result.templates[0]);
|
||||
updated.id = editingId;
|
||||
updated.metadata = { ...(updated.metadata || {}), source: "user" };
|
||||
await persist(userTemplates.map((template) => template.id === editingId ? updated : template));
|
||||
setEditingId(null);
|
||||
setStatus(`${updated.name} 已更新`);
|
||||
setError("");
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="templates-app">
|
||||
<header className="templates-header">
|
||||
<div>
|
||||
<div className="template-eyebrow">AI PARALLEL / PROMPT TEMPLATES</div>
|
||||
<h1>Prompt Template Library</h1>
|
||||
<p>通过 JSON Schema 约束输入和输出,让其他模型生成的模板可以安全导入、复用和导出。</p>
|
||||
</div>
|
||||
<Button variant="ghost" onClick={() => window.close()}>关闭</Button>
|
||||
</header>
|
||||
|
||||
<Card className="library-toolbar">
|
||||
<CardContent className="toolbar-content">
|
||||
<select value={category} aria-label="模板分类" onChange={(event) => setCategory(event.target.value)}>
|
||||
<option value="all">全部分类</option>
|
||||
{categories.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||
</select>
|
||||
<Input type="search" value={query} maxLength={80} placeholder="搜索模板、描述或标签" aria-label="搜索模板" onChange={(event) => setQuery(event.target.value)} />
|
||||
<input id="template-import" className="visually-hidden" type="file" accept="application/json,.json" onChange={async (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.currentTarget.value = "";
|
||||
if (!file) return;
|
||||
try { await importText(await file.text()); } catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)); }
|
||||
}} />
|
||||
<Button variant="outline" onClick={() => document.getElementById("template-import")?.click()}>导入 JSON</Button>
|
||||
<Button variant="outline" onClick={() => downloadJson(utils.toPackage(templates), `ai-parallel-prompt-templates-${new Date().toISOString().slice(0, 10)}.json`)}>导出全部</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="library-status" role="status">{loading ? "正在读取模板…" : status}</div>
|
||||
{error && <div className="template-error" role="alert">{error}</div>}
|
||||
|
||||
{filteredTemplates.length === 0 ? (
|
||||
<Card className="empty-card"><CardContent>没有匹配的模板;可以导入其他模型生成的 JSON。</CardContent></Card>
|
||||
) : (
|
||||
<section className="template-grid" aria-label="模板列表">
|
||||
{filteredTemplates.map((template) => {
|
||||
const userOwned = template.metadata?.source === "user";
|
||||
return (
|
||||
<Card key={template.id} className="template-card">
|
||||
<CardHeader className="template-card-header">
|
||||
<div>
|
||||
<h2>{template.name}</h2>
|
||||
<div className="template-card-meta">{categoryName(template.categoryId, categories)} · {template.output.mode === "json" ? "JSON 输出" : "文本输出"} · v{template.version}</div>
|
||||
</div>
|
||||
<Badge>{userOwned ? "自定义" : "内置"}</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="template-description">{template.description}</p>
|
||||
<div className="template-tags">{(template.tags || []).slice(0, 4).map((tag) => <span key={tag}>#{tag}</span>)}</div>
|
||||
<div className="template-card-actions">
|
||||
<Button size="sm" onClick={() => openUseForm(template)}>使用</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => void duplicateTemplate(template)}>复制</Button>
|
||||
{userOwned && <Button size="sm" variant="outline" onClick={() => openEditor(template)}>编辑</Button>}
|
||||
<Button size="sm" variant="ghost" onClick={() => downloadJson(template, `${template.id}.json`)}>导出</Button>
|
||||
{userOwned && <Button size="sm" variant="danger" onClick={() => void deleteTemplate(template)}>删除</Button>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{activeTemplate && <TemplateForm
|
||||
template={activeTemplate}
|
||||
values={templateValues}
|
||||
onChange={(name, value) => setTemplateValues((current) => ({ ...current, [name]: value }))}
|
||||
onClose={() => setActiveTemplate(null)}
|
||||
onApply={() => void applyTemplate()}
|
||||
/>}
|
||||
|
||||
{editingId && <div className="template-modal-backdrop" role="presentation" onMouseDown={() => setEditingId(null)}>
|
||||
<section className="template-modal editor-modal" role="dialog" aria-modal="true" aria-labelledby="template-editor-title" onMouseDown={(event) => event.stopPropagation()}>
|
||||
<div className="template-modal-header"><div><div className="template-eyebrow">EDIT JSON</div><h2 id="template-editor-title">编辑模板定义</h2><p>保存前会重新执行模板 Schema 校验。</p></div><Button variant="ghost" size="sm" autoFocus onClick={() => setEditingId(null)}>关闭</Button></div>
|
||||
<Textarea className="json-editor" value={editorText} onChange={(event) => setEditorText(event.target.value)} spellCheck={false} />
|
||||
<div className="template-modal-actions"><Button variant="outline" onClick={() => setEditingId(null)}>取消</Button><Button onClick={() => void saveEditor()}>校验并保存</Button></div>
|
||||
</section>
|
||||
</div>}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<TemplateLibraryApp />);
|
||||
@@ -0,0 +1,38 @@
|
||||
body { background: var(--ui-background); color: var(--ui-text); margin: 0; min-width: 960px; }
|
||||
.templates-app { margin: 0 auto; max-width: 1220px; padding: 32px; }
|
||||
.templates-header { align-items: flex-start; display: flex; gap: 20px; justify-content: space-between; margin-bottom: 24px; }
|
||||
.template-eyebrow { color: var(--ui-muted); font-size: 10px; font-weight: 750; letter-spacing: .16em; margin-bottom: 6px; }
|
||||
h1, h2 { letter-spacing: -.03em; margin: 0; }
|
||||
h1 { font-size: 30px; }
|
||||
h2 { font-size: 16px; }
|
||||
.templates-header p { color: var(--ui-muted); font-size: 13px; line-height: 1.55; margin: 9px 0 0; max-width: 680px; }
|
||||
.library-toolbar { margin-bottom: 14px; }
|
||||
.toolbar-content { align-items: center; display: flex; gap: 9px; }
|
||||
.toolbar-content select { max-width: 190px; }
|
||||
.toolbar-content .ui-input { flex: 1; }
|
||||
.visually-hidden { height: 1px; opacity: 0; overflow: hidden; position: absolute; width: 1px; }
|
||||
.library-status { color: var(--ui-muted); font-size: 12px; min-height: 20px; padding: 4px 2px 12px; }
|
||||
.template-error { background: rgba(127, 29, 29, .18); border: 1px solid #7f1d1d; border-radius: 9px; color: #fecaca; font-size: 12px; margin: 0 0 14px; padding: 10px 12px; }
|
||||
.template-grid { display: grid; gap: 13px; grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.template-card { min-height: 210px; }
|
||||
.template-card-header { align-items: flex-start; display: flex; gap: 12px; justify-content: space-between; }
|
||||
.template-card-meta { color: var(--ui-muted); font-size: 11px; margin-top: 7px; }
|
||||
.template-description { color: #c9cad1; font-size: 13px; line-height: 1.5; margin: 0 0 11px; min-height: 40px; }
|
||||
.template-tags { color: #a5b4fc; display: flex; flex-wrap: wrap; font-size: 11px; gap: 6px; min-height: 17px; }
|
||||
.template-card-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 17px; }
|
||||
.empty-card { color: var(--ui-muted); text-align: center; }
|
||||
.template-modal-backdrop { align-items: center; background: rgba(2, 3, 7, .76); display: flex; inset: 0; justify-content: center; padding: 24px; position: fixed; z-index: 10; }
|
||||
.template-modal { background: #101116; border: 1px solid var(--ui-border-strong); border-radius: 15px; box-shadow: 0 22px 80px rgba(0, 0, 0, .48); max-height: calc(100vh - 48px); max-width: 760px; overflow: auto; padding: 20px; width: 100%; }
|
||||
.editor-modal { max-width: 920px; }
|
||||
.template-modal-header { align-items: flex-start; display: flex; gap: 15px; justify-content: space-between; margin-bottom: 18px; }
|
||||
.template-modal-header p { color: var(--ui-muted); font-size: 12px; line-height: 1.5; margin: 7px 0 0; }
|
||||
.template-form-fields { display: grid; gap: 13px; }
|
||||
.template-field { color: #d4d4d8; display: grid; font-size: 12px; gap: 6px; }
|
||||
.template-checkbox { align-items: center; color: #d4d4d8; display: flex; font-size: 12px; gap: 8px; }
|
||||
.schema-details, .template-preview { background: #0b0c0f; border: 1px solid var(--ui-border); border-radius: 9px; margin-top: 14px; padding: 10px 12px; }
|
||||
.schema-details summary { color: #c7d2fe; cursor: pointer; font-size: 12px; }
|
||||
pre { color: #cfd2dc; font-family: "SFMono-Regular", Consolas, monospace; font-size: 11px; line-height: 1.5; margin: 9px 0 0; max-height: 230px; overflow: auto; white-space: pre-wrap; }
|
||||
.template-preview-title { color: var(--ui-muted); font-size: 11px; }
|
||||
.template-modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 17px; }
|
||||
.json-editor { font-family: "SFMono-Regular", Consolas, monospace; min-height: 480px; }
|
||||
@media (max-width: 1000px) { body { min-width: 0; } .templates-app { padding: 20px; } .template-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@@ -0,0 +1,72 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--ui-background: #0b0c0f;
|
||||
--ui-surface: #111217;
|
||||
--ui-surface-raised: #181a21;
|
||||
--ui-border: #292c36;
|
||||
--ui-border-strong: #4b4f62;
|
||||
--ui-text: #f4f4f5;
|
||||
--ui-muted: #8b8d98;
|
||||
--ui-accent: #6366f1;
|
||||
--ui-danger: #ef4444;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
button, input, textarea, select { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
|
||||
.ui-button {
|
||||
align-items: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9px;
|
||||
display: inline-flex;
|
||||
gap: 7px;
|
||||
justify-content: center;
|
||||
transition: background .15s ease, border-color .15s ease, opacity .15s ease;
|
||||
}
|
||||
.ui-button:focus-visible, .ui-input:focus-visible, .ui-textarea:focus-visible, select:focus-visible {
|
||||
outline: 2px solid rgba(129, 140, 248, .9);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.ui-button:disabled { cursor: not-allowed; opacity: .45; }
|
||||
.ui-button-sm { min-height: 30px; padding: 5px 9px; font-size: 12px; }
|
||||
.ui-button-md { min-height: 38px; padding: 8px 13px; font-size: 13px; }
|
||||
.ui-button-lg { min-height: 46px; padding: 11px 17px; font-size: 14px; }
|
||||
.ui-button-primary { background: var(--ui-text); color: #111217; }
|
||||
.ui-button-primary:hover { background: #fff; }
|
||||
.ui-button-secondary { background: var(--ui-surface-raised); border-color: var(--ui-border); color: var(--ui-text); }
|
||||
.ui-button-secondary:hover, .ui-button-outline:hover { border-color: var(--ui-border-strong); background: #20232c; }
|
||||
.ui-button-outline { background: transparent; border-color: var(--ui-border); color: var(--ui-text); }
|
||||
.ui-button-ghost { background: transparent; color: var(--ui-muted); }
|
||||
.ui-button-ghost:hover { background: var(--ui-surface-raised); color: var(--ui-text); }
|
||||
.ui-button-danger { background: rgba(127, 29, 29, .22); border-color: #7f1d1d; color: #fecaca; }
|
||||
.ui-button-danger:hover { background: rgba(153, 27, 27, .36); }
|
||||
|
||||
.ui-input, .ui-textarea, select {
|
||||
background: var(--ui-surface);
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 9px;
|
||||
color: var(--ui-text);
|
||||
padding: 9px 11px;
|
||||
width: 100%;
|
||||
}
|
||||
.ui-input::placeholder, .ui-textarea::placeholder { color: #656875; }
|
||||
.ui-textarea { line-height: 1.5; min-height: 96px; resize: vertical; }
|
||||
.ui-card {
|
||||
background: rgba(18, 19, 24, .84);
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 13px;
|
||||
}
|
||||
.ui-card-header { padding: 13px 14px 0; }
|
||||
.ui-card-content { padding: 13px 14px 14px; }
|
||||
.ui-badge {
|
||||
background: rgba(99, 102, 241, .14);
|
||||
border: 1px solid rgba(129, 140, 248, .35);
|
||||
border-radius: 99px;
|
||||
color: #c7d2fe;
|
||||
display: inline-flex;
|
||||
font-size: 10px;
|
||||
padding: 3px 7px;
|
||||
}
|
||||
@@ -15,7 +15,13 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "26.5.1",
|
||||
"@types/react": "19.1.12",
|
||||
"@types/react-dom": "19.1.9",
|
||||
"typescript": "5.9.3",
|
||||
"wxt": "0.21.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "19.1.1",
|
||||
"react-dom": "19.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+55
@@ -7,10 +7,23 @@ settings:
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
react:
|
||||
specifier: 19.1.1
|
||||
version: 19.1.1
|
||||
react-dom:
|
||||
specifier: 19.1.1
|
||||
version: 19.1.1(react@19.1.1)
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: 26.5.1
|
||||
version: 26.5.1
|
||||
'@types/react':
|
||||
specifier: 19.1.12
|
||||
version: 19.1.12
|
||||
'@types/react-dom':
|
||||
specifier: 19.1.9
|
||||
version: 19.1.9(@types/react@19.1.12)
|
||||
typescript:
|
||||
specifier: 5.9.3
|
||||
version: 5.9.3
|
||||
@@ -197,6 +210,14 @@ packages:
|
||||
'@types/node@26.5.1':
|
||||
resolution: {integrity: sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==}
|
||||
|
||||
'@types/react-dom@19.1.9':
|
||||
resolution: {integrity: sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==}
|
||||
peerDependencies:
|
||||
'@types/react': ^19.0.0
|
||||
|
||||
'@types/react@19.1.12':
|
||||
resolution: {integrity: sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==}
|
||||
|
||||
'@webext-core/fake-browser@2.0.1':
|
||||
resolution: {integrity: sha512-4x5z1z8F0KU8ShF4ForXJ8qnA8oZTy7HYjI91Mbpdzp3H3hU9HR6TNPUxfWtSpFz2FwgEircuJKQg0qFIn6RLg==}
|
||||
|
||||
@@ -283,6 +304,9 @@ packages:
|
||||
cssom@0.5.0:
|
||||
resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==}
|
||||
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
define-lazy-prop@2.0.0:
|
||||
resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -588,6 +612,15 @@ packages:
|
||||
rc9@3.1.0:
|
||||
resolution: {integrity: sha512-ufjkNVzbRHKcCOmTahZkmVsyc3W+MSk3jY03m+a7tGHkIsdVMG9l10/3HvFbWkkKzY5VFp3pkRsIo/UYgmFL7Q==}
|
||||
|
||||
react-dom@19.1.1:
|
||||
resolution: {integrity: sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==}
|
||||
peerDependencies:
|
||||
react: ^19.1.1
|
||||
|
||||
react@19.1.1:
|
||||
resolution: {integrity: sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
readdirp@5.1.1:
|
||||
resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
@@ -601,6 +634,9 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
scheduler@0.26.0:
|
||||
resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==}
|
||||
|
||||
scule@1.3.0:
|
||||
resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==}
|
||||
|
||||
@@ -899,6 +935,14 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 8.9.0
|
||||
|
||||
'@types/react-dom@19.1.9(@types/react@19.1.12)':
|
||||
dependencies:
|
||||
'@types/react': 19.1.12
|
||||
|
||||
'@types/react@19.1.12':
|
||||
dependencies:
|
||||
csstype: 3.2.3
|
||||
|
||||
'@webext-core/fake-browser@2.0.1':
|
||||
dependencies:
|
||||
'@wxt-dev/browser': 0.2.9
|
||||
@@ -987,6 +1031,8 @@ snapshots:
|
||||
|
||||
cssom@0.5.0: {}
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
define-lazy-prop@2.0.0: {}
|
||||
|
||||
defu@6.1.7: {}
|
||||
@@ -1240,6 +1286,13 @@ snapshots:
|
||||
defu: 6.1.7
|
||||
destr: 2.0.5
|
||||
|
||||
react-dom@19.1.1(react@19.1.1):
|
||||
dependencies:
|
||||
react: 19.1.1
|
||||
scheduler: 0.26.0
|
||||
|
||||
react@19.1.1: {}
|
||||
|
||||
readdirp@5.1.1: {}
|
||||
|
||||
require-directory@2.1.1: {}
|
||||
@@ -1265,6 +1318,8 @@ snapshots:
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.2.8
|
||||
'@rolldown/binding-win32-x64-msvc': 1.2.8
|
||||
|
||||
scheduler@0.26.0: {}
|
||||
|
||||
scule@1.3.0: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
@@ -8,9 +8,6 @@ const sourceRoot = resolve(repoDir, "apps/browser-extension");
|
||||
const publicRoot = resolve(repoDir, ".wxt-legacy");
|
||||
|
||||
const files = [
|
||||
"popup.html",
|
||||
"popup.css",
|
||||
"popup.js",
|
||||
"shared/contract-runtime.js",
|
||||
"shared/storage-contract.js",
|
||||
"workspace/index.html",
|
||||
|
||||
@@ -77,6 +77,26 @@ for (const relativePath of referencedFiles) {
|
||||
}
|
||||
}
|
||||
|
||||
for (const page of ["popup.html", "templates.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))) {
|
||||
throw new Error(`Generated ${page} references missing asset: ${reference}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
const generatedContentScript = built.content_scripts[0]?.js?.[0];
|
||||
if (generatedContentScript) {
|
||||
const contentBundle = readFileSync(resolve(buildRoot, generatedContentScript), "utf8");
|
||||
|
||||
@@ -84,6 +84,7 @@ test("extension entry points load the catalog before consuming it", () => {
|
||||
assert.match(popupHtml, /shared\/contract-runtime\.js[\s\S]*shared\/storage-contract\.js[\s\S]*popup\.js/);
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(extensionRoot, "manifest.json"), "utf8"));
|
||||
assert.equal(manifest.background.service_worker, "service-worker-loader.js");
|
||||
assert.equal(manifest.action.default_popup, "popup.html");
|
||||
const contentScripts = manifest.content_scripts[0].js;
|
||||
assert.ok(contentScripts.indexOf("shared/contract-runtime.js") < contentScripts.indexOf("content/frame-bridge.js"));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
|
||||
const extensionRoot = path.join(__dirname, "..", "apps", "browser-extension");
|
||||
|
||||
function read(...parts) {
|
||||
return fs.readFileSync(path.join(extensionRoot, ...parts), "utf8");
|
||||
}
|
||||
|
||||
test("React extension pages use local module and style assets under the extension CSP", () => {
|
||||
const popupHtml = read("entrypoints", "popup", "index.html");
|
||||
const templateHtml = read("entrypoints", "templates.html");
|
||||
|
||||
for (const html of [popupHtml, templateHtml]) {
|
||||
assert.doesNotMatch(html, /https?:\/\//);
|
||||
assert.match(html, /<script type="module" src="[./a-z-]+\.tsx"><\/script>/);
|
||||
}
|
||||
assert.match(popupHtml, /id="root"/);
|
||||
assert.match(templateHtml, /id="root"/);
|
||||
});
|
||||
|
||||
test("Popup React slice preserves provider, storage, message, keyboard, and template entry behavior", () => {
|
||||
const source = read("entrypoints", "popup", "main.tsx");
|
||||
assert.match(source, /AIParallelProviderCatalog/);
|
||||
assert.match(source, /selectedProviders/);
|
||||
assert.match(source, /pendingLaunch/);
|
||||
assert.match(source, /OPEN_WORKSPACE/);
|
||||
assert.match(source, /Ctrl \/ ⌘ \+ Enter/);
|
||||
assert.match(source, /role="checkbox"/);
|
||||
assert.match(source, /templates\.html/);
|
||||
assert.match(source, /createRoot\(document\.getElementById\("root"\)!\)/);
|
||||
});
|
||||
|
||||
test("React template library covers schema validation, import/export, editing, and empty/error states", () => {
|
||||
const source = read("ui", "template-library", "main.tsx");
|
||||
assert.match(source, /parseTemplateImport/);
|
||||
assert.match(source, /validateTemplateDefinition/);
|
||||
assert.match(source, /renderPromptTemplate/);
|
||||
assert.match(source, /toPackage/);
|
||||
assert.match(source, /promptTemplatesV1/);
|
||||
assert.match(source, /role="alert"/);
|
||||
assert.match(source, /role="status"/);
|
||||
assert.match(source, /没有匹配的模板/);
|
||||
assert.match(source, /OPEN_WORKSPACE/);
|
||||
assert.match(source, /编辑模板定义/);
|
||||
assert.match(source, /createRoot\(document\.getElementById\("root"\)!\)/);
|
||||
});
|
||||
@@ -5,12 +5,16 @@
|
||||
"checkJs": false,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"resolveJsonModule": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": [
|
||||
"wxt.config.ts",
|
||||
"apps/browser-extension/entrypoints/**/*.ts",
|
||||
"apps/browser-extension/entrypoints/**/*.tsx",
|
||||
"apps/browser-extension/ui/**/*.ts",
|
||||
"apps/browser-extension/ui/**/*.tsx",
|
||||
"apps/browser-extension/contracts/**/*.ts",
|
||||
"apps/browser-extension/shared/provider-catalog.js",
|
||||
"apps/browser-extension/shared/prompt-template-catalog.js",
|
||||
|
||||
Reference in New Issue
Block a user