feat(workspace): migrate prompt library drawer
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"/);
|
||||
|
||||
Reference in New Issue
Block a user