From 0bb86578f18450e6820fd9641f5e408d2ef9d1fa Mon Sep 17 00:00:00 2001
From: lingxiaotian
Date: Thu, 9 Jul 2026 17:35:45 +0800
Subject: [PATCH 1/2] feat(prompt): add custom output format sequences
Port the Prompt output format contribution from jazzson51569/PromptHub commit fa7b0e6b into a clean upstream branch.
Refs: jazzson51569/PromptHub@fa7b0e6b
Verification:\n- pnpm typecheck\n- pnpm --filter @prompthub/desktop test -- tests/unit/main/prompt-output-format-db.test.ts tests/unit/components/prompt-detail-metadata.test.tsx --run
Co-authored-by: jazzson51569
---
apps/desktop/src/main/database/index.ts | 1 +
apps/desktop/src/main/ipc/index.ts | 5 +
apps/desktop/src/main/ipc/prompt.ipc.ts | 38 +-
apps/desktop/src/preload/api/prompt.ts | 13 +
.../components/layout/MainContent.tsx | 145 ++++-
.../prompt/PromptDetailMetadata.tsx | 80 ++-
.../prompt/PromptOutputFormatPanel.tsx | 536 ++++++++++++++++++
.../desktop/src/renderer/i18n/locales/de.json | 13 +-
.../desktop/src/renderer/i18n/locales/en.json | 13 +-
.../desktop/src/renderer/i18n/locales/es.json | 13 +-
.../desktop/src/renderer/i18n/locales/fr.json | 13 +-
.../desktop/src/renderer/i18n/locales/ja.json | 13 +-
.../src/renderer/i18n/locales/zh-TW.json | 13 +-
.../desktop/src/renderer/i18n/locales/zh.json | 13 +-
.../desktop/src/renderer/services/database.ts | 55 ++
.../src/renderer/stores/prompt.store.ts | 48 +-
.../unit/main/prompt-output-format-db.test.ts | 121 ++++
packages/db/src/index.ts | 1 +
packages/db/src/prompt-output-format.ts | 252 ++++++++
packages/db/src/schema.ts | 16 +
packages/shared/constants/ipc-channels.ts | 5 +
packages/shared/types/prompt.ts | 23 +
.../design.md | 39 ++
.../implementation.md | 19 +
.../proposal.md | 25 +
.../specs/prompt/spec.md | 31 +
.../tasks.md | 11 +
27 files changed, 1527 insertions(+), 28 deletions(-)
create mode 100644 apps/desktop/src/renderer/components/prompt/PromptOutputFormatPanel.tsx
create mode 100644 apps/desktop/tests/unit/main/prompt-output-format-db.test.ts
create mode 100644 packages/db/src/prompt-output-format.ts
create mode 100644 spec/changes/active/prompt-output-format-contribution/design.md
create mode 100644 spec/changes/active/prompt-output-format-contribution/implementation.md
create mode 100644 spec/changes/active/prompt-output-format-contribution/proposal.md
create mode 100644 spec/changes/active/prompt-output-format-contribution/specs/prompt/spec.md
create mode 100644 spec/changes/active/prompt-output-format-contribution/tasks.md
diff --git a/apps/desktop/src/main/database/index.ts b/apps/desktop/src/main/database/index.ts
index 066dc2d8..75b57953 100644
--- a/apps/desktop/src/main/database/index.ts
+++ b/apps/desktop/src/main/database/index.ts
@@ -32,6 +32,7 @@ export type { Database } from "@prompthub/db";
export { SCHEMA_TABLES, SCHEMA_INDEXES, SCHEMA } from "@prompthub/db";
export { PromptDB } from "@prompthub/db";
export { PromptRelationDB } from "@prompthub/db";
+export { PromptOutputFormatDB } from "@prompthub/db";
export { FolderDB } from "@prompthub/db";
export { SkillDB } from "@prompthub/db";
export { RuleDB } from "@prompthub/db";
diff --git a/apps/desktop/src/main/ipc/index.ts b/apps/desktop/src/main/ipc/index.ts
index 7fbe5f1f..1160b64c 100644
--- a/apps/desktop/src/main/ipc/index.ts
+++ b/apps/desktop/src/main/ipc/index.ts
@@ -33,6 +33,11 @@ const REBINDABLE_DB_CHANNELS = [
IPC_CHANNELS.PROMPT_RELATION_LIST,
IPC_CHANNELS.PROMPT_RELATION_UPDATE,
IPC_CHANNELS.PROMPT_RELATION_DELETE,
+ IPC_CHANNELS.PROMPT_OUTPUT_FORMAT_CREATE,
+ IPC_CHANNELS.PROMPT_OUTPUT_FORMAT_LIST,
+ IPC_CHANNELS.PROMPT_OUTPUT_FORMAT_UPDATE,
+ IPC_CHANNELS.PROMPT_OUTPUT_FORMAT_DELETE,
+ IPC_CHANNELS.PROMPT_OUTPUT_FORMAT_REORDER,
IPC_CHANNELS.VERSION_GET_ALL,
IPC_CHANNELS.VERSION_CREATE,
IPC_CHANNELS.VERSION_ROLLBACK,
diff --git a/apps/desktop/src/main/ipc/prompt.ipc.ts b/apps/desktop/src/main/ipc/prompt.ipc.ts
index 39497710..5aa656d9 100644
--- a/apps/desktop/src/main/ipc/prompt.ipc.ts
+++ b/apps/desktop/src/main/ipc/prompt.ipc.ts
@@ -1,17 +1,20 @@
import { ipcMain } from 'electron';
import { IPC_CHANNELS } from '@prompthub/shared/constants';
import { PromptDB } from '../database/prompt';
-import { PromptRelationDB } from '../database';
+import { PromptOutputFormatDB, PromptRelationDB } from '../database';
import { FolderDB } from '../database/folder';
import type Database from '../database/sqlite';
import type {
+ CreateOutputFormatItemDTO,
CreatePromptRelationDTO,
CreatePromptDTO,
Folder,
+ OutputFormatItemQuery,
Prompt,
PromptRelationQuery,
PromptVersion,
SearchQuery,
+ UpdateOutputFormatItemDTO,
UpdatePromptRelationDTO,
UpdatePromptDTO,
} from '@prompthub/shared/types';
@@ -23,6 +26,7 @@ import { syncPromptWorkspaceFromDatabase } from "../services/prompt-workspace";
*/
export function registerPromptIPC(db: PromptDB, folderDb: FolderDB, rawDb: Database.Database): void {
const relationDb = new PromptRelationDB(rawDb);
+ const outputFormatDb = new PromptOutputFormatDB(rawDb);
const syncWorkspace = () => {
syncPromptWorkspaceFromDatabase(db, folderDb);
};
@@ -306,4 +310,36 @@ export function registerPromptIPC(db: PromptDB, folderDb: FolderDB, rawDb: Datab
}
return deleted;
});
+
+ ipcMain.handle(IPC_CHANNELS.PROMPT_OUTPUT_FORMAT_CREATE, async (_, data: CreateOutputFormatItemDTO) => {
+ const item = outputFormatDb.create(data);
+ syncWorkspace();
+ return item;
+ });
+
+ ipcMain.handle(IPC_CHANNELS.PROMPT_OUTPUT_FORMAT_LIST, async (_, query?: OutputFormatItemQuery) => {
+ return outputFormatDb.list(query);
+ });
+
+ ipcMain.handle(IPC_CHANNELS.PROMPT_OUTPUT_FORMAT_UPDATE, async (_, id: string, data: UpdateOutputFormatItemDTO) => {
+ const item = outputFormatDb.update(id, data);
+ if (item) {
+ syncWorkspace();
+ }
+ return item;
+ });
+
+ ipcMain.handle(IPC_CHANNELS.PROMPT_OUTPUT_FORMAT_DELETE, async (_, id: string) => {
+ const deleted = outputFormatDb.delete(id);
+ if (deleted) {
+ syncWorkspace();
+ }
+ return deleted;
+ });
+
+ ipcMain.handle(IPC_CHANNELS.PROMPT_OUTPUT_FORMAT_REORDER, async (_, sourcePromptId: string, itemId: string, newSortOrder: number) => {
+ outputFormatDb.reorder(sourcePromptId, itemId, newSortOrder);
+ syncWorkspace();
+ return true;
+ });
}
diff --git a/apps/desktop/src/preload/api/prompt.ts b/apps/desktop/src/preload/api/prompt.ts
index b49d76a3..b621b5e6 100644
--- a/apps/desktop/src/preload/api/prompt.ts
+++ b/apps/desktop/src/preload/api/prompt.ts
@@ -1,13 +1,16 @@
import { ipcRenderer } from "electron";
import { IPC_CHANNELS } from "@prompthub/shared/constants/ipc-channels";
import type {
+ CreateOutputFormatItemDTO,
CreatePromptRelationDTO,
CreatePromptDTO,
Folder,
+ OutputFormatItemQuery,
Prompt,
PromptRelationQuery,
PromptVersion,
SearchQuery,
+ UpdateOutputFormatItemDTO,
UpdatePromptRelationDTO,
UpdatePromptDTO,
} from "@prompthub/shared/types";
@@ -53,4 +56,14 @@ export const promptApi = {
ipcRenderer.invoke(IPC_CHANNELS.PROMPT_RELATION_UPDATE, id, data),
deleteRelation: (id: string) =>
ipcRenderer.invoke(IPC_CHANNELS.PROMPT_RELATION_DELETE, id),
+ createOutputFormat: (data: CreateOutputFormatItemDTO) =>
+ ipcRenderer.invoke(IPC_CHANNELS.PROMPT_OUTPUT_FORMAT_CREATE, data),
+ listOutputFormat: (query?: OutputFormatItemQuery) =>
+ ipcRenderer.invoke(IPC_CHANNELS.PROMPT_OUTPUT_FORMAT_LIST, query),
+ updateOutputFormat: (id: string, data: UpdateOutputFormatItemDTO) =>
+ ipcRenderer.invoke(IPC_CHANNELS.PROMPT_OUTPUT_FORMAT_UPDATE, id, data),
+ deleteOutputFormat: (id: string) =>
+ ipcRenderer.invoke(IPC_CHANNELS.PROMPT_OUTPUT_FORMAT_DELETE, id),
+ reorderOutputFormat: (sourcePromptId: string, itemId: string, newSortOrder: number) =>
+ ipcRenderer.invoke(IPC_CHANNELS.PROMPT_OUTPUT_FORMAT_REORDER, sourcePromptId, itemId, newSortOrder),
};
diff --git a/apps/desktop/src/renderer/components/layout/MainContent.tsx b/apps/desktop/src/renderer/components/layout/MainContent.tsx
index 0a688618..349e6415 100644
--- a/apps/desktop/src/renderer/components/layout/MainContent.tsx
+++ b/apps/desktop/src/renderer/components/layout/MainContent.tsx
@@ -54,6 +54,7 @@ import {
import { resolvePromptMarkdownHref } from '../prompt/prompt-markdown-url';
import { PromptQuickRewriteTrigger } from '../prompt/PromptQuickRewriteTrigger';
import { PromptRelationshipPanel } from '../prompt/PromptRelationshipPanel';
+import { PromptOutputFormatPanel } from '../prompt/PromptOutputFormatPanel';
import { PromptAiResponsePanel } from '../prompt/PromptAiResponsePanel';
import { PromptDetailMetadata } from '../prompt/PromptDetailMetadata';
import { PromptViewContainers } from '../prompt/PromptViewContainers';
@@ -699,10 +700,14 @@ function PromptSkillMainContent() {
const selectedIds = usePromptStore((state) => state.selectedIds);
const lastSelectedId = usePromptStore((state) => state.lastSelectedId);
const relations = usePromptStore((state) => state.relations ?? []);
+ const outputFormatItems = usePromptStore((state) => state.outputFormatItems ?? []);
const selectPrompt = usePromptStore((state) => state.selectPrompt);
const setSelectedIds = usePromptStore((state) => state.setSelectedIds);
const createPrompt = usePromptStore((state) => state.createPrompt);
const createRelation = usePromptStore((state) => state.createRelation);
+ const createOutputFormatItem = usePromptStore((state) => state.createOutputFormatItem);
+ const deleteOutputFormatItem = usePromptStore((state) => state.deleteOutputFormatItem);
+ const reorderOutputFormatItem = usePromptStore((state) => state.reorderOutputFormatItem);
const toggleFavorite = usePromptStore((state) => state.toggleFavorite);
const togglePinned = usePromptStore((state) => state.togglePinned);
const deletePrompt = usePromptStore((state) => state.deletePrompt);
@@ -735,6 +740,10 @@ function PromptSkillMainContent() {
// 用于列表/画廊视图复制时的变量弹窗
const [isCopyVariableModalOpen, setIsCopyVariableModalOpen] = useState(false);
const [copyPrompt, setCopyPrompt] = useState(null);
+ const [copyPromptQueue, setCopyPromptQueue] = useState([]);
+ const [copyPromptResults, setCopyPromptResults] = useState([]);
+ const [copyPromptQueueIndex, setCopyPromptQueueIndex] = useState(-1);
+ const [copyPromptSourceId, setCopyPromptSourceId] = useState(null);
const [previewImage, setPreviewImage] = useState(null);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; prompt: Prompt } | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; prompt: Prompt | null }>({ isOpen: false, prompt: null });
@@ -1480,6 +1489,9 @@ function PromptSkillMainContent() {
selectedPromptRelations.length +
(selectedParentPrompt ? 1 : 0) +
selectedChildPrompts.length;
+ const selectedOutputFormatCount = selectedPrompt
+ ? outputFormatItems.filter((item) => item.sourcePromptId === selectedPrompt.id).length
+ : 0;
// Auto-select prompt language based on UI language (if English version exists)
// 根据界面语言自动选择 Prompt 语言(如果有英文版本)
@@ -1503,6 +1515,7 @@ function PromptSkillMainContent() {
const [isDetailInlineEditing, setIsDetailInlineEditing] = useState(false);
const [isDetailInlineSaving, setIsDetailInlineSaving] = useState(false);
const [isDetailRelationshipsOpen, setIsDetailRelationshipsOpen] = useState(false);
+ const [isDetailOutputFormatOpen, setIsDetailOutputFormatOpen] = useState(false);
const [detailInlineActiveField, setDetailInlineActiveField] =
useState('title');
const [detailInlineDraft, setDetailInlineDraft] = useState({
@@ -1550,6 +1563,7 @@ function PromptSkillMainContent() {
setIsDetailInlineEditing(false);
setIsDetailInlineSaving(false);
setIsDetailRelationshipsOpen(false);
+ setIsDetailOutputFormatOpen(false);
setDetailInlineActiveField('title');
setInlineAiTestImages([]);
}, [selectedPrompt?.id]);
@@ -1729,22 +1743,111 @@ function PromptSkillMainContent() {
[cancelDetailInlineEdit, saveDetailInlineEdit],
);
+ const getOutputFormatPromptQueue = useCallback(
+ (prompt: Prompt): Prompt[] => {
+ const configuredItems = outputFormatItems
+ .filter((item) => item.sourcePromptId === prompt.id)
+ .sort((left, right) => {
+ const byOrder = left.sortOrder - right.sortOrder;
+ if (byOrder !== 0) return byOrder;
+ return left.createdAt.localeCompare(right.createdAt);
+ });
+
+ if (configuredItems.length === 0) {
+ return [prompt];
+ }
+
+ const queue = configuredItems
+ .map((item) => {
+ if (!item.targetPromptId) {
+ return prompt;
+ }
+ return promptById.get(item.targetPromptId) ?? null;
+ })
+ .filter((item): item is Prompt => item !== null);
+
+ return queue.length > 0 ? queue : [prompt];
+ },
+ [outputFormatItems, promptById],
+ );
+
// Handle copying prompt - check for variables first
// 处理复制 Prompt - 先检查是否有变量
const handleCopyPrompt = async (prompt: Prompt) => {
- const resolvedPrompt = resolvePromptContentByLanguage(prompt, showEnglish);
+ const queue = getOutputFormatPromptQueue(prompt);
+
+ if (queue.length > 1) {
+ setCopyPromptQueue(queue);
+ setCopyPromptResults(new Array(queue.length).fill(""));
+ setCopyPromptQueueIndex(0);
+ setCopyPromptSourceId(prompt.id);
+ return;
+ }
+
+ const resolvedPrompt = resolvePromptContentByLanguage(queue[0], showEnglish);
if (hasUserDefinedPromptVariables(undefined, resolvedPrompt.userPrompt)) {
// 有变量,打开弹窗让用户填写
- setCopyPrompt(prompt);
+ setCopyPrompt(queue[0]);
setIsCopyVariableModalOpen(true);
} else {
await copyTextToClipboard(buildPromptCopyText(resolvedPrompt));
- await incrementUsageCount(prompt.id);
+ await incrementUsageCount(queue[0].id);
showToast(t('toast.copied'), 'success', showCopyNotification);
}
};
+ useEffect(() => {
+ if (copyPromptQueue.length === 0 || copyPromptQueueIndex < 0) {
+ return;
+ }
+
+ if (copyPromptQueueIndex >= copyPromptQueue.length) {
+ const finalText = copyPromptResults.filter((text) => text.trim()).join("\n\n");
+ void copyTextToClipboard(finalText).then(async () => {
+ if (copyPromptSourceId) {
+ await incrementUsageCount(copyPromptSourceId);
+ }
+ triggerCopied();
+ showToast(t('toast.copied'), 'success', showCopyNotification);
+ }).finally(() => {
+ setIsCopyVariableModalOpen(false);
+ setCopyPrompt(null);
+ setCopyPromptQueue([]);
+ setCopyPromptResults([]);
+ setCopyPromptQueueIndex(-1);
+ setCopyPromptSourceId(null);
+ });
+ return;
+ }
+
+ const currentPrompt = copyPromptQueue[copyPromptQueueIndex];
+ const resolvedPrompt = resolvePromptContentByLanguage(currentPrompt, showEnglish);
+ if (hasUserDefinedPromptVariables(undefined, resolvedPrompt.userPrompt)) {
+ setCopyPrompt(currentPrompt);
+ setIsCopyVariableModalOpen(true);
+ return;
+ }
+
+ setCopyPromptResults((currentResults) => {
+ const nextResults = [...currentResults];
+ nextResults[copyPromptQueueIndex] = buildPromptCopyText(resolvedPrompt);
+ return nextResults;
+ });
+ setCopyPromptQueueIndex((currentIndex) => currentIndex + 1);
+ }, [
+ copyPromptQueue,
+ copyPromptQueueIndex,
+ copyPromptResults,
+ copyPromptSourceId,
+ incrementUsageCount,
+ showCopyNotification,
+ showEnglish,
+ showToast,
+ t,
+ triggerCopied,
+ ]);
+
const handleDuplicatePrompt = useCallback(async (prompt: Prompt) => {
const duplicatedPrompt = await createPrompt({
title: `${prompt.title} (${t('prompt.duplicateSuffix', 'Duplicate')})`,
@@ -2530,8 +2633,11 @@ function PromptSkillMainContent() {
childPrompts={selectedChildPrompts}
folderOptions={detailFolderOptions}
relationshipCount={selectedRelationshipCount}
+ outputFormatCount={selectedOutputFormatCount}
isRelatedPromptsOpen={isDetailRelationshipsOpen}
+ isOutputFormatOpen={isDetailOutputFormatOpen}
isRelatedPromptsDisabled={isDetailInlineEditing}
+ isOutputFormatDisabled={isDetailInlineEditing}
t={t}
onMoveToFolder={(prompt, folderId) => {
void handleMovePrompt(prompt, folderId);
@@ -2540,6 +2646,9 @@ function PromptSkillMainContent() {
onToggleRelatedPrompts={() =>
setIsDetailRelationshipsOpen((open) => !open)
}
+ onToggleOutputFormat={() =>
+ setIsDetailOutputFormatOpen((open) => !open)
+ }
/>
{isDetailRelationshipsOpen && (
@@ -2556,6 +2665,20 @@ function PromptSkillMainContent() {
/>
)}
+ {isDetailOutputFormatOpen && (
+
+ )}
+
{/* Images */}
{/* 图片 */}
{selectedPrompt.images && selectedPrompt.images.length > 0 && (
@@ -3036,12 +3159,28 @@ function PromptSkillMainContent() {
onClose={() => {
setIsCopyVariableModalOpen(false);
setCopyPrompt(null);
+ setCopyPromptQueue([]);
+ setCopyPromptResults([]);
+ setCopyPromptQueueIndex(-1);
+ setCopyPromptSourceId(null);
}}
promptId={copyPrompt.id}
systemPrompt={undefined}
userPrompt={resolvePromptContentByLanguage(copyPrompt, showEnglish).userPrompt}
mode="copy"
onCopy={async (text) => {
+ if (copyPromptQueue.length > 0 && copyPromptQueueIndex >= 0) {
+ setCopyPromptResults((currentResults) => {
+ const nextResults = [...currentResults];
+ nextResults[copyPromptQueueIndex] = text;
+ return nextResults;
+ });
+ setIsCopyVariableModalOpen(false);
+ setCopyPrompt(null);
+ setCopyPromptQueueIndex((currentIndex) => currentIndex + 1);
+ return;
+ }
+
await copyTextToClipboard(text);
await incrementUsageCount(copyPrompt.id);
triggerCopied();
diff --git a/apps/desktop/src/renderer/components/prompt/PromptDetailMetadata.tsx b/apps/desktop/src/renderer/components/prompt/PromptDetailMetadata.tsx
index ef3f145b..2e53c6be 100644
--- a/apps/desktop/src/renderer/components/prompt/PromptDetailMetadata.tsx
+++ b/apps/desktop/src/renderer/components/prompt/PromptDetailMetadata.tsx
@@ -3,6 +3,7 @@ import {
ClockIcon,
CornerDownRightIcon,
GitBranchIcon,
+ HashIcon,
ImageIcon,
MessageSquareTextIcon,
} from "lucide-react";
@@ -16,12 +17,16 @@ interface PromptDetailMetadataProps {
childPrompts: Prompt[];
folderOptions: SelectOption[];
relationshipCount?: number;
+ outputFormatCount?: number;
isRelatedPromptsOpen?: boolean;
+ isOutputFormatOpen?: boolean;
isRelatedPromptsDisabled?: boolean;
+ isOutputFormatDisabled?: boolean;
t: ReturnType["t"];
onMoveToFolder: (prompt: Prompt, folderId: string | null) => void;
onSelectPrompt: (promptId: string) => void;
onToggleRelatedPrompts?: () => void;
+ onToggleOutputFormat?: () => void;
}
const MAX_VISIBLE_CHILDREN = 4;
@@ -32,16 +37,23 @@ export function PromptDetailMetadata({
childPrompts,
folderOptions,
relationshipCount = 0,
+ outputFormatCount = 0,
isRelatedPromptsOpen = false,
+ isOutputFormatOpen = false,
isRelatedPromptsDisabled = false,
+ isOutputFormatDisabled = false,
t,
onMoveToFolder,
onSelectPrompt,
onToggleRelatedPrompts,
+ onToggleOutputFormat,
}: PromptDetailMetadataProps) {
const promptType = prompt.promptType || "text";
const showRelationshipRow =
- parentPrompt || childPrompts.length > 0 || onToggleRelatedPrompts;
+ parentPrompt ||
+ childPrompts.length > 0 ||
+ onToggleRelatedPrompts ||
+ onToggleOutputFormat;
const renderRelatedPromptsButton = () => {
if (!onToggleRelatedPrompts) {
return null;
@@ -74,6 +86,38 @@ export function PromptDetailMetadata({
);
};
+ const renderOutputFormatButton = () => {
+ if (!onToggleOutputFormat) {
+ return null;
+ }
+
+ return (
+
+ );
+ };
+
return (
<>
{/* Metadata / 元信息 */}
@@ -131,7 +175,10 @@ export function PromptDetailMetadata({
})}
className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-border/70 bg-card px-2.5 py-1 text-muted-foreground shadow-sm transition-colors hover:border-primary/40 hover:text-primary"
>
-
+
{t("prompt.parentPrompt")}
{parentPrompt.title}
@@ -146,19 +193,21 @@ export function PromptDetailMetadata({
{t("prompt.childPrompts")}
{renderRelatedPromptsButton()}
- {childPrompts.slice(0, MAX_VISIBLE_CHILDREN).map((childPrompt) => (
-
- ))}
+ {childPrompts
+ .slice(0, MAX_VISIBLE_CHILDREN)
+ .map((childPrompt) => (
+
+ ))}
{childPrompts.length > MAX_VISIBLE_CHILDREN && (
{t("prompt.moreChildPrompts", {
@@ -170,6 +219,7 @@ export function PromptDetailMetadata({
)}
{childPrompts.length === 0 && renderRelatedPromptsButton()}
+ {renderOutputFormatButton()}
)}
>
diff --git a/apps/desktop/src/renderer/components/prompt/PromptOutputFormatPanel.tsx b/apps/desktop/src/renderer/components/prompt/PromptOutputFormatPanel.tsx
new file mode 100644
index 00000000..aa7ce2ab
--- /dev/null
+++ b/apps/desktop/src/renderer/components/prompt/PromptOutputFormatPanel.tsx
@@ -0,0 +1,536 @@
+import { useMemo, useState, useCallback, type DragEvent } from "react";
+import {
+ PlusIcon,
+ SearchIcon,
+ Trash2Icon,
+ GripVerticalIcon,
+ HashIcon,
+} from "lucide-react";
+import { useTranslation } from "react-i18next";
+import type {
+ CreateOutputFormatItemDTO,
+ OutputFormatItem,
+ Prompt,
+} from "@prompthub/shared/types";
+
+type OutputFormatViewItem = {
+ key: string;
+ item: OutputFormatItem;
+ targetPrompt: Prompt | null;
+ isSelf: boolean;
+};
+
+export interface PromptOutputFormatPanelProps {
+ currentPrompt: Prompt;
+ prompts: Prompt[];
+ outputFormatItems: OutputFormatItem[];
+ onCreateOutputFormatItem: (
+ data: CreateOutputFormatItemDTO,
+ ) => Promise | void;
+ onDeleteOutputFormatItem: (id: string) => Promise | void;
+ onReorderOutputFormatItem: (
+ sourcePromptId: string,
+ itemId: string,
+ newSortOrder: number,
+ ) => Promise | void;
+ onSelectPrompt: (promptId: string) => void;
+ disabled?: boolean;
+ className?: string;
+}
+
+function createOutputFormatItems(
+ currentPrompt: Prompt,
+ prompts: Prompt[],
+ outputFormatItems: OutputFormatItem[],
+): OutputFormatViewItem[] {
+ const promptById = new Map(prompts.map((prompt) => [prompt.id, prompt]));
+
+ return outputFormatItems
+ .filter((item) => item.sourcePromptId === currentPrompt.id)
+ .map((item) => ({
+ key: `outputFormat:${item.id}`,
+ item,
+ targetPrompt: item.targetPromptId
+ ? (promptById.get(item.targetPromptId) ?? null)
+ : currentPrompt,
+ isSelf: item.targetPromptId === null,
+ }));
+}
+
+function usePromptOutputFormatPanelState({
+ currentPrompt,
+ prompts,
+ outputFormatItems,
+ onCreateOutputFormatItem,
+ onDeleteOutputFormatItem,
+ onReorderOutputFormatItem,
+}: Pick<
+ PromptOutputFormatPanelProps,
+ | "currentPrompt"
+ | "prompts"
+ | "outputFormatItems"
+ | "onCreateOutputFormatItem"
+ | "onDeleteOutputFormatItem"
+ | "onReorderOutputFormatItem"
+>) {
+ const { t } = useTranslation();
+ const [query, setQuery] = useState("");
+ const [savingTargetId, setSavingTargetId] = useState(null);
+ const [deletingId, setDeletingId] = useState(null);
+ const [draggingId, setDraggingId] = useState(null);
+
+ const formatItems = useMemo(
+ () => createOutputFormatItems(currentPrompt, prompts, outputFormatItems),
+ [currentPrompt, prompts, outputFormatItems],
+ );
+
+ const existingTargetIds = useMemo(() => {
+ const ids = new Set();
+ for (const item of formatItems) {
+ if (item.item.targetPromptId) {
+ ids.add(item.item.targetPromptId);
+ } else {
+ ids.add(currentPrompt.id);
+ }
+ }
+ return ids;
+ }, [currentPrompt.id, formatItems]);
+
+ const candidatePrompts = useMemo(
+ () => prompts.filter((prompt) => !existingTargetIds.has(prompt.id)),
+ [existingTargetIds, prompts],
+ );
+
+ const searchResults = useMemo(() => {
+ if (!query.trim()) return [];
+ const lowerQuery = query.toLowerCase();
+ return candidatePrompts.filter(
+ (prompt) =>
+ prompt.title.toLowerCase().includes(lowerQuery) ||
+ prompt.description?.toLowerCase().includes(lowerQuery) ||
+ prompt.tags.some((tag) => tag.toLowerCase().includes(lowerQuery)),
+ );
+ }, [candidatePrompts, query]);
+
+ const handleAddSelf = useCallback(async () => {
+ if (savingTargetId) return;
+ if (!currentPrompt.id) return;
+ setSavingTargetId("self");
+ try {
+ await onCreateOutputFormatItem({
+ sourcePromptId: currentPrompt.id,
+ targetPromptId: null,
+ });
+ } finally {
+ setSavingTargetId(null);
+ }
+ }, [currentPrompt.id, onCreateOutputFormatItem, savingTargetId]);
+
+ const handleAddPrompt = useCallback(
+ async (targetPromptId: string) => {
+ if (savingTargetId) return;
+ if (!currentPrompt.id || !targetPromptId) return;
+ setSavingTargetId(targetPromptId);
+ try {
+ const hasSelf = existingTargetIds.has(currentPrompt.id);
+ if (!hasSelf) {
+ await onCreateOutputFormatItem({
+ sourcePromptId: currentPrompt.id,
+ targetPromptId: null,
+ });
+ }
+ await onCreateOutputFormatItem({
+ sourcePromptId: currentPrompt.id,
+ targetPromptId,
+ });
+ setQuery("");
+ } finally {
+ setSavingTargetId(null);
+ }
+ },
+ [
+ currentPrompt.id,
+ existingTargetIds,
+ onCreateOutputFormatItem,
+ savingTargetId,
+ ],
+ );
+
+ const handleDelete = useCallback(
+ async (itemId: string) => {
+ if (deletingId) return;
+ setDeletingId(itemId);
+ try {
+ await onDeleteOutputFormatItem(itemId);
+ } finally {
+ setDeletingId(null);
+ }
+ },
+ [deletingId, onDeleteOutputFormatItem],
+ );
+
+ const handleDragStart = useCallback((e: DragEvent, itemId: string) => {
+ setDraggingId(itemId);
+ e.dataTransfer.effectAllowed = "move";
+ e.dataTransfer.setData("text/plain", itemId);
+ }, []);
+
+ const handleDragOver = useCallback((e: DragEvent) => {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = "move";
+ }, []);
+
+ const handleDrop = useCallback(
+ async (e: DragEvent, targetItemId: string) => {
+ e.preventDefault();
+ if (!draggingId || draggingId === targetItemId) {
+ setDraggingId(null);
+ return;
+ }
+
+ const targetIndex = formatItems.findIndex(
+ (item) => item.item.id === targetItemId,
+ );
+ if (targetIndex === -1) {
+ setDraggingId(null);
+ return;
+ }
+
+ await onReorderOutputFormatItem(
+ currentPrompt.id,
+ draggingId,
+ targetIndex,
+ );
+ setDraggingId(null);
+ },
+ [currentPrompt.id, draggingId, formatItems, onReorderOutputFormatItem],
+ );
+
+ const handleDragEnd = useCallback(() => {
+ setDraggingId(null);
+ }, []);
+
+ return {
+ t,
+ query,
+ setQuery,
+ formatItems,
+ searchResults,
+ savingTargetId,
+ deletingId,
+ draggingId,
+ handleAddSelf,
+ handleAddPrompt,
+ handleDelete,
+ handleDragStart,
+ handleDragOver,
+ handleDrop,
+ handleDragEnd,
+ };
+}
+
+function PanelHeader({
+ count,
+ t,
+}: {
+ count: number;
+ t: ReturnType["t"];
+}) {
+ return (
+
+
+
+
+ {t("prompt.customOutputFormat.title", "Custom Output Format")}
+
+
+
+ {count}
+
+
+ );
+}
+
+function OutputFormatSearch({
+ query,
+ searchResults,
+ savingTargetId,
+ disabled,
+ onQueryChange,
+ onAdd,
+ onAddSelf,
+ t,
+}: {
+ query: string;
+ searchResults: Prompt[];
+ savingTargetId: string | null;
+ disabled: boolean;
+ onQueryChange: (value: string) => void;
+ onAdd: (promptId: string) => void;
+ onAddSelf: () => void;
+ t: ReturnType["t"];
+}) {
+ const showResults = query.trim().length > 0;
+
+ return (
+
+
+
+ onQueryChange(event.target.value)}
+ placeholder={t(
+ "prompt.customOutputFormat.searchPlaceholder",
+ "Search prompts to add…",
+ )}
+ aria-label={t(
+ "prompt.customOutputFormat.searchPlaceholder",
+ "Search prompts to add…",
+ )}
+ className="h-9 min-w-0 flex-1 bg-transparent text-xs text-foreground outline-none placeholder:text-muted-foreground disabled:opacity-50"
+ />
+
+
+ {showResults && (
+
+ {searchResults.length === 0 ? (
+
+ {t("prompt.customOutputFormat.noMatches", "No matching prompts")}
+
+ ) : (
+ searchResults.map((prompt) => (
+
+ ))
+ )}
+
+ )}
+
+
+
+ );
+}
+
+function OutputFormatItemRow({
+ item,
+ isDeleting,
+ isDragging,
+ disabled,
+ onDelete,
+ onDragStart,
+ onDragOver,
+ onDrop,
+ onDragEnd,
+ onSelectPrompt,
+ t,
+}: {
+ item: OutputFormatViewItem;
+ isDeleting: boolean;
+ isDragging: boolean;
+ disabled: boolean;
+ onDelete: (itemId: string) => void;
+ onDragStart: (e: DragEvent, itemId: string) => void;
+ onDragOver: (e: DragEvent) => void;
+ onDrop: (e: DragEvent, itemId: string) => void;
+ onDragEnd: () => void;
+ onSelectPrompt: (promptId: string) => void;
+ t: ReturnType["t"];
+}) {
+ const removeLabel = t("prompt.customOutputFormat.removeItem", {
+ title: item.isSelf
+ ? t("prompt.customOutputFormat.self", "Current")
+ : item.targetPrompt?.title,
+ defaultValue: `Remove item`,
+ });
+
+ return (
+ onDragStart(e, item.item.id)}
+ onDragOver={onDragOver}
+ onDrop={(e) => onDrop(e, item.item.id)}
+ onDragEnd={onDragEnd}
+ className={`flex items-center gap-2 rounded-lg border border-border/70 bg-card px-2.5 py-2 transition-all ${
+ isDragging ? "opacity-50" : ""
+ } ${disabled ? "opacity-50" : ""}`}
+ >
+
+
+ {item.item.sortOrder + 1}
+
+
+
+
+ );
+}
+
+function OutputFormatList({
+ formatItems,
+ deletingId,
+ draggingId,
+ disabled,
+ t,
+ onDelete,
+ onDragStart,
+ onDragOver,
+ onDrop,
+ onDragEnd,
+ onSelectPrompt,
+}: {
+ formatItems: OutputFormatViewItem[];
+ deletingId: string | null;
+ draggingId: string | null;
+ disabled: boolean;
+ t: ReturnType["t"];
+ onDelete: (itemId: string) => void;
+ onDragStart: (e: DragEvent, itemId: string) => void;
+ onDragOver: (e: DragEvent) => void;
+ onDrop: (e: DragEvent, itemId: string) => void;
+ onDragEnd: () => void;
+ onSelectPrompt: (promptId: string) => void;
+}) {
+ if (formatItems.length === 0) {
+ return (
+
+ {t(
+ "prompt.customOutputFormat.empty",
+ "No output format configured. Add prompts to create a custom output sequence.",
+ )}
+
+ );
+ }
+
+ return (
+
+ {formatItems.map((item) => (
+
+ ))}
+
+ );
+}
+
+export function PromptOutputFormatPanel({
+ currentPrompt,
+ prompts,
+ outputFormatItems,
+ onCreateOutputFormatItem,
+ onDeleteOutputFormatItem,
+ onReorderOutputFormatItem,
+ onSelectPrompt,
+ disabled = false,
+ className = "",
+}: PromptOutputFormatPanelProps) {
+ const state = usePromptOutputFormatPanelState({
+ currentPrompt,
+ prompts,
+ outputFormatItems,
+ onCreateOutputFormatItem,
+ onDeleteOutputFormatItem,
+ onReorderOutputFormatItem,
+ });
+
+ return (
+
+
+ void state.handleAddPrompt(promptId)}
+ onAddSelf={() => void state.handleAddSelf()}
+ t={state.t}
+ />
+ void state.handleDelete(itemId)}
+ onDragStart={state.handleDragStart}
+ onDragOver={state.handleDragOver}
+ onDrop={state.handleDrop}
+ onDragEnd={state.handleDragEnd}
+ onSelectPrompt={onSelectPrompt}
+ />
+
+ );
+}
diff --git a/apps/desktop/src/renderer/i18n/locales/de.json b/apps/desktop/src/renderer/i18n/locales/de.json
index de63325e..bdf744d5 100644
--- a/apps/desktop/src/renderer/i18n/locales/de.json
+++ b/apps/desktop/src/renderer/i18n/locales/de.json
@@ -395,7 +395,18 @@
"batchFavorite": "Stapel-Favoriten",
"batchMove": "Stapel-Verschieben",
"batchDelete": "Stapel-Löschen",
- "confirmBatchDelete": "Diese {{count}} Prompts löschen?"
+ "confirmBatchDelete": "Diese {{count}} Prompts löschen?",
+ "customOutputFormat": {
+ "title": "Benutzerdefiniertes Ausgabeformat",
+ "searchPlaceholder": "Prompts zum Hinzufügen suchen...",
+ "noMatches": "Keine passenden Prompts",
+ "empty": "Kein Ausgabeformat konfiguriert. Fügen Sie Prompts hinzu, um eine benutzerdefinierte Ausgabesequenz zu erstellen.",
+ "addSelf": "Aktuellen Prompt hinzufügen",
+ "self": "Selbst",
+ "missing": "Fehlt",
+ "removeItem": "{{title}} entfernen",
+ "openPrompt": "Prompt {{title}} öffnen"
+ }
},
"folder": {
"new": "Neuer Ordner",
diff --git a/apps/desktop/src/renderer/i18n/locales/en.json b/apps/desktop/src/renderer/i18n/locales/en.json
index cdb92b43..fc651746 100644
--- a/apps/desktop/src/renderer/i18n/locales/en.json
+++ b/apps/desktop/src/renderer/i18n/locales/en.json
@@ -396,7 +396,18 @@
"batchFavorite": "Batch Favorite",
"batchMove": "Batch Move",
"batchDelete": "Batch Delete",
- "confirmBatchDelete": "Are you sure you want to delete these {{count}} prompts?"
+ "confirmBatchDelete": "Are you sure you want to delete these {{count}} prompts?",
+ "customOutputFormat": {
+ "title": "Custom Output Format",
+ "searchPlaceholder": "Search prompts to add...",
+ "noMatches": "No matching prompts",
+ "empty": "No output format configured. Add prompts to create a custom output sequence.",
+ "addSelf": "Add current prompt",
+ "self": "Self",
+ "missing": "Missing",
+ "removeItem": "Remove {{title}}",
+ "openPrompt": "Open prompt {{title}}"
+ }
},
"rules": {
"title": "Rules",
diff --git a/apps/desktop/src/renderer/i18n/locales/es.json b/apps/desktop/src/renderer/i18n/locales/es.json
index 9e02226e..bcb8202d 100644
--- a/apps/desktop/src/renderer/i18n/locales/es.json
+++ b/apps/desktop/src/renderer/i18n/locales/es.json
@@ -395,7 +395,18 @@
"batchFavorite": "Favoritos en lote",
"batchMove": "Mover en lote",
"batchDelete": "Eliminar en lote",
- "confirmBatchDelete": "¿Eliminar estos {{count}} Prompts?"
+ "confirmBatchDelete": "¿Eliminar estos {{count}} Prompts?",
+ "customOutputFormat": {
+ "title": "Formato de salida personalizado",
+ "searchPlaceholder": "Buscar prompts para agregar...",
+ "noMatches": "No hay prompts coincidentes",
+ "empty": "No hay formato de salida configurado. Agrega prompts para crear una secuencia de salida personalizada.",
+ "addSelf": "Agregar prompt actual",
+ "self": "Propio",
+ "missing": "Faltante",
+ "removeItem": "Eliminar {{title}}",
+ "openPrompt": "Abrir prompt {{title}}"
+ }
},
"folder": {
"new": "Nueva carpeta",
diff --git a/apps/desktop/src/renderer/i18n/locales/fr.json b/apps/desktop/src/renderer/i18n/locales/fr.json
index 8ce71c59..5aed75ef 100644
--- a/apps/desktop/src/renderer/i18n/locales/fr.json
+++ b/apps/desktop/src/renderer/i18n/locales/fr.json
@@ -395,7 +395,18 @@
"batchFavorite": "Favoris en lot",
"batchMove": "Déplacer en lot",
"batchDelete": "Supprimer en lot",
- "confirmBatchDelete": "Supprimer ces {{count}} Prompts ?"
+ "confirmBatchDelete": "Supprimer ces {{count}} Prompts ?",
+ "customOutputFormat": {
+ "title": "Format de sortie personnalisé",
+ "searchPlaceholder": "Rechercher des prompts à ajouter...",
+ "noMatches": "Aucun prompt correspondant",
+ "empty": "Aucun format de sortie configuré. Ajoutez des prompts pour créer une séquence de sortie personnalisée.",
+ "addSelf": "Ajouter le prompt actuel",
+ "self": "Soi-même",
+ "missing": "Manquant",
+ "removeItem": "Supprimer {{title}}",
+ "openPrompt": "Ouvrir le prompt {{title}}"
+ }
},
"folder": {
"new": "Nouveau dossier",
diff --git a/apps/desktop/src/renderer/i18n/locales/ja.json b/apps/desktop/src/renderer/i18n/locales/ja.json
index 122faf20..3cccd7e9 100644
--- a/apps/desktop/src/renderer/i18n/locales/ja.json
+++ b/apps/desktop/src/renderer/i18n/locales/ja.json
@@ -395,7 +395,18 @@
"batchFavorite": "一括お気に入り",
"batchMove": "一括移動",
"batchDelete": "一括削除",
- "confirmBatchDelete": "これらの {{count}} 件の Prompt を削除してもよろしいですか?"
+ "confirmBatchDelete": "これらの {{count}} 件の Prompt を削除してもよろしいですか?",
+ "customOutputFormat": {
+ "title": "カスタム出力形式",
+ "searchPlaceholder": "追加するプロンプトを検索...",
+ "noMatches": "一致するプロンプトがありません",
+ "empty": "出力形式が設定されていません。プロンプトを追加してカスタム出力シーケンスを作成します。",
+ "addSelf": "現在のプロンプトを追加",
+ "self": "自分自身",
+ "missing": "見つかりません",
+ "removeItem": "{{title}} を削除",
+ "openPrompt": "プロンプト {{title}} を開く"
+ }
},
"folder": {
"new": "新規フォルダ",
diff --git a/apps/desktop/src/renderer/i18n/locales/zh-TW.json b/apps/desktop/src/renderer/i18n/locales/zh-TW.json
index 2473ac53..6ae6debf 100644
--- a/apps/desktop/src/renderer/i18n/locales/zh-TW.json
+++ b/apps/desktop/src/renderer/i18n/locales/zh-TW.json
@@ -395,7 +395,18 @@
"batchFavorite": "批次收藏",
"batchMove": "批次移動",
"batchDelete": "批次刪除",
- "confirmBatchDelete": "確定要刪除這 {{count}} 個 Prompt 嗎?"
+ "confirmBatchDelete": "確定要刪除這 {{count}} 個 Prompt 嗎?",
+ "customOutputFormat": {
+ "title": "自定義輸出格式",
+ "searchPlaceholder": "搜尋要新增的 Prompt...",
+ "noMatches": "沒有匹配的 Prompt",
+ "empty": "未配置輸出格式。新增 Prompt 以建立自定義輸出序列。",
+ "addSelf": "新增目前 Prompt",
+ "self": "目前",
+ "missing": "遺失",
+ "removeItem": "移除 {{title}}",
+ "openPrompt": "開啟 Prompt {{title}}"
+ }
},
"folder": {
"new": "新增資料夾",
diff --git a/apps/desktop/src/renderer/i18n/locales/zh.json b/apps/desktop/src/renderer/i18n/locales/zh.json
index a6743449..66430642 100644
--- a/apps/desktop/src/renderer/i18n/locales/zh.json
+++ b/apps/desktop/src/renderer/i18n/locales/zh.json
@@ -396,7 +396,18 @@
"batchFavorite": "批量收藏",
"batchMove": "批量移动",
"batchDelete": "批量删除",
- "confirmBatchDelete": "确定要删除这 {{count}} 个 Prompt 吗?"
+ "confirmBatchDelete": "确定要删除这 {{count}} 个 Prompt 吗?",
+ "customOutputFormat": {
+ "title": "自定义输出格式",
+ "searchPlaceholder": "搜索要添加的 Prompt...",
+ "noMatches": "没有匹配的 Prompt",
+ "empty": "未配置输出格式。添加 Prompt 以创建自定义输出序列。",
+ "addSelf": "添加当前 Prompt",
+ "self": "当前",
+ "missing": "缺失",
+ "removeItem": "移除 {{title}}",
+ "openPrompt": "打开 Prompt {{title}}"
+ }
},
"rules": {
"title": "Rules",
diff --git a/apps/desktop/src/renderer/services/database.ts b/apps/desktop/src/renderer/services/database.ts
index c8afab67..5915ce74 100644
--- a/apps/desktop/src/renderer/services/database.ts
+++ b/apps/desktop/src/renderer/services/database.ts
@@ -5,12 +5,16 @@
*/
import type {
+ CreateOutputFormatItemDTO,
CreatePromptRelationDTO,
Folder,
+ OutputFormatItem,
+ OutputFormatItemQuery,
Prompt,
PromptRelation,
PromptRelationQuery,
PromptVersion,
+ UpdateOutputFormatItemDTO,
UpdatePromptRelationDTO,
} from "@prompthub/shared/types";
import { DB_BACKUP_VERSION } from "./database-backup-format";
@@ -463,6 +467,57 @@ export async function deletePromptRelation(id: string): Promise {
return window.api.prompt.deleteRelation(id);
}
+export async function createOutputFormatItem(
+ data: CreateOutputFormatItemDTO,
+): Promise {
+ if (!window.api?.prompt?.createOutputFormat) {
+ throw new Error("Output format requires the desktop database API");
+ }
+
+ return window.api.prompt.createOutputFormat(data);
+}
+
+export async function listOutputFormatItems(
+ query?: OutputFormatItemQuery,
+): Promise {
+ if (!window.api?.prompt?.listOutputFormat) {
+ return [];
+ }
+
+ return (await window.api.prompt.listOutputFormat(query)) ?? [];
+}
+
+export async function updateOutputFormatItem(
+ id: string,
+ data: UpdateOutputFormatItemDTO,
+): Promise {
+ if (!window.api?.prompt?.updateOutputFormat) {
+ throw new Error("Output format requires the desktop database API");
+ }
+
+ return window.api.prompt.updateOutputFormat(id, data);
+}
+
+export async function deleteOutputFormatItem(id: string): Promise {
+ if (!window.api?.prompt?.deleteOutputFormat) {
+ return false;
+ }
+
+ return window.api.prompt.deleteOutputFormat(id);
+}
+
+export async function reorderOutputFormatItem(
+ sourcePromptId: string,
+ itemId: string,
+ newSortOrder: number,
+): Promise {
+ if (!window.api?.prompt?.reorderOutputFormat) {
+ return false;
+ }
+
+ return window.api.prompt.reorderOutputFormat(sourcePromptId, itemId, newSortOrder);
+}
+
function assertPromptMoveAllowed(
prompts: Prompt[],
promptId: string,
diff --git a/apps/desktop/src/renderer/stores/prompt.store.ts b/apps/desktop/src/renderer/stores/prompt.store.ts
index db717ed4..89a615f1 100644
--- a/apps/desktop/src/renderer/stores/prompt.store.ts
+++ b/apps/desktop/src/renderer/stores/prompt.store.ts
@@ -4,6 +4,8 @@ import type {
Prompt,
CreatePromptDTO,
CreatePromptRelationDTO,
+ CreateOutputFormatItemDTO,
+ OutputFormatItem,
PromptRelation,
UpdatePromptDTO,
UpdatePromptRelationDTO,
@@ -44,6 +46,7 @@ function isViewMode(value: unknown): value is ViewMode {
interface PromptState {
prompts: Prompt[];
relations: PromptRelation[];
+ outputFormatItems: OutputFormatItem[];
selectedId: string | null;
selectedIds: string[];
lastSelectedId: string | null;
@@ -70,6 +73,10 @@ interface PromptState {
createRelation: (data: CreatePromptRelationDTO) => Promise;
updateRelation: (id: string, data: UpdatePromptRelationDTO) => Promise;
deleteRelation: (id: string) => Promise;
+ fetchOutputFormatItems: () => Promise;
+ createOutputFormatItem: (data: CreateOutputFormatItemDTO) => Promise;
+ deleteOutputFormatItem: (id: string) => Promise;
+ reorderOutputFormatItem: (sourcePromptId: string, itemId: string, newSortOrder: number) => Promise;
movePrompts: (ids: string[], folderId: string) => Promise;
movePrompt: (promptId: string, newParentId: string | null, newOrder: number) => Promise;
deletePrompt: (id: string) => Promise;
@@ -97,6 +104,7 @@ export const usePromptStore = create()(
(set, get) => ({
prompts: [],
relations: [],
+ outputFormatItems: [],
selectedId: null,
selectedIds: [],
lastSelectedId: null,
@@ -114,11 +122,12 @@ export const usePromptStore = create()(
set({ isLoading: true });
try {
// Get data from IndexedDB
- const [prompts, relations] = await Promise.all([
+ const [prompts, relations, outputFormatItems] = await Promise.all([
db.getAllPrompts(),
db.listPromptRelations(),
+ db.listOutputFormatItems(),
]);
- set({ prompts, relations });
+ set({ prompts, relations, outputFormatItems });
} catch (error) {
console.error("Failed to fetch prompts:", error);
} finally {
@@ -222,6 +231,38 @@ export const usePromptStore = create()(
scheduleAllSaveSync("prompt:relation:delete");
},
+ fetchOutputFormatItems: async () => {
+ const items = await db.listOutputFormatItems();
+ set({ outputFormatItems: items });
+ },
+
+ createOutputFormatItem: async (data) => {
+ const item = await db.createOutputFormatItem(data);
+ set((state) => ({
+ outputFormatItems: [
+ item,
+ ...state.outputFormatItems.filter((existing) => existing.id !== item.id),
+ ],
+ }));
+ scheduleAllSaveSync("prompt:output-format:create");
+ return item;
+ },
+
+ deleteOutputFormatItem: async (id) => {
+ const deleted = await db.deleteOutputFormatItem(id);
+ if (!deleted) return;
+ set((state) => ({
+ outputFormatItems: state.outputFormatItems.filter((item) => item.id !== id),
+ }));
+ scheduleAllSaveSync("prompt:output-format:delete");
+ },
+
+ reorderOutputFormatItem: async (sourcePromptId, itemId, newSortOrder) => {
+ await db.reorderOutputFormatItem(sourcePromptId, itemId, newSortOrder);
+ await get().fetchOutputFormatItems();
+ scheduleAllSaveSync("prompt:output-format:reorder");
+ },
+
movePrompts: async (ids, folderId) => {
await db.movePrompts(ids, folderId);
set((state) => ({
@@ -248,6 +289,9 @@ export const usePromptStore = create()(
(relation) =>
relation.sourcePromptId !== id && relation.targetPromptId !== id,
),
+ outputFormatItems: state.outputFormatItems.filter(
+ (item) => item.sourcePromptId !== id && item.targetPromptId !== id,
+ ),
selectedId: state.selectedId === id ? null : state.selectedId,
selectedIds: state.selectedIds.filter(
(selectedId) => selectedId !== id,
diff --git a/apps/desktop/tests/unit/main/prompt-output-format-db.test.ts b/apps/desktop/tests/unit/main/prompt-output-format-db.test.ts
new file mode 100644
index 00000000..901ee470
--- /dev/null
+++ b/apps/desktop/tests/unit/main/prompt-output-format-db.test.ts
@@ -0,0 +1,121 @@
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { PromptDB, PromptOutputFormatDB } from "../../../src/main/database";
+import {
+ SCHEMA_INDEXES,
+ SCHEMA_TABLES,
+} from "../../../src/main/database/schema";
+import DatabaseAdapter from "../../../src/main/database/sqlite";
+
+describe("PromptOutputFormatDB", () => {
+ let rawDb: DatabaseAdapter.Database;
+ let promptDb: PromptDB;
+ let outputFormatDb: PromptOutputFormatDB;
+
+ beforeEach(() => {
+ rawDb = new DatabaseAdapter(":memory:");
+ rawDb.pragma("foreign_keys = ON");
+ rawDb.exec(SCHEMA_TABLES);
+ rawDb.exec(SCHEMA_INDEXES);
+ promptDb = new PromptDB(rawDb);
+ outputFormatDb = new PromptOutputFormatDB(rawDb);
+ });
+
+ afterEach(() => {
+ rawDb.close();
+ });
+
+ function createPrompt(title: string) {
+ return promptDb.create({ title, userPrompt: title });
+ }
+
+ it("creates an ordered output sequence for a source prompt", () => {
+ const source = createPrompt("Source");
+ const target = createPrompt("Target");
+
+ const self = outputFormatDb.create({
+ sourcePromptId: source.id,
+ targetPromptId: null,
+ });
+ const linked = outputFormatDb.create({
+ sourcePromptId: source.id,
+ targetPromptId: target.id,
+ });
+
+ expect(outputFormatDb.list({ sourcePromptId: source.id })).toEqual([
+ self,
+ linked,
+ ]);
+ expect(self.sortOrder).toBe(0);
+ expect(linked.sortOrder).toBe(1);
+ });
+
+ it("returns the existing item for duplicate self or target entries", () => {
+ const source = createPrompt("Source");
+ const target = createPrompt("Target");
+
+ const self = outputFormatDb.create({
+ sourcePromptId: source.id,
+ targetPromptId: null,
+ });
+ const duplicateSelf = outputFormatDb.create({
+ sourcePromptId: source.id,
+ targetPromptId: null,
+ });
+ const linked = outputFormatDb.create({
+ sourcePromptId: source.id,
+ targetPromptId: target.id,
+ });
+ const duplicateLinked = outputFormatDb.create({
+ sourcePromptId: source.id,
+ targetPromptId: target.id,
+ });
+
+ expect(duplicateSelf.id).toBe(self.id);
+ expect(duplicateLinked.id).toBe(linked.id);
+ expect(outputFormatDb.list({ sourcePromptId: source.id })).toHaveLength(2);
+ });
+
+ it("reorders items and normalizes remaining sort order after delete", () => {
+ const source = createPrompt("Source");
+ const firstTarget = createPrompt("First");
+ const secondTarget = createPrompt("Second");
+ const self = outputFormatDb.create({
+ sourcePromptId: source.id,
+ targetPromptId: null,
+ });
+ const first = outputFormatDb.create({
+ sourcePromptId: source.id,
+ targetPromptId: firstTarget.id,
+ });
+ const second = outputFormatDb.create({
+ sourcePromptId: source.id,
+ targetPromptId: secondTarget.id,
+ });
+
+ outputFormatDb.reorder(source.id, second.id, 0);
+
+ expect(
+ outputFormatDb.list({ sourcePromptId: source.id }).map((item) => item.id),
+ ).toEqual([second.id, self.id, first.id]);
+
+ expect(outputFormatDb.delete(self.id)).toBe(true);
+ expect(
+ outputFormatDb
+ .list({ sourcePromptId: source.id })
+ .map((item) => item.sortOrder),
+ ).toEqual([0, 1]);
+ });
+
+ it("removes output format items when source or target prompts are deleted", () => {
+ const source = createPrompt("Source");
+ const target = createPrompt("Target");
+ outputFormatDb.create({
+ sourcePromptId: source.id,
+ targetPromptId: target.id,
+ });
+
+ expect(outputFormatDb.list()).toHaveLength(1);
+ expect(promptDb.delete(target.id)).toBe(true);
+ expect(outputFormatDb.list()).toEqual([]);
+ });
+});
diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts
index 3b63a1b2..0d69b8c5 100644
--- a/packages/db/src/index.ts
+++ b/packages/db/src/index.ts
@@ -18,6 +18,7 @@ export type { InitDatabaseHooks } from "./init";
// DB classes
export { PromptDB } from "./prompt";
export { PromptRelationDB } from "./prompt-relation";
+export { PromptOutputFormatDB } from "./prompt-output-format";
export { FolderDB } from "./folder";
export { SkillDB } from "./skill";
export { RuleDB } from "./rule";
diff --git a/packages/db/src/prompt-output-format.ts b/packages/db/src/prompt-output-format.ts
new file mode 100644
index 00000000..0bf14af4
--- /dev/null
+++ b/packages/db/src/prompt-output-format.ts
@@ -0,0 +1,252 @@
+import Database from "./adapter";
+import { v4 as uuidv4 } from "uuid";
+import type {
+ CreateOutputFormatItemDTO,
+ OutputFormatItem,
+ OutputFormatItemQuery,
+ UpdateOutputFormatItemDTO,
+} from "@prompthub/shared/types";
+
+interface OutputFormatItemRow {
+ id: string;
+ source_prompt_id: string;
+ target_prompt_id: string | null;
+ sort_order: number;
+ created_at: number;
+ updated_at: number;
+}
+
+export class PromptOutputFormatDB {
+ constructor(private db: Database.Database) {}
+
+ create(data: CreateOutputFormatItemDTO): OutputFormatItem {
+ const normalized = this.normalizeCreateInput(data);
+
+ const existing = this.findExisting(
+ normalized.sourcePromptId,
+ normalized.targetPromptId,
+ );
+
+ if (existing) {
+ return existing;
+ }
+
+ const id = uuidv4();
+ const now = Date.now();
+
+ const maxOrder = this.getMaxSortOrder(normalized.sourcePromptId);
+ const sortOrder = normalized.sortOrder ?? (maxOrder + 1);
+
+ this.db
+ .prepare(
+ `INSERT INTO prompt_output_format_items (
+ id, source_prompt_id, target_prompt_id, sort_order, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?)`,
+ )
+ .run(
+ id,
+ normalized.sourcePromptId,
+ normalized.targetPromptId,
+ sortOrder,
+ now,
+ now,
+ );
+
+ const created = this.getById(id);
+ if (!created) {
+ throw new Error(`Failed to create output format item with id: ${id}`);
+ }
+ return created;
+ }
+
+ update(id: string, data: UpdateOutputFormatItemDTO): OutputFormatItem | null {
+ const existing = this.getById(id);
+ if (!existing) return null;
+
+ const now = Date.now();
+
+ this.db
+ .prepare(
+ `UPDATE prompt_output_format_items
+ SET sort_order = ?, updated_at = ?
+ WHERE id = ?`,
+ )
+ .run(
+ data.sortOrder !== undefined ? data.sortOrder : existing.sortOrder,
+ now,
+ id,
+ );
+
+ return this.getById(id);
+ }
+
+ reorder(sourcePromptId: string, itemId: string, newSortOrder: number): void {
+ const items = this.list({ sourcePromptId });
+ const itemIndex = items.findIndex((item) => item.id === itemId);
+ if (itemIndex === -1) return;
+
+ const item = items[itemIndex];
+ const oldOrder = item.sortOrder;
+
+ if (newSortOrder === oldOrder) return;
+
+ const now = Date.now();
+
+ if (newSortOrder > oldOrder) {
+ this.db
+ .prepare(
+ `UPDATE prompt_output_format_items
+ SET sort_order = sort_order - 1, updated_at = ?
+ WHERE source_prompt_id = ? AND sort_order > ? AND sort_order <= ?`,
+ )
+ .run(now, sourcePromptId, oldOrder, newSortOrder);
+ } else {
+ this.db
+ .prepare(
+ `UPDATE prompt_output_format_items
+ SET sort_order = sort_order + 1, updated_at = ?
+ WHERE source_prompt_id = ? AND sort_order >= ? AND sort_order < ?`,
+ )
+ .run(now, sourcePromptId, newSortOrder, oldOrder);
+ }
+
+ this.db
+ .prepare(
+ `UPDATE prompt_output_format_items
+ SET sort_order = ?, updated_at = ?
+ WHERE id = ?`,
+ )
+ .run(newSortOrder, now, itemId);
+ }
+
+ getById(id: string): OutputFormatItem | null {
+ const row = this.db
+ .prepare("SELECT * FROM prompt_output_format_items WHERE id = ?")
+ .get(id) as OutputFormatItemRow | undefined;
+ return row ? this.rowToItem(row) : null;
+ }
+
+ list(query: OutputFormatItemQuery = {}): OutputFormatItem[] {
+ const clauses: string[] = [];
+ const values: (string | number)[] = [];
+
+ if (query.sourcePromptId) {
+ clauses.push("source_prompt_id = ?");
+ values.push(query.sourcePromptId);
+ }
+
+ const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
+ const rows = this.db
+ .prepare(
+ `SELECT * FROM prompt_output_format_items ${where}
+ ORDER BY sort_order ASC, created_at ASC`,
+ )
+ .all(...values) as OutputFormatItemRow[];
+
+ return rows.map((row) => this.rowToItem(row));
+ }
+
+ delete(id: string): boolean {
+ const item = this.getById(id);
+ if (!item) return false;
+
+ const result = this.db
+ .prepare("DELETE FROM prompt_output_format_items WHERE id = ?")
+ .run(id);
+
+ if (result.changes > 0) {
+ this.normalizeSortOrder(item.sourcePromptId);
+ }
+
+ return result.changes > 0;
+ }
+
+ deleteBySourcePromptId(sourcePromptId: string): void {
+ this.db
+ .prepare(
+ "DELETE FROM prompt_output_format_items WHERE source_prompt_id = ?",
+ )
+ .run(sourcePromptId);
+ }
+
+ private normalizeCreateInput(
+ data: CreateOutputFormatItemDTO,
+ ): CreateOutputFormatItemDTO {
+ if (
+ typeof data.sourcePromptId !== "string" ||
+ data.sourcePromptId.trim().length === 0
+ ) {
+ throw new Error("Source prompt id is required");
+ }
+
+ return {
+ ...data,
+ targetPromptId: data.targetPromptId ?? null,
+ };
+ }
+
+ private findExisting(
+ sourcePromptId: string,
+ targetPromptId: string | null,
+ ): OutputFormatItem | null {
+ const query = `SELECT * FROM prompt_output_format_items
+ WHERE source_prompt_id = ? AND target_prompt_id IS ${
+ targetPromptId === null ? "NULL" : "NOT NULL"
+ }
+ ${
+ targetPromptId !== null ? "AND target_prompt_id = ?" : ""
+ }`;
+
+ const params =
+ targetPromptId !== null
+ ? [sourcePromptId, targetPromptId]
+ : [sourcePromptId];
+
+ const row = this.db
+ .prepare(query)
+ .get(...params) as
+ | OutputFormatItemRow
+ | undefined;
+
+ return row ? this.rowToItem(row) : null;
+ }
+
+ private getMaxSortOrder(sourcePromptId: string): number {
+ const row = this.db
+ .prepare(
+ `SELECT MAX(sort_order) as max_order FROM prompt_output_format_items
+ WHERE source_prompt_id = ?`,
+ )
+ .get(sourcePromptId) as { max_order: number | undefined };
+
+ return row?.max_order ?? -1;
+ }
+
+ private normalizeSortOrder(sourcePromptId: string): void {
+ const items = this.list({ sourcePromptId });
+ const now = Date.now();
+
+ for (let i = 0; i < items.length; i++) {
+ if (items[i].sortOrder !== i) {
+ this.db
+ .prepare(
+ `UPDATE prompt_output_format_items
+ SET sort_order = ?, updated_at = ?
+ WHERE id = ?`,
+ )
+ .run(i, now, items[i].id);
+ }
+ }
+ }
+
+ private rowToItem(row: OutputFormatItemRow): OutputFormatItem {
+ return {
+ id: row.id,
+ sourcePromptId: row.source_prompt_id,
+ targetPromptId: row.target_prompt_id,
+ sortOrder: row.sort_order,
+ createdAt: new Date(row.created_at).toISOString(),
+ updatedAt: new Date(row.updated_at).toISOString(),
+ };
+ }
+}
diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts
index b0f00ce9..e98e5704 100644
--- a/packages/db/src/schema.ts
+++ b/packages/db/src/schema.ts
@@ -72,6 +72,20 @@ CREATE TABLE IF NOT EXISTS prompt_relations (
UNIQUE(source_prompt_id, target_prompt_id, kind)
);
+-- Custom output format items. Stores the ordered prompt list copied from a
+-- source prompt when users need several prompts to become one clipboard text.
+CREATE TABLE IF NOT EXISTS prompt_output_format_items (
+ id TEXT PRIMARY KEY,
+ source_prompt_id TEXT NOT NULL,
+ target_prompt_id TEXT,
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL,
+ FOREIGN KEY (source_prompt_id) REFERENCES prompts(id) ON DELETE CASCADE,
+ FOREIGN KEY (target_prompt_id) REFERENCES prompts(id) ON DELETE CASCADE,
+ UNIQUE(source_prompt_id, target_prompt_id)
+);
+
-- 文件夹表
CREATE TABLE IF NOT EXISTS folders (
id TEXT PRIMARY KEY,
@@ -221,6 +235,8 @@ CREATE INDEX IF NOT EXISTS idx_versions_prompt ON prompt_versions(prompt_id);
CREATE INDEX IF NOT EXISTS idx_prompt_relations_source ON prompt_relations(source_prompt_id);
CREATE INDEX IF NOT EXISTS idx_prompt_relations_target ON prompt_relations(target_prompt_id);
CREATE INDEX IF NOT EXISTS idx_prompt_relations_kind ON prompt_relations(kind);
+CREATE INDEX IF NOT EXISTS idx_prompt_output_format_source ON prompt_output_format_items(source_prompt_id);
+CREATE INDEX IF NOT EXISTS idx_prompt_output_format_target ON prompt_output_format_items(target_prompt_id);
CREATE INDEX IF NOT EXISTS idx_folders_parent ON folders(parent_id);
CREATE INDEX IF NOT EXISTS idx_folders_owner ON folders(owner_user_id);
CREATE INDEX IF NOT EXISTS idx_folders_visibility ON folders(visibility);
diff --git a/packages/shared/constants/ipc-channels.ts b/packages/shared/constants/ipc-channels.ts
index fefd7c92..ac919881 100644
--- a/packages/shared/constants/ipc-channels.ts
+++ b/packages/shared/constants/ipc-channels.ts
@@ -31,6 +31,11 @@ export const IPC_CHANNELS = {
PROMPT_RELATION_LIST: "promptRelation:list",
PROMPT_RELATION_UPDATE: "promptRelation:update",
PROMPT_RELATION_DELETE: "promptRelation:delete",
+ PROMPT_OUTPUT_FORMAT_CREATE: "promptOutputFormat:create",
+ PROMPT_OUTPUT_FORMAT_LIST: "promptOutputFormat:list",
+ PROMPT_OUTPUT_FORMAT_UPDATE: "promptOutputFormat:update",
+ PROMPT_OUTPUT_FORMAT_DELETE: "promptOutputFormat:delete",
+ PROMPT_OUTPUT_FORMAT_REORDER: "promptOutputFormat:reorder",
// Version
VERSION_GET_ALL: "version:getAll",
diff --git a/packages/shared/types/prompt.ts b/packages/shared/types/prompt.ts
index 3574b628..590975f8 100644
--- a/packages/shared/types/prompt.ts
+++ b/packages/shared/types/prompt.ts
@@ -145,6 +145,29 @@ export interface PromptRelationQuery {
direction?: "outgoing" | "incoming" | "both";
}
+export interface OutputFormatItem {
+ id: string;
+ sourcePromptId: string;
+ targetPromptId: string | null;
+ sortOrder: number;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface CreateOutputFormatItemDTO {
+ sourcePromptId: string;
+ targetPromptId: string | null;
+ sortOrder?: number;
+}
+
+export interface UpdateOutputFormatItemDTO {
+ sortOrder?: number;
+}
+
+export interface OutputFormatItemQuery {
+ sourcePromptId?: string;
+}
+
export interface SearchQuery {
scope?: 'private' | 'shared' | 'all';
keyword?: string;
diff --git a/spec/changes/active/prompt-output-format-contribution/design.md b/spec/changes/active/prompt-output-format-contribution/design.md
new file mode 100644
index 00000000..5be25813
--- /dev/null
+++ b/spec/changes/active/prompt-output-format-contribution/design.md
@@ -0,0 +1,39 @@
+# Design
+
+## Source
+
+This is a clean port of the Prompt output-format portion of `jazzson51569/PromptHub` commit `fa7b0e6b`, not a merge of the fork branch.
+
+## Data
+
+Add `prompt_output_format_items`:
+
+- `source_prompt_id`: Prompt whose copy action owns the sequence.
+- `target_prompt_id`: target Prompt to append, or `NULL` for the source Prompt itself.
+- `sort_order`: display and copy order.
+- foreign keys cascade on Prompt deletion.
+
+## Contract
+
+New IPC channels:
+
+- `promptOutputFormat:create`
+- `promptOutputFormat:list`
+- `promptOutputFormat:update`
+- `promptOutputFormat:delete`
+- `promptOutputFormat:reorder`
+
+Renderer store keeps `outputFormatItems` alongside `prompts` and `relations`.
+
+## UI
+
+The Prompt detail metadata row gets a compact "Custom Output Format" toggle. The panel lets users:
+
+- add the current Prompt,
+- search and add other Prompts,
+- drag to reorder,
+- remove entries.
+
+## Copy Behavior
+
+If no output format is configured, copy remains the existing single-Prompt flow. If a sequence is configured, PromptHub copies the ordered queue joined by blank lines. Prompts with variables still use the existing variable-fill modal one item at a time before the final clipboard write.
diff --git a/spec/changes/active/prompt-output-format-contribution/implementation.md b/spec/changes/active/prompt-output-format-contribution/implementation.md
new file mode 100644
index 00000000..3f8f5cef
--- /dev/null
+++ b/spec/changes/active/prompt-output-format-contribution/implementation.md
@@ -0,0 +1,19 @@
+# Implementation
+
+## Changes
+
+- Ported the useful Prompt output-format contribution from `jazzson51569/PromptHub` commit `fa7b0e6b`.
+- Added `prompt_output_format_items` with cascade cleanup.
+- Added `PromptOutputFormatDB` and desktop IPC/preload/store wiring.
+- Added a Prompt detail panel for configuring output sequences.
+- Updated copy behavior so configured sequences are copied as one combined clipboard text.
+- Added seven-locale i18n keys and DB regression tests.
+
+## Verification
+
+- `pnpm typecheck`
+- `pnpm --filter @prompthub/desktop test -- tests/unit/main/prompt-output-format-db.test.ts tests/unit/components/prompt-detail-metadata.test.tsx --run`
+
+## Notes
+
+The fork branch was not merged directly because it has unrelated build, debug, package, and local data-path changes. This change intentionally ports only the output-format/Prompt-copy feature.
diff --git a/spec/changes/active/prompt-output-format-contribution/proposal.md b/spec/changes/active/prompt-output-format-contribution/proposal.md
new file mode 100644
index 00000000..0bf34a1b
--- /dev/null
+++ b/spec/changes/active/prompt-output-format-contribution/proposal.md
@@ -0,0 +1,25 @@
+# Prompt Output Format Contribution
+
+## Summary
+
+Port the useful part of `jazzson51569/PromptHub` contribution `fa7b0e6b` into a clean upstream branch: configurable Prompt output sequences that let one Prompt copy action concatenate several Prompt bodies.
+
+## Scope
+
+- Add persistent Prompt output format items.
+- Expose desktop IPC/preload/store actions for CRUD and reorder.
+- Add a detail-page panel for configuring the output sequence.
+- Make copy use the configured sequence while preserving the existing variable-fill modal path.
+- Add regression coverage for the persistence boundary.
+
+## Non-Goals
+
+- Do not merge the contributor fork history.
+- Do not include the contributor debug tooling, build output path edits, package version churn, or unrelated data-path changes.
+- Do not change Prompt relationship semantics.
+
+## Risks
+
+- This touches schema, IPC, preload, renderer state, and copy behavior.
+- Custom output format rows must be removed when either source or target Prompt is deleted.
+- Multi-Prompt copy must not regress the single-Prompt variable flow.
diff --git a/spec/changes/active/prompt-output-format-contribution/specs/prompt/spec.md b/spec/changes/active/prompt-output-format-contribution/specs/prompt/spec.md
new file mode 100644
index 00000000..387cc403
--- /dev/null
+++ b/spec/changes/active/prompt-output-format-contribution/specs/prompt/spec.md
@@ -0,0 +1,31 @@
+# Prompt Output Format Spec
+
+## ADDED Requirements
+
+### Requirement: Persistent output format sequences
+
+PromptHub SHALL allow a Prompt to own an ordered list of output format items. Each item SHALL point either to the source Prompt itself or to another Prompt.
+
+#### Scenario: Create and list sequence items
+
+Given a source Prompt and a target Prompt
+When an output format item is created for each
+Then listing by the source Prompt returns both items in sort order.
+
+### Requirement: Copy uses configured sequence
+
+When a Prompt has output format items
+Then copying the Prompt SHALL copy the ordered Prompt bodies joined by blank lines.
+
+### Requirement: Existing copy flow remains compatible
+
+When a Prompt has no output format items
+Then copy SHALL use the existing single-Prompt copy behavior.
+
+When a Prompt in the output sequence has variables
+Then PromptHub SHALL use the existing variable-fill modal before finalizing the clipboard text.
+
+### Requirement: Prompt deletion cleans sequence rows
+
+When a source or target Prompt is deleted
+Then related output format rows SHALL be removed by the database relationship.
diff --git a/spec/changes/active/prompt-output-format-contribution/tasks.md b/spec/changes/active/prompt-output-format-contribution/tasks.md
new file mode 100644
index 00000000..24645d78
--- /dev/null
+++ b/spec/changes/active/prompt-output-format-contribution/tasks.md
@@ -0,0 +1,11 @@
+# Tasks
+
+- [x] Add output format schema, indexes, DB class, and exports.
+- [x] Add IPC channels, handlers, preload methods, and renderer service functions.
+- [x] Add Prompt store state/actions.
+- [x] Add detail metadata toggle and output format panel.
+- [x] Wire configured sequences into copy behavior.
+- [x] Add i18n keys for supported locales.
+- [x] Add DB regression tests.
+- [x] Run typecheck and targeted tests.
+- [x] Open draft PR against `main` (`#182`).
From d70616f4a6704e46e96d39881a78202591290cea Mon Sep 17 00:00:00 2001
From: lingxiaotian
Date: Thu, 9 Jul 2026 19:11:36 +0800
Subject: [PATCH 2/2] fix(prompt): persist output format sequences in backups
Refs: #182
Verification:
- pnpm typecheck
- pnpm --filter @prompthub/desktop test -- tests/unit/main/prompt-output-format-db.test.ts tests/unit/services/database-backup-format.test.ts tests/unit/services/database-backup.test.ts --run
- git diff --check
---
.../settings/BackupImportConfirmDialog.tsx | 2 +-
.../hooks/useBackupImportController.tsx | 3 +
.../services/database-backup-format.ts | 55 ++++++++++++++++
.../src/renderer/services/database-backup.ts | 36 +++++++++++
.../unit/components/data-settings.test.tsx | 2 +
.../unit/main/prompt-output-format-db.test.ts | 23 +++++++
.../services/database-backup-format.test.ts | 63 +++++++++++++++++++
.../unit/services/database-backup.test.ts | 23 +++++++
packages/db/src/schema.ts | 3 +
.../design.md | 7 +++
.../implementation.md | 4 ++
.../tasks.md | 3 +
12 files changed, 223 insertions(+), 1 deletion(-)
diff --git a/apps/desktop/src/renderer/components/settings/BackupImportConfirmDialog.tsx b/apps/desktop/src/renderer/components/settings/BackupImportConfirmDialog.tsx
index 86af4845..edb7de22 100644
--- a/apps/desktop/src/renderer/components/settings/BackupImportConfirmDialog.tsx
+++ b/apps/desktop/src/renderer/components/settings/BackupImportConfirmDialog.tsx
@@ -40,7 +40,7 @@ export function BackupImportConfirmDialog({
).toLocaleString()}
- {t("settings.importPreviewCounts", "Will import")}: {importPreview.summary.counts.prompts} prompts, {importPreview.summary.counts.folders} folders, {importPreview.summary.counts.versions} versions, {importPreview.summary.counts.rules} rules, {importPreview.summary.counts.skills} skills
+ {t("settings.importPreviewCounts", "Will import")}: {importPreview.summary.counts.prompts} prompts, {importPreview.summary.counts.folders} folders, {importPreview.summary.counts.versions} versions, {importPreview.summary.counts.outputFormatItems} output format items, {importPreview.summary.counts.rules} rules, {importPreview.summary.counts.skills} skills
{t(
diff --git a/apps/desktop/src/renderer/hooks/useBackupImportController.tsx b/apps/desktop/src/renderer/hooks/useBackupImportController.tsx
index b2efecbb..8da6bf78 100644
--- a/apps/desktop/src/renderer/hooks/useBackupImportController.tsx
+++ b/apps/desktop/src/renderer/hooks/useBackupImportController.tsx
@@ -26,6 +26,9 @@ export function formatImportSkippedDetails(
skipped.prompts > 0 ? `prompts: ${skipped.prompts}` : null,
skipped.folders > 0 ? `folders: ${skipped.folders}` : null,
skipped.versions > 0 ? `versions: ${skipped.versions}` : null,
+ skipped.outputFormatItems > 0
+ ? `output format items: ${skipped.outputFormatItems}`
+ : null,
skipped.rules > 0 ? `rules: ${skipped.rules}` : null,
skipped.skills > 0 ? `skills: ${skipped.skills}` : null,
skipped.skillVersions > 0 ? `skill versions: ${skipped.skillVersions}` : null,
diff --git a/apps/desktop/src/renderer/services/database-backup-format.ts b/apps/desktop/src/renderer/services/database-backup-format.ts
index 59662328..9ce59aaf 100644
--- a/apps/desktop/src/renderer/services/database-backup-format.ts
+++ b/apps/desktop/src/renderer/services/database-backup-format.ts
@@ -6,6 +6,7 @@ import type {
PluginLibraryFile,
PluginPackageSnapshot,
Prompt,
+ OutputFormatItem,
PromptVersion,
RuleBackupRecord,
} from "@prompthub/shared/types";
@@ -39,6 +40,7 @@ export interface DatabaseBackup {
prompts: Prompt[];
folders: Folder[];
versions: PromptVersion[];
+ outputFormatItems?: OutputFormatItem[];
images?: { [fileName: string]: string };
videos?: { [fileName: string]: string };
aiConfig?: {
@@ -115,6 +117,9 @@ export function normalizeImportedBackup(
prompts: Array.isArray(backup?.prompts) ? backup.prompts : [],
folders: Array.isArray(backup?.folders) ? backup.folders : [],
versions,
+ outputFormatItems: Array.isArray(backup?.outputFormatItems)
+ ? backup.outputFormatItems
+ : undefined,
images:
backup?.images && typeof backup.images === "object"
? backup.images
@@ -204,6 +209,20 @@ function hasPromptVersionShape(value: unknown): boolean {
);
}
+function hasOutputFormatItemShape(value: unknown): boolean {
+ return (
+ isRecord(value) &&
+ typeof value.id === "string" &&
+ typeof value.sourcePromptId === "string" &&
+ (value.targetPromptId === null ||
+ typeof value.targetPromptId === "string") &&
+ typeof value.sortOrder === "number" &&
+ Number.isFinite(value.sortOrder) &&
+ typeof value.createdAt === "string" &&
+ typeof value.updatedAt === "string"
+ );
+}
+
function hasSkillShape(value: unknown): boolean {
if (!isRecord(value)) {
return false;
@@ -381,6 +400,7 @@ export interface ImportSkippedStats {
prompts: number;
folders: number;
versions: number;
+ outputFormatItems: number;
rules: number;
skills: number;
skillVersions: number;
@@ -397,6 +417,7 @@ export function hasMeaningfulBackupContent(backup: DatabaseBackup): boolean {
backup.prompts.length > 0 ||
backup.folders.length > 0 ||
backup.versions.length > 0 ||
+ (backup.outputFormatItems?.length ?? 0) > 0 ||
(backup.rules?.length ?? 0) > 0 ||
(backup.skills?.length ?? 0) > 0 ||
(backup.skillVersions?.length ?? 0) > 0 ||
@@ -422,6 +443,7 @@ export function createEmptySkippedStats(): ImportSkippedStats {
prompts: 0,
folders: 0,
versions: 0,
+ outputFormatItems: 0,
rules: 0,
skills: 0,
skillVersions: 0,
@@ -434,6 +456,7 @@ export function hasAnySkipped(stats: ImportSkippedStats): boolean {
stats.prompts > 0 ||
stats.folders > 0 ||
stats.versions > 0 ||
+ stats.outputFormatItems > 0 ||
stats.rules > 0 ||
stats.skills > 0 ||
stats.skillVersions > 0 ||
@@ -464,6 +487,15 @@ function validateImportedBackupShape(backup: DatabaseBackup): void {
throw new Error("Invalid PromptHub backup: versions payload is malformed.");
}
+ if (
+ backup.outputFormatItems &&
+ !backup.outputFormatItems.every(hasOutputFormatItemShape)
+ ) {
+ throw new Error(
+ "Invalid PromptHub backup: output format payload is malformed.",
+ );
+ }
+
if (backup.rules && !backup.rules.every(hasRuleShape)) {
throw new Error("Invalid PromptHub backup: rules payload is malformed.");
}
@@ -621,6 +653,27 @@ export function sanitizeImportedBackup(raw: DatabaseBackup): ParsedBackup {
);
skipped.versions = originalVersionsLen - validVersions.length;
+ let validOutputFormatItems = raw.outputFormatItems;
+ if (raw.outputFormatItems) {
+ const originalOutputFormatLen = raw.outputFormatItems.length;
+ const structurallyValid = raw.outputFormatItems.filter(
+ hasOutputFormatItemShape,
+ );
+ validOutputFormatItems = structurallyValid.filter((item) => {
+ const { sourcePromptId, targetPromptId } = item as unknown as {
+ sourcePromptId: string;
+ targetPromptId: string | null;
+ };
+
+ return (
+ validPromptIds.has(sourcePromptId) &&
+ (targetPromptId === null || validPromptIds.has(targetPromptId))
+ );
+ });
+ skipped.outputFormatItems =
+ originalOutputFormatLen - validOutputFormatItems.length;
+ }
+
let validRules = raw.rules;
if (raw.rules) {
const originalRulesLen = raw.rules.length;
@@ -679,6 +732,7 @@ export function sanitizeImportedBackup(raw: DatabaseBackup): ParsedBackup {
prompts: validPrompts,
folders: validFolders,
versions: validVersions,
+ outputFormatItems: validOutputFormatItems,
rules: validRules,
skills: validSkills,
skillVersions: validSkillVersions,
@@ -706,6 +760,7 @@ function parseEnvelope(text: string): DatabaseBackup {
"prompts" in parsed ||
"folders" in parsed ||
"versions" in parsed ||
+ "outputFormatItems" in parsed ||
"skills" in parsed ||
"skillVersions" in parsed ||
"skillFiles" in parsed ||
diff --git a/apps/desktop/src/renderer/services/database-backup.ts b/apps/desktop/src/renderer/services/database-backup.ts
index 6447b176..ce96147d 100644
--- a/apps/desktop/src/renderer/services/database-backup.ts
+++ b/apps/desktop/src/renderer/services/database-backup.ts
@@ -14,9 +14,11 @@ import type {
} from "@prompthub/shared/types/skill";
import {
clearDatabase,
+ createOutputFormatItem,
getAllFolders,
getAllPrompts,
getDatabase,
+ listOutputFormatItems,
} from "./database";
import {
DB_BACKUP_VERSION,
@@ -604,6 +606,7 @@ export interface ImportPreviewSummary {
prompts: number;
folders: number;
versions: number;
+ outputFormatItems: number;
rules: number;
skills: number;
skillVersions: number;
@@ -664,6 +667,7 @@ export async function previewImportFile(
prompts: backup.prompts.length,
folders: backup.folders.length,
versions: backup.versions.length,
+ outputFormatItems: backup.outputFormatItems?.length ?? 0,
rules: backup.rules?.length ?? 0,
skills: backup.skills?.length ?? 0,
skillVersions: backup.skillVersions?.length ?? 0,
@@ -777,6 +781,16 @@ async function importDatabaseViaMainProcess(
await window.api.version.insertDirect(version);
}
+ if (normalizedBackup.outputFormatItems) {
+ for (const item of normalizedBackup.outputFormatItems) {
+ await createOutputFormatItem({
+ sourcePromptId: item.sourcePromptId,
+ targetPromptId: item.targetPromptId,
+ sortOrder: item.sortOrder,
+ });
+ }
+ }
+
await window.api.prompt.syncWorkspace?.();
return true;
}
@@ -812,6 +826,7 @@ export async function exportDatabase(options?: {
mcpLibrary,
pluginSnapshot,
agentAssetFiles,
+ outputFormatItems,
] = await Promise.all([
collectImages(prompts, imageLimits),
options?.skipVideoContent
@@ -822,6 +837,7 @@ export async function exportDatabase(options?: {
collectMcpLibrary(),
collectPluginSnapshot(),
collectAgentAssetFilesSnapshot(),
+ listOutputFormatItems(),
]);
const settingsSnapshot = getSettingsStateSnapshot({
@@ -835,6 +851,8 @@ export async function exportDatabase(options?: {
prompts,
folders,
versions,
+ outputFormatItems:
+ outputFormatItems.length > 0 ? outputFormatItems : undefined,
images,
videos,
aiConfig: getAiConfigSnapshot({ includeRootApiKey: true }),
@@ -914,6 +932,21 @@ export async function importDatabase(backup: DatabaseBackup): Promise {
});
}
+ if (!restoredViaMainProcess && normalizedBackup.outputFormatItems) {
+ for (const item of normalizedBackup.outputFormatItems) {
+ try {
+ await createOutputFormatItem({
+ sourcePromptId: item.sourcePromptId,
+ targetPromptId: item.targetPromptId,
+ sortOrder: item.sortOrder,
+ });
+ } catch (error) {
+ restoreFailures.push(`output format ${item.id}`);
+ console.warn(`Failed to restore output format ${item.id}:`, error);
+ }
+ }
+ }
+
if (normalizedBackup.images) {
for (const [fileName, base64] of Object.entries(normalizedBackup.images)) {
try {
@@ -1190,6 +1223,9 @@ export async function downloadSelectiveExport(
prompts: normalized.prompts ? fullBackup.prompts : [],
folders: normalized.folders ? fullBackup.folders : [],
versions: normalized.versions ? fullBackup.versions : [],
+ outputFormatItems: normalized.prompts
+ ? fullBackup.outputFormatItems
+ : undefined,
images: normalized.images ? fullBackup.images : undefined,
videos: normalized.videos ? fullBackup.videos : undefined,
aiConfig: normalized.aiConfig ? fullBackup.aiConfig : undefined,
diff --git a/apps/desktop/tests/unit/components/data-settings.test.tsx b/apps/desktop/tests/unit/components/data-settings.test.tsx
index e68a6844..6e48028f 100644
--- a/apps/desktop/tests/unit/components/data-settings.test.tsx
+++ b/apps/desktop/tests/unit/components/data-settings.test.tsx
@@ -785,6 +785,7 @@ describe("DataSettings", { timeout: 60_000 }, () => {
prompts: 0,
folders: 0,
versions: 0,
+ outputFormatItems: 0,
skills: 0,
skillVersions: 0,
skillFiles: 0,
@@ -804,6 +805,7 @@ describe("DataSettings", { timeout: 60_000 }, () => {
vi.mocked(restoreFromFile).mockResolvedValue({
folders: 0,
prompts: 0,
+ outputFormatItems: 0,
skillFiles: 0,
skillVersions: 0,
skills: 0,
diff --git a/apps/desktop/tests/unit/main/prompt-output-format-db.test.ts b/apps/desktop/tests/unit/main/prompt-output-format-db.test.ts
index 901ee470..0c3c9112 100644
--- a/apps/desktop/tests/unit/main/prompt-output-format-db.test.ts
+++ b/apps/desktop/tests/unit/main/prompt-output-format-db.test.ts
@@ -75,6 +75,29 @@ describe("PromptOutputFormatDB", () => {
expect(outputFormatDb.list({ sourcePromptId: source.id })).toHaveLength(2);
});
+ it("enforces a single self item per source prompt at the database boundary", () => {
+ const source = createPrompt("Source");
+ const now = Date.now();
+
+ rawDb
+ .prepare(
+ `INSERT INTO prompt_output_format_items (
+ id, source_prompt_id, target_prompt_id, sort_order, created_at, updated_at
+ ) VALUES (?, ?, NULL, ?, ?, ?)`,
+ )
+ .run("self-1", source.id, 0, now, now);
+
+ expect(() =>
+ rawDb
+ .prepare(
+ `INSERT INTO prompt_output_format_items (
+ id, source_prompt_id, target_prompt_id, sort_order, created_at, updated_at
+ ) VALUES (?, ?, NULL, ?, ?, ?)`,
+ )
+ .run("self-2", source.id, 1, now, now),
+ ).toThrow();
+ });
+
it("reorders items and normalizes remaining sort order after delete", () => {
const source = createPrompt("Source");
const firstTarget = createPrompt("First");
diff --git a/apps/desktop/tests/unit/services/database-backup-format.test.ts b/apps/desktop/tests/unit/services/database-backup-format.test.ts
index 20e4c67c..274155b0 100644
--- a/apps/desktop/tests/unit/services/database-backup-format.test.ts
+++ b/apps/desktop/tests/unit/services/database-backup-format.test.ts
@@ -214,11 +214,74 @@ describe("database-backup-format", () => {
expect(skipped.prompts).toBe(0);
expect(skipped.folders).toBe(0);
expect(skipped.versions).toBe(0);
+ expect(skipped.outputFormatItems).toBe(0);
expect(skipped.skills).toBe(0);
expect(skipped.skillVersions).toBe(0);
expect(skipped.skillFiles).toBe(0);
});
+ it("lenient parser drops output format items that reference missing prompts", () => {
+ const prompt = {
+ id: "prompt-1",
+ title: "Imported",
+ userPrompt: "User",
+ variables: [],
+ tags: [],
+ isFavorite: false,
+ isPinned: false,
+ version: 1,
+ currentVersion: 1,
+ usageCount: 0,
+ createdAt: "2026-04-07T00:00:00.000Z",
+ updatedAt: "2026-04-07T00:00:00.000Z",
+ };
+
+ const { backup, skipped } = parsePromptHubBackupFile(
+ JSON.stringify({
+ kind: "prompthub-backup",
+ exportedAt: "2026-04-07T00:00:00.000Z",
+ payload: {
+ exportedAt: "2026-04-07T00:00:00.000Z",
+ version: 1,
+ prompts: [prompt],
+ folders: [],
+ versions: [],
+ outputFormatItems: [
+ {
+ id: "keep-self",
+ sourcePromptId: "prompt-1",
+ targetPromptId: null,
+ sortOrder: 0,
+ createdAt: "2026-04-07T00:00:00.000Z",
+ updatedAt: "2026-04-07T00:00:00.000Z",
+ },
+ {
+ id: "drop-source",
+ sourcePromptId: "missing-source",
+ targetPromptId: null,
+ sortOrder: 1,
+ createdAt: "2026-04-07T00:00:00.000Z",
+ updatedAt: "2026-04-07T00:00:00.000Z",
+ },
+ {
+ id: "drop-target",
+ sourcePromptId: "prompt-1",
+ targetPromptId: "missing-target",
+ sortOrder: 2,
+ createdAt: "2026-04-07T00:00:00.000Z",
+ updatedAt: "2026-04-07T00:00:00.000Z",
+ },
+ ],
+ },
+ }),
+ );
+
+ expect(backup.outputFormatItems?.map((item) => item.id)).toEqual([
+ "keep-self",
+ ]);
+ expect(skipped.outputFormatItems).toBe(2);
+ });
+
it("lenient parser clears invalid folder parent references instead of keeping broken links", () => {
const { backup, skipped } = parsePromptHubBackupFile(
JSON.stringify({
diff --git a/apps/desktop/tests/unit/services/database-backup.test.ts b/apps/desktop/tests/unit/services/database-backup.test.ts
index a2f311d3..e6181166 100644
--- a/apps/desktop/tests/unit/services/database-backup.test.ts
+++ b/apps/desktop/tests/unit/services/database-backup.test.ts
@@ -15,6 +15,8 @@ const clearDatabaseMock = vi.fn().mockResolvedValue(undefined);
const getDatabaseMock = vi.fn();
const getAllFoldersMock = vi.fn();
const getAllPromptsMock = vi.fn();
+const createOutputFormatItemMock = vi.fn();
+const listOutputFormatItemsMock = vi.fn();
const restoreAiConfigSnapshotMock = vi.fn();
const restoreSettingsStateSnapshotMock = vi.fn();
const getAiConfigSnapshotMock = vi.fn();
@@ -22,9 +24,13 @@ const getSettingsStateSnapshotMock = vi.fn();
vi.mock("../../../src/renderer/services/database", () => ({
clearDatabase: () => clearDatabaseMock(),
+ createOutputFormatItem: (...args: unknown[]) =>
+ createOutputFormatItemMock(...args),
getAllFolders: () => getAllFoldersMock(),
getAllPrompts: () => getAllPromptsMock(),
getDatabase: () => getDatabaseMock(),
+ listOutputFormatItems: (...args: unknown[]) =>
+ listOutputFormatItemsMock(...args),
}));
vi.mock("../../../src/renderer/services/settings-snapshot", () => ({
@@ -83,6 +89,8 @@ describe("database-backup restore", () => {
localStorage.clear();
getAllFoldersMock.mockResolvedValue([]);
getAllPromptsMock.mockResolvedValue([]);
+ createOutputFormatItemMock.mockResolvedValue(undefined);
+ listOutputFormatItemsMock.mockResolvedValue([]);
getAiConfigSnapshotMock.mockReturnValue(undefined);
getSettingsStateSnapshotMock.mockReturnValue(undefined);
getDatabaseMock.mockResolvedValue({
@@ -792,6 +800,14 @@ describe("database-backup restore", () => {
userPrompt: "Old user",
createdAt: "2026-04-06T00:00:00.000Z",
};
+ const outputFormatItem = {
+ id: "output-format-1",
+ sourcePromptId: "prompt-1",
+ targetPromptId: null,
+ sortOrder: 0,
+ createdAt: "2026-04-07T00:00:00.000Z",
+ updatedAt: "2026-04-07T00:00:00.000Z",
+ };
const skill = {
id: "skill-1",
name: "writer",
@@ -818,6 +834,7 @@ describe("database-backup restore", () => {
getAllPromptsMock.mockResolvedValue([prompt]);
getAllFoldersMock.mockResolvedValue([folder]);
+ listOutputFormatItemsMock.mockResolvedValue([outputFormatItem]);
getDatabaseMock.mockResolvedValue({
transaction: () => createTransactionMock([version]),
});
@@ -882,6 +899,7 @@ describe("database-backup restore", () => {
expect(backup.prompts).toEqual([prompt]);
expect(backup.folders).toEqual([folder]);
expect(backup.versions).toEqual([version]);
+ expect(backup.outputFormatItems).toEqual([outputFormatItem]);
expect(backup.images).toEqual({ "image-1.png": "base64-image" });
expect(backup.videos).toEqual({ "video-1.mp4": "base64-video" });
expect(backup.skills).toEqual([skill]);
@@ -907,6 +925,11 @@ describe("database-backup restore", () => {
expect(window.api.folder.insertDirect).toHaveBeenCalledWith(folder);
expect(window.api.prompt.insertDirect).toHaveBeenCalledWith(prompt);
expect(window.api.version.insertDirect).toHaveBeenCalledWith(version);
+ expect(createOutputFormatItemMock).toHaveBeenCalledWith({
+ sourcePromptId: "prompt-1",
+ targetPromptId: null,
+ sortOrder: 0,
+ });
expect(window.api.prompt.syncWorkspace).toHaveBeenCalledTimes(1);
expect(window.electron.saveImageBase64).toHaveBeenCalledWith(
"image-1.png",
diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts
index e98e5704..9d5aa01b 100644
--- a/packages/db/src/schema.ts
+++ b/packages/db/src/schema.ts
@@ -237,6 +237,9 @@ CREATE INDEX IF NOT EXISTS idx_prompt_relations_target ON prompt_relations(targe
CREATE INDEX IF NOT EXISTS idx_prompt_relations_kind ON prompt_relations(kind);
CREATE INDEX IF NOT EXISTS idx_prompt_output_format_source ON prompt_output_format_items(source_prompt_id);
CREATE INDEX IF NOT EXISTS idx_prompt_output_format_target ON prompt_output_format_items(target_prompt_id);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_prompt_output_format_self_unique
+ ON prompt_output_format_items(source_prompt_id)
+ WHERE target_prompt_id IS NULL;
CREATE INDEX IF NOT EXISTS idx_folders_parent ON folders(parent_id);
CREATE INDEX IF NOT EXISTS idx_folders_owner ON folders(owner_user_id);
CREATE INDEX IF NOT EXISTS idx_folders_visibility ON folders(visibility);
diff --git a/spec/changes/active/prompt-output-format-contribution/design.md b/spec/changes/active/prompt-output-format-contribution/design.md
index 5be25813..7aea852c 100644
--- a/spec/changes/active/prompt-output-format-contribution/design.md
+++ b/spec/changes/active/prompt-output-format-contribution/design.md
@@ -12,6 +12,13 @@ Add `prompt_output_format_items`:
- `target_prompt_id`: target Prompt to append, or `NULL` for the source Prompt itself.
- `sort_order`: display and copy order.
- foreign keys cascade on Prompt deletion.
+- a partial unique index keeps only one self-reference row per source Prompt.
+
+Output format items are part of the Prompt data boundary:
+
+- full backups include `outputFormatItems`;
+- selective Prompt exports include them when the Prompt scope is selected;
+- imports validate item shape and drop rows that reference missing Prompts.
## Contract
diff --git a/spec/changes/active/prompt-output-format-contribution/implementation.md b/spec/changes/active/prompt-output-format-contribution/implementation.md
index 3f8f5cef..ced96a48 100644
--- a/spec/changes/active/prompt-output-format-contribution/implementation.md
+++ b/spec/changes/active/prompt-output-format-contribution/implementation.md
@@ -8,11 +8,15 @@
- Added a Prompt detail panel for configuring output sequences.
- Updated copy behavior so configured sequences are copied as one combined clipboard text.
- Added seven-locale i18n keys and DB regression tests.
+- Added backup/export/import support for output format items, including import-shape validation, orphan filtering, preview counts, and selective Prompt export inclusion.
+- Added a partial unique index for source self-reference rows so SQLite enforces the one-self-item invariant even outside the app service path.
## Verification
- `pnpm typecheck`
- `pnpm --filter @prompthub/desktop test -- tests/unit/main/prompt-output-format-db.test.ts tests/unit/components/prompt-detail-metadata.test.tsx --run`
+- `pnpm --filter @prompthub/desktop test -- tests/unit/main/prompt-output-format-db.test.ts tests/unit/services/database-backup-format.test.ts tests/unit/services/database-backup.test.ts --run`
+- `git diff --check`
## Notes
diff --git a/spec/changes/active/prompt-output-format-contribution/tasks.md b/spec/changes/active/prompt-output-format-contribution/tasks.md
index 24645d78..1e306eff 100644
--- a/spec/changes/active/prompt-output-format-contribution/tasks.md
+++ b/spec/changes/active/prompt-output-format-contribution/tasks.md
@@ -7,5 +7,8 @@
- [x] Wire configured sequences into copy behavior.
- [x] Add i18n keys for supported locales.
- [x] Add DB regression tests.
+- [x] Include output format sequences in backup/export/import validation and restore.
+- [x] Add backup format regressions for orphaned output format rows.
+- [x] Enforce one self-reference output format row per source prompt at the DB boundary.
- [x] Run typecheck and targeted tests.
- [x] Open draft PR against `main` (`#182`).