mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-12 02:57:12 +00:00
Compare commits
9
Commits
dev
...
feature/chat-md
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90d6bbb4a9 | ||
|
|
5f7dc04657 | ||
|
|
ebae55f158 | ||
|
|
1c64328b71 | ||
|
|
962b26879d | ||
|
|
6d6cddca16 | ||
|
|
ef5237620b | ||
|
|
82cfe868b5 | ||
|
|
e557854d8c |
No files matched your search
@@ -26,7 +26,7 @@ module.exports = {
|
|||||||
'abstract-paperless-service',
|
'abstract-paperless-service',
|
||||||
],
|
],
|
||||||
transformIgnorePatterns: [
|
transformIgnorePatterns: [
|
||||||
'node_modules/(?!.*(\\.mjs$|tslib|lodash-es|normalize-diacritics|@angular/common/locales/.*\\.js$))',
|
'node_modules/(?!.*(\\.mjs$|tslib|lodash-es|normalize-diacritics|marked|@angular/common/locales/.*\\.js$))',
|
||||||
],
|
],
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
...esmPreset.moduleNameMapper,
|
...esmPreset.moduleNameMapper,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"lodash-es": "^4.18.1",
|
"lodash-es": "^4.18.1",
|
||||||
|
"marked": "~18.0.14",
|
||||||
"mime-names": "^1.0.0",
|
"mime-names": "^1.0.0",
|
||||||
"ngx-bootstrap-icons": "^1.9.3",
|
"ngx-bootstrap-icons": "^1.9.3",
|
||||||
"ngx-color": "^10.1.0",
|
"ngx-color": "^10.1.0",
|
||||||
|
|||||||
Generated
+10
@@ -53,6 +53,9 @@ importers:
|
|||||||
lodash-es:
|
lodash-es:
|
||||||
specifier: ^4.18.1
|
specifier: ^4.18.1
|
||||||
version: 4.18.1
|
version: 4.18.1
|
||||||
|
marked:
|
||||||
|
specifier: ~18.0.14
|
||||||
|
version: 18.0.14
|
||||||
mime-names:
|
mime-names:
|
||||||
specifier: ^1.0.0
|
specifier: ^1.0.0
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
@@ -3226,6 +3229,11 @@ packages:
|
|||||||
make-error@1.3.6:
|
make-error@1.3.6:
|
||||||
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
|
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
|
||||||
|
|
||||||
|
marked@18.0.14:
|
||||||
|
resolution: {integrity: sha512-mBHK6FBHuBAlhgRe88w9F0O1AbwwXJUcQibUbC/QcdTbVGAD7aWza+xt3N6oT/jCZx3/OMeS+8rnuiHZcQ9s7A==}
|
||||||
|
engines: {node: '>= 20'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
material-colors@1.2.6:
|
material-colors@1.2.6:
|
||||||
resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==}
|
resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==}
|
||||||
|
|
||||||
@@ -7409,6 +7417,8 @@ snapshots:
|
|||||||
|
|
||||||
make-error@1.3.6: {}
|
make-error@1.3.6: {}
|
||||||
|
|
||||||
|
marked@18.0.14: {}
|
||||||
|
|
||||||
material-colors@1.2.6: {}
|
material-colors@1.2.6: {}
|
||||||
|
|
||||||
merge-stream@2.0.0: {}
|
merge-stream@2.0.0: {}
|
||||||
|
|||||||
@@ -5,14 +5,16 @@
|
|||||||
</button>
|
</button>
|
||||||
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown">
|
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown">
|
||||||
<div class="chat-container bg-light p-2">
|
<div class="chat-container bg-light p-2">
|
||||||
<div class="chat-messages font-monospace 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'">
|
<div class="p-2 m-2" [class.bg-body]="message.role === 'user'">
|
||||||
<span class="text-break">
|
@if (message.role === 'assistant') {
|
||||||
{{ message.content }}
|
<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> }
|
||||||
</span>
|
} @else {
|
||||||
|
<span class="text-break">{{ message.content }}</span>
|
||||||
|
}
|
||||||
@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">
|
||||||
@for (reference of message.references; track reference.id) {
|
@for (reference of message.references; track reference.id) {
|
||||||
|
|||||||
@@ -8,10 +8,6 @@
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-references {
|
|
||||||
font-family: var(--bs-font-sans-serif);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-toggle::after {
|
.dropdown-toggle::after {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -40,3 +36,19 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-markdown ::ng-deep {
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
> :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -207,4 +207,33 @@ describe('ChatComponent', () => {
|
|||||||
component.searchInputKeyDown(event)
|
component.searchInputKeyDown(event)
|
||||||
expect(component.sendMessage).not.toHaveBeenCalled()
|
expect(component.sendMessage).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should render markdown for assistant messages only', () => {
|
||||||
|
component.messages.set([
|
||||||
|
{ role: 'user', content: '**user**' },
|
||||||
|
{ role: 'assistant', content: '**assistant** <script>x</script>' },
|
||||||
|
])
|
||||||
|
fixture.detectChanges()
|
||||||
|
const el: HTMLElement = fixture.nativeElement
|
||||||
|
expect(el.querySelector('.chat-markdown strong')?.textContent).toBe(
|
||||||
|
'assistant'
|
||||||
|
)
|
||||||
|
expect(el.querySelector('.chat-markdown script')).toBeNull()
|
||||||
|
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,6 +10,7 @@ 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 { MarkdownPipe } from 'src/app/pipes/markdown.pipe'
|
||||||
import { filter, map } from 'rxjs'
|
import { filter, map } from 'rxjs'
|
||||||
import {
|
import {
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
RouterModule,
|
RouterModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
|
MarkdownPipe,
|
||||||
],
|
],
|
||||||
templateUrl: './chat.component.html',
|
templateUrl: './chat.component.html',
|
||||||
styleUrl: './chat.component.scss',
|
styleUrl: './chat.component.scss',
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { MarkdownPipe } from './markdown.pipe'
|
||||||
|
|
||||||
|
describe('MarkdownPipe', () => {
|
||||||
|
const pipe = new MarkdownPipe()
|
||||||
|
|
||||||
|
it('should return empty string for empty input', () => {
|
||||||
|
expect(pipe.transform(null)).toEqual('')
|
||||||
|
expect(pipe.transform(undefined)).toEqual('')
|
||||||
|
expect(pipe.transform('')).toEqual('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render basic markdown', () => {
|
||||||
|
const html = pipe.transform(
|
||||||
|
'**bold** _em_ `code`\n\n- one\n- two\n\n| a | b |\n|---|---|\n| 1 | 2 |'
|
||||||
|
)
|
||||||
|
expect(html).toContain('<strong>bold</strong>')
|
||||||
|
expect(html).toContain('<em>em</em>')
|
||||||
|
expect(html).toContain('<code>code</code>')
|
||||||
|
expect(html).toContain('<li>one</li>')
|
||||||
|
expect(html).toContain('<table>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should escape raw html', () => {
|
||||||
|
const html = pipe.transform(
|
||||||
|
'hi <img src=x onerror="alert(1)"> <b>x</b>\n\n<script>alert(1)</script>'
|
||||||
|
)
|
||||||
|
expect(html).not.toContain('<img')
|
||||||
|
expect(html).not.toContain('<script')
|
||||||
|
expect(html).not.toContain('<b>')
|
||||||
|
expect(html).toContain('<script>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not render images', () => {
|
||||||
|
const html = pipe.transform(
|
||||||
|
''
|
||||||
|
)
|
||||||
|
expect(html).not.toContain('<img')
|
||||||
|
expect(html).not.toContain('otherdomain.example')
|
||||||
|
expect(html).toContain('secret')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render safe links with target and rel', () => {
|
||||||
|
const html = pipe.transform('[docs](https://docs.paperless-ngx.com "Docs")')
|
||||||
|
expect(html).toContain(
|
||||||
|
'<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(
|
||||||
|
'href="mailto:a@b.c"'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should drop unsafe or relative links but keep their text', () => {
|
||||||
|
for (const href of [
|
||||||
|
'javascript:alert(1)',
|
||||||
|
'JaVaScRiPt:alert(1)',
|
||||||
|
'data:text/html,<script>alert(1)</script>',
|
||||||
|
'vbscript:msgbox',
|
||||||
|
'/api/documents/',
|
||||||
|
]) {
|
||||||
|
const html = pipe.transform(`[click](${href})`)
|
||||||
|
expect(html).not.toContain('<a')
|
||||||
|
expect(html).toContain('click')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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', () => {
|
||||||
|
const html = pipe.transform(
|
||||||
|
'[x](https://a.example "a\\" onmouseover=\\"1")'
|
||||||
|
)
|
||||||
|
expect(html).not.toMatch(/"\s*onmouseover=/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { Pipe, PipeTransform } from '@angular/core'
|
||||||
|
import { Marked, Renderer, Tokens } from 'marked'
|
||||||
|
|
||||||
|
const ALLOWED_LINK_PROTOCOLS = new Set(['http:', 'https:', 'mailto:'])
|
||||||
|
|
||||||
|
function allowedUrl(href: string): URL | null {
|
||||||
|
try {
|
||||||
|
const url = new URL(href)
|
||||||
|
return ALLOWED_LINK_PROTOCOLS.has(url.protocol) ? url : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Treat chat content as untrusted: no raw HTML, no images, and only
|
||||||
|
// 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> = {
|
||||||
|
html(this: Renderer, { raw, text }: Tokens.HTML | Tokens.Tag): string {
|
||||||
|
return this.text({ type: 'text', raw, text })
|
||||||
|
},
|
||||||
|
image(this: Renderer, { raw, text }: Tokens.Image): string {
|
||||||
|
return this.text({ type: 'text', raw, text })
|
||||||
|
},
|
||||||
|
link(this: Renderer, token: Tokens.Link): string {
|
||||||
|
const url = allowedUrl(token.href)
|
||||||
|
if (!url) return this.parser.parseInline(token.tokens)
|
||||||
|
const anchor = Renderer.prototype.link
|
||||||
|
.call(this, token)
|
||||||
|
.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>`
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const markdown = new Marked({
|
||||||
|
async: false,
|
||||||
|
gfm: true,
|
||||||
|
breaks: true,
|
||||||
|
renderer,
|
||||||
|
})
|
||||||
|
|
||||||
|
@Pipe({
|
||||||
|
name: 'markdown',
|
||||||
|
})
|
||||||
|
export class MarkdownPipe implements PipeTransform {
|
||||||
|
transform(value: string | null | undefined): string {
|
||||||
|
if (!value) return ''
|
||||||
|
return markdown.parse(value) as string
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user