feat(workspace): migrate prompt library drawer

This commit is contained in:
2026-09-16 03:13:12 +08:00
parent b06685e548
commit 541e5a1e2e
6 changed files with 210 additions and 12 deletions
@@ -0,0 +1,85 @@
import { useState } from "react";
import { Badge } from "../../components/badge";
import { Button } from "../../components/button";
import type { WorkspacePromptSummary } from "./prompt-status";
export type WorkspacePromptAction =
| { type: "save"; title: string }
| { type: "use"; id: string }
| { type: "delete"; id: string };
export function PromptLibraryDrawer({
open,
prompts,
status,
onClose,
onAction
}: {
open: boolean;
prompts: readonly WorkspacePromptSummary[];
status: string;
onClose: () => void;
onAction: (action: WorkspacePromptAction) => void;
}) {
const [title, setTitle] = useState("");
if (!open) return null;
function savePrompt() {
onAction({ type: "save", title: title.trim() });
setTitle("");
}
return (
<div className="workspace-react-drawer-layer" role="presentation">
<button className="workspace-react-drawer-backdrop" type="button" aria-label="关闭 Prompt Library" onClick={onClose} />
<aside className="workspace-react-drawer" role="dialog" aria-modal="true" aria-labelledby="workspacePromptDrawerTitle">
<header className="workspace-react-drawer-header">
<div>
<div id="workspacePromptDrawerTitle" className="workspace-react-drawer-title">Prompt Library</div>
<div className="workspace-react-drawer-status">{status || "保存在当前浏览器扩展存储中"}</div>
</div>
<Button size="sm" variant="ghost" aria-label="关闭 Prompt Library" onClick={onClose}>×</Button>
</header>
<div className="workspace-react-drawer-create">
<input
type="text"
maxLength={80}
placeholder="Prompt 名称(可选)"
value={title}
onChange={(event) => setTitle(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") savePrompt();
}}
/>
<Button size="sm" onClick={savePrompt}> Prompt</Button>
</div>
<div className="workspace-react-prompt-list">
{!prompts.length && <div className="workspace-react-drawer-empty"> Prompt</div>}
{prompts.map((prompt) => (
<article className="workspace-react-prompt-card" key={prompt.id}>
<header className="workspace-react-prompt-card-header">
<strong title={prompt.title}>{prompt.title}</strong>
<Badge>{prompt.contentLength} </Badge>
</header>
<time dateTime={prompt.updatedAt}>{formatPromptDate(prompt.updatedAt)}</time>
<p>{prompt.content}</p>
<div className="workspace-react-prompt-actions">
<Button size="sm" variant="secondary" onClick={() => onAction({ type: "use", id: prompt.id })}>使</Button>
<Button size="sm" variant="danger" onClick={() => onAction({ type: "delete", id: prompt.id })}></Button>
</div>
</article>
))}
</div>
</aside>
</div>
);
}
function formatPromptDate(value: string) {
const date = new Date(value);
return Number.isNaN(date.getTime())
? "时间未知"
: date.toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
}
@@ -6,6 +6,7 @@ export interface WorkspacePromptSummary {
title: string;
updatedAt: string;
contentLength: number;
content: string;
}
function formatPromptDate(value: string) {
@@ -6,6 +6,7 @@ import { ProviderReadinessPanel, type ProviderPanelAction } from "./features/pro
import { CompareStatus, type CompareSummary } from "./features/compare-status";
import { HandoffStatus } from "./features/handoff-status";
import { LibraryStatus, type WorkspaceLibrarySummary } from "./features/library-status";
import { PromptLibraryDrawer, type WorkspacePromptAction } from "./features/prompt-library-drawer";
import { PromptStatus, type WorkspacePromptSummary } from "./features/prompt-status";
import { SessionStatus, type WorkspaceSessionSummary } from "./features/session-status";
import { TemplateStatus, type WorkspaceTemplateSummary } from "./features/template-status";
@@ -111,7 +112,8 @@ function readPromptSummaries(value: unknown): WorkspacePromptSummary[] {
id: record.id,
title: typeof record.title === "string" && record.title.trim() ? record.title : "Untitled Prompt",
updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : "",
contentLength
contentLength,
content: typeof record.content === "string" ? record.content : ""
}];
}).slice(0, 50);
}
@@ -142,6 +144,12 @@ function emitSelection(providerIds: readonly ProviderId[]) {
}));
}
function emitPromptAction(action: WorkspacePromptAction) {
window.dispatchEvent(new CustomEvent("ai-parallel:workspace-prompt-action", {
detail: action
}));
}
function triggerLegacyAction(action: WorkspaceAction) {
document.getElementById(legacyActionIds[action])?.click();
}
@@ -162,6 +170,8 @@ export function WorkspaceShell() {
const [sessions, setSessions] = useState<WorkspaceSessionSummary[]>([]);
const [prompts, setPrompts] = useState<WorkspacePromptSummary[]>([]);
const [templates, setTemplates] = useState<WorkspaceTemplateSummary[]>([]);
const [promptDrawerOpen, setPromptDrawerOpen] = useState(false);
const [promptActionStatus, setPromptActionStatus] = useState("");
useEffect(() => {
let active = true;
@@ -191,10 +201,18 @@ export function WorkspaceShell() {
if (detail?.prompts) setPrompts(readPromptSummaries(detail.prompts));
if (detail?.templates) setTemplates(readTemplateSummaries(detail.templates));
};
const handlePromptActionResult = (event: Event) => {
const detail = (event as CustomEvent<{ ok?: unknown; message?: unknown; error?: unknown }>).detail;
if (detail?.ok === true) setPromptActionStatus(typeof detail.message === "string" ? detail.message : "操作已完成");
else if (detail?.ok === false) setPromptActionStatus(typeof detail.error === "string" ? detail.error : "Prompt 操作失败");
};
window.addEventListener("ai-parallel:workspace-state", handleWorkspaceState);
window.addEventListener("ai-parallel:workspace-prompt-action-result", handlePromptActionResult);
window.dispatchEvent(new CustomEvent("ai-parallel:workspace-state-request"));
return () => {
active = false;
window.removeEventListener("ai-parallel:workspace-state", handleWorkspaceState);
window.removeEventListener("ai-parallel:workspace-prompt-action-result", handlePromptActionResult);
};
}, []);
@@ -211,6 +229,25 @@ export function WorkspaceShell() {
document.querySelector<HTMLButtonElement>(`.layout-switch button[data-layout="${next}"]`)?.click();
}
function openWorkspaceAction(action: WorkspaceAction) {
if (action === "prompt") {
setPromptActionStatus("");
setPromptDrawerOpen(true);
return;
}
openLegacyAction(action);
}
function openLegacyAction(action: WorkspaceAction) {
setPromptDrawerOpen(false);
triggerLegacyAction(action);
}
function handlePromptAction(action: WorkspacePromptAction) {
setPromptActionStatus("正在处理…");
emitPromptAction(action);
}
return (
<div className="workspace-react-toolbar">
<div className="workspace-react-brand">
@@ -219,7 +256,7 @@ export function WorkspaceShell() {
<span className="workspace-react-version">React Shell</span>
</div>
<ProviderStrip providers={providers} selected={selected} readiness={readiness} onToggle={toggleProvider} />
<WorkspaceActions onAction={triggerLegacyAction} />
<WorkspaceActions onAction={openWorkspaceAction} />
<div className="workspace-layout-switch" aria-label="布局">
{layouts.map((value) => (
<Button key={value} size="sm" variant={layout === value ? "secondary" : "ghost"} aria-pressed={layout === value} onClick={() => changeLayout(value)}>
@@ -233,12 +270,19 @@ export function WorkspaceShell() {
readiness={readiness}
onAction={triggerProviderPanelAction}
/>
<CompareStatus selectedCount={selected.length} summary={compare} onOpen={() => triggerLegacyAction("compare")} />
<HandoffStatus responseCount={compare.responseCount} onOpen={() => triggerLegacyAction("compare")} />
<SessionStatus sessions={sessions} onOpen={() => triggerLegacyAction("session")} />
<PromptStatus prompts={prompts} onOpen={() => triggerLegacyAction("prompt")} />
<TemplateStatus templates={templates} onOpen={() => triggerLegacyAction("template")} />
<LibraryStatus summary={libraries} onOpen={triggerLegacyAction} />
<CompareStatus selectedCount={selected.length} summary={compare} onOpen={() => openLegacyAction("compare")} />
<HandoffStatus responseCount={compare.responseCount} onOpen={() => openLegacyAction("compare")} />
<SessionStatus sessions={sessions} onOpen={() => openLegacyAction("session")} />
<PromptStatus prompts={prompts} onOpen={() => openWorkspaceAction("prompt")} />
<TemplateStatus templates={templates} onOpen={() => openLegacyAction("template")} />
<LibraryStatus summary={libraries} onOpen={openWorkspaceAction} />
<PromptLibraryDrawer
open={promptDrawerOpen}
prompts={prompts}
status={promptActionStatus}
onClose={() => setPromptDrawerOpen(false)}
onAction={handlePromptAction}
/>
</div>
);
}
@@ -1,4 +1,5 @@
body[data-react-workspace="true"] .workspace-legacy-toolbar { display: none; }
body[data-react-workspace="true"] #promptLibraryDrawer { display: none !important; }
body[data-react-workspace="true"] #workspaceReactRoot { display: block; grid-column: 1 / -1; min-width: 0; }
#workspaceReactRoot { display: none; }
.workspace-react-toolbar { align-items: center; display: grid; gap: 12px; grid-template-columns: auto minmax(0, 1fr) auto auto; min-height: 48px; padding: 0 14px; }
@@ -71,6 +72,22 @@ body[data-react-workspace="true"] #workspaceReactRoot { display: block; grid-col
.workspace-actions { justify-content: flex-end; }
.workspace-layout-switch { background: #0d0f13; border: 1px solid var(--ui-border); border-radius: 8px; gap: 2px; padding: 2px; }
.workspace-layout-switch .ui-button { min-width: 30px; }
.workspace-react-drawer-layer { inset: 0; position: fixed; z-index: 30; }
.workspace-react-drawer-backdrop { background: rgba(0, 0, 0, .58); border: 0; cursor: default; inset: 0; padding: 0; position: absolute; width: 100%; }
.workspace-react-drawer { background: #101216; border-left: 1px solid var(--ui-border); box-shadow: -12px 0 32px rgba(0, 0, 0, .36); display: flex; flex-direction: column; height: 100%; margin-left: auto; max-width: min(520px, 94vw); position: relative; width: 100%; }
.workspace-react-drawer-header { align-items: flex-start; border-bottom: 1px solid var(--ui-border); display: flex; gap: 10px; justify-content: space-between; padding: 16px; }
.workspace-react-drawer-title { font-size: 15px; font-weight: 700; }
.workspace-react-drawer-status { color: var(--ui-muted); font-size: 11px; margin-top: 4px; }
.workspace-react-drawer-create { border-bottom: 1px solid var(--ui-border); display: flex; gap: 8px; padding: 12px 16px; }
.workspace-react-drawer-create input { background: #0d0f13; border: 1px solid var(--ui-border); border-radius: 7px; color: var(--ui-text); min-width: 0; padding: 7px 9px; width: 100%; }
.workspace-react-prompt-list { display: grid; gap: 8px; min-height: 0; overflow: auto; padding: 12px 16px 20px; }
.workspace-react-prompt-card { background: rgba(17, 18, 23, .78); border: 1px solid var(--ui-border); border-radius: 9px; display: grid; gap: 7px; padding: 10px; }
.workspace-react-prompt-card-header { align-items: center; display: flex; gap: 8px; justify-content: space-between; min-width: 0; }
.workspace-react-prompt-card-header strong { font-size: 12px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.workspace-react-prompt-card time { color: var(--ui-muted); font-size: 10px; }
.workspace-react-prompt-card p { color: var(--ui-text); font-size: 11px; line-height: 1.45; margin: 0; max-height: 180px; overflow: auto; white-space: pre-wrap; word-break: break-word; }
.workspace-react-prompt-actions { display: flex; gap: 6px; }
.workspace-react-drawer-empty { color: var(--ui-muted); font-size: 11px; padding: 18px 4px; text-align: center; }
@media (max-width: 1100px) {
.workspace-react-toolbar { grid-template-columns: auto minmax(0, 1fr) auto; }
.workspace-actions { grid-column: 2 / -1; grid-row: 2; justify-content: flex-start; }
+41 -4
View File
@@ -162,7 +162,8 @@ function workspacePromptState() {
id: entry.id,
title: typeof entry.title === "string" ? entry.title : "Untitled Prompt",
updatedAt: typeof entry.updatedAt === "string" ? entry.updatedAt : (typeof entry.createdAt === "string" ? entry.createdAt : ""),
contentLength: typeof entry.content === "string" ? entry.content.length : 0
contentLength: typeof entry.content === "string" ? entry.content.length : 0,
content: typeof entry.content === "string" ? entry.content : ""
}));
}
@@ -194,6 +195,8 @@ function notifyWorkspaceShell() {
}));
}
window.addEventListener("ai-parallel:workspace-state-request", notifyWorkspaceShell);
function setCompareStatus(message) {
compareStatus.textContent = message;
notifyWorkspaceShell();
@@ -1534,17 +1537,17 @@ async function loadSession(entry) {
closeSessionDrawer();
}
async function saveCurrentPrompt() {
async function saveCurrentPrompt(titleOverride) {
const content = promptInput.value.trim();
if (!content) {
promptLibraryStatus.textContent = "当前没有可保存的 Prompt";
return;
return promptLibraryStatus.textContent;
}
const now = new Date().toISOString();
const entry = {
id: crypto.randomUUID(),
title: promptTitleInput.value.trim() || promptTitleFromContent(content),
title: (typeof titleOverride === "string" ? titleOverride.trim() : promptTitleInput.value.trim()) || promptTitleFromContent(content),
content,
createdAt: now,
updatedAt: now
@@ -1554,6 +1557,7 @@ async function saveCurrentPrompt() {
promptTitleInput.value = "";
renderPromptLibrary();
promptLibraryStatus.textContent = "Prompt 已保存";
return promptLibraryStatus.textContent;
}
async function deletePrompt(id) {
@@ -1561,6 +1565,7 @@ async function deletePrompt(id) {
await storage.set({ [PROMPT_LIBRARY_KEY]: promptLibraryEntries });
renderPromptLibrary();
promptLibraryStatus.textContent = "Prompt 已删除";
return promptLibraryStatus.textContent;
}
function usePrompt(entry) {
@@ -1571,6 +1576,7 @@ function usePrompt(entry) {
updateMeta();
showError("");
closePromptLibraryDrawer();
return "Prompt 已填入编辑器";
}
function buildComparisonMarkdown() {
@@ -1743,6 +1749,37 @@ closePromptLibraryBtn.addEventListener("click", closePromptLibraryDrawer);
savePromptBtn.addEventListener("click", () => saveCurrentPrompt().catch((error) => {
promptLibraryStatus.textContent = error instanceof Error ? error.message : String(error);
}));
window.addEventListener("ai-parallel:workspace-prompt-action", (event) => {
const detail = event instanceof CustomEvent ? event.detail : null;
if (!detail || typeof detail !== "object" || typeof detail.type !== "string") return;
let operation;
if (detail.type === "save") {
operation = saveCurrentPrompt(typeof detail.title === "string" ? detail.title : "");
} else if (detail.type === "use" || detail.type === "delete") {
if (typeof detail.id !== "string" || !detail.id.trim()) return;
const entry = promptLibraryEntries.find((candidate) => candidate.id === detail.id);
if (!entry) {
window.dispatchEvent(new CustomEvent("ai-parallel:workspace-prompt-action-result", {
detail: { ok: false, error: "Prompt 不存在或已被删除" }
}));
return;
}
operation = detail.type === "use" ? usePrompt(entry) : deletePrompt(entry.id);
} else {
return;
}
Promise.resolve(operation).then((message) => {
window.dispatchEvent(new CustomEvent("ai-parallel:workspace-prompt-action-result", {
detail: { ok: true, message: typeof message === "string" ? message : "Prompt 操作已完成" }
}));
}).catch((error) => {
window.dispatchEvent(new CustomEvent("ai-parallel:workspace-prompt-action-result", {
detail: { ok: false, error: error instanceof Error ? error.message : String(error) }
}));
});
});
templateLibraryBtn.addEventListener("click", openTemplateLibraryDrawer);
closeTemplateLibraryBtn.addEventListener("click", closeTemplateLibraryDrawer);
templateCategorySelect.addEventListener("change", renderTemplateLibrary);
+14
View File
@@ -55,6 +55,7 @@ test("Workspace React shell owns the generated toolbar and delegates legacy runt
const compareStatus = read("ui", "workspace", "features", "compare-status.tsx");
const handoffStatus = read("ui", "workspace", "features", "handoff-status.tsx");
const libraryStatus = read("ui", "workspace", "features", "library-status.tsx");
const promptDrawer = read("ui", "workspace", "features", "prompt-library-drawer.tsx");
const sessionStatus = read("ui", "workspace", "features", "session-status.tsx");
const promptStatus = read("ui", "workspace", "features", "prompt-status.tsx");
const templateStatus = read("ui", "workspace", "features", "template-status.tsx");
@@ -99,9 +100,18 @@ test("Workspace React shell owns the generated toolbar and delegates legacy runt
assert.doesNotMatch(sessionStatus, /entry\.prompt|responseBundles|iframe/);
assert.match(shell, /PromptStatus/);
assert.match(shell, /prompts/);
assert.match(shell, /PromptLibraryDrawer/);
assert.match(shell, /ai-parallel:workspace-prompt-action/);
assert.match(shell, /ai-parallel:workspace-state-request/);
assert.match(promptStatus, /WorkspacePromptSummary/);
assert.match(promptStatus, /contentLength/);
assert.doesNotMatch(promptStatus, /entry\.content|responseBundles|iframe/);
assert.match(promptDrawer, /WorkspacePromptAction/);
assert.match(promptDrawer, /onAction/);
assert.match(promptDrawer, /prompt\.content/);
assert.match(promptDrawer, /type: "use"/);
assert.match(promptDrawer, /type: "delete"/);
assert.doesNotMatch(promptDrawer, /responseBundles|contentWindow|iframe|dangerouslySetInnerHTML/);
assert.match(shell, /TemplateStatus/);
assert.match(shell, /templates/);
assert.match(templateStatus, /WorkspaceTemplateSummary/);
@@ -116,6 +126,7 @@ test("Workspace React shell owns the generated toolbar and delegates legacy runt
assert.match(entrypoint, /workspaceReactRoot/);
assert.match(workspaceHtml, /id="workspaceReactRoot"/);
assert.match(legacyWorkspace, /ai-parallel:workspace-state/);
assert.match(legacyWorkspace, /ai-parallel:workspace-state-request/);
assert.match(legacyWorkspace, /function workspaceProviderStates/);
assert.match(legacyWorkspace, /providerStates: workspaceProviderStates\(\)/);
assert.match(legacyWorkspace, /function workspaceCompareState/);
@@ -127,6 +138,9 @@ test("Workspace React shell owns the generated toolbar and delegates legacy runt
assert.match(legacyWorkspace, /sessions: workspaceSessionState\(\)/);
assert.match(legacyWorkspace, /function workspacePromptState/);
assert.match(legacyWorkspace, /contentLength: typeof entry\.content === "string" \? entry\.content\.length : 0/);
assert.match(legacyWorkspace, /content: typeof entry\.content === "string" \? entry\.content : ""/);
assert.match(legacyWorkspace, /ai-parallel:workspace-prompt-action/);
assert.match(legacyWorkspace, /saveCurrentPrompt\(typeof detail\.title === "string"/);
assert.match(legacyWorkspace, /prompts: workspacePromptState\(\)/);
assert.match(legacyWorkspace, /function workspaceTemplateState/);
assert.match(legacyWorkspace, /outputMode: template\.output\?\.mode === "json" \? "json" : "text"/);