@nixxie-cms/ai-rag 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +23 -0
- package/README.md +163 -0
- package/dist/declarations/src/AiRagService.d.ts +50 -0
- package/dist/declarations/src/AiRagService.d.ts.map +1 -0
- package/dist/declarations/src/admin-page.d.ts +29 -0
- package/dist/declarations/src/admin-page.d.ts.map +1 -0
- package/dist/declarations/src/chunking.d.ts +8 -0
- package/dist/declarations/src/chunking.d.ts.map +1 -0
- package/dist/declarations/src/collection.d.ts +18 -0
- package/dist/declarations/src/collection.d.ts.map +1 -0
- package/dist/declarations/src/express.d.ts +36 -0
- package/dist/declarations/src/express.d.ts.map +1 -0
- package/dist/declarations/src/graphql.d.ts +23 -0
- package/dist/declarations/src/graphql.d.ts.map +1 -0
- package/dist/declarations/src/index.d.ts +39 -0
- package/dist/declarations/src/index.d.ts.map +1 -0
- package/dist/declarations/src/plugin.d.ts +53 -0
- package/dist/declarations/src/plugin.d.ts.map +1 -0
- package/dist/declarations/src/prompt.d.ts +14 -0
- package/dist/declarations/src/prompt.d.ts.map +1 -0
- package/dist/declarations/src/providers/AnthropicRagProvider.d.ts +16 -0
- package/dist/declarations/src/providers/AnthropicRagProvider.d.ts.map +1 -0
- package/dist/declarations/src/providers/GeminiRagProvider.d.ts +19 -0
- package/dist/declarations/src/providers/GeminiRagProvider.d.ts.map +1 -0
- package/dist/declarations/src/providers/OllamaRagProvider.d.ts +23 -0
- package/dist/declarations/src/providers/OllamaRagProvider.d.ts.map +1 -0
- package/dist/declarations/src/providers/OpenAiRagProvider.d.ts +17 -0
- package/dist/declarations/src/providers/OpenAiRagProvider.d.ts.map +1 -0
- package/dist/declarations/src/providers/ServiceRagProvider.d.ts +17 -0
- package/dist/declarations/src/providers/ServiceRagProvider.d.ts.map +1 -0
- package/dist/declarations/src/providers/index.d.ts +14 -0
- package/dist/declarations/src/providers/index.d.ts.map +1 -0
- package/dist/declarations/src/providers/types.d.ts +45 -0
- package/dist/declarations/src/providers/types.d.ts.map +1 -0
- package/dist/declarations/src/similarity.d.ts +12 -0
- package/dist/declarations/src/similarity.d.ts.map +1 -0
- package/dist/declarations/src/types.d.ts +319 -0
- package/dist/declarations/src/types.d.ts.map +1 -0
- package/dist/declarations/src/vector-store.d.ts +34 -0
- package/dist/declarations/src/vector-store.d.ts.map +1 -0
- package/dist/nixxie-cms-ai-rag.cjs.d.ts +2 -0
- package/dist/nixxie-cms-ai-rag.cjs.js +2507 -0
- package/dist/nixxie-cms-ai-rag.esm.js +2481 -0
- package/package.json +37 -0
- package/src/AiRagService.ts +640 -0
- package/src/admin-page.ts +135 -0
- package/src/chunking.ts +78 -0
- package/src/collection.ts +79 -0
- package/src/express.ts +212 -0
- package/src/graphql.ts +196 -0
- package/src/guard.ts +75 -0
- package/src/index.ts +102 -0
- package/src/plugin.ts +162 -0
- package/src/prompt.ts +62 -0
- package/src/providers/AnthropicRagProvider.ts +91 -0
- package/src/providers/GeminiRagProvider.ts +147 -0
- package/src/providers/OllamaRagProvider.ts +157 -0
- package/src/providers/OpenAiRagProvider.ts +108 -0
- package/src/providers/ServiceRagProvider.ts +44 -0
- package/src/providers/index.ts +67 -0
- package/src/providers/types.ts +44 -0
- package/src/semaphore.ts +26 -0
- package/src/similarity.ts +31 -0
- package/src/types.ts +346 -0
- package/src/vector-store.ts +136 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { AdminFileToWrite } from '@nixxie-cms/core/types'
|
|
2
|
+
|
|
3
|
+
export type RagAdminPageOptions = {
|
|
4
|
+
/**
|
|
5
|
+
* Route the page is served at inside the Admin UI (also its filename under `pages/`).
|
|
6
|
+
* @default 'ai-rag'
|
|
7
|
+
*/
|
|
8
|
+
route?: string
|
|
9
|
+
/**
|
|
10
|
+
* Base path of the HTTP routes the page talks to (must match `ragPlugin({ routes })`).
|
|
11
|
+
* @default '/api/ai-rag'
|
|
12
|
+
*/
|
|
13
|
+
apiPath?: string
|
|
14
|
+
/** Heading shown at the top of the page. @default 'AI Assistant' */
|
|
15
|
+
title?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Source of the self-contained Admin UI page (plain React + fetch, no extra deps). */
|
|
19
|
+
function pageSource(apiPath: string, title: string): string {
|
|
20
|
+
return `// Generated by @nixxie-cms/ai-rag — a chat + knowledge-base console for the Admin UI.
|
|
21
|
+
import React, { useState } from 'react'
|
|
22
|
+
|
|
23
|
+
const API = ${JSON.stringify(apiPath)}
|
|
24
|
+
|
|
25
|
+
export default function AiRagPage() {
|
|
26
|
+
const [messages, setMessages] = useState([])
|
|
27
|
+
const [input, setInput] = useState('')
|
|
28
|
+
const [busy, setBusy] = useState(false)
|
|
29
|
+
const [docTitle, setDocTitle] = useState('')
|
|
30
|
+
const [docContent, setDocContent] = useState('')
|
|
31
|
+
|
|
32
|
+
async function send() {
|
|
33
|
+
const question = input.trim()
|
|
34
|
+
if (!question || busy) return
|
|
35
|
+
setInput('')
|
|
36
|
+
const next = [...messages, { role: 'user', content: question }]
|
|
37
|
+
setMessages(next)
|
|
38
|
+
setBusy(true)
|
|
39
|
+
try {
|
|
40
|
+
const res = await fetch(API + '/chat', {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: { 'Content-Type': 'application/json' },
|
|
43
|
+
body: JSON.stringify({ messages: next, stream: false }),
|
|
44
|
+
})
|
|
45
|
+
const answer = await res.json()
|
|
46
|
+
const sources = (answer.sources || [])
|
|
47
|
+
.map(function (s, i) { return '[' + (i + 1) + '] ' + (s.title || s.source || s.documentId) })
|
|
48
|
+
.join(' ')
|
|
49
|
+
setMessages(function (m) {
|
|
50
|
+
return m.concat([{ role: 'assistant', content: answer.text + (sources ? '\\n\\nSources: ' + sources : '') }])
|
|
51
|
+
})
|
|
52
|
+
} catch (err) {
|
|
53
|
+
setMessages(function (m) { return m.concat([{ role: 'assistant', content: 'Error: ' + String(err) }]) })
|
|
54
|
+
} finally {
|
|
55
|
+
setBusy(false)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function addDocument() {
|
|
60
|
+
if (!docContent.trim()) return
|
|
61
|
+
await fetch(API + '/documents', {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
headers: { 'Content-Type': 'application/json' },
|
|
64
|
+
body: JSON.stringify({ title: docTitle, content: docContent }),
|
|
65
|
+
})
|
|
66
|
+
setDocTitle('')
|
|
67
|
+
setDocContent('')
|
|
68
|
+
alert('Document added and queued for indexing.')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
React.createElement('div', { style: { maxWidth: 820, margin: '0 auto', padding: 24 } },
|
|
73
|
+
React.createElement('h1', { style: { fontSize: 24, fontWeight: 700, marginBottom: 16 } }, ${JSON.stringify(title)}),
|
|
74
|
+
React.createElement('div', { style: { border: '1px solid #e2e8f0', borderRadius: 8, padding: 16, minHeight: 240, marginBottom: 12 } },
|
|
75
|
+
messages.length === 0
|
|
76
|
+
? React.createElement('p', { style: { color: '#94a3b8' } }, 'Ask the assistant a question about your knowledge base…')
|
|
77
|
+
: messages.map(function (m, i) {
|
|
78
|
+
return React.createElement('div', { key: i, style: { margin: '8px 0' } },
|
|
79
|
+
React.createElement('strong', null, m.role === 'user' ? 'You: ' : 'Assistant: '),
|
|
80
|
+
React.createElement('span', { style: { whiteSpace: 'pre-wrap' } }, m.content))
|
|
81
|
+
})
|
|
82
|
+
),
|
|
83
|
+
React.createElement('div', { style: { display: 'flex', gap: 8, marginBottom: 32 } },
|
|
84
|
+
React.createElement('input', {
|
|
85
|
+
value: input,
|
|
86
|
+
onChange: function (e) { setInput(e.target.value) },
|
|
87
|
+
onKeyDown: function (e) { if (e.key === 'Enter') send() },
|
|
88
|
+
placeholder: 'Type your question…',
|
|
89
|
+
style: { flex: 1, padding: 8, borderRadius: 6, border: '1px solid #cbd5e1' },
|
|
90
|
+
}),
|
|
91
|
+
React.createElement('button', { onClick: send, disabled: busy, style: { padding: '8px 16px' } }, busy ? '…' : 'Send')
|
|
92
|
+
),
|
|
93
|
+
React.createElement('h2', { style: { fontSize: 18, fontWeight: 600, marginBottom: 8 } }, 'Add to knowledge base'),
|
|
94
|
+
React.createElement('input', {
|
|
95
|
+
value: docTitle,
|
|
96
|
+
onChange: function (e) { setDocTitle(e.target.value) },
|
|
97
|
+
placeholder: 'Title',
|
|
98
|
+
style: { width: '100%', padding: 8, borderRadius: 6, border: '1px solid #cbd5e1', marginBottom: 8 },
|
|
99
|
+
}),
|
|
100
|
+
React.createElement('textarea', {
|
|
101
|
+
value: docContent,
|
|
102
|
+
onChange: function (e) { setDocContent(e.target.value) },
|
|
103
|
+
placeholder: 'Content the assistant should learn from…',
|
|
104
|
+
rows: 6,
|
|
105
|
+
style: { width: '100%', padding: 8, borderRadius: 6, border: '1px solid #cbd5e1', marginBottom: 8 },
|
|
106
|
+
}),
|
|
107
|
+
React.createElement('button', { onClick: addDocument, style: { padding: '8px 16px' } }, 'Add document')
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
`
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Returns an `AdminFileToWrite` that adds a chat + knowledge-base console page to the
|
|
116
|
+
* Admin UI. Spread it into `ui.getAdditionalFiles`:
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* import { ragAdminPage } from '@nixxie-cms/ai-rag'
|
|
120
|
+
* export default config({
|
|
121
|
+
* ui: { getAdditionalFiles: [async () => [ragAdminPage()]] },
|
|
122
|
+
* })
|
|
123
|
+
*
|
|
124
|
+
* The page appears at `/<route>` (default `/ai-rag`) in the Admin UI.
|
|
125
|
+
*/
|
|
126
|
+
export function ragAdminPage(options: RagAdminPageOptions = {}): AdminFileToWrite {
|
|
127
|
+
const route = options.route ?? 'ai-rag'
|
|
128
|
+
const apiPath = (options.apiPath ?? '/api/ai-rag').replace(/\/$/, '')
|
|
129
|
+
const title = options.title ?? 'AI Assistant'
|
|
130
|
+
return {
|
|
131
|
+
mode: 'write',
|
|
132
|
+
src: pageSource(apiPath, title),
|
|
133
|
+
outputPath: `pages/${route}.js`,
|
|
134
|
+
}
|
|
135
|
+
}
|
package/src/chunking.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { RagChunkingConfig } from './types'
|
|
2
|
+
|
|
3
|
+
const DEFAULTS = {
|
|
4
|
+
strategy: 'recursive' as const,
|
|
5
|
+
chunkSize: 1200,
|
|
6
|
+
chunkOverlap: 200,
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Boundary separators tried in order, coarsest first (recursive strategy). */
|
|
10
|
+
const SEPARATORS = ['\n\n', '\n', '. ', '! ', '? ', '; ', ', ', ' ']
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Split text into overlapping chunks. The recursive strategy prefers to cut on the
|
|
14
|
+
* coarsest natural boundary that keeps a chunk under `chunkSize`, falling back to finer
|
|
15
|
+
* boundaries (and finally a hard slice) so no chunk overruns the budget.
|
|
16
|
+
*/
|
|
17
|
+
export function chunkText(text: string, config: RagChunkingConfig = {}): string[] {
|
|
18
|
+
const strategy = config.strategy ?? DEFAULTS.strategy
|
|
19
|
+
const size = Math.max(1, config.chunkSize ?? DEFAULTS.chunkSize)
|
|
20
|
+
const overlap = Math.max(0, Math.min(config.chunkOverlap ?? DEFAULTS.chunkOverlap, size - 1))
|
|
21
|
+
|
|
22
|
+
const normalized = text.replace(/\r\n/g, '\n').trim()
|
|
23
|
+
if (!normalized) return []
|
|
24
|
+
if (normalized.length <= size) return [normalized]
|
|
25
|
+
|
|
26
|
+
const units =
|
|
27
|
+
strategy === 'sentence'
|
|
28
|
+
? splitSentences(normalized)
|
|
29
|
+
: strategy === 'fixed'
|
|
30
|
+
? hardSlice(normalized, size)
|
|
31
|
+
: recursiveSplit(normalized, size)
|
|
32
|
+
|
|
33
|
+
return packWithOverlap(units, size, overlap)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Greedily pack atomic units into chunks <= size, carrying `overlap` chars between them. */
|
|
37
|
+
function packWithOverlap(units: string[], size: number, overlap: number): string[] {
|
|
38
|
+
const chunks: string[] = []
|
|
39
|
+
let current = ''
|
|
40
|
+
for (const unit of units) {
|
|
41
|
+
if (current && current.length + unit.length + 1 > size) {
|
|
42
|
+
chunks.push(current.trim())
|
|
43
|
+
current = overlap > 0 ? current.slice(Math.max(0, current.length - overlap)) : ''
|
|
44
|
+
}
|
|
45
|
+
current = current ? `${current} ${unit}`.trim() : unit
|
|
46
|
+
// A single oversized unit: hard-slice it.
|
|
47
|
+
while (current.length > size) {
|
|
48
|
+
chunks.push(current.slice(0, size).trim())
|
|
49
|
+
current = current.slice(size - overlap)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (current.trim()) chunks.push(current.trim())
|
|
53
|
+
return chunks.filter(Boolean)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function recursiveSplit(text: string, size: number): string[] {
|
|
57
|
+
if (text.length <= size) return [text]
|
|
58
|
+
for (const sep of SEPARATORS) {
|
|
59
|
+
if (!text.includes(sep)) continue
|
|
60
|
+
const parts = text.split(sep).filter(Boolean)
|
|
61
|
+
if (parts.length < 2) continue
|
|
62
|
+
return parts.flatMap(p => (p.length > size ? recursiveSplit(p, size) : [p]))
|
|
63
|
+
}
|
|
64
|
+
return hardSlice(text, size)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function splitSentences(text: string): string[] {
|
|
68
|
+
return text
|
|
69
|
+
.split(/(?<=[.!?])\s+/)
|
|
70
|
+
.map(s => s.trim())
|
|
71
|
+
.filter(Boolean)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function hardSlice(text: string, size: number): string[] {
|
|
75
|
+
const out: string[] = []
|
|
76
|
+
for (let i = 0; i < text.length; i += size) out.push(text.slice(i, i + size))
|
|
77
|
+
return out
|
|
78
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { integer, json, select, text, timestamp } from '@nixxie-cms/core/fields'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The knowledge-base source list the assistant is trained on. Users add rows here; each
|
|
5
|
+
* row's `content` is chunked, embedded and made retrievable. `ragPlugin()` adds this
|
|
6
|
+
* collection automatically, but you can register it yourself for full control over access.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* config({
|
|
10
|
+
* collections: { KnowledgeBase: knowledgeBaseCollection(), ...collections },
|
|
11
|
+
* })
|
|
12
|
+
*/
|
|
13
|
+
export function knowledgeBaseCollection(): any {
|
|
14
|
+
return {
|
|
15
|
+
fields: {
|
|
16
|
+
title: text({ validation: { isRequired: true }, isIndexed: true }),
|
|
17
|
+
content: text({ ui: { displayMode: 'textarea' }, validation: { isRequired: true } }),
|
|
18
|
+
source: text(),
|
|
19
|
+
tags: json({ defaultValue: [] }),
|
|
20
|
+
metadata: json(),
|
|
21
|
+
status: select({
|
|
22
|
+
type: 'string',
|
|
23
|
+
options: [
|
|
24
|
+
{ label: 'Pending', value: 'pending' },
|
|
25
|
+
{ label: 'Indexing', value: 'indexing' },
|
|
26
|
+
{ label: 'Indexed', value: 'indexed' },
|
|
27
|
+
{ label: 'Error', value: 'error' },
|
|
28
|
+
{ label: 'Disabled', value: 'disabled' },
|
|
29
|
+
],
|
|
30
|
+
defaultValue: 'pending',
|
|
31
|
+
isIndexed: true,
|
|
32
|
+
ui: { displayMode: 'segmented-control' },
|
|
33
|
+
}),
|
|
34
|
+
chunkCount: integer({ defaultValue: 0 }),
|
|
35
|
+
error: text(),
|
|
36
|
+
indexedAt: timestamp(),
|
|
37
|
+
createdAt: timestamp({ defaultValue: { kind: 'now' }, db: { isNullable: false } }),
|
|
38
|
+
updatedAt: timestamp({ db: { updatedAt: true } }),
|
|
39
|
+
},
|
|
40
|
+
ui: {
|
|
41
|
+
labelField: 'title',
|
|
42
|
+
description: 'Knowledge base the AI assistant is trained on.',
|
|
43
|
+
listView: {
|
|
44
|
+
initialColumns: ['title', 'status', 'chunkCount', 'tags', 'updatedAt'],
|
|
45
|
+
initialSort: { field: 'updatedAt', direction: 'DESC' },
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The chunk/embedding list backing retrieval. Managed entirely by ai-rag — rows are written
|
|
53
|
+
* during indexing and deleted with their document. Hidden from create/delete in the Admin UI.
|
|
54
|
+
* `ragPlugin()` adds it automatically.
|
|
55
|
+
*/
|
|
56
|
+
export function knowledgeChunkCollection(): any {
|
|
57
|
+
return {
|
|
58
|
+
fields: {
|
|
59
|
+
documentId: text({ validation: { isRequired: true }, isIndexed: true }),
|
|
60
|
+
content: text({ ui: { displayMode: 'textarea' } }),
|
|
61
|
+
embedding: json(),
|
|
62
|
+
title: text(),
|
|
63
|
+
source: text(),
|
|
64
|
+
tags: json({ defaultValue: [] }),
|
|
65
|
+
metadata: json(),
|
|
66
|
+
createdAt: timestamp({ defaultValue: { kind: 'now' }, db: { isNullable: false } }),
|
|
67
|
+
},
|
|
68
|
+
graphql: {
|
|
69
|
+
omit: { create: true, update: true, delete: true },
|
|
70
|
+
},
|
|
71
|
+
ui: {
|
|
72
|
+
isHidden: true,
|
|
73
|
+
labelField: 'title',
|
|
74
|
+
hideCreate: true,
|
|
75
|
+
hideDelete: true,
|
|
76
|
+
itemView: { defaultFieldMode: 'read' },
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
}
|
package/src/express.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import type { NixxieAiRagService, NixxieContext, NixxieRagStreamEvent } from '@nixxie-cms/core'
|
|
2
|
+
|
|
3
|
+
export type AiRagRoutesOptions = {
|
|
4
|
+
/** The service. Defaults to `context.services.aiRag`. */
|
|
5
|
+
service?: NixxieAiRagService
|
|
6
|
+
/**
|
|
7
|
+
* Base path the routes mount under.
|
|
8
|
+
* @default '/api/ai-rag'
|
|
9
|
+
*/
|
|
10
|
+
path?: string
|
|
11
|
+
/**
|
|
12
|
+
* Authorize a request before it reaches the assistant. Receives a request-bound context;
|
|
13
|
+
* returning false answers 401. Applied to every route.
|
|
14
|
+
* @default requires a session
|
|
15
|
+
*/
|
|
16
|
+
isAuthorized?: (context: NixxieContext) => boolean | Promise<boolean>
|
|
17
|
+
/**
|
|
18
|
+
* Require authorization for read/chat routes too (not just mutations).
|
|
19
|
+
* @default true
|
|
20
|
+
*/
|
|
21
|
+
protectReads?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getService(context: NixxieContext, options: AiRagRoutesOptions): NixxieAiRagService {
|
|
25
|
+
const svc = options.service ?? context.services.aiRag
|
|
26
|
+
if (!svc) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
'[@nixxie-cms/ai-rag] No aiRag service configured. Pass `service` or set `config({ aiRag })`.'
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
return svc
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Read a JSON body whether or not a body parser ran upstream. */
|
|
35
|
+
async function readJson(req: any): Promise<any> {
|
|
36
|
+
if (req.body && typeof req.body === 'object') return req.body
|
|
37
|
+
return new Promise(resolve => {
|
|
38
|
+
let raw = ''
|
|
39
|
+
req.on('data', (c: any) => (raw += c))
|
|
40
|
+
req.on('end', () => {
|
|
41
|
+
try {
|
|
42
|
+
resolve(raw ? JSON.parse(raw) : {})
|
|
43
|
+
} catch {
|
|
44
|
+
resolve({})
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
req.on('error', () => resolve({}))
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function writeSse(res: any, event: NixxieRagStreamEvent): void {
|
|
52
|
+
const type = event.type
|
|
53
|
+
res.write(`event: ${type}\ndata: ${JSON.stringify(event)}\n\n`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Mount the assistant's HTTP API on an Express app:
|
|
58
|
+
*
|
|
59
|
+
* - `POST <path>/chat` — body `{ messages?, question?, topK?, tags?, stream? }`. When
|
|
60
|
+
* `stream` is true (or `Accept: text/event-stream`), responds with SSE: `sources`,
|
|
61
|
+
* then `token` events, then a final `done` event carrying the full answer.
|
|
62
|
+
* - `GET <path>/documents` — list knowledge-base documents.
|
|
63
|
+
* - `POST <path>/documents` — add a document `{ content, title?, source?, tags? }`.
|
|
64
|
+
* - `DELETE <path>/documents/:id` — remove a document.
|
|
65
|
+
* - `POST <path>/reindex` — `{ force? }` → index stats.
|
|
66
|
+
*
|
|
67
|
+
* Call from `server.extendExpressApp`, or use `ragPlugin()` to wire it automatically.
|
|
68
|
+
*/
|
|
69
|
+
export function installAiRagRoutes(
|
|
70
|
+
app: any,
|
|
71
|
+
context: NixxieContext,
|
|
72
|
+
options: AiRagRoutesOptions = {}
|
|
73
|
+
): void {
|
|
74
|
+
const path = (options.path ?? '/api/ai-rag').replace(/\/$/, '')
|
|
75
|
+
const protectReads = options.protectReads ?? true
|
|
76
|
+
|
|
77
|
+
const authorize = async (req: any, res: any): Promise<NixxieContext | null> => {
|
|
78
|
+
const requestContext = await context.withRequest(req, res)
|
|
79
|
+
const check = options.isAuthorized ?? ((c: NixxieContext) => !!c.session)
|
|
80
|
+
if (!(await check(requestContext))) {
|
|
81
|
+
res.statusCode = 401
|
|
82
|
+
res.end('Unauthorized')
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
return requestContext
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const wrap =
|
|
89
|
+
(handler: (req: any, res: any, ctx: NixxieContext) => Promise<void>, gated: boolean) =>
|
|
90
|
+
(req: any, res: any) => {
|
|
91
|
+
void (async () => {
|
|
92
|
+
const ctx = gated ? await authorize(req, res) : await context.withRequest(req, res)
|
|
93
|
+
if (!ctx) return
|
|
94
|
+
await handler(req, res, ctx)
|
|
95
|
+
})().catch((err: unknown) => {
|
|
96
|
+
console.error('[@nixxie-cms/ai-rag] Route error:', err)
|
|
97
|
+
if (!res.headersSent) {
|
|
98
|
+
res.statusCode = 500
|
|
99
|
+
res.setHeader('Content-Type', 'application/json')
|
|
100
|
+
res.end(JSON.stringify({ error: err instanceof Error ? err.message : 'Internal error' }))
|
|
101
|
+
} else {
|
|
102
|
+
res.end()
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Chat (streaming or one-shot) ──
|
|
108
|
+
app.post(
|
|
109
|
+
`${path}/chat`,
|
|
110
|
+
wrap(async (req, res, ctx) => {
|
|
111
|
+
const svc = getService(ctx, options)
|
|
112
|
+
const body = await readJson(req)
|
|
113
|
+
const messages =
|
|
114
|
+
Array.isArray(body.messages) && body.messages.length
|
|
115
|
+
? body.messages
|
|
116
|
+
: body.question
|
|
117
|
+
? [{ role: 'user', content: String(body.question) }]
|
|
118
|
+
: []
|
|
119
|
+
if (!messages.length) {
|
|
120
|
+
res.statusCode = 400
|
|
121
|
+
res.setHeader('Content-Type', 'application/json')
|
|
122
|
+
res.end(JSON.stringify({ error: 'Provide `messages` or `question`.' }))
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
const askOptions = {
|
|
126
|
+
topK: body.topK,
|
|
127
|
+
tags: body.tags,
|
|
128
|
+
temperature: body.temperature,
|
|
129
|
+
model: body.model,
|
|
130
|
+
}
|
|
131
|
+
const wantsStream =
|
|
132
|
+
body.stream === true || String(req.headers?.accept ?? '').includes('text/event-stream')
|
|
133
|
+
|
|
134
|
+
if (!wantsStream) {
|
|
135
|
+
const answer = await svc.chat(messages, askOptions)
|
|
136
|
+
res.setHeader('Content-Type', 'application/json')
|
|
137
|
+
res.end(JSON.stringify(answer))
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
res.setHeader('Content-Type', 'text/event-stream')
|
|
142
|
+
res.setHeader('Cache-Control', 'no-cache')
|
|
143
|
+
res.setHeader('Connection', 'keep-alive')
|
|
144
|
+
res.flushHeaders?.()
|
|
145
|
+
let aborted = false
|
|
146
|
+
req.on?.('close', () => (aborted = true))
|
|
147
|
+
for await (const event of svc.stream(messages, askOptions)) {
|
|
148
|
+
if (aborted) break
|
|
149
|
+
writeSse(res, event)
|
|
150
|
+
}
|
|
151
|
+
res.end()
|
|
152
|
+
}, protectReads)
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
// ── Documents CRUD ──
|
|
156
|
+
app.get(
|
|
157
|
+
`${path}/documents`,
|
|
158
|
+
wrap(async (req, res, ctx) => {
|
|
159
|
+
const svc = getService(ctx, options)
|
|
160
|
+
const take = req.query?.take ? Number(req.query.take) : undefined
|
|
161
|
+
const skip = req.query?.skip ? Number(req.query.skip) : undefined
|
|
162
|
+
const docs = await svc.listDocuments({ take, skip, search: req.query?.search })
|
|
163
|
+
res.setHeader('Content-Type', 'application/json')
|
|
164
|
+
res.end(JSON.stringify({ documents: docs }))
|
|
165
|
+
}, protectReads)
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
app.post(
|
|
169
|
+
`${path}/documents`,
|
|
170
|
+
wrap(async (req, res, ctx) => {
|
|
171
|
+
const svc = getService(ctx, options)
|
|
172
|
+
const body = await readJson(req)
|
|
173
|
+
if (!body.content) {
|
|
174
|
+
res.statusCode = 400
|
|
175
|
+
res.setHeader('Content-Type', 'application/json')
|
|
176
|
+
res.end(JSON.stringify({ error: '`content` is required.' }))
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
const doc = await svc.addDocument({
|
|
180
|
+
content: String(body.content),
|
|
181
|
+
title: body.title,
|
|
182
|
+
source: body.source,
|
|
183
|
+
tags: body.tags,
|
|
184
|
+
metadata: body.metadata,
|
|
185
|
+
})
|
|
186
|
+
res.statusCode = 201
|
|
187
|
+
res.setHeader('Content-Type', 'application/json')
|
|
188
|
+
res.end(JSON.stringify(doc))
|
|
189
|
+
}, true)
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
app.delete(
|
|
193
|
+
`${path}/documents/:id`,
|
|
194
|
+
wrap(async (req, res, ctx) => {
|
|
195
|
+
const svc = getService(ctx, options)
|
|
196
|
+
await svc.removeDocument(req.params.id)
|
|
197
|
+
res.setHeader('Content-Type', 'application/json')
|
|
198
|
+
res.end(JSON.stringify({ ok: true }))
|
|
199
|
+
}, true)
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
app.post(
|
|
203
|
+
`${path}/reindex`,
|
|
204
|
+
wrap(async (req, res, ctx) => {
|
|
205
|
+
const svc = getService(ctx, options)
|
|
206
|
+
const body = await readJson(req)
|
|
207
|
+
const stats = await svc.reindex({ force: !!body.force })
|
|
208
|
+
res.setHeader('Content-Type', 'application/json')
|
|
209
|
+
res.end(JSON.stringify(stats))
|
|
210
|
+
}, true)
|
|
211
|
+
)
|
|
212
|
+
}
|