-
-
Notifications
You must be signed in to change notification settings - Fork 239
feat(chat): summarize attachments + recordings knowledge base #431
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
09fc306
af7395b
83ca323
be10754
291c0ce
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,6 +10,7 @@ | |
| import { createStyles, PILL_ICON_SIZE, ANIM_DURATION_IN, ANIM_DURATION_OUT } from './styles'; | ||
| import { QueueRow } from './Toolbar'; | ||
| import { AttachmentPreview, useAttachments } from './Attachments'; | ||
| import { useSummarizeAttachment } from './useSummarizeAttachment'; | ||
| import { useVoiceInput } from './Voice'; | ||
| import { QuickSettingsPopover, AttachPickerPopover } from './Popovers'; | ||
| import { useKeyboardAwarePopover } from './useKeyboardAwarePopover'; | ||
|
|
@@ -56,7 +57,7 @@ | |
|
|
||
| // ─── Main Component ───────────────────────────────────────────────────────── | ||
|
|
||
| export const ChatInput: React.FC<ChatInputProps> = ({ | ||
| onSend, | ||
| onStop, | ||
| disabled, | ||
|
|
@@ -103,6 +104,11 @@ | |
|
|
||
| const { attachments, removeAttachment, clearAttachments, handlePickImage, handlePickDocument, addAudioAttachment } = useAttachments(setAlertState); | ||
| attachmentsRef.current = attachments; | ||
| const { summarizingId, handleSummarize } = useSummarizeAttachment(); | ||
| const onSummarizeAttachment = async (attachment: MediaAttachment) => { | ||
| await handleSummarize(attachment); | ||
| removeAttachment(attachment.id); | ||
| }; | ||
|
Comment on lines
+107
to
+111
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 1. Attachment removed on failure ChatInput always calls removeAttachment() after awaiting handleSummarize(), but handleSummarize() can return early (busy/no text/no model) and also swallows errors, so attachments can be discarded even when no summary was produced. This causes silent data loss and prevents retrying summarization. Agent Prompt
|
||
| const interfaceMode = useUiModeStore((s) => s.interfaceMode); | ||
| const isAudioMode = interfaceMode === 'audio'; | ||
|
|
||
|
|
@@ -306,7 +312,12 @@ | |
|
|
||
| return ( | ||
| <View style={styles.container}> | ||
| <AttachmentPreview attachments={attachments} onRemove={removeAttachment} /> | ||
| <AttachmentPreview | ||
| attachments={attachments} | ||
| onRemove={removeAttachment} | ||
| onSummarize={onSummarizeAttachment} | ||
| summarizingId={summarizingId} | ||
| /> | ||
| <QueueRow | ||
| queueCount={queueCount} | ||
| queuedTexts={queuedTexts} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -30,6 +30,13 @@ export const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({ | |
| borderRadius: 8, | ||
| overflow: 'hidden' as const, | ||
| }, | ||
| // Wider, taller chip for document/transcript attachments so the file name and | ||
| // the Summarize action are both fully visible (the square image size clipped | ||
| // the button). | ||
| attachmentPreviewDoc: { | ||
| width: 168, | ||
| height: 76, | ||
| }, | ||
| attachmentImage: { | ||
| width: '100%' as const, | ||
| height: '100%' as const, | ||
|
|
@@ -42,13 +49,51 @@ export const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({ | |
| alignItems: 'center' as const, | ||
| padding: 4, | ||
| }, | ||
| documentPreviewDoc: { | ||
| justifyContent: 'space-between' as const, | ||
| alignItems: 'stretch' as const, | ||
| padding: 8, | ||
| paddingRight: 22, | ||
| }, | ||
| documentNameRow: { | ||
| flexDirection: 'row' as const, | ||
| alignItems: 'center' as const, | ||
| gap: 6, | ||
| }, | ||
| documentName: { | ||
| fontSize: 10, | ||
| fontFamily: FONTS.mono, | ||
| color: colors.textMuted, | ||
| textAlign: 'center' as const, | ||
| marginTop: 4, | ||
| }, | ||
|
Comment on lines
+58
to
69
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Split the inline filename style from the stacked one.
🤖 Prompt for AI Agents |
||
| summarizeButton: { | ||
| flexDirection: 'row' as const, | ||
| alignItems: 'center' as const, | ||
| justifyContent: 'center' as const, | ||
| gap: 4, | ||
| paddingHorizontal: SPACING.sm, | ||
| paddingVertical: 5, | ||
| borderRadius: 8, | ||
| backgroundColor: colors.primary, | ||
| }, | ||
| summarizeButtonText: { | ||
| fontSize: 11, | ||
| fontFamily: FONTS.mono, | ||
| color: colors.background, | ||
| }, | ||
| summarizeBusy: { | ||
| flexDirection: 'row' as const, | ||
| alignItems: 'center' as const, | ||
| justifyContent: 'center' as const, | ||
| gap: 6, | ||
| paddingVertical: 4, | ||
| }, | ||
| summarizeBusyText: { | ||
| fontSize: 11, | ||
| fontFamily: FONTS.mono, | ||
| color: colors.primary, | ||
| }, | ||
| removeAttachment: { | ||
| position: 'absolute' as const, | ||
| top: 2, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| import { useState } from 'react'; | ||
| import { MediaAttachment } from '../../types'; | ||
| import { transcriptSummarizer } from '../../services'; | ||
| import { useChatStore, useAppStore } from '../../stores'; | ||
| import logger from '../../utils/logger'; | ||
|
|
||
| /** Throttle for streaming the summary into the message (~20 paints/sec). */ | ||
| const STREAM_FLUSH_MS = 50; | ||
|
|
||
| /** mm:ss for a millisecond offset, used to label an attached transcript range. */ | ||
| function fmtClock(ms: number): string { | ||
| const total = Math.floor(ms / 1000); | ||
| const m = Math.floor(total / 60); | ||
| const s = total % 60; | ||
| return `${m}:${s.toString().padStart(2, '0')}`; | ||
| } | ||
|
|
||
| /** | ||
| * Summarize an attached document/transcript that is too large to fit the model's | ||
| * context window. Posts a user message ("Summarize <file>") and an assistant | ||
| * message, then streams progress into that assistant message (part i of N, | ||
| * combining) before replacing it with the final summary. Self-contained: reads | ||
| * the active conversation + model from the global stores, so it does not need | ||
| * props threaded down from the chat screen. | ||
| */ | ||
| export function useSummarizeAttachment() { | ||
| const [summarizingId, setSummarizingId] = useState<string | null>(null); | ||
|
|
||
| const handleSummarize = async (attachment: MediaAttachment): Promise<void> => { | ||
| if (summarizingId) return; | ||
| const text = attachment.textContent?.trim(); | ||
| if (!text) return; | ||
|
|
||
| const chat = useChatStore.getState(); | ||
| let conversationId = chat.activeConversationId; | ||
| if (!conversationId) { | ||
| const modelId = useAppStore.getState().activeModelId; | ||
| if (!modelId) return; // no model loaded - nothing to summarize with | ||
| conversationId = chat.createConversation(modelId); | ||
| chat.setActiveConversation(conversationId); | ||
| } | ||
|
|
||
| const label = attachment.fileName || 'transcript'; | ||
| const range = | ||
| attachment.transcriptStartMs != null && attachment.transcriptEndMs != null | ||
| ? ` (${fmtClock(attachment.transcriptStartMs)} to ${fmtClock(attachment.transcriptEndMs)})` | ||
| : ''; | ||
| chat.addMessage(conversationId, { role: 'user', content: `Summarize ${label}${range}` }); | ||
| const placeholder = chat.addMessage(conversationId, { role: 'assistant', content: 'Starting...' }); | ||
|
|
||
| setSummarizingId(attachment.id); | ||
| // Stream the work in place. The map phase streams each part as it is written | ||
| // (so a multi-chunk run shows text from part 1, not a static counter for | ||
| // minutes), then the final combine pass restreams the answer over the top. | ||
| // updateMessageContent rebuilds the conversations tree on every call, so we | ||
| // flush on a ~50ms timer (matching the main generation loop) rather than per | ||
| // token, otherwise the JS thread saturates and the UI only paints at the end. | ||
| let uiPhase: 'map' | 'final' = 'map'; | ||
| let total = 0; | ||
| let current = 0; | ||
| const doneParts: string[] = []; | ||
| let curPart = ''; | ||
| let finalText = ''; | ||
| let flushTimer: ReturnType<typeof setTimeout> | null = null; | ||
|
|
||
| const compose = (): string => { | ||
| if (uiPhase === 'final') return finalText || 'Combining the parts...'; | ||
| const parts = [...doneParts, curPart].filter((s) => s.trim()); | ||
| const header = total > 1 ? `Summarizing part ${current} of ${total}\n\n` : 'Summarizing...\n\n'; | ||
| return parts.length ? header + parts.join('\n\n') : header.trim(); | ||
| }; | ||
| const flush = () => { | ||
| flushTimer = null; | ||
| useChatStore.getState().updateMessageContent(conversationId!, placeholder.id, compose()); | ||
| }; | ||
| const scheduleFlush = () => { if (!flushTimer) flushTimer = setTimeout(flush, STREAM_FLUSH_MS); }; | ||
|
|
||
| try { | ||
| const summary = await transcriptSummarizer.summarize(text, { | ||
| onProgress: (p) => { | ||
| if (p.phase === 'chunking') { | ||
| total = p.total; | ||
| } else if (p.phase === 'mapping') { | ||
| if (p.total <= 1) { | ||
| uiPhase = 'final'; // single pass: the streamed text is the answer | ||
| } else { | ||
| if (curPart.trim()) doneParts.push(curPart.trim()); | ||
| curPart = ''; | ||
| total = p.total; | ||
| current = p.current; | ||
| } | ||
| } else if (p.phase === 'combining') { | ||
| if (curPart.trim()) doneParts.push(curPart.trim()); | ||
| curPart = ''; | ||
| uiPhase = 'final'; | ||
| finalText = ''; | ||
| } | ||
| scheduleFlush(); | ||
| }, | ||
| onToken: (delta) => { | ||
| if (uiPhase === 'final') finalText += delta; | ||
| else curPart += delta; | ||
| scheduleFlush(); | ||
| }, | ||
| }); | ||
| if (flushTimer) clearTimeout(flushTimer); | ||
| // Final trimmed summary (streamed text may have leading/trailing space). | ||
| useChatStore.getState().updateMessageContent(conversationId, placeholder.id, summary); | ||
| } catch (e) { | ||
| if (flushTimer) clearTimeout(flushTimer); | ||
| const msg = e instanceof Error ? e.message : 'Summarization failed'; | ||
| useChatStore.getState().updateMessageContent( | ||
| conversationId, | ||
| placeholder.id, | ||
| `Could not summarize this transcript.\n\n${msg}`, | ||
| ); | ||
| logger.warn('[useSummarizeAttachment] failed:', e); | ||
| } finally { | ||
| setSummarizingId(null); | ||
| } | ||
| }; | ||
|
|
||
| return { summarizingId, handleSummarize }; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| /** | ||
| * Chat Attachment Inbox | ||
| * | ||
| * A one-shot hand-off for seeding the chat composer with an attachment created | ||
| * elsewhere (e.g. the Pro recorder's "Attach to chat", which builds a transcript | ||
| * document and navigates to the Chat screen). The composer consumes the pending | ||
| * attachments once on mount, then clears them. | ||
| * | ||
| * Kept as a tiny module-level store (not a route param) so a large transcript | ||
| * body never has to be serialized through navigation, and so Pro can hand off to | ||
| * core without core importing anything from Pro. | ||
| */ | ||
| import { MediaAttachment } from '../types'; | ||
|
|
||
| let pending: MediaAttachment[] = []; | ||
|
|
||
| /** Queue attachments to seed the next chat composer mount. Replaces any pending. */ | ||
| export function setPendingChatAttachments(attachments: MediaAttachment[]): void { | ||
| pending = attachments; | ||
| } | ||
|
|
||
| /** Return and clear the pending attachments (empty array if none). */ | ||
| export function takePendingChatAttachments(): MediaAttachment[] { | ||
| const taken = pending; | ||
| pending = []; | ||
| return taken; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Only remove the attachment after a successful summary.
handleSummarize()catches its own failures and also returns early on some no-op paths, so this unconditionalremoveAttachment()drops the original attachment even when summarization failed or never started.Suggested fix
const { summarizingId, handleSummarize } = useSummarizeAttachment(); const onSummarizeAttachment = async (attachment: MediaAttachment) => { - await handleSummarize(attachment); - removeAttachment(attachment.id); + const summarized = await handleSummarize(attachment); + if (summarized) removeAttachment(attachment.id); };handleSummarize()should return a success flag (or rethrow on failure) so the caller can make this decision correctly.🤖 Prompt for AI Agents