feat(ui): add React popup and template library

This commit is contained in:
2026-09-15 23:26:44 +08:00
parent 790005e56e
commit 604fd63e3b
23 changed files with 964 additions and 8 deletions
+6 -4
View File
@@ -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
View File
@@ -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>
+2 -1
View File
@@ -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} />;
});
+3
View File
@@ -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)); } }
+72
View File
@@ -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;
}
+6
View File
@@ -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"
}
}
+55
View File
@@ -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: {}
-3
View File
@@ -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",
+20
View File
@@ -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");
+1
View File
@@ -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"));
});
+49
View File
@@ -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"\)!\)/);
});
+4
View File
@@ -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",