diff --git a/backend/src/agents/builtin_tools/excel_spreadsheet_tool.py b/backend/src/agents/builtin_tools/excel_spreadsheet_tool.py index a7c5cd5e5..bfc13e8d1 100644 --- a/backend/src/agents/builtin_tools/excel_spreadsheet_tool.py +++ b/backend/src/agents/builtin_tools/excel_spreadsheet_tool.py @@ -277,6 +277,28 @@ async def create_excel_spreadsheet( ws.append(['Q1', 100]) ws.append(['Q2', 120]) ws['B4'] = '=SUM(B2:B3)' + # Freeze the header so it stays visible while + # scrolling -- do this on EVERY sheet that has a + # header row ('A2' freezes row 1, 'B2' also freezes + # column A): + ws.freeze_panes = 'A2' + # Give every number a format. Without one, Excel + # shows raw values -- 1234567.891 instead of + # $1,234,568, and 0.1834 instead of 18.3%: + for cell in ws['B'][1:]: + cell.number_format = '#,##0' + # Common formats: '#,##0.00' (2dp), '$#,##0' + # (currency), '0.0%' (percent -- store 0.183, not + # 18.3), 'yyyy-mm-dd' (date). + + Formulas are written WITHOUT a cached result, because + openpyxl does not evaluate them. Excel fills them in on + open, but until then the cell reads as empty to + everything else -- including read_excel_spreadsheet and + the in-app preview. So when a total is meant to be read + back or shown, compute it in Python and write the value + (optionally alongside the formula on another cell): + ws['B4'] = sum(r[1] for r in rows) Example (add a second sheet + a bar chart): ws2 = wb.create_sheet('Chart') diff --git a/backend/src/agents/builtin_tools/powerpoint_presentation_tool.py b/backend/src/agents/builtin_tools/powerpoint_presentation_tool.py index 3dedddadc..d2dbf0419 100644 --- a/backend/src/agents/builtin_tools/powerpoint_presentation_tool.py +++ b/backend/src/agents/builtin_tools/powerpoint_presentation_tool.py @@ -368,6 +368,14 @@ async def create_powerpoint_presentation( Inches(8), Inches(2)).table tbl.cell(0, 0).text = 'Quarter'; tbl.cell(0, 1).text = 'Revenue' + Speaker notes -- add them to every content slide unless + the user says otherwise. They are what the presenter + actually says, so keep the detail there and the slide + itself sparse: + slide.notes_slide.notes_text_frame.text = ( + 'Revenue grew 15% on enterprise renewals; ' + 'call out the churn improvement before moving on.') + A matplotlib chart image: import matplotlib.pyplot as plt plt.figure(figsize=(8, 4.5)) diff --git a/backend/src/agents/main_agent/core/system_prompt_builder.py b/backend/src/agents/main_agent/core/system_prompt_builder.py index 035b28a88..b784264f6 100644 --- a/backend/src/agents/main_agent/core/system_prompt_builder.py +++ b/backend/src/agents/main_agent/core/system_prompt_builder.py @@ -81,7 +81,12 @@ - Respond using markdown. - You can ONLY use tools that are explicitly provided to you in each conversation - When approriate, you may use KaTeX to render mathematical equations. -- Since the $ character is used to denote a variable in KaTeX, other uses of $ should be use the HTML entity $ +- KaTeX treats $ as a math delimiter, so in your own chat replies write other + uses of $ as the HTML entity $. This applies ONLY to the markdown you + send to the user. Never use the entity inside a file you generate, inside + code, or inside a tool argument -- a spreadsheet cell or slide holding + "$100K" is simply wrong, and it stays wrong when the user opens the file. + There, write a plain $. - When the user asks for a diagram or chart, you may use Mermaid to render it. - Available tools may change throughout the conversation based on user preferences - When multiple tools are available, select and use the most appropriate combination in the optimal order to fulfill the user's request @@ -89,6 +94,13 @@ - Always explain your reasoning when using tools - If you don't have the right tool for a task, clearly inform the user about the limitation +PREVIEWING FILES: +Every .docx and .pptx in the conversation has a "Preview" button the user +clicks to see it laid out; you cannot open it for them. When they ask to LOOK +at one ("show me this deck"), say to use that button -- never read the file +or re-create it just to show it. Reading is still right when the request is +about its CONTENT: summarize, check, answer from it, edit it. + HANDLING MISSING TOOLS: Users can toggle individual tools on and off from Customize → Tools in the sidebar. When a user asks for something you would normally handle with a tool diff --git a/frontend/ai.client/package-lock.json b/frontend/ai.client/package-lock.json index e826acc42..43e950f65 100644 --- a/frontend/ai.client/package-lock.json +++ b/frontend/ai.client/package-lock.json @@ -22,11 +22,13 @@ "chart.js": "4.5.1", "clipboard": "2.0.11", "docx-preview": "0.4.0", + "echarts": "file:./shims/echarts-stub", "katex": "0.16.45", "marked": "17.0.6", "mermaid": "11.16.1", "ng2-charts": "10.0.0", "ngx-markdown": "21.2.0", + "pptx-preview": "1.0.7", "prismjs": "1.30.0", "rxjs": "7.8.2", "tslib": "2.8.1", @@ -7963,6 +7965,10 @@ "node": ">= 0.4" } }, + "node_modules/echarts": { + "resolved": "shims/echarts-stub", + "link": true + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -9523,6 +9529,12 @@ "@lmdb/lmdb-win32-x64": "3.5.1" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/lodash-es": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", @@ -10889,6 +10901,19 @@ "postcss": "^8.4.31" } }, + "node_modules/pptx-preview": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/pptx-preview/-/pptx-preview-1.0.7.tgz", + "integrity": "sha512-YByocJuyxAR4YB4Q3+VAxdLfEvA5LojG1gAJsx2Mw0QU5FJPps/2fkJOupJ6oBbA+KdWRpuAk6G6T34rKCHVxw==", + "license": "ISC", + "dependencies": { + "echarts": "^5.5.1", + "jszip": "^3.10.1", + "lodash": "^4.17.21", + "tslib": "^2.7.0", + "uuid": "^10.0.0" + } + }, "node_modules/prismjs": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", @@ -12946,6 +12971,11 @@ "integrity": "sha512-Eky7p2Z1Ig3NnbfodSPoARCjKBSTFMnE/ACsP1L/XJEfY4SdOFce19BsUCWVwL6K5ABZFy5J3bjcMWffX+YM3Q==", "license": "MIT", "peer": true + }, + "shims/echarts-stub": { + "name": "echarts-stub", + "version": "5.6.0", + "license": "MIT" } } } diff --git a/frontend/ai.client/package.json b/frontend/ai.client/package.json index 0895e4fbf..a16611ca3 100644 --- a/frontend/ai.client/package.json +++ b/frontend/ai.client/package.json @@ -43,11 +43,13 @@ "chart.js": "4.5.1", "clipboard": "2.0.11", "docx-preview": "0.4.0", + "echarts": "file:./shims/echarts-stub", "katex": "0.16.45", "marked": "17.0.6", "mermaid": "11.16.1", "ng2-charts": "10.0.0", "ngx-markdown": "21.2.0", + "pptx-preview": "1.0.7", "prismjs": "1.30.0", "rxjs": "7.8.2", "tslib": "2.8.1", @@ -88,6 +90,9 @@ "@babel/core": ">=7.29.6 <8.0.0", "mermaid": { "uuid": "14.0.0" + }, + "pptx-preview": { + "uuid": "14.0.0" } } } diff --git a/frontend/ai.client/shims/echarts-stub/README.md b/frontend/ai.client/shims/echarts-stub/README.md new file mode 100644 index 000000000..2841192f6 --- /dev/null +++ b/frontend/ai.client/shims/echarts-stub/README.md @@ -0,0 +1,72 @@ +# echarts-stub + +A deliberately empty stand-in for [`echarts`](https://echarts.apache.org/), +substituted into `pptx-preview` by declaring it as the project's own +`echarts` dependency: + +```json +"dependencies": { "echarts": "file:./shims/echarts-stub" } +``` + +Its version (`5.6.0`) satisfies the `^5.5.1` that `pptx-preview` asks +for, so npm dedupes both onto this one directory instead of fetching the +real library. + +## Why + +`pptx-preview` reaches for ECharts in exactly one place: rendering a +*native* OOXML chart part (`ppt/charts/*.xml`) — a chart PowerPoint draws +itself from embedded data, rather than a picture. It does so via +`import * as echarts from 'echarts'`, a static namespace import, so no +amount of tree-shaking will drop it. Depending on `pptx-preview` means +shipping the whole charting library. + +Measured against this repo's build, that is the difference between a +**357 kB** and a **~60 kB** gzipped lazy chunk — ECharts is roughly five +sixths of the viewer's weight. + +We do not need it. `create_powerpoint_presentation` builds charts by +rendering a matplotlib PNG and calling `add_picture`, which lands in the +deck as an ordinary image. A survey of every `.pptx` in the dev files +store found **zero** native chart parts across four real decks, including +a 4.3 MB branded template deck carrying 15 images. + +## What it costs + +An *uploaded* deck that does contain a native chart throws instead of +drawing it, and `PptxViewerComponent` reports the file as unreadable +rather than silently dropping a slide's centrepiece. That is the whole +trade. + +## Why a dependency, and not `tsconfig` `paths` + +The import lives inside `node_modules/pptx-preview/dist/pptx-preview.es.js`, +a pre-built JavaScript file. `paths` governs TypeScript's resolution of +*our own sources*; the bundler resolves a dependency's own imports with +the node resolver and walks straight past it. This was tried first and +the built chunk still contained the whole of ECharts. + +Substituting the package is the only mechanism that applies at the point +the import is resolved. It has to be a **top-level dependency** rather +than a `file:` spec nested under `overrides`: npm resolves the latter +relative to wherever it happens to place the package, so the symlink +lands in a different spot depending on whether the dependency is hoisted, +and it pointed at a non-existent path. A top-level `file:` spec is +defined to resolve against the package root. + +Verified by grepping the built chunks for `zrender`, ECharts' renderer, +which is absent. + +## Removing it + +Replace the `echarts` dependency with a real version and delete this +directory. Pin **6.1.0 or later**: every release below it carries +GHSA-fgmj-fm8m-jvvx, and `pptx-preview`'s own `^5.5.1` range resolves to +a vulnerable one. + +## Why the version says 5.6.0 + +It has to satisfy the `^5.5.1` range `pptx-preview` declares, or `npm ls` +reports the tree as invalid and exits non-zero. The number tracks that +range and says nothing about the contents — there is no ECharts code +here at any version. diff --git a/frontend/ai.client/shims/echarts-stub/index.d.ts b/frontend/ai.client/shims/echarts-stub/index.d.ts new file mode 100644 index 000000000..4ab70f160 --- /dev/null +++ b/frontend/ai.client/shims/echarts-stub/index.d.ts @@ -0,0 +1,4 @@ +export declare function init(): never; +export declare function use(): void; +declare const _default: { init: typeof init; use: typeof use }; +export default _default; diff --git a/frontend/ai.client/shims/echarts-stub/index.js b/frontend/ai.client/shims/echarts-stub/index.js new file mode 100644 index 000000000..647d87b87 --- /dev/null +++ b/frontend/ai.client/shims/echarts-stub/index.js @@ -0,0 +1,24 @@ +/** + * Build-time stand-in for `echarts`. See README.md for why this exists. + * + * Only the two entry points `pptx-preview` actually calls are provided. + */ + +/** Thrown when a deck really does contain a native OOXML chart. */ +function unsupported() { + throw new Error( + 'pptx-preview: native OOXML charts are not supported in this build', + ); +} + +export function init() { + return unsupported(); +} + +export function use() { + // No-op. Registration is meaningless without a charting runtime, and + // it may be called before the deck is known to contain a chart — + // throwing here would fail decks that have none. +} + +export default { init, use }; diff --git a/frontend/ai.client/shims/echarts-stub/package.json b/frontend/ai.client/shims/echarts-stub/package.json new file mode 100644 index 000000000..c713013b2 --- /dev/null +++ b/frontend/ai.client/shims/echarts-stub/package.json @@ -0,0 +1,17 @@ +{ + "name": "echarts-stub", + "version": "5.6.0", + "description": "Build-time stand-in for echarts, substituted into pptx-preview. Version tracks the range pptx-preview asks for (^5.5.1) so npm ls does not report the tree as invalid; it carries no echarts code. See README.md.", + "license": "MIT", + "private": true, + "type": "module", + "main": "index.js", + "module": "index.js", + "types": "index.d.ts", + "exports": { + ".": { + "types": "./index.d.ts", + "default": "./index.js" + } + } +} diff --git a/frontend/ai.client/src/app/artifacts/artifact-library.page.ts b/frontend/ai.client/src/app/artifacts/artifact-library.page.ts index b3ac21e79..eb36ca965 100644 --- a/frontend/ai.client/src/app/artifacts/artifact-library.page.ts +++ b/frontend/ai.client/src/app/artifacts/artifact-library.page.ts @@ -72,37 +72,37 @@ const TYPE_STYLES: Record = { label: 'Markdown', icon: 'heroDocumentText', bg: 'bg-filetype-markdown-100 dark:bg-filetype-markdown-900/60', - text: 'text-filetype-markdown-600 dark:text-filetype-markdown-300', + text: 'text-filetype-markdown-700 dark:text-filetype-markdown-300', }, 'text/x-markdown': { label: 'Markdown', icon: 'heroDocumentText', bg: 'bg-filetype-markdown-100 dark:bg-filetype-markdown-900/60', - text: 'text-filetype-markdown-600 dark:text-filetype-markdown-300', + text: 'text-filetype-markdown-700 dark:text-filetype-markdown-300', }, 'text/html': { label: 'Web page', icon: 'heroCodeBracket', bg: 'bg-filetype-code-100 dark:bg-filetype-code-900/60', - text: 'text-filetype-code-600 dark:text-filetype-code-300', + text: 'text-filetype-code-700 dark:text-filetype-code-300', }, 'application/xhtml+xml': { label: 'Web page', icon: 'heroCodeBracket', bg: 'bg-filetype-code-100 dark:bg-filetype-code-900/60', - text: 'text-filetype-code-600 dark:text-filetype-code-300', + text: 'text-filetype-code-700 dark:text-filetype-code-300', }, 'text/csv': { label: 'CSV', icon: 'heroTableCells', bg: 'bg-filetype-sheet-100 dark:bg-filetype-sheet-900/60', - text: 'text-filetype-sheet-600 dark:text-filetype-sheet-300', + text: 'text-filetype-sheet-700 dark:text-filetype-sheet-300', }, 'image/svg+xml': { label: 'SVG', icon: 'heroPhoto', bg: 'bg-filetype-image-100 dark:bg-filetype-image-900/60', - text: 'text-filetype-image-600 dark:text-filetype-image-300', + text: 'text-filetype-image-700 dark:text-filetype-image-300', }, }; diff --git a/frontend/ai.client/src/app/components/file-card/file-card.component.ts b/frontend/ai.client/src/app/components/file-card/file-card.component.ts index e4aceeeed..57b7e1f5d 100644 --- a/frontend/ai.client/src/app/components/file-card/file-card.component.ts +++ b/frontend/ai.client/src/app/components/file-card/file-card.component.ts @@ -48,12 +48,12 @@ const FILE_TYPE_ICONS: Record = { const FILE_TYPE_COLORS: Record = { 'application/pdf': { bg: 'bg-filetype-pdf-100 dark:bg-filetype-pdf-900/60', - text: 'text-filetype-pdf-600 dark:text-filetype-pdf-300', + text: 'text-filetype-pdf-700 dark:text-filetype-pdf-300', border: 'border-filetype-pdf-300 dark:border-filetype-pdf-700' }, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': { bg: 'bg-filetype-doc-100 dark:bg-filetype-doc-900/60', - text: 'text-filetype-doc-600 dark:text-filetype-doc-300', + text: 'text-filetype-doc-700 dark:text-filetype-doc-300', border: 'border-filetype-doc-300 dark:border-filetype-doc-700' }, 'text/plain': { @@ -63,48 +63,48 @@ const FILE_TYPE_COLORS: Record { expect(unstyled).toEqual([]); }); }); + +/** + * Where a click on an attachment card goes. + * + * An uploaded `.docx`/`.pptx` used to fall through to the presigned-URL + * branch, which hands the browser an OOXML file it cannot render — so + * "open" silently became "download". The docked pane already renders both, + * and it only ever appeared on the *generated*-file download card, so an + * uploaded deck and a generated one behaved differently for no reason the + * user could see. + */ +describe('FileAttachmentBadgeComponent click routing', () => { + const PPTX_MIME = + 'application/vnd.openxmlformats-officedocument.presentationml.presentation'; + const PDF_MIME = 'application/pdf'; + + async function mount(filename: string, mimeType: string) { + const opened: unknown[] = []; + const windowOpen = vi.fn(); + vi.stubGlobal('open', windowOpen); + + const getPreviewUrl = vi + .fn() + .mockResolvedValue({ url: 'https://s3.example/x?X-Amz-Signature=abc' }); + + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [FileAttachmentBadgeComponent], + providers: [ + { + provide: FilePreviewStateService, + useValue: { open: (ref: unknown) => opened.push(ref) }, + }, + { + provide: FileUploadService, + useValue: { + getPreviewUrl, + getTextSnippet: vi.fn().mockResolvedValue({ snippet: '' }), + getThumbnail: vi.fn().mockResolvedValue({ status: 'error' }), + }, + }, + ], + }).compileComponents(); + + const fixture = TestBed.createComponent(FileAttachmentBadgeComponent); + fixture.componentRef.setInput('attachment', { + uploadId: 'up1', + filename, + mimeType, + sizeBytes: 1024, + }); + fixture.detectChanges(); + await fixture.whenStable(); + return { fixture, opened, windowOpen, getPreviewUrl }; + } + + it('opens an uploaded .pptx in the docked pane, not a new tab', async () => { + const { fixture, opened, windowOpen } = await mount('deck.pptx', PPTX_MIME); + + fixture.nativeElement.querySelector('button').click(); + await fixture.whenStable(); + + expect(opened).toEqual([{ uploadId: 'up1', filename: 'deck.pptx' }]); + expect(windowOpen).not.toHaveBeenCalled(); + }); + + it('still opens a format the pane cannot render in a new tab', async () => { + const { fixture, opened, getPreviewUrl } = await mount('paper.pdf', PDF_MIME); + + fixture.nativeElement.querySelector('button').click(); + await fixture.whenStable(); + + expect(opened).toEqual([]); + expect(getPreviewUrl).toHaveBeenCalledWith('up1'); + }); + + it('advertises Preview on the card at rest, not only on hover', async () => { + // The card is the whole affordance — there is no separate button — so a + // hover-only hint tells a reader of the thread nothing, and a touch user + // nothing at all. + const { fixture } = await mount('deck.pptx', PPTX_MIME); + + const label = fixture.nativeElement.textContent; + expect(label).toContain('PREVIEW'); + + const badge = [...fixture.nativeElement.querySelectorAll('span')].find( + (el: HTMLElement) => el.textContent?.includes('PREVIEW'), + ) as HTMLElement; + expect(badge.className).not.toContain('opacity-0'); + }); + + it('does not advertise Preview for a format the pane cannot render', async () => { + const { fixture } = await mount('paper.pdf', PDF_MIME); + expect(fixture.nativeElement.textContent).not.toContain('PREVIEW'); + }); + + it('names the action Preview only when it previews', async () => { + const deck = await mount('deck.pptx', PPTX_MIME); + expect( + deck.fixture.nativeElement.querySelector('button').getAttribute('aria-label'), + ).toBe('Preview deck.pptx'); + + const pdf = await mount('paper.pdf', PDF_MIME); + expect( + pdf.fixture.nativeElement.querySelector('button').getAttribute('aria-label'), + ).toBe('Open paper.pdf'); + }); +}); diff --git a/frontend/ai.client/src/app/session/components/message-list/components/file-attachment/file-attachment-badge.component.ts b/frontend/ai.client/src/app/session/components/message-list/components/file-attachment/file-attachment-badge.component.ts index f95ae6b6c..b6d7c1f5f 100644 --- a/frontend/ai.client/src/app/session/components/message-list/components/file-attachment/file-attachment-badge.component.ts +++ b/frontend/ai.client/src/app/session/components/message-list/components/file-attachment/file-attachment-badge.component.ts @@ -8,10 +8,13 @@ import { heroPhoto, heroPresentationChartBar, heroArrowTopRightOnSquare, + heroEye, } from '@ng-icons/heroicons/outline'; import { MarkdownComponent } from 'ngx-markdown'; import { formatBytes, FileUploadService } from '../../../../../services/file-upload'; import { FileAttachmentData } from '../../../../services/models/message.model'; +import { FilePreviewStateService } from '../../../../services/file-preview/file-preview-state.service'; +import { isPreviewableFilename } from '../../../../services/file-preview/file-preview.model'; import { MarkdownPreviewModalComponent } from './markdown-preview-modal.component'; interface FileTypeStyle { @@ -34,13 +37,13 @@ export const FILE_TYPE_STYLES: Record = { 'application/pdf': { icon: 'heroDocument', label: 'PDF', - accent_text: 'text-filetype-pdf-600 dark:text-filetype-pdf-300', + accent_text: 'text-filetype-pdf-700 dark:text-filetype-pdf-300', header_bg: 'bg-filetype-pdf-50 dark:bg-filetype-pdf-950/40', }, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': { icon: 'heroDocumentText', label: 'DOCX', - accent_text: 'text-filetype-doc-600 dark:text-filetype-doc-300', + accent_text: 'text-filetype-doc-700 dark:text-filetype-doc-300', header_bg: 'bg-filetype-doc-50 dark:bg-filetype-doc-950/40', }, 'text/plain': { @@ -52,25 +55,25 @@ export const FILE_TYPE_STYLES: Record = { 'text/html': { icon: 'heroCodeBracket', label: 'HTML', - accent_text: 'text-filetype-code-600 dark:text-filetype-code-300', + accent_text: 'text-filetype-code-700 dark:text-filetype-code-300', header_bg: 'bg-filetype-code-50 dark:bg-filetype-code-950/40', }, 'text/csv': { icon: 'heroTableCells', label: 'CSV', - accent_text: 'text-filetype-sheet-600 dark:text-filetype-sheet-300', + accent_text: 'text-filetype-sheet-700 dark:text-filetype-sheet-300', header_bg: 'bg-filetype-sheet-50 dark:bg-filetype-sheet-950/40', }, 'application/vnd.ms-excel': { icon: 'heroTableCells', label: 'XLS', - accent_text: 'text-filetype-sheet-600 dark:text-filetype-sheet-300', + accent_text: 'text-filetype-sheet-700 dark:text-filetype-sheet-300', header_bg: 'bg-filetype-sheet-50 dark:bg-filetype-sheet-950/40', }, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': { icon: 'heroTableCells', label: 'XLSX', - accent_text: 'text-filetype-sheet-600 dark:text-filetype-sheet-300', + accent_text: 'text-filetype-sheet-700 dark:text-filetype-sheet-300', header_bg: 'bg-filetype-sheet-50 dark:bg-filetype-sheet-950/40', }, // filetype-presentation is PowerPoint's orange brand association, which makes @@ -80,37 +83,37 @@ export const FILE_TYPE_STYLES: Record = { 'application/vnd.openxmlformats-officedocument.presentationml.presentation': { icon: 'heroPresentationChartBar', label: 'PPTX', - accent_text: 'text-filetype-presentation-600 dark:text-filetype-presentation-300', + accent_text: 'text-filetype-presentation-700 dark:text-filetype-presentation-300', header_bg: 'bg-filetype-presentation-50 dark:bg-filetype-presentation-950/40', }, 'text/markdown': { icon: 'heroDocumentText', label: 'MD', - accent_text: 'text-filetype-markdown-600 dark:text-filetype-markdown-300', + accent_text: 'text-filetype-markdown-700 dark:text-filetype-markdown-300', header_bg: 'bg-filetype-markdown-50 dark:bg-filetype-markdown-950/40', }, 'image/png': { icon: 'heroPhoto', label: 'PNG', - accent_text: 'text-filetype-image-600 dark:text-filetype-image-300', + accent_text: 'text-filetype-image-700 dark:text-filetype-image-300', header_bg: 'bg-filetype-image-50 dark:bg-filetype-image-950/40', }, 'image/jpeg': { icon: 'heroPhoto', label: 'JPG', - accent_text: 'text-filetype-image-600 dark:text-filetype-image-300', + accent_text: 'text-filetype-image-700 dark:text-filetype-image-300', header_bg: 'bg-filetype-image-50 dark:bg-filetype-image-950/40', }, 'image/gif': { icon: 'heroPhoto', label: 'GIF', - accent_text: 'text-filetype-image-600 dark:text-filetype-image-300', + accent_text: 'text-filetype-image-700 dark:text-filetype-image-300', header_bg: 'bg-filetype-image-50 dark:bg-filetype-image-950/40', }, 'image/webp': { icon: 'heroPhoto', label: 'WEBP', - accent_text: 'text-filetype-image-600 dark:text-filetype-image-300', + accent_text: 'text-filetype-image-700 dark:text-filetype-image-300', header_bg: 'bg-filetype-image-50 dark:bg-filetype-image-950/40', }, }; @@ -137,7 +140,9 @@ const SLIDE_BULLET_WIDTHS = [78, 92, 60]; * excerpt (for txt/md/csv/html) or skeleton lines (for binary docs), a * folded top-right corner detail, and a footer with filename + size. * - * Clicking opens the file in a new tab via a short-lived presigned URL. + * Clicking previews the file where we can render one — Markdown in a modal, + * `.docx` / `.pptx` in the docked pane — and otherwise opens it in a new tab + * via a short-lived presigned URL. */ @Component({ selector: 'app-file-attachment-badge', @@ -152,6 +157,7 @@ const SLIDE_BULLET_WIDTHS = [78, 92, 60]; heroPhoto, heroPresentationChartBar, heroArrowTopRightOnSquare, + heroEye, }), ], host: { class: 'contents' }, @@ -235,32 +241,53 @@ const SLIDE_BULLET_WIDTHS = [78, 92, 60]; type="button" (click)="openFile()" class="group flex w-60 shrink-0 flex-col overflow-hidden rounded-xl border border-gray-200 bg-white text-left shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500 dark:border-gray-700 dark:bg-gray-800" - [attr.aria-label]="'Open ' + attachment().filename" + [attr.aria-label]="actionLabel() + ' ' + attachment().filename" >
-
-
-
@@ -373,6 +400,7 @@ export class FileAttachmentBadgeComponent { readonly attachment = input.required(); private readonly fileUploadService = inject(FileUploadService); + private readonly filePreview = inject(FilePreviewStateService); protected readonly skeletonWidths = SKELETON_LINE_WIDTHS; protected readonly slideBulletWidths = SLIDE_BULLET_WIDTHS; @@ -402,6 +430,18 @@ export class FileAttachmentBadgeComponent { () => this.attachment().mimeType === PRESENTATION_MIME, ); + /** Whether clicking opens the docked preview pane rather than a new tab. + * Keyed off the filename, the same gate the generated-file download card + * uses, so the two surfaces can never disagree about what is previewable. */ + protected readonly isPanePreviewable = computed(() => + isPreviewableFilename(this.attachment().filename), + ); + + /** What the click will do, for the button's accessible name. */ + protected readonly actionLabel = computed(() => + this.isPanePreviewable() || this.isMarkdown() ? 'Preview' : 'Open', + ); + /** Cap chars so very long unbroken lines don't blow out the card. */ protected readonly truncatedSnippet = computed(() => { const raw = this.snippet(); @@ -462,6 +502,14 @@ export class FileAttachmentBadgeComponent { this.markdownModalOpen.set(true); return; } + // An uploaded .docx/.pptx gets the same docked pane as a generated one. + // Falling through to the presigned URL would just hand the browser an + // OOXML file it cannot render, which downloads it instead of showing it. + if (this.isPanePreviewable()) { + const att = this.attachment(); + this.filePreview.open({ uploadId: att.uploadId, filename: att.filename }); + return; + } try { const response = await this.fileUploadService.getPreviewUrl(this.attachment().uploadId); window.open(response.url, '_blank', 'noopener,noreferrer'); diff --git a/frontend/ai.client/src/app/session/components/message-list/components/file-preview/file-preview-panel.component.spec.ts b/frontend/ai.client/src/app/session/components/message-list/components/file-preview/file-preview-panel.component.spec.ts index 74a01cbc2..8adcdb4f5 100644 --- a/frontend/ai.client/src/app/session/components/message-list/components/file-preview/file-preview-panel.component.spec.ts +++ b/frontend/ai.client/src/app/session/components/message-list/components/file-preview/file-preview-panel.component.spec.ts @@ -74,7 +74,7 @@ describe('FilePreviewPanelComponent', () => { const aside = fixture.nativeElement.querySelector('aside'); expect(aside).not.toBeNull(); expect(aside.getAttribute('aria-label')).toBe( - 'Document preview: plan.docx', + 'File preview: plan.docx', ); expect(fixture.nativeElement.textContent).toContain('plan.docx'); expect(fetchDocument).toHaveBeenCalledExactlyOnceWith('up1'); @@ -91,7 +91,7 @@ describe('FilePreviewPanelComponent', () => { it('clears the loading state once the document paints', async () => { await openPreview(); - expect(fixture.nativeElement.textContent).not.toContain('Loading document'); + expect(fixture.nativeElement.textContent).not.toContain('Loading preview'); }); it('shows a retry affordance for a retryable failure', async () => { diff --git a/frontend/ai.client/src/app/session/components/message-list/components/file-preview/file-preview-panel.component.ts b/frontend/ai.client/src/app/session/components/message-list/components/file-preview/file-preview-panel.component.ts index 442b9d41e..6d3040257 100644 --- a/frontend/ai.client/src/app/session/components/message-list/components/file-preview/file-preview-panel.component.ts +++ b/frontend/ai.client/src/app/session/components/message-list/components/file-preview/file-preview-panel.component.ts @@ -23,9 +23,16 @@ import { ConfigService } from '../../../../../services/config.service'; import { downloadUrlFor } from '../../../../../shared/utils/file-download-url'; import { TooltipDirective } from '../../../../../components/tooltip/tooltip.directive'; import { DocxViewerComponent } from './docx-viewer.component'; +import { PptxViewerComponent } from './pptx-viewer.component'; +import { + PREVIEW_KIND_LABELS, + PreviewKind, + previewKindFor, +} from '../../../../services/file-preview/file-preview.model'; /** - * Right-docked pane that previews one uploaded `.docx` in the browser. + * Right-docked pane that previews one uploaded Office file in the + * browser — `.docx` and `.pptx` today. * * Shares the rail with `ArtifactPanelComponent` through * `DockedPaneService` — same width, same resize affordance, same @@ -45,7 +52,7 @@ import { DocxViewerComponent } from './docx-viewer.component'; @Component({ selector: 'app-file-preview-panel', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [NgIcon, TooltipDirective, DocxViewerComponent], + imports: [NgIcon, TooltipDirective, DocxViewerComponent, PptxViewerComponent], providers: [ provideIcons({ heroArrowDownTray, @@ -63,7 +70,7 @@ import { DocxViewerComponent } from './docx-viewer.component'; class="fixed inset-y-0 right-0 z-40 flex w-full flex-col border-l border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-900" [style.maxWidth]="paneWidthCss()" [class.select-none]="dragging()" - [attr.aria-label]="'Document preview: ' + ref.filename" + [attr.aria-label]="'File preview: ' + ref.filename" >

- Word document + {{ kindLabel() }}

@@ -151,11 +158,22 @@ import { DocxViewerComponent } from './docx-viewer.component'; } } @else { - + @switch (kind()) { + @case ('pptx') { + + } + @default { + + } + } @if (!ready()) { } @@ -191,6 +209,17 @@ export class FilePreviewPanelComponent { protected readonly open = this.previewState.openFile; protected readonly bytes = signal(null); + /** Which viewer renders the current file. Derived from the filename so + * the header reads correctly while the fetch is still in flight, then + * confirmed against the server's MIME type in + * `FilePreviewHttpService.fetchDocument` before any bytes are shown. */ + protected readonly kind = computed(() => { + const ref = this.open(); + return (ref && previewKindFor(ref.filename)) || 'docx'; + }); + protected readonly kindLabel = computed( + () => PREVIEW_KIND_LABELS[this.kind()], + ); protected readonly error = signal(null); protected readonly retryable = signal(false); /** Cleared only once the renderer reports a painted document, so the @@ -251,7 +280,7 @@ export class FilePreviewPanelComponent { const failure = e instanceof FilePreviewError ? e - : new FilePreviewError('Something went wrong loading this document.', true); + : new FilePreviewError('Something went wrong loading this file.', true); this.error.set(failure.message); this.retryable.set(failure.retryable); } diff --git a/frontend/ai.client/src/app/session/components/message-list/components/file-preview/pptx-viewer.component.spec.ts b/frontend/ai.client/src/app/session/components/message-list/components/file-preview/pptx-viewer.component.spec.ts new file mode 100644 index 000000000..4f523fba0 --- /dev/null +++ b/frontend/ai.client/src/app/session/components/message-list/components/file-preview/pptx-viewer.component.spec.ts @@ -0,0 +1,145 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { PptxViewerComponent } from './pptx-viewer.component'; + +/** Slides the fake previewer will paint on the next `preview()`. */ +let slidesToRender = 1; +/** Rejection for the next `preview()`, if the test wants one. */ +let previewRejection: Error | null = null; + +const init = vi.fn(); + +// Intercepts the component's dynamic `import('pptx-preview')`. The real +// library unzips the OOXML and lays out every shape, which is neither +// fast nor the thing under test: what matters here is that the component +// swaps the deck in only on success, discards a stale render, and treats +// a zero-slide result as a failure. +vi.mock('pptx-preview', () => ({ + init: (dom: HTMLElement, options: unknown) => { + init(dom, options); + return { + get slideCount() { + return slidesToRender; + }, + preview: async () => { + if (previewRejection) throw previewRejection; + for (let i = 0; i < slidesToRender; i++) { + const slide = document.createElement('div'); + slide.className = 'pptx-preview-slide-wrapper'; + dom.appendChild(slide); + } + }, + destroy: () => undefined, + }; + }, +})); + +describe('PptxViewerComponent', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + init.mockReset(); + slidesToRender = 1; + previewRejection = null; + + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [PptxViewerComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(PptxViewerComponent); + }); + + /** + * Set bytes and let the async render settle. + * + * The component caches its dynamic `import('pptx-preview')` in a + * static field, so whichever test runs first pays for resolving the + * module graph — more turns of the event loop than a single + * `whenStable()` covers. Draining a fixed number of macrotasks keeps + * that cost off the assertions without depending on which test the + * runner happens to schedule first. + */ + async function render(bytes: ArrayBuffer | null): Promise { + fixture.componentRef.setInput('bytes', bytes); + fixture.detectChanges(); + + for (let i = 0; i < 10; i++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + await fixture.whenStable(); + fixture.detectChanges(); + } + } + + function slides(): NodeListOf { + return fixture.nativeElement.querySelectorAll('.pptx-preview-slide-wrapper'); + } + + it('paints the deck and reports it rendered', async () => { + slidesToRender = 3; + const rendered = vi.fn(); + fixture.componentInstance.rendered.subscribe(rendered); + + await render(new ArrayBuffer(16)); + + expect(slides().length).toBe(3); + expect(rendered).toHaveBeenCalledTimes(1); + }); + + it('renders at a fixed width and scales to fit rather than re-parsing', async () => { + await render(new ArrayBuffer(16)); + + // The library has no responsive mode — it lays the deck out against + // the size given at init. Re-initialising on a rail drag would + // re-parse the file, so the width must not come from the pane. + expect(init).toHaveBeenCalledTimes(1); + expect(init.mock.calls[0][1]).toMatchObject({ mode: 'list' }); + const { width, height } = init.mock.calls[0][1] as { + width: number; + height: number; + }; + expect(width).toBeGreaterThan(0); + // 16:9, the ratio the PowerPoint tool always emits. + expect(Math.round((width * 9) / 16)).toBe(height); + }); + + it('treats a deck that parsed into zero slides as a failure', async () => { + // `pptx-preview` resolves successfully when it cannot make sense of + // a presentation's theme or layout parts, returning no slides. + // Without this guard the pane paints empty, with no error and no + // retry, which reads as the app being broken rather than the file + // being unreadable. + slidesToRender = 0; + const failed = vi.fn(); + const rendered = vi.fn(); + fixture.componentInstance.renderFailed.subscribe(failed); + fixture.componentInstance.rendered.subscribe(rendered); + + await render(new ArrayBuffer(16)); + + expect(failed).toHaveBeenCalledTimes(1); + expect(failed.mock.calls[0][0]).toContain('PowerPoint'); + expect(rendered).not.toHaveBeenCalled(); + expect(slides().length).toBe(0); + }); + + it('reports a parse failure without painting a partial deck', async () => { + previewRejection = new Error('corrupt zip'); + const failed = vi.fn(); + fixture.componentInstance.renderFailed.subscribe(failed); + + await render(new ArrayBuffer(16)); + + expect(failed).toHaveBeenCalledTimes(1); + expect(slides().length).toBe(0); + }); + + it('clears the deck when the bytes go away', async () => { + await render(new ArrayBuffer(16)); + expect(slides().length).toBe(1); + + await render(null); + + expect(slides().length).toBe(0); + }); +}); diff --git a/frontend/ai.client/src/app/session/components/message-list/components/file-preview/pptx-viewer.component.ts b/frontend/ai.client/src/app/session/components/message-list/components/file-preview/pptx-viewer.component.ts new file mode 100644 index 000000000..ae43c2362 --- /dev/null +++ b/frontend/ai.client/src/app/session/components/message-list/components/file-preview/pptx-viewer.component.ts @@ -0,0 +1,247 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + effect, + input, + output, + signal, + viewChild, +} from '@angular/core'; + +/** + * Width, in CSS px, the deck is rendered at before it is scaled to fit. + * + * `pptx-preview` takes a fixed pixel size at `init()` and lays the deck + * out against it — it has no responsive mode. Re-initialising on every + * frame of a rail drag would re-parse the whole file, so instead the + * deck is rendered once at this width and CSS-zoomed to fit, exactly as + * the docx viewer scales its pages. + * + * 960 is a deliberate over-render: comfortably wider than the rail gets, + * so fitting always scales *down* and text stays sharp rather than being + * enlarged from an undersized layout. + */ +const RENDER_WIDTH_PX = 960; + +/** 16:9, the aspect ratio `create_powerpoint_presentation` always emits. */ +const RENDER_HEIGHT_PX = Math.round((RENDER_WIDTH_PX * 9) / 16); + +/** + * Renders a `.pptx` in the browser, from its raw bytes. + * + * Uses `pptx-preview`, which reads the OOXML and reproduces each slide's + * own geometry — shape positions, fills, text frames, tables, images and + * the theme inherited from slide masters and layouts — as absolutely + * positioned DOM. That last part is what makes it worth a dependency: + * decks built on an uploaded corporate template get their branding from + * the master, and a renderer that only walked the slides would drop it. + * + * Nothing leaves the browser, for the same reason as the docx viewer — + * the alternative is a server-side render at Microsoft, which needs the + * file reachable by an unauthenticated URL. + * + * The library is loaded with a dynamic `import()`: it is dead weight in + * the initial bundle for the large majority of sessions that never open + * a deck, and it touches `document` at module scope, so a static import + * would run during SSR. + * + * Native OOXML charts are deliberately not supported — see + * `shims/echarts-stub/README.md` for why, and what a deck containing one + * does. + */ +@Component({ + selector: 'app-pptx-viewer', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { '[style.--pptx-zoom]': 'zoom()' }, + template: ` +
+ +
+
+ `, + styles: ` + :host { + display: block; + height: 100%; + } + + /* The library builds its subtree imperatively, so it never receives + Angular's emulated-encapsulation attribute and cannot be reached + by an ordinary rule here. ::ng-deep under :host is the supported + escape hatch and keeps the selector anchored to this component. + + The !important declarations below are load-bearing, not + defensive: the library writes those three properties as *inline* + styles on the wrapper — a black backdrop, a fixed pixel height + sized for its own internal scroller, and an overflow that drives + it — and an inline style beats any selector we can write here. + Without them the deck sits in a black letterbox inside a second, + nested scrollbar. */ + :host ::ng-deep .pptx-preview-wrapper { + /* Transparent so the scroller's themed gutter shows through, + rather than the library's black. */ + background: transparent !important; + display: flex; + flex-direction: column; + align-items: center; + gap: 1.5rem; + /* CSS zoom rather than transform: scale() — zoom participates in + layout, so the flow collapses to the scaled height on its own. A + transform would paint smaller while still reserving the + unscaled height, leaving dead space under every slide. Same + reasoning as the docx viewer. */ + zoom: var(--pptx-zoom, 1); + /* The pane is the scroller, so let the content decide the height + and scroll the pane rather than a box inside it. */ + height: auto !important; + overflow: visible !important; + } + + /* Each slide. Kept on a white card in both themes: this previews + something the user will present or export, and recolouring a slide + would misrepresent the deck. The gutter carries the theme. */ + :host ::ng-deep .pptx-preview-slide-wrapper { + background: var(--color-white); + box-shadow: + 0 1px 3px rgb(0 0 0 / 0.12), + 0 1px 2px rgb(0 0 0 / 0.08); + border-radius: 0.125rem; + flex: none; + max-width: 100%; + } + `, +}) +export class PptxViewerComponent { + /** Raw `.pptx` bytes. Null clears the view (pane closed / still loading). */ + readonly bytes = input(null); + + /** The deck painted. */ + readonly rendered = output(); + + /** Rendering failed — the bytes were not a readable `.pptx`. */ + readonly renderFailed = output(); + + private readonly deckHost = + viewChild.required>('deckHost'); + private readonly scroller = + viewChild.required>('scroller'); + + /** Scale applied to the deck so a full slide fits the pane width. + * Capped at 1 — see `applyFit`. */ + protected readonly zoom = signal(1); + + /** Bumped per render so a slow parse that resolves after the input + * changed (or the pane closed) cannot paint over the current deck. */ + private renderSeq = 0; + + /** Cached module so reopening the pane doesn't re-request the chunk. */ + private static libraryPromise: Promise< + typeof import('pptx-preview') + > | null = null; + + constructor() { + // Re-fit when the rail is resized. The deck's own layout is fixed at + // render time, so this only adjusts the zoom — no re-parse. Guarded + // for SSR, where there is no ResizeObserver and no layout to fit to. + effect((onCleanup) => { + if (typeof ResizeObserver === 'undefined') return; + const ro = new ResizeObserver(() => this.applyFit()); + ro.observe(this.scroller().nativeElement); + onCleanup(() => ro.disconnect()); + }); + + effect(() => { + const data = this.bytes(); + const host = this.deckHost().nativeElement; + + const seq = ++this.renderSeq; + + if (!data) { + host.replaceChildren(); + this.zoom.set(1); + return; + } + + void this.render(data, host, seq); + }); + } + + private async render( + data: ArrayBuffer, + host: HTMLElement, + seq: number, + ): Promise { + // Render into a detached container and swap it in only on success, + // so two overlapping renders cannot interleave their output into the + // live host — the sequence guard below cannot undo DOM the library + // wrote on its own. + const target = document.createElement('div'); + + try { + PptxViewerComponent.libraryPromise ??= import('pptx-preview'); + const { init } = await PptxViewerComponent.libraryPromise; + if (seq !== this.renderSeq) return; + + const previewer = init(target, { + width: RENDER_WIDTH_PX, + height: RENDER_HEIGHT_PX, + // Every slide stacked in one scrollable column, matching how the + // docx viewer presents pages. 'slide' would paginate with the + // library's own prev/next chrome, which duplicates the pane's + // scrollbar and reads as a second, competing set of controls. + mode: 'list', + }); + await previewer.preview(data); + + if (seq !== this.renderSeq) return; + + // A deck that parsed but produced no slides is a failure, not an + // empty document. `pptx-preview` resolves successfully when it + // cannot make sense of a presentation's theme or layout parts — + // observed on a deck with a stripped-down theme, which returned + // zero slides without throwing. Left unchecked that paints an + // empty pane with no error and no retry, which reads as the app + // being broken rather than the file being unreadable. + if (previewer.slideCount === 0) { + throw new Error('pptx-preview produced no slides'); + } + + host.replaceChildren(target); + this.applyFit(); + this.rendered.emit(); + } catch { + if (seq !== this.renderSeq) return; + host.replaceChildren(); + this.renderFailed.emit( + "This file couldn't be read as a PowerPoint presentation. It may be corrupted or saved in an older format.", + ); + } + } + + /** + * Scale the deck so one full slide fits the pane's content box. + * + * Never scales above 1: the deck is laid out at `RENDER_WIDTH_PX`, + * wider than the rail goes, so enlarging would only magnify a layout + * that is already the reference size. + */ + private applyFit(): void { + const el = this.scroller().nativeElement; + const styles = getComputedStyle(el); + const available = + el.clientWidth - + parseFloat(styles.paddingLeft || '0') - + parseFloat(styles.paddingRight || '0'); + + if (available <= 0) { + this.zoom.set(1); + return; + } + this.zoom.set(Math.min(1, available / RENDER_WIDTH_PX)); + } +} diff --git a/frontend/ai.client/src/app/session/components/message-list/components/inline-visual/renderers/file-download-renderer.component.spec.ts b/frontend/ai.client/src/app/session/components/message-list/components/inline-visual/renderers/file-download-renderer.component.spec.ts index fcb34fc37..c3b04b255 100644 --- a/frontend/ai.client/src/app/session/components/message-list/components/inline-visual/renderers/file-download-renderer.component.spec.ts +++ b/frontend/ai.client/src/app/session/components/message-list/components/inline-visual/renderers/file-download-renderer.component.spec.ts @@ -90,10 +90,21 @@ describe('FileDownloadRendererComponent', () => { }); }); + it('offers a preview for a .pptx as well as a .docx', () => { + expect(render({ filename: 'deck.pptx', upload_id: 'up2' })).not.toBeNull(); + expect(previewButton()).not.toBeNull(); + }); + it('offers no preview for formats the pane cannot render', () => { - // .xlsx and legacy binary .doc both still get their download link; - // only the button is withheld. - for (const filename of ['budget.xlsx', 'deck.pptx', 'old.doc', 'notes.txt']) { + // .xlsx has no renderer we are willing to ship, and the legacy + // binary .doc/.ppt formats are not OOXML at all. All of them still + // get their download link; only the button is withheld. + for (const filename of [ + 'budget.xlsx', + 'old.doc', + 'old.ppt', + 'notes.txt', + ]) { expect(render({ filename, upload_id: 'up1' })).not.toBeNull(); expect(previewButton()).toBeNull(); } diff --git a/frontend/ai.client/src/app/session/components/message-list/components/inline-visual/renderers/file-download-renderer.component.ts b/frontend/ai.client/src/app/session/components/message-list/components/inline-visual/renderers/file-download-renderer.component.ts index 8a729f097..632f87201 100644 --- a/frontend/ai.client/src/app/session/components/message-list/components/inline-visual/renderers/file-download-renderer.component.ts +++ b/frontend/ai.client/src/app/session/components/message-list/components/inline-visual/renderers/file-download-renderer.component.ts @@ -43,15 +43,15 @@ const PRESENTATION_ICON = const WORD_STYLE: FileKindStyle = { iconPath: DOCUMENT_ICON, - badgeClass: 'bg-filetype-doc-50 text-filetype-doc-600 dark:bg-filetype-doc-900/30 dark:text-filetype-doc-400', + badgeClass: 'bg-filetype-doc-50 text-filetype-doc-700 dark:bg-filetype-doc-900/30 dark:text-filetype-doc-400', }; const EXCEL_STYLE: FileKindStyle = { iconPath: SPREADSHEET_ICON, - badgeClass: 'bg-filetype-sheet-50 text-filetype-sheet-600 dark:bg-filetype-sheet-900/30 dark:text-filetype-sheet-400', + badgeClass: 'bg-filetype-sheet-50 text-filetype-sheet-700 dark:bg-filetype-sheet-900/30 dark:text-filetype-sheet-400', }; const POWERPOINT_STYLE: FileKindStyle = { iconPath: PRESENTATION_ICON, - badgeClass: 'bg-filetype-presentation-50 text-filetype-presentation-600 dark:bg-filetype-presentation-900/30 dark:text-filetype-presentation-400', + badgeClass: 'bg-filetype-presentation-50 text-filetype-presentation-700 dark:bg-filetype-presentation-900/30 dark:text-filetype-presentation-400', }; const GENERIC_STYLE: FileKindStyle = { iconPath: DOCUMENT_ICON, diff --git a/frontend/ai.client/src/app/session/services/chat/stream-parser.file-preview.spec.ts b/frontend/ai.client/src/app/session/services/chat/stream-parser.file-preview.spec.ts new file mode 100644 index 000000000..ce55496ae --- /dev/null +++ b/frontend/ai.client/src/app/session/services/chat/stream-parser.file-preview.spec.ts @@ -0,0 +1,112 @@ +import { TestBed } from '@angular/core/testing'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { StreamParserService } from './stream-parser.service'; +import { ChatStateService } from './chat-state.service'; +import { ErrorService } from '../../../services/error/error.service'; +import { QuotaWarningService } from '../../../services/quota/quota-warning.service'; +import { FilePreviewStateService } from '../file-preview/file-preview-state.service'; + +/** + * A file the turn just produced opens in the docked pane, the way an + * artifact pops its panel. + * + * The office tools have no SSE event of their own — the download card is a + * `file_download` inline visual inside the tool result — so the hook hangs + * off `tool_result`. That also gives the live-vs-hydrated distinction for + * free: `tool_result` only arrives mid-stream, so reopening an old + * conversation replays the card without seizing the rail. + */ +describe('StreamParserService — auto-opening the file preview pane', () => { + let service: StreamParserService; + let chatState: ChatStateService; + let preview: FilePreviewStateService; + + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + StreamParserService, + ChatStateService, + ErrorService, + QuotaWarningService, + ], + }); + service = TestBed.inject(StreamParserService); + chatState = TestBed.inject(ChatStateService); + preview = TestBed.inject(FilePreviewStateService); + chatState.setViewedSession('s1'); + service.reset('s1'); + }); + + afterEach(() => TestBed.resetTestingModule()); + + /** Drive a turn that calls one tool and returns a file_download card. */ + function streamFile( + payload: Record, + sessionId = 's1', + ): void { + service.parseEventSourceMessage(sessionId, 'message_start', { + role: 'assistant', + }); + service.parseEventSourceMessage(sessionId, 'content_block_delta', { + contentBlockIndex: 0, + text: 'Building it…', + }); + service.parseEventSourceMessage(sessionId, 'tool_result', { + tool_result: { + toolUseId: 't1', + status: 'success', + content: [ + { + text: JSON.stringify({ + success: true, + ui_type: 'file_download', + ui_display: 'inline', + payload, + }), + }, + ], + }, + }); + } + + it('opens a generated .pptx without the user clicking', () => { + streamFile({ filename: 'deck.pptx', upload_id: 'up1', size_kb: '30 KB' }); + + expect(preview.openFile()).toEqual({ + uploadId: 'up1', + filename: 'deck.pptx', + }); + }); + + it('opens a generated .docx too', () => { + streamFile({ filename: 'report.docx', upload_id: 'up2' }); + + expect(preview.openFile()?.filename).toBe('report.docx'); + }); + + it('leaves a format the pane cannot render alone', () => { + // Opening a pane that could only show an error is worse than letting + // the download card speak for itself. + streamFile({ filename: 'budget.xlsx', upload_id: 'up3' }); + + expect(preview.openFile()).toBeNull(); + }); + + it('never seizes the rail for a conversation streaming in the background', () => { + // Same guard as onArtifact: the user is reading a different thread. + chatState.setViewedSession('other-session'); + service.reset('s2'); + streamFile({ filename: 'deck.pptx', upload_id: 'up4' }, 's2'); + + expect(preview.openFile()).toBeNull(); + }); + + it('ignores a legacy card that carries no upload id', () => { + // Pre-upload_id cards hold an expired presigned URL and cannot be + // resolved; they also never arrive live, but the guard is cheap. + streamFile({ filename: 'old.docx', download_url: 'https://s3/old.docx' }); + + expect(preview.openFile()).toBeNull(); + }); +}); diff --git a/frontend/ai.client/src/app/session/services/chat/stream-parser.service.ts b/frontend/ai.client/src/app/session/services/chat/stream-parser.service.ts index f470b91b2..236de12d5 100644 --- a/frontend/ai.client/src/app/session/services/chat/stream-parser.service.ts +++ b/frontend/ai.client/src/app/session/services/chat/stream-parser.service.ts @@ -23,6 +23,8 @@ import { CompactionSummaryService } from './compaction-summary.service'; import { SteeringService } from './steering.service'; import { buildSteeringMessage } from './steering'; import { ArtifactStateService } from '../artifacts/artifact-state.service'; +import { FilePreviewStateService } from '../file-preview/file-preview-state.service'; +import { isPreviewableFilename } from '../file-preview/file-preview.model'; import { McpAppStateService } from '../mcp-apps/mcp-app-state.service'; import { ToolInsightService } from './tool-insight.service'; import { SessionService } from '../session/session.service'; @@ -168,6 +170,7 @@ export class StreamParserService { private compactionSummary = inject(CompactionSummaryService); private steering = inject(SteeringService); private artifactState = inject(ArtifactStateService); + private filePreview = inject(FilePreviewStateService); private mcpAppState = inject(McpAppStateService); private sessionService = inject(SessionService); private toolInsight = inject(ToolInsightService); @@ -483,6 +486,48 @@ export class StreamParserService { * and re-hydrate from the server, so a background stream must not push * into them while another conversation is on screen. */ + /** + * Surface a file the turn just produced in the docked preview pane. + * + * Parity with artifacts, which pop their panel from `onArtifact`. The + * office tools have no SSE event of their own — the download card is + * just a `file_download` inline visual inside the tool result — so the + * hook lives here instead. That is the right place for a second reason: + * `tool_result` only ever arrives mid-stream, so reopening an old + * conversation replays the card without reopening the pane, matching + * `seedFromHydration` on the artifact side. + * + * Viewed-session only, for the same reason as `onArtifact`: a + * conversation streaming in the background must never seize the rail. + * + * A turn that writes several files opens each in turn and the last one + * wins, which is also how the artifact panel behaves. Formats the pane + * cannot render (.xlsx today) are skipped, so the card is left to speak + * for itself rather than opening a pane that would only show an error. + */ + private maybeOpenFilePreview( + state: ParserSessionState, + resultContent: ReadonlyArray<{ json?: unknown }>, + ): void { + if (!this.isViewedSession(state)) return; + + for (const entry of resultContent) { + const json = entry.json as + | { ui_type?: string; payload?: { filename?: string; upload_id?: string } } + | undefined; + if (!json || json.ui_type !== 'file_download') continue; + + const filename = json.payload?.filename; + const uploadId = json.payload?.upload_id; + // `upload_id` is the current contract; cards persisted before it + // carry only an expired presigned URL, and those never stream live. + if (!filename || !uploadId) continue; + if (!isPreviewableFilename(filename)) continue; + + this.filePreview.open({ uploadId, filename }); + } + } + private isViewedSession(state: ParserSessionState): boolean { return this.chatStateService.viewedSessionId() === state.sessionId; } @@ -865,6 +910,13 @@ export class StreamParserService { return; } + // Parsed before the block lookup, and the pane opened from it, so + // surfacing a file the turn produced does not depend on the block + // bookkeeping below finding its tool_use — an unmatched result still + // means the file exists. + const resultContent = parseToolResultContent(content); + this.maybeOpenFilePreview(state, resultContent); + // Find the tool_use block let foundIndex: number | null = null; for (const [index, block] of currentBuilder.contentBlocks.entries()) { @@ -881,8 +933,6 @@ export class StreamParserService { return; // Tool use block not found } - const resultContent = parseToolResultContent(content); - state.currentMessageBuilder.update((builder) => { if (!builder) return builder; diff --git a/frontend/ai.client/src/app/session/services/file-preview/file-preview-http.service.spec.ts b/frontend/ai.client/src/app/session/services/file-preview/file-preview-http.service.spec.ts index 7981fb47c..69aaf7952 100644 --- a/frontend/ai.client/src/app/session/services/file-preview/file-preview-http.service.spec.ts +++ b/frontend/ai.client/src/app/session/services/file-preview/file-preview-http.service.spec.ts @@ -12,7 +12,7 @@ import { FilePreviewError, FilePreviewHttpService, } from './file-preview-http.service'; -import { DOCX_MIME } from './file-preview.model'; +import { DOCX_MIME, PPTX_MIME } from './file-preview.model'; import { ConfigService } from '../../../services/config.service'; describe('FilePreviewHttpService', () => { @@ -69,6 +69,46 @@ describe('FilePreviewHttpService', () => { expect(doc.bytes).toBe(bytes); expect(doc.filename).toBe('plan.docx'); expect(doc.mimeType).toBe(DOCX_MIME); + expect(doc.kind).toBe('docx'); + }); + + it('resolves a .pptx to the pptx viewer', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), + }); + + const pending = service.fetchDocument('up1'); + flushPreviewUrl({ mimeType: PPTX_MIME, filename: 'deck.pptx' }); + const doc = await pending; + + expect(doc.kind).toBe('pptx'); + expect(doc.mimeType).toBe(PPTX_MIME); + }); + + it('refuses a file whose MIME type contradicts its extension', async () => { + // The extension picked the viewer before any request was made, so a + // file named .pptx that the server knows to be a .docx has to fail + // here rather than reach a renderer that cannot read it. + const pending = service.fetchDocument('up1'); + flushPreviewUrl({ mimeType: DOCX_MIME, filename: 'deck.pptx' }); + + await expect(pending).rejects.toThrow(FilePreviewError); + await expect(pending).rejects.toMatchObject({ retryable: false }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('refuses a format the pane has no renderer for', async () => { + const pending = service.fetchDocument('up1'); + flushPreviewUrl({ + mimeType: + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + filename: 'budget.xlsx', + }); + + await expect(pending).rejects.toThrow(FilePreviewError); + expect(fetchMock).not.toHaveBeenCalled(); }); it('fetches S3 without credentials', async () => { diff --git a/frontend/ai.client/src/app/session/services/file-preview/file-preview-http.service.ts b/frontend/ai.client/src/app/session/services/file-preview/file-preview-http.service.ts index a857bf008..ba95895e9 100644 --- a/frontend/ai.client/src/app/session/services/file-preview/file-preview-http.service.ts +++ b/frontend/ai.client/src/app/session/services/file-preview/file-preview-http.service.ts @@ -2,7 +2,11 @@ import { Injectable, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { firstValueFrom } from 'rxjs'; import { ConfigService } from '../../../services/config.service'; -import { DOCX_MIME } from './file-preview.model'; +import { + PREVIEW_KIND_MIMES, + PreviewKind, + previewKindFor, +} from './file-preview.model'; /** `GET /files/{uploadId}/preview-url` — camelCase aliases on the wire. */ interface PreviewUrlResponseDto { @@ -18,6 +22,8 @@ export interface PreviewDocument { bytes: ArrayBuffer; mimeType: string; filename: string; + /** Which viewer should render these bytes. */ + kind: PreviewKind; } /** The preview failed in a way the pane should explain, not swallow. */ @@ -66,16 +72,23 @@ export class FilePreviewHttpService { private readonly config = inject(ConfigService); /** - * Resolve `uploadId` to a Word document's bytes. + * Resolve `uploadId` to a previewable document's bytes. * * Rejects with a `FilePreviewError` on every failure path so the pane * has one thing to catch and a `retryable` flag to decide whether to * offer the button. + * + * The MIME type the server recorded must agree with the extension the + * card was rendered from. Checking both directions is the point: the + * extension chose the viewer before any request was made, so a file + * named `.pptx` that the server knows to be a `.docx` has to fail here + * rather than reach a renderer that cannot read it. */ async fetchDocument(uploadId: string): Promise { const meta = await this.requestPreviewUrl(uploadId); - if (meta.mimeType !== DOCX_MIME) { + const kind = previewKindFor(meta.filename); + if (kind === null || meta.mimeType !== PREVIEW_KIND_MIMES[kind]) { throw new FilePreviewError( `This file is a ${meta.mimeType || 'unknown type'}, which can't be previewed here.`, false, @@ -83,7 +96,7 @@ export class FilePreviewHttpService { } const bytes = await this.fetchBytes(meta.url); - return { bytes, mimeType: meta.mimeType, filename: meta.filename }; + return { bytes, mimeType: meta.mimeType, filename: meta.filename, kind }; } private async requestPreviewUrl( diff --git a/frontend/ai.client/src/app/session/services/file-preview/file-preview.model.spec.ts b/frontend/ai.client/src/app/session/services/file-preview/file-preview.model.spec.ts new file mode 100644 index 000000000..999bf627c --- /dev/null +++ b/frontend/ai.client/src/app/session/services/file-preview/file-preview.model.spec.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { + isPreviewableFilename, + PREVIEW_KIND_LABELS, + previewKindFor, +} from './file-preview.model'; + +describe('previewKindFor', () => { + it('maps the OOXML formats the pane can render', () => { + expect(previewKindFor('plan.docx')).toBe('docx'); + expect(previewKindFor('deck.pptx')).toBe('pptx'); + }); + + it('ignores case and surrounding whitespace', () => { + expect(previewKindFor(' REPORT.DOCX ')).toBe('docx'); + expect(previewKindFor('Quarterly Review.PPTX')).toBe('pptx'); + }); + + it('declines the pre-2007 binary formats', () => { + // Not OOXML at all — the renderers cannot read them, so offering a + // preview would only produce an error the user cannot act on. + expect(previewKindFor('old.doc')).toBeNull(); + expect(previewKindFor('old.ppt')).toBeNull(); + }); + + it('declines .xlsx', () => { + // Deliberate: there is no spreadsheet renderer we are willing to + // ship. See the note on previewKindFor. + expect(previewKindFor('budget.xlsx')).toBeNull(); + }); + + it('declines an extension that merely contains a known one', () => { + expect(previewKindFor('plan.docx.pdf')).toBeNull(); + expect(previewKindFor('notdocx')).toBeNull(); + }); + + it('agrees with isPreviewableFilename', () => { + for (const name of ['a.docx', 'b.pptx', 'c.xlsx', 'd.txt', 'e.doc']) { + expect(isPreviewableFilename(name)).toBe(previewKindFor(name) !== null); + } + }); + + it('labels every kind it can return', () => { + for (const name of ['a.docx', 'b.pptx']) { + const kind = previewKindFor(name); + expect(kind).not.toBeNull(); + expect(PREVIEW_KIND_LABELS[kind!]).toBeTruthy(); + } + }); +}); diff --git a/frontend/ai.client/src/app/session/services/file-preview/file-preview.model.ts b/frontend/ai.client/src/app/session/services/file-preview/file-preview.model.ts index 0e5ca0ed3..02f800058 100644 --- a/frontend/ai.client/src/app/session/services/file-preview/file-preview.model.ts +++ b/frontend/ai.client/src/app/session/services/file-preview/file-preview.model.ts @@ -15,7 +15,30 @@ export const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; /** - * Whether a filename is one the preview pane can render. + * MIME type of a PowerPoint presentation (OOXML). Matches + * `apis.shared.files.ALLOWED_MIME_TYPES` and the `_PPTX_MIME` constant in + * `agents/builtin_tools/powerpoint_presentation_tool.py`. + */ +export const PPTX_MIME = + 'application/vnd.openxmlformats-officedocument.presentationml.presentation'; + +/** What the pane knows how to render, and which viewer does it. */ +export type PreviewKind = 'docx' | 'pptx'; + +/** Human label for the pane header's subtitle. */ +export const PREVIEW_KIND_LABELS: Readonly> = { + docx: 'Word document', + pptx: 'PowerPoint presentation', +}; + +/** The MIME type each viewer requires, checked against `/preview-url`. */ +export const PREVIEW_KIND_MIMES: Readonly> = { + docx: DOCX_MIME, + pptx: PPTX_MIME, +}; + +/** + * Which viewer a filename maps to, or null if the pane can't render it. * * Extension-based rather than MIME-based on purpose: the inline download * card is rendered from a persisted tool payload that carries only @@ -26,9 +49,24 @@ export const DOCX_MIME = * `/preview-url` reports, so a mislabelled `.docx` fails there rather * than feeding garbage to the renderer. * - * Legacy `.doc` is deliberately excluded: it is the pre-2007 binary - * format, which the OOXML renderer cannot read at all. + * Legacy `.doc` and `.ppt` are deliberately excluded: they are the + * pre-2007 binary formats, which the OOXML renderers cannot read at all. + * + * `.xlsx` is deliberately absent. There is no renderer for it we are + * willing to ship: the npm build of SheetJS is frozen at a 2022 release + * carrying unfixed advisories, and the only maintained grid renderer is + * built on ExcelJS, which throws outright on the workbooks + * `create_excel_spreadsheet` produces whenever one contains a native + * chart. Download-and-open remains the path for spreadsheets. */ +export function previewKindFor(filename: string): PreviewKind | null { + const name = filename.trim(); + if (/\.docx$/i.test(name)) return 'docx'; + if (/\.pptx$/i.test(name)) return 'pptx'; + return null; +} + +/** Whether a filename is one the preview pane can render. */ export function isPreviewableFilename(filename: string): boolean { - return /\.docx$/i.test(filename.trim()); + return previewKindFor(filename) !== null; } diff --git a/frontend/ai.client/src/app/shared/artifact/shared-artifact-card.component.ts b/frontend/ai.client/src/app/shared/artifact/shared-artifact-card.component.ts index fc9fbc184..77959362d 100644 --- a/frontend/ai.client/src/app/shared/artifact/shared-artifact-card.component.ts +++ b/frontend/ai.client/src/app/shared/artifact/shared-artifact-card.component.ts @@ -35,37 +35,37 @@ const TYPE_STYLES: Record = { label: 'Markdown', icon: 'heroDocumentText', bg: 'bg-filetype-markdown-100 dark:bg-filetype-markdown-900/60', - text: 'text-filetype-markdown-600 dark:text-filetype-markdown-300', + text: 'text-filetype-markdown-700 dark:text-filetype-markdown-300', }, 'text/x-markdown': { label: 'Markdown', icon: 'heroDocumentText', bg: 'bg-filetype-markdown-100 dark:bg-filetype-markdown-900/60', - text: 'text-filetype-markdown-600 dark:text-filetype-markdown-300', + text: 'text-filetype-markdown-700 dark:text-filetype-markdown-300', }, 'text/html': { label: 'Web page', icon: 'heroCodeBracket', bg: 'bg-filetype-code-100 dark:bg-filetype-code-900/60', - text: 'text-filetype-code-600 dark:text-filetype-code-300', + text: 'text-filetype-code-700 dark:text-filetype-code-300', }, 'application/xhtml+xml': { label: 'Web page', icon: 'heroCodeBracket', bg: 'bg-filetype-code-100 dark:bg-filetype-code-900/60', - text: 'text-filetype-code-600 dark:text-filetype-code-300', + text: 'text-filetype-code-700 dark:text-filetype-code-300', }, 'text/csv': { label: 'CSV', icon: 'heroTableCells', bg: 'bg-filetype-sheet-100 dark:bg-filetype-sheet-900/60', - text: 'text-filetype-sheet-600 dark:text-filetype-sheet-300', + text: 'text-filetype-sheet-700 dark:text-filetype-sheet-300', }, 'image/svg+xml': { label: 'SVG', icon: 'heroPhoto', bg: 'bg-filetype-image-100 dark:bg-filetype-image-900/60', - text: 'text-filetype-image-600 dark:text-filetype-image-300', + text: 'text-filetype-image-700 dark:text-filetype-image-300', }, };