Compare commits

..
Author SHA1 Message Date
shamoon 90d6bbb4a9 Drop the custom replace, let marked escape it 2026-10-11 15:43:27 -07:00
shamoon 5f7dc04657 Add hostname label 2026-10-11 15:40:09 -07:00
shamoon ebae55f158 Sonar 2026-10-11 15:40:08 -07:00
shamoon 1c64328b71 some chat component visual improvements 2026-10-11 15:40:08 -07:00
shamoon 962b26879d Ditch monospace 2026-10-11 15:40:08 -07:00
shamoon 6d6cddca16 Update chat.component.html 2026-10-11 15:40:08 -07:00
shamoon ef5237620b Use the markdown pipe 2026-10-11 15:40:08 -07:00
shamoon 82cfe868b5 markdown pipe 2026-10-11 15:40:07 -07:00
shamoon e557854d8c Add marked 2026-10-11 15:40:07 -07:00
shamoon 973af89f61 Chore: update frontend TS libs to ES2022 2026-10-11 15:39:56 -07:00
7 changed files with 102 additions and 210 deletions

No files matched your search

@@ -1,5 +1,5 @@
<li ngbDropdown class="nav-item" display="dynamic" placement="bottom-end" [popperOptions]="popperOptions" (openChange)="onOpenChange($event)"> <li ngbDropdown class="nav-item" (openChange)="onOpenChange($event)">
<button class="btn navbar-action border-0" id="chatDropdown" ngbDropdownToggle aria-label="Chat" i18n-aria-label> <button class="btn navbar-action border-0" id="chatDropdown" ngbDropdownToggle aria-label="Chat" i18n-aria-label>
<i-bs width="1.3em" height="1.3em" name="chatSquareDots"></i-bs> <i-bs width="1.3em" height="1.3em" name="chatSquareDots"></i-bs>
</button> </button>
@@ -8,17 +8,8 @@
<div class="chat-messages small"> <div class="chat-messages small">
@for (message of messages(); track message) { @for (message of messages(); track message) {
<div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'"> <div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'">
<div class="p-2 m-2" [class.bg-body]="message.role === 'user'" [class.flex-grow-1]="message.role === 'assistant'"> <div class="p-2 m-2" [class.bg-body]="message.role === 'user'">
@if (message.isStreaming && !message.content) { @if (message.role === 'assistant') {
<div class="d-flex align-items-center text-muted">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
@if (slowResponse()) {
<ng-container i18n>Still thinking...</ng-container>
} @else {
<ng-container i18n>Thinking...</ng-container>
}
</div>
} @else if (message.role === 'assistant') {
<div class="chat-markdown text-break text-wrap" [innerHTML]="message.content | markdown"></div> <div class="chat-markdown text-break text-wrap" [innerHTML]="message.content | markdown"></div>
@if (message.isStreaming) { <span class="blinking-cursor">|</span> } @if (message.isStreaming) { <span class="blinking-cursor">|</span> }
} @else { } @else {
@@ -26,10 +17,9 @@
} }
@if (message.role === 'assistant' && message.references?.length) { @if (message.role === 'assistant' && message.references?.length) {
<div class="chat-references list-group mt-3"> <div class="chat-references list-group mt-3">
<div class="list-group-item text-muted small py-1" i18n>Sources</div>
@for (reference of message.references; track reference.id) { @for (reference of message.references; track reference.id) {
<a class="list-group-item list-group-item-action d-flex align-items-center text-primary" [routerLink]="['/documents', reference.id]" [title]="reference.title"> <a class="list-group-item list-group-item-action d-flex text-primary text-break" [routerLink]="['/documents', reference.id]">
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-2 flex-shrink-0"></i-bs><span class="text-truncate">{{ reference.title }}</span> <i-bs width="0.9em" height="0.9em" name="file-text" class="me-1"></i-bs><span>{{ reference.title }}</span>
</a> </a>
} }
</div> </div>
@@ -53,11 +43,7 @@
(ngModelChange)="input.set($event)" (ngModelChange)="input.set($event)"
(keydown)="searchInputKeyDown($event)" (keydown)="searchInputKeyDown($event)"
/> />
@if (loading()) { <button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()" [disabled]="loading()">Send</button>
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="stopStreaming()" i18n>Stop</button>
} @else {
<button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()">Send</button>
}
</div> </div>
</form> </form>
</div> </div>
@@ -1,9 +1,9 @@
.dropdown-menu { .dropdown-menu {
width: min(36rem, calc(100vw - 20px)); width: var(--pngx-toast-max-width);
} }
.chat-messages { .chat-messages {
max-height: min(60dvh, 36rem); max-height: 350px;
overflow-y: auto; overflow-y: auto;
white-space: pre-wrap; white-space: pre-wrap;
} }
@@ -16,9 +16,9 @@
white-space: initial; white-space: initial;
} }
@media screen and (max-width: 575.98px) { @media screen and (max-width: 400px) {
.chat-messages { :host ::ng-deep .dropdown-menu-end {
max-height: calc(100dvh - 12rem); right: -3rem;
} }
} }
@@ -80,15 +80,8 @@ describe('ChatComponent', () => {
expect(component.messages()[0].content).toBe('Hello') expect(component.messages()[0].content).toBe('Hello')
expect(component.loading()).toBe(true) expect(component.loading()).toBe(true)
fixture.detectChanges()
const messagesEl = fixture.nativeElement.querySelector('.chat-messages')
expect(messagesEl.querySelector('.spinner-border')).not.toBeNull()
expect(messagesEl.textContent).toContain('Thinking...')
mockStream$.next('Hi') mockStream$.next('Hi')
expect(component.messages()[1].content).toBe('H') expect(component.messages()[1].content).toBe('H')
fixture.detectChanges()
expect(messagesEl.querySelector('.spinner-border')).toBeNull()
mockStream$.next('Hi there') mockStream$.next('Hi there')
// advance time to process the typewriter effect // advance time to process the typewriter effect
await jest.runAllTimersAsync() await jest.runAllTimersAsync()
@@ -163,75 +156,13 @@ describe('ChatComponent', () => {
component.input.set('Hello') component.input.set('Hello')
component.sendMessage() component.sendMessage()
mockStream$.next('Partial')
mockStream$.error('Error') mockStream$.error('Error')
expect(component.messages()[1].content).toBe( expect(component.messages()[1].content).toContain(
'Partial\n\n⚠️ Error receiving response.' '⚠️ Error receiving response.'
) )
expect(component.loading()).toBe(false) expect(component.loading()).toBe(false)
}) })
it('should keep streaming until the typewriter catches up', () => {
component.input.set('Hello')
component.sendMessage()
mockStream$.next('Hi there')
mockStream$.complete()
expect(component.messages()[1].isStreaming).toBe(true)
expect(component.loading()).toBe(true)
jest.advanceTimersByTime(1000)
expect(component.messages()[1].content).toBe('Hi there')
expect(component.messages()[1].isStreaming).toBe(false)
expect(component.loading()).toBe(false)
})
it('should show a slow response message while waiting', () => {
component.input.set('Hello')
component.sendMessage()
fixture.detectChanges()
const messagesEl = fixture.nativeElement.querySelector('.chat-messages')
expect(messagesEl.textContent).not.toContain('Still thinking...')
jest.advanceTimersByTime(8000)
fixture.detectChanges()
expect(messagesEl.textContent).toContain('Still thinking...')
mockStream$.next('Hi')
expect(component.slowResponse()).toBe(false)
})
it('should stop streaming and keep the partial response', () => {
component.input.set('Hello')
component.sendMessage()
fixture.detectChanges()
const stopButton = fixture.nativeElement.querySelector('.chat-input button')
expect(stopButton.textContent).toContain('Stop')
mockStream$.next('Hi there')
jest.advanceTimersByTime(20)
stopButton.click()
expect(mockStream$.observed).toBe(false)
expect(component.messages()[1].content).toBe('Hi there')
expect(component.messages()[1].isStreaming).toBe(false)
expect(component.loading()).toBe(false)
jest.advanceTimersByTime(1000)
expect(component.messages()[1].content).toBe('Hi there')
})
it('should drop the empty response when stopped before any content', () => {
component.input.set('Hello')
component.sendMessage()
component.stopStreaming()
expect(component.messages()).toHaveLength(1)
expect(component.loading()).toBe(false)
component.stopStreaming() // no-op once stopped
expect(component.messages()).toHaveLength(1)
})
it('should enqueue typewriter chunks correctly', () => { it('should enqueue typewriter chunks correctly', () => {
const message = { content: '', role: 'assistant', isStreaming: true } const message = { content: '', role: 'assistant', isStreaming: true }
component.enqueueTypewriter(null, message as any) // coverage for null component.enqueueTypewriter(null, message as any) // coverage for null
@@ -290,4 +221,19 @@ describe('ChatComponent', () => {
expect(el.querySelector('.chat-markdown script')).toBeNull() expect(el.querySelector('.chat-markdown script')).toBeNull()
expect(el.textContent).toContain('**user**') expect(el.textContent).toContain('**user**')
}) })
it('should keep the destination hostname visible after sanitizing chat links', () => {
component.messages.set([
{
role: 'assistant',
content:
'[View original document](https://otherdomain.example/?data=secret)',
},
])
fixture.detectChanges()
const el: HTMLElement = fixture.nativeElement
const link = el.querySelector('.chat-markdown a')
expect(link?.textContent).toBe('View original document')
expect(link?.nextElementSibling?.textContent).toBe('(otherdomain.example)')
})
}) })
@@ -10,16 +10,13 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NavigationEnd, Router, RouterModule } from '@angular/router' import { NavigationEnd, Router, RouterModule } from '@angular/router'
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { filter, map, Subscription } from 'rxjs'
import { MarkdownPipe } from 'src/app/pipes/markdown.pipe' import { MarkdownPipe } from 'src/app/pipes/markdown.pipe'
import { filter, map } from 'rxjs'
import { import {
ChatMessage, ChatMessage,
ChatService, ChatService,
parseChatResponse, parseChatResponse,
} from 'src/app/services/chat.service' } from 'src/app/services/chat.service'
import { pngxPopperOptions } from 'src/app/utils/popper-options'
const SLOW_RESPONSE_DELAY_MS = 8000
@Component({ @Component({
selector: 'pngx-chat', selector: 'pngx-chat',
@@ -39,8 +36,6 @@ export class ChatComponent implements OnInit {
readonly loading = signal(false) readonly loading = signal(false)
readonly input = signal('') readonly input = signal('')
readonly documentId = signal<number>(undefined) readonly documentId = signal<number>(undefined)
readonly slowResponse = signal(false)
public popperOptions = pngxPopperOptions
private chatService: ChatService = inject(ChatService) private chatService: ChatService = inject(ChatService)
private router: Router = inject(Router) private router: Router = inject(Router)
@@ -50,11 +45,6 @@ export class ChatComponent implements OnInit {
private typewriterBuffer: string[] = [] private typewriterBuffer: string[] = []
private typewriterActive = false private typewriterActive = false
private typewriterTimeout: ReturnType<typeof setTimeout>
private streamSubscription: Subscription
private streamingMessage: ChatMessage
private streamFinished = false
private slowResponseTimeout: ReturnType<typeof setTimeout>
public get placeholder(): string { public get placeholder(): string {
return this.documentId() return this.documentId()
@@ -93,90 +83,47 @@ export class ChatComponent implements OnInit {
} }
this.messages.update((messages) => [...messages, assistantMessage]) this.messages.update((messages) => [...messages, assistantMessage])
this.loading.set(true) this.loading.set(true)
this.streamingMessage = assistantMessage
this.streamFinished = false
this.slowResponseTimeout = setTimeout(
() => this.slowResponse.set(true),
SLOW_RESPONSE_DELAY_MS
)
let lastVisibleContent = '' let lastVisibleContent = ''
this.streamSubscription = this.chatService this.chatService.streamChat(this.documentId(), this.input()).subscribe({
.streamChat(this.documentId(), this.input()) next: (chunk) => {
.subscribe({ const nextResponse = parseChatResponse(chunk)
next: (chunk) => {
this.clearSlowResponse()
const nextResponse = parseChatResponse(chunk)
if (nextResponse.content.length < lastVisibleContent.length) { if (nextResponse.content.length < lastVisibleContent.length) {
this.resetTypewriter(assistantMessage, nextResponse.content) this.resetTypewriter(assistantMessage, nextResponse.content)
lastVisibleContent = nextResponse.content lastVisibleContent = nextResponse.content
} else { } else {
const visibleDelta = nextResponse.content.substring( const visibleDelta = nextResponse.content.substring(
lastVisibleContent.length lastVisibleContent.length
) )
lastVisibleContent = nextResponse.content lastVisibleContent = nextResponse.content
this.enqueueTypewriter(visibleDelta, assistantMessage) this.enqueueTypewriter(visibleDelta, assistantMessage)
} }
assistantMessage.references = nextResponse.references assistantMessage.references = nextResponse.references
this.notifyMessagesChanged() this.notifyMessagesChanged()
}, },
error: () => { error: () => {
this.flushTypewriter(assistantMessage) assistantMessage.content += '\n\n⚠️ Error receiving response.'
assistantMessage.content += '\n\n⚠️ Error receiving response.' assistantMessage.isStreaming = false
this.finishStreaming(assistantMessage) this.notifyMessagesChanged()
}, this.loading.set(false)
complete: () => { },
// let the typewriter catch up before hiding the cursor complete: () => {
this.streamFinished = true assistantMessage.isStreaming = false
if (!this.typewriterActive) this.finishStreaming(assistantMessage) this.notifyMessagesChanged()
}, this.loading.set(false)
}) this.scrollToBottom()
},
})
this.input.set('') this.input.set('')
} }
stopStreaming(): void { private resetTypewriter(message: ChatMessage, content: string): void {
const message = this.streamingMessage
if (!message?.isStreaming) return
this.streamSubscription?.unsubscribe()
this.flushTypewriter(message)
if (!message.content) {
this.messages.update((messages) => messages.filter((m) => m !== message))
}
this.finishStreaming(message)
}
private finishStreaming(message: ChatMessage): void {
this.clearSlowResponse()
this.streamFinished = false
message.isStreaming = false
this.notifyMessagesChanged()
this.loading.set(false)
this.scrollToBottom()
}
private clearSlowResponse(): void {
clearTimeout(this.slowResponseTimeout)
this.slowResponse.set(false)
}
private flushTypewriter(message: ChatMessage): void {
message.content += this.typewriterBuffer.join('')
this.cancelTypewriter()
}
private cancelTypewriter(): void {
clearTimeout(this.typewriterTimeout)
this.typewriterBuffer = [] this.typewriterBuffer = []
this.typewriterActive = false this.typewriterActive = false
}
private resetTypewriter(message: ChatMessage, content: string): void {
this.cancelTypewriter()
message.content = content message.content = content
this.notifyMessagesChanged() this.notifyMessagesChanged()
this.scrollToBottom() this.scrollToBottom()
@@ -196,7 +143,6 @@ export class ChatComponent implements OnInit {
playTypewriter(message: ChatMessage): void { playTypewriter(message: ChatMessage): void {
if (this.typewriterBuffer.length === 0) { if (this.typewriterBuffer.length === 0) {
this.typewriterActive = false this.typewriterActive = false
if (this.streamFinished) this.finishStreaming(message)
return return
} }
@@ -205,8 +151,7 @@ export class ChatComponent implements OnInit {
this.notifyMessagesChanged() this.notifyMessagesChanged()
this.scrollToBottom() this.scrollToBottom()
// 10ms per character setTimeout(() => this.playTypewriter(message), 10) // 10ms per character
this.typewriterTimeout = setTimeout(() => this.playTypewriter(message), 10)
} }
private notifyMessagesChanged(): void { private notifyMessagesChanged(): void {
+23 -3
View File
@@ -31,16 +31,18 @@ describe('MarkdownPipe', () => {
}) })
it('should not render images', () => { it('should not render images', () => {
const html = pipe.transform('![secret](https://evil.example/x.png?d=1)') const html = pipe.transform(
'![secret](https://otherdomain.example/x.png?d=1)'
)
expect(html).not.toContain('<img') expect(html).not.toContain('<img')
expect(html).not.toContain('evil.example') expect(html).not.toContain('otherdomain.example')
expect(html).toContain('secret') expect(html).toContain('secret')
}) })
it('should render safe links with target and rel', () => { it('should render safe links with target and rel', () => {
const html = pipe.transform('[docs](https://docs.paperless-ngx.com "Docs")') const html = pipe.transform('[docs](https://docs.paperless-ngx.com "Docs")')
expect(html).toContain( expect(html).toContain(
'<a href="https://docs.paperless-ngx.com/" title="Docs" target="_blank" rel="noopener noreferrer nofollow">docs</a>' '<a target="_blank" rel="noopener noreferrer nofollow" href="https://docs.paperless-ngx.com" title="Docs">docs</a>'
) )
expect(pipe.transform('[mail](mailto:a@b.c)')).toContain( expect(pipe.transform('[mail](mailto:a@b.c)')).toContain(
'href="mailto:a@b.c"' 'href="mailto:a@b.c"'
@@ -61,6 +63,24 @@ describe('MarkdownPipe', () => {
} }
}) })
it('should show the actual hostname beside web links with misleading labels', () => {
for (const protocol of ['http', 'https']) {
const html = pipe.transform(
`[docs.paperless-ngx.com](${protocol}://docs.paperless-ngx.com@otherdomain.example/path?secret=1)`
)
expect(html).toContain(
'</a> <span class="text-muted small">(otherdomain.example)</span>'
)
expect(html).not.toContain('(docs.paperless-ngx.com)')
}
})
it('should omit the hostname label for mailto links', () => {
expect(pipe.transform('[mail](mailto:a@b.c)')).toBe(
'<p><a target="_blank" rel="noopener noreferrer nofollow" href="mailto:a@b.c">mail</a></p>\n'
)
})
it('should escape link titles', () => { it('should escape link titles', () => {
const html = pipe.transform( const html = pipe.transform(
'[x](https://a.example "a\\" onmouseover=\\"1")' '[x](https://a.example "a\\" onmouseover=\\"1")'
+18 -23
View File
@@ -1,41 +1,36 @@
import { Pipe, PipeTransform } from '@angular/core' import { Pipe, PipeTransform } from '@angular/core'
import { Marked, Renderer, Tokens } from 'marked' import { Marked, Renderer, Tokens } from 'marked'
const ALLOWED_LINK_PROTOCOLS = ['http:', 'https:', 'mailto:'] const ALLOWED_LINK_PROTOCOLS = new Set(['http:', 'https:', 'mailto:'])
function escapeHtml(text: string): string { function allowedUrl(href: string): URL | null {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function safeHref(href: string): string | null {
try { try {
const url = new URL(href) const url = new URL(href)
return ALLOWED_LINK_PROTOCOLS.includes(url.protocol) ? url.href : null return ALLOWED_LINK_PROTOCOLS.has(url.protocol) ? url : null
} catch { } catch {
return null return null
} }
} }
// Treat chat content as untrusted: no raw HTML, no images, and only // Treat chat content as untrusted: no raw HTML, no images, and only
// absolute links. Angular sanitizer runs on top of this as well. // absolute links. All escaping is done by marked's default renderer, and
// Angular's sanitizer runs on top of this as well.
const renderer: Partial<Renderer> = { const renderer: Partial<Renderer> = {
html({ text }: Tokens.HTML | Tokens.Tag): string { html(this: Renderer, { raw, text }: Tokens.HTML | Tokens.Tag): string {
return escapeHtml(text) return this.text({ type: 'text', raw, text })
}, },
image({ text }: Tokens.Image): string { image(this: Renderer, { raw, text }: Tokens.Image): string {
return escapeHtml(text) return this.text({ type: 'text', raw, text })
}, },
link(this: Renderer, { href, title, tokens }: Tokens.Link): string { link(this: Renderer, token: Tokens.Link): string {
const text = this.parser.parseInline(tokens) const url = allowedUrl(token.href)
const url = safeHref(href) if (!url) return this.parser.parseInline(token.tokens)
if (!url) return text const anchor = Renderer.prototype.link
const titleAttr = title ? ` title="${escapeHtml(title)}"` : '' .call(this, token)
return `<a href="${escapeHtml(url)}"${titleAttr} target="_blank" rel="noopener noreferrer nofollow">${text}</a>` .replace('<a ', '<a target="_blank" rel="noopener noreferrer nofollow" ')
if (!url.hostname) return anchor
const hostname = this.text({ type: 'text', raw: '', text: url.hostname })
return `${anchor} <span class="text-muted small">(${hostname})</span>`
}, },
} }
+1 -1
View File
@@ -15,7 +15,7 @@
"target": "ES2022", "target": "ES2022",
"module": "es2020", "module": "es2020",
"lib": [ "lib": [
"es2020", "es2022",
"dom" "dom"
], ],
"useDefineForClassFields": false "useDefineForClassFields": false