mirror of
https://github.com/lfnovo/open-notebook.git
synced 2026-08-14 19:43:51 +00:00
refactor(frontend): split GeneratePodcastDialog and fix token-count races (#1067)
Extract ContentSelectionPanel and shared selection helpers into their own modules, deduplicate the sources/notes context-config reduction into selectionsToContextConfigs, delete the obsolete translation cache (plain react-i18next t() now), debounce the token/char counter with a stale-response guard, and close the dialog when the episode refetch resolves instead of after a fixed 500ms timer.
This commit is contained in:
parent
12ced05b3a
commit
2bd9211abc
4 changed files with 455 additions and 449 deletions
|
|
@ -14,6 +14,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- Release confidence process, documented and executable: `.github/RELEASE_PROCESS.md` now covers the risk-based test matrix (buckets A/B/C), the Docker image gate, the fix-loop re-test policy and the communication/credits/retro structure, backed by a new decision record (ADR-005) and versioned tooling under `scripts/release-test/` — `make release-test TAG= OLD_TAG=` runs fresh-install + upgrade scenarios against real images, and `make release-stack TAG= [DUMP=]` boots a browsable, isolated release-candidate stack (optionally with a copy of dev data) for manual verification
|
||||
- CI now gates every PR on `ruff check` (backend lint), `npm run lint` (frontend ESLint) and `npm run build` (frontend production build), in addition to the existing test suites
|
||||
|
||||
### Fixed
|
||||
- Podcast generation dialog: the token/char counter no longer fires a request storm on rapid checkbox toggling (debounced, with a stale-response guard so a slow response can't overwrite a fresher count) and the dialog now closes as soon as the episode-list refetch completes instead of after a fixed 500ms timer; the 983-line component was also split (content selection panel and selection helpers extracted, duplicated context-config logic deduplicated) with no behavior changes
|
||||
|
||||
### Changed
|
||||
- Re-enabled the ruff rules for unused imports (`F401`), unused local variables (`F841`) and bare `except:` (`E722`) that were ignored to silence legacy Streamlit-era noise, and cleaned up the remaining fallout (10 unused imports, 2 unused test variables; no bare excepts remained)
|
||||
- Chat, source chat, Ask and transformation prompts now steer models to write math as `$$...$$` (display) / `$...$` (inline) so formulas render via KaTeX, reserving fenced `latex` code blocks for when the user explicitly asks for the LaTeX source (#1051)
|
||||
|
|
|
|||
313
frontend/src/components/podcasts/ContentSelectionPanel.tsx
Normal file
313
frontend/src/components/podcasts/ContentSelectionPanel.tsx
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
'use client'
|
||||
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { sourcesApi } from '@/lib/api/sources'
|
||||
import { notesApi } from '@/lib/api/notes'
|
||||
import { NoteResponse, NotebookResponse, SourceListResponse } from '@/lib/types/api'
|
||||
import { QUERY_KEYS } from '@/lib/api/query-client'
|
||||
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
|
||||
|
||||
import {
|
||||
NotebookSelection,
|
||||
NotebookSummary,
|
||||
SourceMode,
|
||||
formatNumber,
|
||||
getSourceDefaultMode,
|
||||
} from './generate-podcast-selection'
|
||||
|
||||
interface ContentSelectionPanelProps {
|
||||
notebooks: NotebookResponse[]
|
||||
isLoading: boolean
|
||||
selectedNotebookSummaries: NotebookSummary[]
|
||||
tokenCount: number
|
||||
charCount: number
|
||||
expandedNotebooks: string[]
|
||||
setExpandedNotebooks: (notebooks: string[]) => void
|
||||
selections: Record<string, NotebookSelection>
|
||||
sourcesByNotebook: Record<string, SourceListResponse[]>
|
||||
notesByNotebook: Record<string, NoteResponse[]>
|
||||
fetchingNotebookIds: Set<string>
|
||||
onNotebookToggle: (notebookId: string, checked: boolean | 'indeterminate') => void
|
||||
onSourceModeChange: (notebookId: string, sourceId: string, mode: SourceMode) => void
|
||||
onNoteToggle: (notebookId: string, noteId: string, checked: boolean | 'indeterminate') => void
|
||||
}
|
||||
|
||||
export function ContentSelectionPanel({
|
||||
notebooks,
|
||||
isLoading,
|
||||
selectedNotebookSummaries,
|
||||
tokenCount,
|
||||
charCount,
|
||||
expandedNotebooks,
|
||||
setExpandedNotebooks,
|
||||
selections,
|
||||
sourcesByNotebook,
|
||||
notesByNotebook,
|
||||
fetchingNotebookIds,
|
||||
onNotebookToggle,
|
||||
onSourceModeChange,
|
||||
onNoteToggle,
|
||||
}: ContentSelectionPanelProps) {
|
||||
const { t, language } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const sourceModes = [
|
||||
{ value: 'insights', label: t('podcasts.summary') },
|
||||
{ value: 'full', label: t('podcasts.fullContent') },
|
||||
] as const
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t('podcasts.content')}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('podcasts.contentDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">
|
||||
{t('podcasts.itemsSelected').replace(
|
||||
'{count}',
|
||||
selectedNotebookSummaries.reduce(
|
||||
(acc: number, summary: NotebookSummary) => acc + summary.sources + summary.notes,
|
||||
0
|
||||
).toString()
|
||||
)}
|
||||
</Badge>
|
||||
{(tokenCount > 0 || charCount > 0) && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{tokenCount > 0 && t('podcasts.tokens').replace('{count}', formatNumber(tokenCount))}
|
||||
{tokenCount > 0 && charCount > 0 && ' / '}
|
||||
{charCount > 0 && t('podcasts.chars').replace('{count}', formatNumber(charCount))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-muted/30">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16 text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> {t('podcasts.loadingNotebooks')}
|
||||
</div>
|
||||
) : notebooks.length === 0 ? (
|
||||
<div className="p-6 text-sm text-muted-foreground">
|
||||
{t('podcasts.noNotebooksFoundInPodcasts')}
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-[60vh]">
|
||||
<Accordion
|
||||
type="multiple"
|
||||
value={expandedNotebooks}
|
||||
onValueChange={(value) => setExpandedNotebooks(value as string[])}
|
||||
className="w-full"
|
||||
>
|
||||
{notebooks.map((notebook: NotebookResponse, index: number) => {
|
||||
const sources = sourcesByNotebook[notebook.id] ?? []
|
||||
const notes = notesByNotebook[notebook.id] ?? []
|
||||
const selection = selections[notebook.id]
|
||||
const summary = selectedNotebookSummaries[index]
|
||||
const notebookChecked = summary.sources + summary.notes > 0
|
||||
const totalItems = sources.length + notes.length
|
||||
const isIndeterminate =
|
||||
notebookChecked &&
|
||||
summary.sources + summary.notes > 0 &&
|
||||
summary.sources + summary.notes < totalItems
|
||||
|
||||
return (
|
||||
<AccordionItem key={notebook.id} value={notebook.id}>
|
||||
<div className="flex items-start gap-3 px-4 pt-3">
|
||||
<Checkbox
|
||||
id={`notebook-toggle-${notebook.id}`}
|
||||
checked={isIndeterminate ? 'indeterminate' : notebookChecked}
|
||||
onCheckedChange={(checked) => {
|
||||
onNotebookToggle(notebook.id, checked)
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: QUERY_KEYS.sources(notebook.id),
|
||||
queryFn: () => sourcesApi.list({ notebook_id: notebook.id }),
|
||||
})
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: QUERY_KEYS.notes(notebook.id),
|
||||
queryFn: () => notesApi.list({ notebook_id: notebook.id }),
|
||||
})
|
||||
}}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
/>
|
||||
<AccordionTrigger className="flex-1 px-0 py-0 hover:no-underline">
|
||||
<Label
|
||||
htmlFor={`notebook-toggle-${notebook.id}`}
|
||||
className="flex w-full items-center justify-between gap-3 pointer-events-none"
|
||||
>
|
||||
<div className="text-left">
|
||||
<p className="font-medium text-sm text-foreground">
|
||||
{notebook.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{summary.sources + summary.notes > 0
|
||||
? `${summary.sources} ${t('podcasts.sources')}, ${summary.notes} ${t('podcasts.notes')}`
|
||||
: t('podcasts.noContentSelected')}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{sources.length} {t('podcasts.sources')} · {notes.length} {t('podcasts.notes')}
|
||||
</Badge>
|
||||
</Label>
|
||||
</AccordionTrigger>
|
||||
</div>
|
||||
<AccordionContent>
|
||||
<div className="space-y-4 px-4 pb-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t('podcasts.sources')}
|
||||
</h4>
|
||||
{fetchingNotebookIds.has(notebook.id) && (
|
||||
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
{sources.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('podcasts.noSources')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{sources.map((source: SourceListResponse) => {
|
||||
const mode = selection?.sources?.[source.id] ?? 'off'
|
||||
return (
|
||||
<div
|
||||
key={source.id}
|
||||
className="flex items-center gap-3 rounded border bg-background px-3 py-2"
|
||||
>
|
||||
<Checkbox
|
||||
id={`source-selection-${source.id}`}
|
||||
checked={mode !== 'off'}
|
||||
onCheckedChange={(checked) =>
|
||||
onSourceModeChange(
|
||||
notebook.id,
|
||||
source.id,
|
||||
checked ? getSourceDefaultMode(source) : 'off'
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`source-selection-${source.id}`}
|
||||
className="flex flex-1 flex-col gap-1 cursor-pointer"
|
||||
>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{source.title || t('podcasts.untitledSource')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{source.asset?.url ? t('podcasts.link') : t('podcasts.file')}</span>
|
||||
<span>•</span>
|
||||
<span>{source.embedded ? t('podcasts.embedded') : t('podcasts.notEmbedded')}</span>
|
||||
</div>
|
||||
</Label>
|
||||
<Select
|
||||
value={mode === 'off' ? 'off' : mode}
|
||||
onValueChange={(value) =>
|
||||
onSourceModeChange(
|
||||
notebook.id,
|
||||
source.id,
|
||||
value as SourceMode
|
||||
)
|
||||
}
|
||||
disabled={mode === 'off'}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder={t('podcasts.selectMode')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sourceModes.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={
|
||||
option.value === 'insights' &&
|
||||
(!source.insights_count || source.insights_count === 0)
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t('podcasts.notes')}
|
||||
</h4>
|
||||
{notes.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('podcasts.noNotes')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{notes.map((note: NoteResponse) => {
|
||||
const mode = selection?.notes?.[note.id] ?? 'off'
|
||||
return (
|
||||
<div
|
||||
key={note.id}
|
||||
className="flex items-center gap-3 rounded border bg-background px-3 py-2"
|
||||
>
|
||||
<Checkbox
|
||||
id={`note-selection-${note.id}`}
|
||||
checked={mode !== 'off'}
|
||||
onCheckedChange={(checked) =>
|
||||
onNoteToggle(
|
||||
notebook.id,
|
||||
note.id,
|
||||
Boolean(checked)
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`note-selection-${note.id}`}
|
||||
className="flex flex-1 flex-col cursor-pointer"
|
||||
>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{note.title || t('podcasts.untitledNote')}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('common.updated')}{' '}
|
||||
{new Date(note.updated).toLocaleString(
|
||||
language.startsWith('zh') ? language : 'en-US'
|
||||
)}
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
})}
|
||||
</Accordion>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,16 +1,15 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useQueries, useQueryClient } from '@tanstack/react-query'
|
||||
import { useQueries } from '@tanstack/react-query'
|
||||
|
||||
import { useNotebooks } from '@/lib/hooks/use-notebooks'
|
||||
import { useEpisodeProfiles, useGeneratePodcast } from '@/lib/hooks/use-podcasts'
|
||||
import { chatApi } from '@/lib/api/chat'
|
||||
import { sourcesApi } from '@/lib/api/sources'
|
||||
import { notesApi } from '@/lib/api/notes'
|
||||
import { BuildContextRequest, NoteResponse, NotebookResponse, SourceListResponse } from '@/lib/types/api'
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import { NoteResponse, SourceListResponse } from '@/lib/types/api'
|
||||
import { PodcastGenerationRequest } from '@/lib/types/podcasts'
|
||||
import { QUERY_KEYS } from '@/lib/api/query-client'
|
||||
import { useToast } from '@/lib/hooks/use-toast'
|
||||
|
|
@ -23,381 +22,30 @@ import {
|
|||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
|
||||
|
||||
type SourceMode = 'off' | 'insights' | 'full'
|
||||
import { ContentSelectionPanel } from './ContentSelectionPanel'
|
||||
import {
|
||||
NotebookSelection,
|
||||
SourceMode,
|
||||
getSourceDefaultMode,
|
||||
hasSelections,
|
||||
selectionsToContextConfigs,
|
||||
} from './generate-podcast-selection'
|
||||
|
||||
interface NotebookSelection {
|
||||
sources: Record<string, SourceMode>
|
||||
notes: Record<string, SourceMode>
|
||||
}
|
||||
|
||||
// Helper function to format large numbers with K/M suffixes
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1000000) {
|
||||
return `${(num / 1000000).toFixed(1)}M`
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return `${(num / 1000).toFixed(1)}K`
|
||||
}
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
function hasSelections(selection?: NotebookSelection): boolean {
|
||||
if (!selection) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
Object.values(selection.sources).some((mode) => mode !== 'off') ||
|
||||
Object.values(selection.notes).some((mode) => mode !== 'off')
|
||||
)
|
||||
}
|
||||
|
||||
function getSourceDefaultMode(source: SourceListResponse): SourceMode {
|
||||
return source.insights_count && source.insights_count > 0 ? 'insights' : 'full'
|
||||
}
|
||||
const TOKEN_COUNT_DEBOUNCE_MS = 400
|
||||
|
||||
interface GeneratePodcastDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
interface NotebookSummary {
|
||||
notebookId: string
|
||||
sources: number
|
||||
notes: number
|
||||
}
|
||||
|
||||
interface ContentSelectionPanelProps {
|
||||
notebooks: NotebookResponse[]
|
||||
isLoading: boolean
|
||||
selectedNotebookSummaries: NotebookSummary[]
|
||||
tokenCount: number
|
||||
charCount: number
|
||||
expandedNotebooks: string[]
|
||||
setExpandedNotebooks: (notebooks: string[]) => void
|
||||
selections: Record<string, NotebookSelection>
|
||||
sourcesByNotebook: Record<string, SourceListResponse[]>
|
||||
notesByNotebook: Record<string, NoteResponse[]>
|
||||
fetchingNotebookIds: Set<string>
|
||||
handleNotebookToggle: (notebookId: string, checked: boolean | 'indeterminate') => void
|
||||
handleSourceModeChange: (notebookId: string, sourceId: string, mode: SourceMode) => void
|
||||
handleNoteToggle: (notebookId: string, noteId: string, checked: boolean | 'indeterminate') => void
|
||||
queryClient: QueryClient
|
||||
}
|
||||
|
||||
// Extracted component for content selection panel
|
||||
function ContentSelectionPanel({
|
||||
notebooks,
|
||||
isLoading,
|
||||
selectedNotebookSummaries,
|
||||
tokenCount,
|
||||
charCount,
|
||||
expandedNotebooks,
|
||||
setExpandedNotebooks,
|
||||
selections,
|
||||
sourcesByNotebook,
|
||||
notesByNotebook,
|
||||
fetchingNotebookIds,
|
||||
handleNotebookToggle,
|
||||
handleSourceModeChange,
|
||||
handleNoteToggle,
|
||||
queryClient,
|
||||
}: ContentSelectionPanelProps) {
|
||||
const { t, language } = useTranslation()
|
||||
|
||||
// Cache all translation strings at render time to avoid repeated Proxy accesses in loops
|
||||
// This prevents the infinite loop detection from triggering
|
||||
const tr = {
|
||||
content: t('podcasts.content'),
|
||||
contentDesc: t('podcasts.contentDesc'),
|
||||
itemsSelected: t('podcasts.itemsSelected'),
|
||||
tokens: t('podcasts.tokens'),
|
||||
chars: t('podcasts.chars'),
|
||||
loadingNotebooks: t('podcasts.loadingNotebooks'),
|
||||
noNotebooksFoundInPodcasts: t('podcasts.noNotebooksFoundInPodcasts'),
|
||||
sources: t('podcasts.sources'),
|
||||
notes: t('podcasts.notes'),
|
||||
noContentSelected: t('podcasts.noContentSelected'),
|
||||
noSources: t('podcasts.noSources'),
|
||||
untitledSource: t('podcasts.untitledSource'),
|
||||
link: t('podcasts.link'),
|
||||
file: t('podcasts.file'),
|
||||
embedded: t('podcasts.embedded'),
|
||||
notEmbedded: t('podcasts.notEmbedded'),
|
||||
selectMode: t('podcasts.selectMode'),
|
||||
noNotes: t('podcasts.noNotes'),
|
||||
untitledNote: t('podcasts.untitledNote'),
|
||||
commonUpdated: t('common.updated'),
|
||||
summary: t('podcasts.summary'),
|
||||
fullContent: t('podcasts.fullContent'),
|
||||
}
|
||||
|
||||
// Pre-compute source modes once to avoid repeated t.podcasts access in loops
|
||||
const sourceModes = [
|
||||
{ value: 'insights', label: tr.summary },
|
||||
{ value: 'full', label: tr.fullContent },
|
||||
] as const
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{tr.content}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{tr.contentDesc}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">
|
||||
{tr.itemsSelected.replace(
|
||||
'{count}',
|
||||
selectedNotebookSummaries.reduce(
|
||||
(acc: number, summary: NotebookSummary) => acc + summary.sources + summary.notes,
|
||||
0
|
||||
).toString()
|
||||
)}
|
||||
</Badge>
|
||||
{(tokenCount > 0 || charCount > 0) && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{tokenCount > 0 && tr.tokens.replace('{count}', formatNumber(tokenCount))}
|
||||
{tokenCount > 0 && charCount > 0 && ' / '}
|
||||
{charCount > 0 && tr.chars.replace('{count}', formatNumber(charCount))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-muted/30">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16 text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> {tr.loadingNotebooks}
|
||||
</div>
|
||||
) : notebooks.length === 0 ? (
|
||||
<div className="p-6 text-sm text-muted-foreground">
|
||||
{tr.noNotebooksFoundInPodcasts}
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-[60vh]">
|
||||
<Accordion
|
||||
type="multiple"
|
||||
value={expandedNotebooks}
|
||||
onValueChange={(value) => setExpandedNotebooks(value as string[])}
|
||||
className="w-full"
|
||||
>
|
||||
{notebooks.map((notebook: NotebookResponse, index: number) => {
|
||||
const sources = sourcesByNotebook[notebook.id] ?? []
|
||||
const notes = notesByNotebook[notebook.id] ?? []
|
||||
const selection = selections[notebook.id]
|
||||
const summary = selectedNotebookSummaries[index]
|
||||
const notebookChecked = summary.sources + summary.notes > 0
|
||||
const totalItems = sources.length + notes.length
|
||||
const isIndeterminate =
|
||||
notebookChecked &&
|
||||
summary.sources + summary.notes > 0 &&
|
||||
summary.sources + summary.notes < totalItems
|
||||
|
||||
return (
|
||||
<AccordionItem key={notebook.id} value={notebook.id}>
|
||||
<div className="flex items-start gap-3 px-4 pt-3">
|
||||
<Checkbox
|
||||
id={`notebook-toggle-${notebook.id}`}
|
||||
checked={isIndeterminate ? 'indeterminate' : notebookChecked}
|
||||
onCheckedChange={(checked) => {
|
||||
handleNotebookToggle(notebook.id, checked)
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: QUERY_KEYS.sources(notebook.id),
|
||||
queryFn: () => sourcesApi.list({ notebook_id: notebook.id }),
|
||||
})
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: QUERY_KEYS.notes(notebook.id),
|
||||
queryFn: () => notesApi.list({ notebook_id: notebook.id }),
|
||||
})
|
||||
}}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
/>
|
||||
<AccordionTrigger className="flex-1 px-0 py-0 hover:no-underline">
|
||||
<Label
|
||||
htmlFor={`notebook-toggle-${notebook.id}`}
|
||||
className="flex w-full items-center justify-between gap-3 pointer-events-none"
|
||||
>
|
||||
<div className="text-left">
|
||||
<p className="font-medium text-sm text-foreground">
|
||||
{notebook.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{summary.sources + summary.notes > 0
|
||||
? `${summary.sources} ${tr.sources}, ${summary.notes} ${tr.notes}`
|
||||
: tr.noContentSelected}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{sources.length} {tr.sources} · {notes.length} {tr.notes}
|
||||
</Badge>
|
||||
</Label>
|
||||
</AccordionTrigger>
|
||||
</div>
|
||||
<AccordionContent>
|
||||
<div className="space-y-4 px-4 pb-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{tr.sources}
|
||||
</h4>
|
||||
{fetchingNotebookIds.has(notebook.id) && (
|
||||
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
{sources.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{tr.noSources}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{sources.map((source: SourceListResponse) => {
|
||||
const mode = selection?.sources?.[source.id] ?? 'off'
|
||||
return (
|
||||
<div
|
||||
key={source.id}
|
||||
className="flex items-center gap-3 rounded border bg-background px-3 py-2"
|
||||
>
|
||||
<Checkbox
|
||||
id={`source-selection-${source.id}`}
|
||||
checked={mode !== 'off'}
|
||||
onCheckedChange={(checked) =>
|
||||
handleSourceModeChange(
|
||||
notebook.id,
|
||||
source.id,
|
||||
checked ? getSourceDefaultMode(source) : 'off'
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`source-selection-${source.id}`}
|
||||
className="flex flex-1 flex-col gap-1 cursor-pointer"
|
||||
>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{source.title || tr.untitledSource}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{source.asset?.url ? tr.link : tr.file}</span>
|
||||
<span>•</span>
|
||||
<span>{source.embedded ? tr.embedded : tr.notEmbedded}</span>
|
||||
</div>
|
||||
</Label>
|
||||
<Select
|
||||
value={mode === 'off' ? 'off' : mode}
|
||||
onValueChange={(value) =>
|
||||
handleSourceModeChange(
|
||||
notebook.id,
|
||||
source.id,
|
||||
value as SourceMode
|
||||
)
|
||||
}
|
||||
disabled={mode === 'off'}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder={tr.selectMode} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sourceModes.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={
|
||||
option.value === 'insights' &&
|
||||
(!source.insights_count || source.insights_count === 0)
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{tr.notes}
|
||||
</h4>
|
||||
{notes.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{tr.noNotes}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{notes.map((note: NoteResponse) => {
|
||||
const mode = selection?.notes?.[note.id] ?? 'off'
|
||||
return (
|
||||
<div
|
||||
key={note.id}
|
||||
className="flex items-center gap-3 rounded border bg-background px-3 py-2"
|
||||
>
|
||||
<Checkbox
|
||||
id={`note-selection-${note.id}`}
|
||||
checked={mode !== 'off'}
|
||||
onCheckedChange={(checked) =>
|
||||
handleNoteToggle(
|
||||
notebook.id,
|
||||
note.id,
|
||||
Boolean(checked)
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`note-selection-${note.id}`}
|
||||
className="flex flex-1 flex-col cursor-pointer"
|
||||
>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{note.title || tr.untitledNote}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{tr.commonUpdated}{' '}
|
||||
{new Date(note.updated).toLocaleString(
|
||||
language.startsWith('zh') ? language : 'en-US'
|
||||
)}
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
})}
|
||||
</Accordion>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const { toast } = useToast()
|
||||
const queryClient = useQueryClient()
|
||||
const [expandedNotebooks, setExpandedNotebooks] = useState<string[]>([])
|
||||
const [selections, setSelections] = useState<Record<string, NotebookSelection>>({})
|
||||
const [episodeProfileId, setEpisodeProfileId] = useState<string>('')
|
||||
|
|
@ -553,59 +201,42 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
|
|||
}
|
||||
}, [open, resetState])
|
||||
|
||||
// Update token/char counts when selections change
|
||||
// Generation counter: any newer effect run invalidates in-flight count requests,
|
||||
// so a slow, stale response can never overwrite a fresher one.
|
||||
const countRequestIdRef = useRef(0)
|
||||
|
||||
// Update token/char counts when selections change (debounced + stale-guarded)
|
||||
useEffect(() => {
|
||||
const requestId = ++countRequestIdRef.current
|
||||
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
|
||||
const updateContextCounts = async () => {
|
||||
// Check if there are any selections
|
||||
const hasAnySelections = Object.values(selections).some((selection) =>
|
||||
Object.values(selection.sources).some((mode) => mode !== 'off') ||
|
||||
Object.values(selection.notes).some((mode) => mode !== 'off')
|
||||
)
|
||||
const configs = selectionsToContextConfigs(selections)
|
||||
|
||||
if (!hasAnySelections) {
|
||||
setTokenCount(0)
|
||||
setCharCount(0)
|
||||
return
|
||||
}
|
||||
if (configs.length === 0) {
|
||||
setTokenCount(0)
|
||||
setCharCount(0)
|
||||
return
|
||||
}
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
let totalTokens = 0
|
||||
let totalChars = 0
|
||||
|
||||
// Build context for each notebook and sum up counts
|
||||
for (const [notebookId, selection] of Object.entries(selections)) {
|
||||
const sourcesConfig = Object.entries(selection.sources)
|
||||
.filter(([, mode]) => mode !== 'off')
|
||||
.reduce<Record<string, string>>((acc, [sourceId, mode]) => {
|
||||
const normalizedId = sourceId.replace(/^source:/, '')
|
||||
acc[normalizedId] = mode === 'insights' ? 'insights' : 'full content'
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const notesConfig = Object.entries(selection.notes)
|
||||
.filter(([, mode]) => mode !== 'off')
|
||||
.reduce<Record<string, string>>((acc, [noteId]) => {
|
||||
const normalizedId = noteId.replace(/^note:/, '')
|
||||
acc[normalizedId] = 'full content'
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
if (Object.keys(sourcesConfig).length === 0 && Object.keys(notesConfig).length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const { notebookId, contextConfig } of configs) {
|
||||
const response = await chatApi.buildContext({
|
||||
notebook_id: notebookId,
|
||||
context_config: {
|
||||
sources: sourcesConfig,
|
||||
notes: notesConfig,
|
||||
},
|
||||
context_config: contextConfig,
|
||||
})
|
||||
|
||||
if (requestId !== countRequestIdRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
totalTokens += response.token_count
|
||||
totalChars += response.char_count
|
||||
}
|
||||
|
|
@ -613,12 +244,14 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
|
|||
setTokenCount(totalTokens)
|
||||
setCharCount(totalChars)
|
||||
} catch (error) {
|
||||
console.error('Error updating context counts:', error)
|
||||
// Don't reset counts on error, keep previous values
|
||||
if (requestId === countRequestIdRef.current) {
|
||||
console.error('Error updating context counts:', error)
|
||||
// Don't reset counts on error, keep previous values
|
||||
}
|
||||
}
|
||||
}
|
||||
}, TOKEN_COUNT_DEBOUNCE_MS)
|
||||
|
||||
updateContextCounts()
|
||||
return () => clearTimeout(timer)
|
||||
}, [open, selections])
|
||||
|
||||
const selectedEpisodeProfile = useMemo(() => {
|
||||
|
|
@ -724,40 +357,7 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
|
|||
const buildContentFromSelections = useCallback(async () => {
|
||||
const parts: string[] = []
|
||||
|
||||
const tasks: Array<{ notebookId: string; payload: BuildContextRequest }> = []
|
||||
|
||||
Object.entries(selections).forEach(([notebookId, selection]) => {
|
||||
const sourcesConfig = Object.entries(selection.sources)
|
||||
.filter(([, mode]) => mode !== 'off')
|
||||
.reduce<Record<string, string>>((acc, [sourceId, mode]) => {
|
||||
const normalizedId = sourceId.replace(/^source:/, '')
|
||||
acc[normalizedId] = mode === 'insights' ? 'insights' : 'full content'
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const notesConfig = Object.entries(selection.notes)
|
||||
.filter(([, mode]) => mode !== 'off')
|
||||
.reduce<Record<string, string>>((acc, [noteId]) => {
|
||||
const normalizedId = noteId.replace(/^note:/, '')
|
||||
acc[normalizedId] = 'full content'
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
if (Object.keys(sourcesConfig).length === 0 && Object.keys(notesConfig).length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
tasks.push({
|
||||
notebookId,
|
||||
payload: {
|
||||
notebook_id: notebookId,
|
||||
context_config: {
|
||||
sources: sourcesConfig,
|
||||
notes: notesConfig,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
const tasks = selectionsToContextConfigs(selections)
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return ''
|
||||
|
|
@ -765,7 +365,10 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
|
|||
|
||||
for (const task of tasks) {
|
||||
try {
|
||||
const response = await chatApi.buildContext(task.payload)
|
||||
const response = await chatApi.buildContext({
|
||||
notebook_id: task.notebookId,
|
||||
context_config: task.contextConfig,
|
||||
})
|
||||
const notebookName = notebooks.find((nb) => nb.id === task.notebookId)?.name ?? task.notebookId
|
||||
const contextString = JSON.stringify(response.context, null, 2)
|
||||
const snippet = `${t('common.notebookLabel').replace('{name}', notebookName)}\n${contextString}`
|
||||
|
|
@ -818,6 +421,8 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
|
|||
briefing_suffix: instructions.trim() ? instructions.trim() : undefined,
|
||||
}
|
||||
|
||||
// mutateAsync resolves only after the mutation's onSuccess handler has
|
||||
// awaited the episode list refetch, so it is safe to close immediately.
|
||||
await generatePodcast.mutateAsync(payload)
|
||||
|
||||
toast({
|
||||
|
|
@ -825,11 +430,8 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
|
|||
description: t('podcasts.podcastTaskStarted'),
|
||||
})
|
||||
|
||||
// Delay closing dialog slightly to ensure refetch completes
|
||||
setTimeout(() => {
|
||||
onOpenChange(false)
|
||||
resetState()
|
||||
}, 500)
|
||||
onOpenChange(false)
|
||||
resetState()
|
||||
} catch (error) {
|
||||
console.error('Failed to generate podcast', error)
|
||||
toast({
|
||||
|
|
@ -882,10 +484,9 @@ export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDia
|
|||
sourcesByNotebook={sourcesByNotebook}
|
||||
notesByNotebook={notesByNotebook}
|
||||
fetchingNotebookIds={fetchingNotebookIds}
|
||||
handleNotebookToggle={handleNotebookToggle}
|
||||
handleSourceModeChange={handleSourceModeChange}
|
||||
handleNoteToggle={handleNoteToggle}
|
||||
queryClient={queryClient}
|
||||
onNotebookToggle={handleNotebookToggle}
|
||||
onSourceModeChange={handleSourceModeChange}
|
||||
onNoteToggle={handleNoteToggle}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
import { SourceListResponse } from '@/lib/types/api'
|
||||
|
||||
export type SourceMode = 'off' | 'insights' | 'full'
|
||||
|
||||
export interface NotebookSelection {
|
||||
sources: Record<string, SourceMode>
|
||||
notes: Record<string, SourceMode>
|
||||
}
|
||||
|
||||
export interface NotebookSummary {
|
||||
notebookId: string
|
||||
sources: number
|
||||
notes: number
|
||||
}
|
||||
|
||||
export interface NotebookContextConfig {
|
||||
notebookId: string
|
||||
contextConfig: {
|
||||
sources: Record<string, string>
|
||||
notes: Record<string, string>
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to format large numbers with K/M suffixes
|
||||
export function formatNumber(num: number): string {
|
||||
if (num >= 1000000) {
|
||||
return `${(num / 1000000).toFixed(1)}M`
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return `${(num / 1000).toFixed(1)}K`
|
||||
}
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
export function hasSelections(selection?: NotebookSelection): boolean {
|
||||
if (!selection) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
Object.values(selection.sources).some((mode) => mode !== 'off') ||
|
||||
Object.values(selection.notes).some((mode) => mode !== 'off')
|
||||
)
|
||||
}
|
||||
|
||||
export function getSourceDefaultMode(source: SourceListResponse): SourceMode {
|
||||
return source.insights_count && source.insights_count > 0 ? 'insights' : 'full'
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the per-notebook selection state into build-context configs,
|
||||
* skipping notebooks with no active selections.
|
||||
*/
|
||||
export function selectionsToContextConfigs(
|
||||
selections: Record<string, NotebookSelection>
|
||||
): NotebookContextConfig[] {
|
||||
const configs: NotebookContextConfig[] = []
|
||||
|
||||
Object.entries(selections).forEach(([notebookId, selection]) => {
|
||||
const sourcesConfig = Object.entries(selection.sources)
|
||||
.filter(([, mode]) => mode !== 'off')
|
||||
.reduce<Record<string, string>>((acc, [sourceId, mode]) => {
|
||||
const normalizedId = sourceId.replace(/^source:/, '')
|
||||
acc[normalizedId] = mode === 'insights' ? 'insights' : 'full content'
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const notesConfig = Object.entries(selection.notes)
|
||||
.filter(([, mode]) => mode !== 'off')
|
||||
.reduce<Record<string, string>>((acc, [noteId]) => {
|
||||
const normalizedId = noteId.replace(/^note:/, '')
|
||||
acc[normalizedId] = 'full content'
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
if (Object.keys(sourcesConfig).length === 0 && Object.keys(notesConfig).length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
configs.push({
|
||||
notebookId,
|
||||
contextConfig: {
|
||||
sources: sourcesConfig,
|
||||
notes: notesConfig,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
return configs
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue