返回 oh-my-ppt
SessionCreateSuggestionDialog.tsx
根目录 / src / renderer / src / components / session-create / SessionCreateSuggestionDialog.tsx
1 import { useState, type Dispatch, type ReactElement, type SetStateAction } from 'react'
2 import { Check, FileText, Pencil, Sparkles, X } from 'lucide-react'
3 import { Button } from '../ui/Button'
4 import {
5 Dialog,
6 DialogContent,
7 DialogFooter,
8 DialogHeader,
9 DialogTitle
10 } from '../ui/Dialog'
11 import { Input, Textarea } from '../ui/Input'
12 import { useT } from '@renderer/i18n'
13 import {
14 isInternalDocumentPlanPageReason,
15 type DocumentPlanPageSkeletonItem,
16 type ParsedDocumentPlanResult,
17 type SourceDocumentPlan
18 } from '@shared/generation'
19
20 type AttachedReferenceFile = ParsedDocumentPlanResult['files'][number]
21
22 export type DocumentPlanSuggestion = Pick<
23 ParsedDocumentPlanResult,
24 'topic' | 'pageCount' | 'briefText' | 'sourcePlan'
25 >
26
27 export type DocumentPlanSuggestionDraft = {
28 topic: string
29 pageCount: string
30 briefText: string
31 sourcePlan?: SourceDocumentPlan
32 }
33
34 const renumberPageSkeleton = (
35 items: DocumentPlanPageSkeletonItem[]
36 ): DocumentPlanPageSkeletonItem[] =>
37 items.map((item, index) => ({
38 ...item,
39 pageNumber: index + 1
40 }))
41
42 const inferOutlineLanguageIsChinese = (text: string): boolean => /[\u3400-\u9fff]/.test(text)
43
44 const normalizeOutlineItemContent = (item: DocumentPlanPageSkeletonItem): string => {
45 return item.reason && !isInternalDocumentPlanPageReason(item.reason) ? item.reason : item.title
46 }
47
48 export const formatSourceOutlineBriefText = (items: DocumentPlanPageSkeletonItem[]): string => {
49 const joinedText = items.map((item) => `${item.title}\n${item.reason}`).join('\n')
50 const useChineseLabels = inferOutlineLanguageIsChinese(joinedText)
51 const outlineLabel = useChineseLabels ? '建议大纲' : 'Recommended outline'
52 const contentLabel = useChineseLabels ? '简要总结' : 'Brief summary'
53 const pageLabel = useChineseLabels ? '第' : 'Page'
54 const pageSuffix = useChineseLabels ? ' 页' : ''
55
56 return [
57 `## ${outlineLabel}`,
58 ...items.map(
59 (item) =>
60 `### ${pageLabel} ${item.pageNumber}${pageSuffix}: ${item.title}\n\n- **${contentLabel}:** ${normalizeOutlineItemContent(
61 item
62 )}`
63 )
64 ].join('\n\n')
65 }
66
67 export const buildSuggestionDraft = (
68 suggestion: DocumentPlanSuggestion
69 ): DocumentPlanSuggestionDraft => {
70 const pageSkeleton = suggestion.sourcePlan?.pageSkeleton
71 return {
72 topic: suggestion.topic,
73 pageCount: String(pageSkeleton?.length || suggestion.pageCount),
74 briefText: suggestion.briefText,
75 sourcePlan: suggestion.sourcePlan
76 ? {
77 ...suggestion.sourcePlan,
78 pageSkeleton: renumberPageSkeleton(suggestion.sourcePlan.pageSkeleton).map((item) => ({
79 ...item,
80 reason: normalizeOutlineItemContent(item)
81 }))
82 }
83 : undefined
84 }
85 }
86
87 export const updateDraftSourcePlanItems = (
88 draft: DocumentPlanSuggestionDraft,
89 updater: (items: DocumentPlanPageSkeletonItem[]) => DocumentPlanPageSkeletonItem[]
90 ): DocumentPlanSuggestionDraft => {
91 if (!draft.sourcePlan) return draft
92 const pageSkeleton = renumberPageSkeleton(updater(draft.sourcePlan.pageSkeleton))
93 return {
94 ...draft,
95 pageCount: pageSkeleton.length > 0 ? String(pageSkeleton.length) : draft.pageCount,
96 sourcePlan:
97 pageSkeleton.length > 0
98 ? {
99 ...draft.sourcePlan,
100 pageSkeleton
101 }
102 : undefined
103 }
104 }
105
106 export function SessionCreateSuggestionDialog({
107 open,
108 onOpenChange,
109 attachedReferenceFile,
110 suggestionDraft,
111 setSuggestionDraft,
112 applyTopicSuggestion,
113 setApplyTopicSuggestion,
114 applyPageCountSuggestion,
115 setApplyPageCountSuggestion,
116 applyBriefSuggestion,
117 setApplyBriefSuggestion,
118 onApplySelected
119 }: {
120 open: boolean
121 onOpenChange: (open: boolean) => void
122 attachedReferenceFile: AttachedReferenceFile | null
123 suggestionDraft: DocumentPlanSuggestionDraft | null
124 setSuggestionDraft: Dispatch<SetStateAction<DocumentPlanSuggestionDraft | null>>
125 applyTopicSuggestion: boolean
126 setApplyTopicSuggestion: (value: boolean) => void
127 applyPageCountSuggestion: boolean
128 setApplyPageCountSuggestion: (value: boolean) => void
129 applyBriefSuggestion: boolean
130 setApplyBriefSuggestion: (value: boolean) => void
131 onApplySelected: () => void
132 }): ReactElement {
133 const t = useT()
134 const [editingSuggestionField, setEditingSuggestionField] = useState<
135 'topic' | 'pageCount' | 'brief' | null
136 >(null)
137 const [editingOutlineIndex, setEditingOutlineIndex] = useState<number | null>(null)
138 const sourceOutlineItems = suggestionDraft?.sourcePlan?.pageSkeleton ?? []
139 const hasSourceOutline = sourceOutlineItems.length > 0
140
141 const close = (nextOpen: boolean): void => {
142 onOpenChange(nextOpen)
143 if (!nextOpen) {
144 setEditingSuggestionField(null)
145 setEditingOutlineIndex(null)
146 }
147 }
148
149 const updateSuggestionOutlineItem = (
150 index: number,
151 patch: Partial<DocumentPlanPageSkeletonItem>
152 ): void => {
153 setSuggestionDraft((current) =>
154 current
155 ? updateDraftSourcePlanItems(current, (items) =>
156 items.map((item, itemIndex) => (itemIndex === index ? { ...item, ...patch } : item))
157 )
158 : current
159 )
160 }
161
162 const deleteSuggestionOutlineItem = (index: number): void => {
163 setSuggestionDraft((current) =>
164 current
165 ? updateDraftSourcePlanItems(current, (items) =>
166 items.filter((_, itemIndex) => itemIndex !== index)
167 )
168 : current
169 )
170 }
171
172 const suggestionCardClass =
173 'rounded-lg border border-[#d7e8cc] bg-[#fbfff7] px-3 py-2.5 shadow-[0_4px_10px_rgba(93,107,77,0.06)]'
174 const suggestionIconButtonClass =
175 'flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-[#bfd9ae] bg-[#f8fff3] text-[#5f7448] transition-colors hover:bg-[#edf8e5]'
176 const deleteSuggestionIconButtonClass =
177 'flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-[#e1c4b9] bg-[#fff8f5] text-[#9a5d4d] transition-colors hover:bg-[#f5ded6]'
178
179 return (
180 <Dialog open={open} onOpenChange={close}>
181 <DialogContent className="max-w-4xl gap-0 overflow-hidden border-[#d8ccb5]/85 bg-[#f7f1e8] p-0">
182 <DialogHeader className="border-b border-[#ded4c1] bg-[#fffaf1] px-5 py-4 pr-12">
183 <div className="flex items-start gap-3">
184 <span className="mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-[#b9cda7]/75 bg-[#e6f1dc] text-[#405333] shadow-[0_4px_10px_rgba(93,107,77,0.08)]">
185 <Sparkles className="h-4 w-4" />
186 </span>
187 <div className="min-w-0">
188 <DialogTitle className="text-sm">{t('home.analysisSuggestionTitle')}</DialogTitle>
189 {attachedReferenceFile && (
190 <span
191 className="mt-2 inline-flex max-w-full items-center gap-1.5 rounded-full border border-[#d8ccb5]/72 bg-[#fff9ef]/86 px-2 py-1 text-[11px] font-medium text-[#5d6b4d]"
192 title={attachedReferenceFile.path}
193 >
194 <FileText className="h-3 w-3 shrink-0" />
195 <span className="min-w-0 truncate">{attachedReferenceFile.name}</span>
196 </span>
197 )}
198 </div>
199 </div>
200 </DialogHeader>
201
202 {suggestionDraft && (
203 <div className="max-h-[64vh] overflow-y-auto px-5 py-4">
204 <div className="space-y-2.5">
205 <section
206 className={`overflow-hidden rounded-xl border bg-[#fffdf8] shadow-[0_8px_18px_rgba(74,59,42,0.06)] transition-colors ${
207 applyTopicSuggestion
208 ? 'border-[#a9c693] ring-1 ring-[#cfe2c1]'
209 : 'border-[#e1d7c6]'
210 }`}
211 >
212 <div className="p-3">
213 <div className={suggestionCardClass}>
214 <div className="mb-1.5 flex items-center justify-between gap-2">
215 <input
216 type="checkbox"
217 checked={applyTopicSuggestion}
218 onChange={(event) => setApplyTopicSuggestion(event.target.checked)}
219 className="h-4 w-4 accent-[#6f8f64]"
220 aria-label={t('home.topic')}
221 />
222 <button
223 type="button"
224 onClick={() =>
225 setEditingSuggestionField(
226 editingSuggestionField === 'topic' ? null : 'topic'
227 )
228 }
229 className={suggestionIconButtonClass}
230 aria-label={
231 editingSuggestionField === 'topic' ? t('common.save') : t('common.edit')
232 }
233 title={
234 editingSuggestionField === 'topic' ? t('common.save') : t('common.edit')
235 }
236 >
237 {editingSuggestionField === 'topic' ? (
238 <Check className="h-3.5 w-3.5" />
239 ) : (
240 <Pencil className="h-3.5 w-3.5" />
241 )}
242 </button>
243 </div>
244 {editingSuggestionField === 'topic' ? (
245 <Input
246 value={suggestionDraft.topic}
247 onChange={(event) =>
248 setSuggestionDraft((current) =>
249 current ? { ...current, topic: event.target.value } : current
250 )
251 }
252 className="h-8 border-[#cddfbe] bg-white text-xs text-[#405333]"
253 />
254 ) : (
255 <p className="whitespace-pre-wrap text-sm font-medium leading-5 text-[#34402c]">
256 {suggestionDraft.topic || t('home.emptyValue')}
257 </p>
258 )}
259 </div>
260 </div>
261 </section>
262
263 {hasSourceOutline && (
264 <section
265 className={`overflow-hidden rounded-xl border bg-[#fffdf8] shadow-[0_8px_18px_rgba(74,59,42,0.06)] transition-colors ${
266 applyBriefSuggestion
267 ? 'border-[#a9c693] ring-1 ring-[#cfe2c1]'
268 : 'border-[#e1d7c6]'
269 }`}
270 >
271 <div className="p-3">
272 <div className="rounded-lg bg-[#eef6e8] px-3 py-2.5">
273 <div className="mb-2 flex items-center justify-between gap-2">
274 <input
275 type="checkbox"
276 checked={applyBriefSuggestion}
277 onChange={(event) => setApplyBriefSuggestion(event.target.checked)}
278 className="h-4 w-4 accent-[#6f8f64]"
279 aria-label={t('home.documentOutline')}
280 />
281 <span className="rounded-full border border-[#bfd9ae] bg-[#f8fff3] px-2 py-0.5 text-[11px] font-medium text-[#405333]">
282 {t('home.outlinePageCount', { count: sourceOutlineItems.length })}
283 </span>
284 </div>
285 <ol className="grid max-h-80 gap-2 overflow-y-auto pr-1 md:grid-cols-2">
286 {sourceOutlineItems.map((item, index) => (
287 <li
288 key={`${item.pageNumber}-${item.lineStart}-${item.sourceHeading}`}
289 className="rounded-lg border border-[#d7e8cc] bg-[#fbfff7] px-3 py-2.5 shadow-[0_4px_10px_rgba(93,107,77,0.06)]"
290 >
291 <div className="flex min-w-0 items-start justify-between gap-2">
292 <div className="flex min-w-0 items-start gap-2">
293 <span className="mt-0.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-[#dceccb] text-[10px] font-semibold text-[#405333]">
294 {item.pageNumber}
295 </span>
296 <div className="min-w-0">
297 {editingOutlineIndex === index ? (
298 <p className="text-[10px] font-medium text-[#6a8054]">
299 {t('home.outlineItemTitle')}
300 </p>
301 ) : (
302 <h4 className="min-w-0 break-words text-sm font-semibold leading-5 text-[#34402c]">
303 {item.title || t('home.emptyValue')}
304 </h4>
305 )}
306 </div>
307 </div>
308 <div className="flex shrink-0 items-center gap-1.5">
309 <button
310 type="button"
311 onClick={() =>
312 setEditingOutlineIndex(
313 editingOutlineIndex === index ? null : index
314 )
315 }
316 className={suggestionIconButtonClass}
317 aria-label={
318 editingOutlineIndex === index
319 ? t('common.save')
320 : t('common.edit')
321 }
322 title={
323 editingOutlineIndex === index
324 ? t('common.save')
325 : t('common.edit')
326 }
327 >
328 {editingOutlineIndex === index ? (
329 <Check className="h-3.5 w-3.5" />
330 ) : (
331 <Pencil className="h-3.5 w-3.5" />
332 )}
333 </button>
334 <button
335 type="button"
336 onClick={() => deleteSuggestionOutlineItem(index)}
337 className={deleteSuggestionIconButtonClass}
338 aria-label={t('common.delete')}
339 title={t('common.delete')}
340 >
341 <X className="h-3.5 w-3.5" />
342 </button>
343 </div>
344 </div>
345 {editingOutlineIndex === index ? (
346 <div className="mt-2 grid min-w-0 gap-1.5">
347 <Input
348 value={item.title}
349 onChange={(event) =>
350 updateSuggestionOutlineItem(index, {
351 title: event.target.value
352 })
353 }
354 className="h-7 min-w-0 border-[#cddfbe] bg-white px-2 text-xs font-semibold text-[#34402c]"
355 />
356 <p className="text-[10px] font-medium text-[#6a8054]">
357 {t('home.outlineItemContent')}
358 </p>
359 <Textarea
360 value={item.reason}
361 onChange={(event) =>
362 updateSuggestionOutlineItem(index, {
363 reason: event.target.value
364 })
365 }
366 className="max-h-24 min-h-14 resize-y border-[#d7e8cc] bg-white px-2 py-1.5 text-[11px] leading-5 text-[#6d604d]"
367 />
368 </div>
369 ) : (
370 <div className="mt-2">
371 <p className="line-clamp-4 whitespace-pre-wrap break-words text-xs leading-5 text-[#6d604d]">
372 {item.reason || t('home.emptyValue')}
373 </p>
374 </div>
375 )}
376 </li>
377 ))}
378 </ol>
379 </div>
380 </div>
381 </section>
382 )}
383
384 {!hasSourceOutline && (
385 <section
386 className={`overflow-hidden rounded-xl border bg-[#fffdf8] shadow-[0_8px_18px_rgba(74,59,42,0.06)] transition-colors ${
387 applyPageCountSuggestion
388 ? 'border-[#a9c693] ring-1 ring-[#cfe2c1]'
389 : 'border-[#e1d7c6]'
390 }`}
391 >
392 <div className="grid gap-3 p-3 md:grid-cols-[120px_1fr] md:items-center">
393 <label className="flex cursor-pointer items-center gap-2">
394 <input
395 type="checkbox"
396 checked={applyPageCountSuggestion}
397 onChange={(event) => setApplyPageCountSuggestion(event.target.checked)}
398 className="h-4 w-4 accent-[#6f8f64]"
399 />
400 <span className="text-sm font-semibold text-[#34402c]">
401 {t('home.pageCount')}
402 </span>
403 </label>
404 <div className={suggestionCardClass}>
405 <div className="mb-1.5 flex items-center justify-between gap-2">
406 <button
407 type="button"
408 onClick={() =>
409 setEditingSuggestionField(
410 editingSuggestionField === 'pageCount' ? null : 'pageCount'
411 )
412 }
413 className={suggestionIconButtonClass}
414 aria-label={
415 editingSuggestionField === 'pageCount'
416 ? t('common.save')
417 : t('common.edit')
418 }
419 title={
420 editingSuggestionField === 'pageCount'
421 ? t('common.save')
422 : t('common.edit')
423 }
424 >
425 {editingSuggestionField === 'pageCount' ? (
426 <Check className="h-3.5 w-3.5" />
427 ) : (
428 <Pencil className="h-3.5 w-3.5" />
429 )}
430 </button>
431 </div>
432 {editingSuggestionField === 'pageCount' ? (
433 <Input
434 value={suggestionDraft.pageCount}
435 inputMode="numeric"
436 onChange={(event) => {
437 const next = event.target.value
438 if (next === '' || /^\d+$/.test(next)) {
439 setSuggestionDraft((current) =>
440 current ? { ...current, pageCount: next } : current
441 )
442 }
443 }}
444 className="h-8 border-[#cddfbe] bg-white text-xs text-[#405333]"
445 />
446 ) : (
447 <p className="text-sm font-semibold leading-5 text-[#34402c]">
448 {suggestionDraft.pageCount || t('home.emptyValue')}
449 </p>
450 )}
451 </div>
452 </div>
453 </section>
454 )}
455
456 {!hasSourceOutline && (
457 <section
458 className={`overflow-hidden rounded-xl border bg-[#fffdf8] shadow-[0_8px_18px_rgba(74,59,42,0.06)] transition-colors ${
459 applyBriefSuggestion
460 ? 'border-[#a9c693] ring-1 ring-[#cfe2c1]'
461 : 'border-[#e1d7c6]'
462 }`}
463 >
464 <div className="grid gap-3 p-3 md:grid-cols-[120px_1fr] md:items-start">
465 <label className="flex cursor-pointer items-center gap-2 pt-1">
466 <input
467 type="checkbox"
468 checked={applyBriefSuggestion}
469 onChange={(event) => setApplyBriefSuggestion(event.target.checked)}
470 className="h-4 w-4 accent-[#6f8f64]"
471 />
472 <span className="truncate text-sm font-semibold text-[#34402c]">
473 {t('home.brief')}
474 </span>
475 </label>
476 <div className={suggestionCardClass}>
477 <div className="mb-1.5 flex items-center justify-between gap-2">
478 <button
479 type="button"
480 onClick={() =>
481 setEditingSuggestionField(
482 editingSuggestionField === 'brief' ? null : 'brief'
483 )
484 }
485 className={suggestionIconButtonClass}
486 aria-label={
487 editingSuggestionField === 'brief' ? t('common.save') : t('common.edit')
488 }
489 title={
490 editingSuggestionField === 'brief' ? t('common.save') : t('common.edit')
491 }
492 >
493 {editingSuggestionField === 'brief' ? (
494 <Check className="h-3.5 w-3.5" />
495 ) : (
496 <Pencil className="h-3.5 w-3.5" />
497 )}
498 </button>
499 </div>
500 {editingSuggestionField === 'brief' ? (
501 <Textarea
502 value={suggestionDraft.briefText}
503 onChange={(event) =>
504 setSuggestionDraft((current) =>
505 current ? { ...current, briefText: event.target.value } : current
506 )
507 }
508 className="max-h-40 min-h-24 resize-y border-[#cddfbe] bg-white text-xs leading-5 text-[#405333]"
509 />
510 ) : (
511 <p className="max-h-40 overflow-y-auto whitespace-pre-wrap break-words text-xs leading-5 text-[#405333]">
512 {suggestionDraft.briefText || t('home.emptyValue')}
513 </p>
514 )}
515 </div>
516 </div>
517 </section>
518 )}
519 </div>
520 </div>
521 )}
522
523 <DialogFooter className="flex-col-reverse gap-1.5 border-t border-[#ded4c1] bg-[#fffaf1] px-5 py-2.5 sm:flex-row">
524 <Button
525 variant="ghost"
526 size="sm"
527 className="h-8 px-3 text-xs"
528 onClick={() => close(false)}
529 >
530 {t('common.cancel')}
531 </Button>
532 <Button size="sm" className="h-8 px-3 text-xs" onClick={onApplySelected}>
533 {t('home.applySelectedFields')}
534 </Button>
535 </DialogFooter>
536 </DialogContent>
537 </Dialog>
538 )
539 }
540
540 lines Plain Text