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" > -