-
Notifications
You must be signed in to change notification settings - Fork 191
Expand file tree
/
Copy pathMainContent.tsx
More file actions
2918 lines (2693 loc) · 121 KB
/
Copy pathMainContent.tsx
File metadata and controls
2918 lines (2693 loc) · 121 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { useState, useEffect, useMemo, useCallback, useRef, Children, isValidElement, cloneElement, memo, lazy, Suspense, type CSSProperties } from 'react';
import type { DragEvent as ReactDragEvent } from 'react';
import { flushSync } from 'react-dom';
import { useVirtualizer } from '@tanstack/react-virtual';
import { usePromptStore, ViewMode } from '../../stores/prompt.store';
import { useFolderStore } from '../../stores/folder.store';
import { useSettingsStore } from '../../stores/settings.store';
import { useUIStore } from '../../stores/ui.store';
import {
PROMPT_LIST_PANE_WIDTH_DEFAULT,
PROMPT_LIST_PANE_WIDTH_MAX,
PROMPT_LIST_PANE_WIDTH_MIN,
} from '../../stores/ui.store';
import { resolveScenarioModel } from '../../services/ai-defaults';
import { PromptListHeader } from '../prompt/PromptListHeader';
import type { OutputFormatConfig, VariableInputImageAttachment } from '../prompt/VariableInputModal';
// Lazy load SkillManager for better initial load performance
// 懒加载 SkillManager 以提升初始加载性能
const SkillManager = lazy(() => import('../skill/SkillManager').then(m => ({ default: m.SkillManager })));
const RulesManager = lazy(() => import('../rules/RulesManager').then(m => ({ default: m.RulesManager })));
const EditPromptModal = lazy(() => import('../prompt/EditPromptModal').then(m => ({ default: m.EditPromptModal })));
const PromptQuickRewriteDialog = lazy(() => import('../prompt/PromptQuickRewriteDialog').then(m => ({ default: m.PromptQuickRewriteDialog })));
const PromptGalleryView = lazy(() => import('../prompt/PromptGalleryView').then(m => ({ default: m.PromptGalleryView })));
const PromptKanbanView = lazy(() => import('../prompt/PromptKanbanView').then(m => ({ default: m.PromptKanbanView })));
const PromptListView = lazy(() => import('../prompt/PromptListView').then(m => ({ default: m.PromptListView })));
const AiTestModal = lazy(() => import('../prompt/AiTestModal').then(m => ({ default: m.AiTestModal })));
const PromptDetailModal = lazy(() => import('../prompt/PromptDetailModal').then(m => ({ default: m.PromptDetailModal })));
const VariableInputModal = lazy(() => import('../prompt/VariableInputModal').then(m => ({ default: m.VariableInputModal })));
const VersionHistoryModal = lazy(() => import('../prompt/VersionHistoryModal').then(m => ({ default: m.VersionHistoryModal })));
const loadingFallback = (
<div className="flex-1 flex items-center justify-center">
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
);
import { StarIcon, CopyIcon, HistoryIcon, HashIcon, FolderIcon, SparklesIcon, EditIcon, TrashIcon, CheckIcon, PlayIcon, LoaderIcon, XIcon, GitCompareIcon, ClockIcon, GlobeIcon, PinIcon, MessageSquareTextIcon, ImageIcon, DownloadIcon, SaveIcon, ZoomInIcon, Share2Icon, PaperclipIcon } from 'lucide-react';
import { ContextMenu, ContextMenuItem } from '../ui/ContextMenu';
import { ImagePreviewModal } from '../ui/ImagePreviewModal';
import { LocalImage } from '../ui/LocalImage';
import { Input } from '../ui/Input';
import { Select } from '../ui/Select';
import { handleMarkdownListKeyDown } from '../ui/Textarea';
import { ConfirmDialog } from '../ui/ConfirmDialog';
import { CollapsibleThinking } from '../ui/CollapsibleThinking';
import { ColumnResizer } from '../ui/ColumnResizer';
import { useToast } from '../ui/Toast';
import { chatCompletion, generateImage, buildMessagesFromPrompt, multiModelCompare, AITestResult, StreamCallbacks } from '../../services/ai';
import { useTranslation } from 'react-i18next';
import type { Prompt, PromptVersion, UpdatePromptDTO } from '@prompthub/shared/types';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeSanitize from 'rehype-sanitize';
import rehypeHighlight from 'rehype-highlight';
import { defaultSchema } from 'hast-util-sanitize';
import {
buildPromptCopyText,
hasUserDefinedPromptVariables,
resolvePromptContentByLanguage,
} from '../prompt/prompt-copy-utils';
import { PromptQuickRewriteTrigger } from '../prompt/PromptQuickRewriteTrigger';
import {
filterVisiblePrompts,
sortVisiblePrompts,
} from '../../services/prompt-filter';
import { getFlattenedTree } from './tree/utilities';
import { renderFolderIcon } from './folderIconHelper';
const PROMPT_CARD_ESTIMATED_HEIGHT = 76;
const MAX_AI_TEST_IMAGES = 8;
const MAX_AI_TEST_IMAGE_BYTES = 10 * 1024 * 1024;
const SUPPORTED_AI_TEST_IMAGE_MIME_TYPES = new Set([
'image/png',
'image/jpeg',
'image/jpg',
'image/webp',
'image/gif',
]);
function escapeRegExp(str: string) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function getHighlightTerms(searchQuery: string): string[] {
const queryLower = (searchQuery || '').trim().toLowerCase().slice(0, 128);
if (!queryLower) return [];
const keywords = queryLower
.split(/\s+/)
.filter((k) => k.length > 0 && k.length <= 64);
const compact = queryLower.replace(/\s+/g, '');
const terms = [...keywords];
if (compact && compact.length <= 64 && !terms.includes(compact)) terms.push(compact);
return Array.from(new Set(terms))
.filter(Boolean)
.slice(0, 20)
.sort((a, b) => b.length - a.length);
}
function renderHighlightedText(text: string, terms: string[], highlightClassName: string) {
if (!text || terms.length === 0) return text;
const pattern = terms.map(escapeRegExp).join('|');
if (!pattern) return text;
const regex = new RegExp(`(${pattern})`, 'gi');
const parts = text.split(regex);
if (parts.length <= 1) return text;
return parts.map((part, idx) => {
if (!part) return null;
if (idx % 2 === 1) {
return (
<span key={idx} className={highlightClassName}>
{part}
</span>
);
}
return <span key={idx}>{part}</span>;
});
}
function renderHighlightedChildren(children: any, terms: string[], highlightClassName: string, skipTypes: any[]) {
return Children.map(children, (child) => {
if (typeof child === 'string') {
return renderHighlightedText(child, terms, highlightClassName);
}
if (!isValidElement(child)) return child;
if (skipTypes.includes(child.type)) return child;
const props = (child.props ?? {}) as any;
const nextChildren = renderHighlightedChildren(props.children, terms, highlightClassName, skipTypes);
return cloneElement(child as any, { ...props, children: nextChildren });
});
}
// Prompt card component (compact version) - wrapped with React.memo for performance
// Prompt 卡片组件(紧凑版本)- 使用 React.memo 包装以提升性能
const PromptCard = memo(function PromptCard({
prompt,
isSelected,
onSelect,
onContextMenu,
highlightTerms
}: {
prompt: Prompt;
isSelected: boolean;
onSelect: (e: React.MouseEvent) => void;
onContextMenu: (e: React.MouseEvent) => void;
highlightTerms: string[];
}) {
const highlightClassName = isSelected
? 'bg-white/20 text-white rounded px-0.5'
: 'bg-primary/15 text-primary rounded px-0.5';
return (
<div
onClick={onSelect}
onContextMenu={onContextMenu}
className={`
w-full text-left px-3 py-2.5 rounded-lg cursor-pointer
transition-all duration-base animate-in fade-in slide-in-from-left-2
${isSelected
? 'bg-primary text-white'
: 'prompt-list-card bg-card hover:bg-accent'
}
`}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 min-w-0 flex-1">
{prompt.isPinned && (
<PinIcon className={`w-3 h-3 flex-shrink-0 ${isSelected ? 'text-white' : 'text-primary'}`} />
)}
{/* Prompt type icon - only show for image/media type */}
{prompt.promptType === 'image' && (
<ImageIcon className={`w-3 h-3 flex-shrink-0 ${isSelected ? 'text-white/70' : 'text-blue-500'}`} />
)}
<h3
className="font-medium text-sm leading-snug break-words line-clamp-2"
title={prompt.title}
>
{renderHighlightedText(prompt.title, highlightTerms, highlightClassName)}
</h3>
</div>
{prompt.isFavorite && (
<StarIcon className={`w-3.5 h-3.5 flex-shrink-0 ${isSelected ? 'fill-white text-white' : 'fill-yellow-400 text-yellow-400'
}`} />
)}
</div>
{prompt.description && (
<p className={`text-xs line-clamp-2 break-words mt-0.5 ${isSelected ? 'text-white/70' : 'text-muted-foreground'
}`}>
{renderHighlightedText(prompt.description, highlightTerms, highlightClassName)}
</p>
)}
</div>
);
});
interface VirtualizedPromptListProps {
prompts: Prompt[];
selectedPromptIdSet: Set<string>;
highlightTerms: string[];
onSelect: (prompt: Prompt, event: React.MouseEvent) => void;
onContextMenu: (event: React.MouseEvent, prompt: Prompt) => void;
}
/**
* Virtualized list of prompt cards. Replaces the previous chunked-render
* scheme that progressively painted more cards via setTimeout.
*
* The component owns its own scroll element so the parent pane can stay
* `overflow-hidden`. Heights are dynamically measured because card height
* varies with title wrapping and the optional description line. We seed
* estimateSize with a typical card height so initial scrollbar geometry is
* roughly correct before measurement runs.
*
* Wrapped in React.memo so that re-renders triggered by modal toggles or
* AI workbench state in the parent do not cascade into the (potentially
* thousands-strong) list — only changes to the shallow-equal prop set
* actually invalidate the list view.
*
* 虚拟化的 prompt 列表,替代以前的"setTimeout 分批渲染"补丁。
* 组件自带滚动容器,父级保持 overflow-hidden 即可;卡片高度因标题换行与
* 描述显示而异,所以使用 measureElement 动态测量;estimateSize 给一个典型
* 高度让初始滚动条几何大致正确。
*
* 用 React.memo 包裹,避免父级 modal 开关或 AI 工作区状态变化把上千条卡片
* 列表全量重渲染;只有真正影响展示的 props 变化才会触发列表重新渲染。
*/
const VirtualizedPromptList = memo(function VirtualizedPromptList({
prompts,
selectedPromptIdSet,
highlightTerms,
onSelect,
onContextMenu,
}: VirtualizedPromptListProps) {
const scrollRef = useRef<HTMLDivElement | null>(null);
const rowVirtualizer = useVirtualizer({
count: prompts.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => PROMPT_CARD_ESTIMATED_HEIGHT,
overscan: 8,
getItemKey: (index) => prompts[index]?.id ?? `__missing-${index}`,
});
const virtualItems = rowVirtualizer.getVirtualItems();
const totalHeight = rowVirtualizer.getTotalSize();
// Match the previous `<div className="p-3 space-y-2">` layout: 12px gutter
// around the list and 8px gap between cards. Padding lives on the spacer
// wrapper rather than the inner box because absolutely positioned children
// ignore their parent's padding.
// 还原原来 `<div className="p-3 space-y-2">` 的视觉:列表四周 12px 间距、
// 卡片之间 8px gap。padding 写在外层 spacer 上,因为绝对定位的子元素不会
// 受父级 padding 影响,需要靠 top/left/height 自己处理上下左右间距。
const LIST_PADDING_X = 12;
const LIST_PADDING_TOP = 12;
const LIST_PADDING_BOTTOM = 12;
return (
<div ref={scrollRef} className="flex-1 overflow-y-auto">
<div
style={{
position: 'relative',
height: `${totalHeight + LIST_PADDING_TOP + LIST_PADDING_BOTTOM}px`,
}}
>
{virtualItems.map((virtualRow) => {
const prompt = prompts[virtualRow.index];
if (!prompt) return null;
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: LIST_PADDING_X,
right: LIST_PADDING_X,
transform: `translateY(${virtualRow.start + LIST_PADDING_TOP}px)`,
paddingBottom: 8,
}}
>
<PromptCard
prompt={prompt}
isSelected={selectedPromptIdSet.has(prompt.id)}
onSelect={(e) => onSelect(prompt, e)}
onContextMenu={(e) => onContextMenu(e, prompt)}
highlightTerms={highlightTerms}
/>
</div>
);
})}
</div>
</div>
);
});
type DetailInlineEditDraft = {
title: string;
description: string;
systemPrompt: string;
userPrompt: string;
};
type DetailInlineEditField = 'title' | 'description' | 'systemPrompt' | 'userPrompt';
function createDetailInlineEditDraft(
prompt: Prompt,
showEnglish: boolean,
): DetailInlineEditDraft {
return {
title: prompt.title,
description: prompt.description ?? '',
systemPrompt: showEnglish ? (prompt.systemPromptEn || prompt.systemPrompt || '') : (prompt.systemPrompt || ''),
userPrompt: showEnglish ? (prompt.userPromptEn || prompt.userPrompt) : prompt.userPrompt,
};
}
function getDetailInlineSystemPromptField(
prompt: Prompt,
showEnglish: boolean,
): 'systemPrompt' | 'systemPromptEn' {
return showEnglish && !!prompt.systemPromptEn ? 'systemPromptEn' : 'systemPrompt';
}
function getDetailInlineUserPromptField(
prompt: Prompt,
showEnglish: boolean,
): 'userPrompt' | 'userPromptEn' {
return showEnglish && !!prompt.userPromptEn ? 'userPromptEn' : 'userPrompt';
}
export function MainContent() {
const appModule = useUIStore((state) => state.appModule);
if (appModule === 'rules') {
return <Suspense fallback={loadingFallback}><RulesManager /></Suspense>;
}
return <PromptSkillMainContent />;
}
function PromptSkillMainContent() {
const { t, i18n } = useTranslation();
const prompts = usePromptStore((state) => state.prompts);
const selectedId = usePromptStore((state) => state.selectedId);
const selectedIds = usePromptStore((state) => state.selectedIds);
const lastSelectedId = usePromptStore((state) => state.lastSelectedId);
const selectPrompt = usePromptStore((state) => state.selectPrompt);
const setSelectedIds = usePromptStore((state) => state.setSelectedIds);
const createPrompt = usePromptStore((state) => state.createPrompt);
const toggleFavorite = usePromptStore((state) => state.toggleFavorite);
const togglePinned = usePromptStore((state) => state.togglePinned);
const deletePrompt = usePromptStore((state) => state.deletePrompt);
const updatePrompt = usePromptStore((state) => state.updatePrompt);
const searchQuery = usePromptStore((state) => state.searchQuery);
const filterTags = usePromptStore((state) => state.filterTags);
const toggleFilterTag = usePromptStore((state) => state.toggleFilterTag);
const sortBy = usePromptStore((state) => state.sortBy);
const sortOrder = usePromptStore((state) => state.sortOrder);
const viewMode = usePromptStore((state) => state.viewMode);
const incrementUsageCount = usePromptStore((state) => state.incrementUsageCount);
const movePrompt = usePromptStore((state) => state.movePrompt);
// Resizable prompt-list pane width (#119)
const promptListPaneWidth = useUIStore((state) => state.promptListPaneWidth);
const setPromptListPaneWidth = useUIStore(
(state) => state.setPromptListPaneWidth,
);
const selectedFolderId = useFolderStore((state) => state.selectedFolderId);
const unlockedFolderIds = useFolderStore((state) => state.unlockedFolderIds);
const folders = useFolderStore((state) => state.folders);
const [copied, setCopied] = useState(false);
const [shared, setShared] = useState(false);
const [selectedModelIds, setSelectedModelIds] = useState<string[]>([]);
const [isVariableModalOpen, setIsVariableModalOpen] = useState(false);
const [isAiTestVariableModalOpen, setIsAiTestVariableModalOpen] = useState(false);
const [isCompareVariableModalOpen, setIsCompareVariableModalOpen] = useState(false);
// 用于列表/画廊视图复制时的变量弹窗
const [isCopyVariableModalOpen, setIsCopyVariableModalOpen] = useState(false);
const [copyPrompt, setCopyPrompt] = useState<Prompt | null>(null);
const [previewImage, setPreviewImage] = useState<string | null>(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 });
const renderMarkdownPref = useSettingsStore((state) => state.renderMarkdown);
const setRenderMarkdownPref = useSettingsStore((state) => state.setRenderMarkdown);
const [renderMarkdownEnabled, setRenderMarkdownEnabled] = useState(renderMarkdownPref);
const [showEnglish, setShowEnglish] = useState(false);
const [isTagDropActive, setIsTagDropActive] = useState(false);
const promptTypeFilter = usePromptStore((state) => state.promptTypeFilter);
const setPromptTypeFilter = usePromptStore((state) => state.setPromptTypeFilter);
const tagFilterMode = useSettingsStore((state) => state.tagFilterMode);
const uiViewMode = useUIStore((state) => state.viewMode);
const { showToast } = useToast();
const compareBuffersRef = useRef<Record<string, { response: string; thinkingContent: string }>>({});
const compareFlushRafRef = useRef<number | null>(null);
const flushCompareBuffers = useCallback(() => {
setCompareResults((prev) => {
if (!prev) return prev;
return prev.map((result) => {
const buffered = result.id ? compareBuffersRef.current[result.id] : undefined;
if (!buffered) {
return result;
}
return {
...result,
response: buffered.response,
thinkingContent: buffered.thinkingContent,
};
});
});
}, []);
const scheduleCompareFlush = useCallback(() => {
if (compareFlushRafRef.current !== null) return;
compareFlushRafRef.current = requestAnimationFrame(() => {
compareFlushRafRef.current = null;
flushSync(() => {
flushCompareBuffers();
});
});
}, [flushCompareBuffers]);
const resetCompareBuffers = useCallback(() => {
if (compareFlushRafRef.current !== null) {
cancelAnimationFrame(compareFlushRafRef.current);
compareFlushRafRef.current = null;
}
compareBuffersRef.current = {};
}, []);
const handleSelectPrompt = useCallback((prompt: Prompt, e: React.MouseEvent) => {
// Check if we are in multi-select mode (Ctrl/Cmd/Shift)
// 检查是否在多选模式 (Ctrl/Cmd/Shift)
if (e.metaKey || e.ctrlKey) {
// Toggle selection
if (selectedIds.includes(prompt.id)) {
setSelectedIds(selectedIds.filter(id => id !== prompt.id));
} else {
setSelectedIds([...selectedIds, prompt.id]);
}
} else if (e.shiftKey) {
// Range selection (simplified: add to selection for now)
// 范围选择(简化:目前只添加到选择)
// Ideally this would find the range between last selected and current
if (!selectedIds.includes(prompt.id)) {
setSelectedIds([...selectedIds, prompt.id]);
}
} else {
// Single select
// 单选
selectPrompt(prompt.id);
}
}, [selectedIds, selectPrompt, setSelectedIds]);
const preferEnglish = useMemo(() => {
const lang = (i18n.language || '').toLowerCase();
return !(lang.startsWith('zh'));
}, [i18n.language]);
const uiLangTag = useMemo(() => {
const lang = (i18n.language || '').toLowerCase();
if (!lang) return 'LANG';
if (lang.startsWith('zh')) return 'ZH';
if (lang.startsWith('ja')) return 'JA';
if (lang.startsWith('en')) return 'EN';
return lang.split('-')[0].toUpperCase();
}, [i18n.language]);
const highlightTerms = useMemo(() => getHighlightTerms(searchQuery), [searchQuery]);
const selectedPromptIdSet = useMemo(() => new Set(selectedIds), [selectedIds]);
// Store test states/results by prompt ID (persisted in component state)
// 按 prompt ID 保存测试状态和结果(持久化)
const [promptTestStates, setPromptTestStates] = useState<Record<string, {
isTestingAI: boolean;
isComparingModels: boolean;
aiResponse: string | null;
aiThinking: string | null;
isAiResponseImage?: boolean;
compareResults: AITestResult[] | null;
compareError: string | null;
}>>({});
// Get current prompt test state and results
// 获取当前 prompt 的测试状态和结果
const currentState = selectedId ? promptTestStates[selectedId] : null;
const isTestingAI = currentState?.isTestingAI || false;
const isComparingModels = currentState?.isComparingModels || false;
const compareResults = currentState?.compareResults || null;
const compareError = currentState?.compareError || null;
// Separate streaming state for real-time display (bypasses complex state updates)
// 独立的流式状态,用于实时显示(绕过复杂的状态更新)
const [streamingContent, setStreamingContent] = useState<string>('');
const [streamingThinking, setStreamingThinking] = useState<string>('');
const [isStreaming, setIsStreaming] = useState(false);
// Use streaming content when actively streaming, otherwise use stored state
// 流式传输时使用流式内容,否则使用存储的状态
const aiResponse = isStreaming ? streamingContent : (currentState?.aiResponse || null);
const aiThinking = isStreaming ? streamingThinking : (currentState?.aiThinking || null);
const isAiResponseImage = currentState?.isAiResponseImage || false;
// Update current prompt test state
// 更新当前 prompt 的测试状态
const updatePromptState = (promptId: string, updates: Partial<typeof currentState>) => {
setPromptTestStates(prev => ({
...prev,
[promptId]: {
isTestingAI: prev[promptId]?.isTestingAI || false,
isComparingModels: prev[promptId]?.isComparingModels || false,
aiResponse: prev[promptId]?.aiResponse || null,
aiThinking: prev[promptId]?.aiThinking || null,
isAiResponseImage: prev[promptId]?.isAiResponseImage || false,
compareResults: prev[promptId]?.compareResults || null,
compareError: prev[promptId]?.compareError || null,
...updates
}
}));
};
const setIsTestingAI = (testing: boolean) => {
if (selectedId) updatePromptState(selectedId, { isTestingAI: testing });
};
const setIsComparingModels = (comparing: boolean) => {
if (selectedId) updatePromptState(selectedId, { isComparingModels: comparing });
};
const setAiResponse = (response: string | null | ((prev: string | null) => string | null)) => {
if (selectedId) {
if (typeof response === 'function') {
const currentValue = promptTestStates[selectedId]?.aiResponse || null;
updatePromptState(selectedId, { aiResponse: response(currentValue) });
} else {
updatePromptState(selectedId, { aiResponse: response });
}
}
};
const setAiThinking = (thinking: string | null | ((prev: string | null) => string | null)) => {
if (selectedId) {
if (typeof thinking === 'function') {
const currentValue = promptTestStates[selectedId]?.aiThinking || null;
updatePromptState(selectedId, { aiThinking: thinking(currentValue) });
} else {
updatePromptState(selectedId, { aiThinking: thinking });
}
}
};
const setIsAiResponseImage = (isImage: boolean) => {
if (selectedId) {
updatePromptState(selectedId, { isAiResponseImage: isImage });
}
};
const setCompareResults = (results: AITestResult[] | null | ((prev: AITestResult[] | null) => AITestResult[] | null)) => {
if (selectedId) {
if (typeof results === 'function') {
const currentValue = promptTestStates[selectedId]?.compareResults || null;
updatePromptState(selectedId, { compareResults: results(currentValue) });
} else {
updatePromptState(selectedId, { compareResults: results });
}
}
};
const setCompareError = (error: string | null) => {
if (selectedId) updatePromptState(selectedId, { compareError: error });
};
// Reset selected prompt when switching folders (privacy)
// 切换 Folder 时重置选中的 Prompt (隐私保护)
useEffect(() => {
selectPrompt(null);
}, [selectedFolderId, selectPrompt]);
// Reset selected models when switching prompts
// 切换 Prompt 时重置选中的模型
useEffect(() => {
setSelectedModelIds((prev) => (prev.length === 0 ? prev : []));
}, [selectedId]);
// AI configuration
// AI 配置
const aiProvider = useSettingsStore((state) => state.aiProvider);
const aiApiProtocol = useSettingsStore((state) => state.aiApiProtocol);
const aiApiKey = useSettingsStore((state) => state.aiApiKey);
const aiApiUrl = useSettingsStore((state) => state.aiApiUrl);
const aiModel = useSettingsStore((state) => state.aiModel);
const aiModels = useSettingsStore((state) => state.aiModels);
const scenarioModelDefaults = useSettingsStore((state) => state.scenarioModelDefaults);
const modelRouteDefaults = useSettingsStore((state) => state.modelRouteDefaults);
const showCopyNotification = useSettingsStore((state) => state.showCopyNotification);
const defaultChatModel = useMemo(() => {
return resolveScenarioModel(
aiModels,
scenarioModelDefaults,
'promptTest',
'chat',
undefined,
modelRouteDefaults,
);
}, [aiModels, modelRouteDefaults, scenarioModelDefaults]);
const defaultImageModel = useMemo(() => {
return resolveScenarioModel(
aiModels,
scenarioModelDefaults,
'imageTest',
'image',
undefined,
modelRouteDefaults,
);
}, [aiModels, modelRouteDefaults, scenarioModelDefaults]);
const compareModels = useMemo(() => {
const isImagePrompt = prompts.find((p) => p.id === selectedId)?.promptType === 'image';
if (isImagePrompt) {
return [];
}
return aiModels.filter((model) => (model.type ?? 'chat') === 'chat');
}, [aiModels, prompts, selectedId]);
useEffect(() => {
setSelectedModelIds((prev) => {
const next = prev.filter((id) => compareModels.some((model) => model.id === id));
return next.length === prev.length ? prev : next;
});
}, [compareModels]);
useEffect(() => {
return () => {
resetCompareBuffers();
};
}, [resetCompareBuffers]);
const singleChatConfig = useMemo(() => {
if (defaultChatModel) {
return {
id: defaultChatModel.id,
provider: defaultChatModel.provider,
apiProtocol: defaultChatModel.apiProtocol,
apiKey: defaultChatModel.apiKey,
apiUrl: defaultChatModel.apiUrl,
model: defaultChatModel.model,
chatParams: defaultChatModel.chatParams,
};
}
return {
provider: aiProvider,
apiProtocol: aiApiProtocol,
apiKey: aiApiKey,
apiUrl: aiApiUrl,
model: aiModel,
};
}, [defaultChatModel, aiProvider, aiApiProtocol, aiApiKey, aiApiUrl, aiModel]);
const canRunSingleAiTest = !!((singleChatConfig.apiKey && singleChatConfig.apiUrl && singleChatConfig.model) ||
(defaultImageModel && defaultImageModel.apiKey && defaultImageModel.apiUrl && defaultImageModel.model));
useEffect(() => {
setRenderMarkdownEnabled((prev) =>
prev === renderMarkdownPref ? prev : renderMarkdownPref,
);
}, [renderMarkdownPref]);
const sanitizeSchema: any = useMemo(() => {
const schema = { ...defaultSchema, attributes: { ...defaultSchema.attributes } };
schema.attributes.code = [...(schema.attributes.code || []), ['className']];
schema.attributes.span = [...(schema.attributes.span || []), ['className']];
schema.attributes.pre = [...(schema.attributes.pre || []), ['className']];
return schema;
}, []);
const rehypePlugins = useMemo(
() => [
[rehypeHighlight, { ignoreMissing: true }] as any,
[rehypeSanitize, sanitizeSchema] as any,
],
[sanitizeSchema],
);
const highlightClassName = useMemo(() => 'bg-primary/15 text-primary rounded px-0.5', []);
const markdownComponents = useMemo(() => {
const Code = (props: any) => <code className="px-1 py-0.5 rounded bg-muted font-mono text-[13px]" {...props} />;
const Pre = (props: any) => (
<pre className="p-3 rounded-lg bg-muted overflow-x-auto text-[13px] leading-relaxed" {...props} />
);
const skipTypes = [Code, Pre];
const withHighlight = (Tag: any, className: string) => (props: any) => (
<Tag className={className} {...props}>
{renderHighlightedChildren(props.children, highlightTerms, highlightClassName, skipTypes)}
</Tag>
);
return {
h1: withHighlight('h1', 'text-2xl font-bold mb-4 text-foreground'),
h2: withHighlight('h2', 'text-xl font-semibold mb-3 mt-5 text-foreground'),
h3: withHighlight('h3', 'text-lg font-semibold mb-3 mt-4 text-foreground'),
h4: withHighlight('h4', 'text-base font-semibold mb-2 mt-3 text-foreground'),
p: withHighlight('p', 'mb-3 leading-relaxed text-foreground/90'),
ul: withHighlight('ul', 'list-disc pl-5 mb-3 space-y-1'),
ol: withHighlight('ol', 'list-decimal pl-5 mb-3 space-y-1'),
li: withHighlight('li', 'leading-relaxed'),
code: Code,
pre: Pre,
blockquote: withHighlight('blockquote', 'border-l-4 border-border pl-3 text-muted-foreground italic mb-3'),
hr: () => <hr className="my-4 border-border" />,
table: (props: any) => <table className="table-auto border-collapse w-full text-sm mb-3" {...props} />,
th: withHighlight('th', 'border border-border px-2 py-1 bg-muted text-left font-medium'),
td: withHighlight('td', 'border border-border px-2 py-1'),
a: (props: any) => (
<a className="text-primary hover:underline" {...props} target="_blank" rel="noreferrer">
{renderHighlightedChildren(props.children, highlightTerms, highlightClassName, skipTypes)}
</a>
),
strong: withHighlight('strong', 'font-semibold text-foreground'),
em: withHighlight('em', 'italic text-foreground/90'),
};
}, [highlightTerms, highlightClassName]);
const renderPromptContent = (content?: string) => {
if (!content) {
return (
<div className="p-4 rounded-xl app-wallpaper-surface border border-border text-sm text-muted-foreground">
{t('prompt.noContent')}
</div>
);
}
if (!renderMarkdownEnabled) {
return (
<div className="p-4 rounded-xl app-wallpaper-surface border border-border font-mono text-[14px] leading-relaxed whitespace-pre-wrap break-words">
{renderHighlightedText(content, highlightTerms, highlightClassName)}
</div>
);
}
return (
<div className="p-4 rounded-xl app-wallpaper-surface border border-border text-[15px] leading-relaxed markdown-content space-y-3 break-words">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={rehypePlugins}
components={markdownComponents}
>
{content}
</ReactMarkdown>
</div>
);
};
const renderAiResponseContent = (content?: string) => {
if (!content) {
return null;
}
if (!renderMarkdownEnabled) {
return (
<div className="text-sm leading-relaxed whitespace-pre-wrap break-words">
{content}
</div>
);
}
return (
<div className="text-[15px] leading-relaxed markdown-content space-y-3 break-words">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={rehypePlugins}
components={markdownComponents}
>
{content}
</ReactMarkdown>
</div>
);
};
const toggleRenderMarkdown = () => {
const next = !renderMarkdownEnabled;
setRenderMarkdownEnabled(next);
setRenderMarkdownPref(next);
};
const handleRestoreVersion = async (version: PromptVersion) => {
if (selectedPrompt) {
await updatePrompt(selectedPrompt.id, {
systemPrompt: version.systemPrompt,
userPrompt: version.userPrompt,
});
showToast(t('toast.restored'), 'success');
}
};
const formatAiTestImageSize = (bytes: number): string => {
if (bytes >= 1024 * 1024) {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
};
const readInlineAiTestImage = (file: File): Promise<VariableInputImageAttachment> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result !== 'string') {
reject(new Error(t('prompt.aiTestImageReadFailed')));
return;
}
const commaIndex = reader.result.indexOf(',');
if (commaIndex === -1) {
reject(new Error(t('prompt.aiTestImageReadFailed')));
return;
}
resolve({
id: `${file.name}-${file.size}-${file.lastModified}-${Math.random().toString(36).slice(2, 8)}`,
name: file.name,
mimeType: file.type,
size: file.size,
dataUrl: reader.result,
base64: reader.result.slice(commaIndex + 1),
});
};
reader.onerror = () => reject(new Error(t('prompt.aiTestImageReadFailed')));
reader.readAsDataURL(file);
});
};
const handleInlineAiTestImageSelection = async (files: FileList | null) => {
if (!files || files.length === 0) return;
const remainingSlots = MAX_AI_TEST_IMAGES - inlineAiTestImages.length;
if (remainingSlots <= 0) {
showToast(t('prompt.aiTestImageLimit', { count: MAX_AI_TEST_IMAGES }), 'error');
return;
}
const acceptedFiles: File[] = [];
for (const file of Array.from(files).slice(0, remainingSlots)) {
if (!SUPPORTED_AI_TEST_IMAGE_MIME_TYPES.has(file.type)) {
showToast(t('prompt.aiTestImageUnsupported', { name: file.name }), 'error');
continue;
}
if (file.size > MAX_AI_TEST_IMAGE_BYTES) {
showToast(t('prompt.aiTestImageTooLarge', { name: file.name, size: formatAiTestImageSize(MAX_AI_TEST_IMAGE_BYTES) }), 'error');
continue;
}
acceptedFiles.push(file);
}
if (acceptedFiles.length === 0) return;
try {
const attachments = await Promise.all(acceptedFiles.map(readInlineAiTestImage));
setInlineAiTestImages((prev) => [...prev, ...attachments].slice(0, MAX_AI_TEST_IMAGES));
} catch (error) {
showToast(error instanceof Error ? error.message : t('prompt.aiTestImageReadFailed'), 'error');
}
};
const runAiTest = async (
systemPrompt: string | undefined,
userPrompt: string,
promptId?: string,
outputFormat?: OutputFormatConfig,
imageAttachments: VariableInputImageAttachment[] = inlineAiTestImages,
) => {
// Do not use modal in card view; render results inline
// 卡片视图不使用弹窗,直接在页面内显示结果
setIsTestingAI(true);
setAiResponse(null);
setAiThinking(null);
setIsAiResponseImage(false);
setIsAiTestVariableModalOpen(false);
// Increment usage count
// 增加使用次数
const targetId = promptId || selectedId;
if (targetId) {
await incrementUsageCount(targetId);
}
// Get the current prompt to check its type
// 获取当前 prompt 以检查其类型
const currentPrompt = prompts.find(p => p.id === targetId);
const currentPromptType = currentPrompt?.promptType || 'text';
try {
if (!canRunSingleAiTest) {
throw new Error(t('toast.configAI') || '请先配置 AI');
}
// Use promptType to decide which API to call
// 根据 promptType 决定调用哪个 API
if (currentPromptType === 'image') {
if (!defaultImageModel) {
throw new Error(t('prompt.mismatchImage') || 'Prompt type is Image but no Image Model configured');
}
console.log('[MainContent] Image Prompt. Using model:', defaultImageModel.name || defaultImageModel.model);
try {
const result = await generateImage({
provider: defaultImageModel.provider,
apiProtocol: defaultImageModel.apiProtocol,
apiKey: defaultImageModel.apiKey,
apiUrl: defaultImageModel.apiUrl,
model: defaultImageModel.model,
imageParams: defaultImageModel.imageParams
} as any, userPrompt);
const imageUrl = result.data?.[0]?.url;
const imageBase64 = result.data?.[0]?.b64_json;
if (imageUrl || imageBase64) {
const displayUrl = imageUrl || `data:image/png;base64,${imageBase64}`;
setIsAiResponseImage(true);
setAiResponse(displayUrl);
// Save generated image to prompt's images array
// 将生成的图片保存到 prompt 的预览图中
if (targetId) {
try {
let savedFileName: string | null = null;
if (imageUrl) {
// Download from URL
savedFileName = await (window.electron as any).downloadImage(imageUrl);
} else if (imageBase64) {
// Save base64 directly
const fileName = `ai-generated-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.png`;
const success = await (window.electron as any).saveImageBase64(fileName, imageBase64);
if (success) savedFileName = fileName;
}
if (savedFileName && currentPrompt) {
const updatedImages = [...(currentPrompt.images || []), savedFileName];
await updatePrompt(targetId, { images: updatedImages });
showToast(t('toast.imageSaved'), 'success');
}
} catch (saveErr) {
console.warn('[MainContent] Failed to save generated image:', saveErr);
// Still show the image even if saving failed
}
}
return;
}
} catch (e) {
console.error("[MainContent] Image generation failed:", e);
setAiResponse(`${t('common.error')}: ${e instanceof Error ? e.message : 'Image generation failed'}`);
showToast(t('toast.aiFailed'), 'error');
return;
}
}
if (currentPromptType === 'video') {
// Video generation not yet implemented
// 视频生成尚未实现
setAiResponse(t('prompt.videoNotSupported'));
showToast(t('prompt.videoNotSupported'), 'info');
return;
}
// Default: Text/Chat mode
// 默认:文本对话模式
if (!(singleChatConfig.apiKey && singleChatConfig.apiUrl && singleChatConfig.model)) {
if (defaultImageModel) {
throw new Error(t('prompt.mismatchText'));
}
throw new Error(t('toast.configAI'));
}
const messages = buildMessagesFromPrompt(systemPrompt, userPrompt, undefined, imageAttachments);
const useStream = !!singleChatConfig.chatParams?.stream;
const useThinking = !!singleChatConfig.chatParams?.enableThinking;
// Debug: Log stream configuration / 调试:记录流式配置
console.log('[MainContent] AI Test - Stream:', useStream, 'Thinking:', useThinking);
console.log('[MainContent] chatParams:', singleChatConfig.chatParams);
if (useStream) {
// Start streaming mode - use independent state for real-time updates
// 开始流式模式 - 使用独立状态进行实时更新
setIsStreaming(true);