@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.
Files changed (65) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +163 -0
  3. package/dist/declarations/src/AiRagService.d.ts +50 -0
  4. package/dist/declarations/src/AiRagService.d.ts.map +1 -0
  5. package/dist/declarations/src/admin-page.d.ts +29 -0
  6. package/dist/declarations/src/admin-page.d.ts.map +1 -0
  7. package/dist/declarations/src/chunking.d.ts +8 -0
  8. package/dist/declarations/src/chunking.d.ts.map +1 -0
  9. package/dist/declarations/src/collection.d.ts +18 -0
  10. package/dist/declarations/src/collection.d.ts.map +1 -0
  11. package/dist/declarations/src/express.d.ts +36 -0
  12. package/dist/declarations/src/express.d.ts.map +1 -0
  13. package/dist/declarations/src/graphql.d.ts +23 -0
  14. package/dist/declarations/src/graphql.d.ts.map +1 -0
  15. package/dist/declarations/src/index.d.ts +39 -0
  16. package/dist/declarations/src/index.d.ts.map +1 -0
  17. package/dist/declarations/src/plugin.d.ts +53 -0
  18. package/dist/declarations/src/plugin.d.ts.map +1 -0
  19. package/dist/declarations/src/prompt.d.ts +14 -0
  20. package/dist/declarations/src/prompt.d.ts.map +1 -0
  21. package/dist/declarations/src/providers/AnthropicRagProvider.d.ts +16 -0
  22. package/dist/declarations/src/providers/AnthropicRagProvider.d.ts.map +1 -0
  23. package/dist/declarations/src/providers/GeminiRagProvider.d.ts +19 -0
  24. package/dist/declarations/src/providers/GeminiRagProvider.d.ts.map +1 -0
  25. package/dist/declarations/src/providers/OllamaRagProvider.d.ts +23 -0
  26. package/dist/declarations/src/providers/OllamaRagProvider.d.ts.map +1 -0
  27. package/dist/declarations/src/providers/OpenAiRagProvider.d.ts +17 -0
  28. package/dist/declarations/src/providers/OpenAiRagProvider.d.ts.map +1 -0
  29. package/dist/declarations/src/providers/ServiceRagProvider.d.ts +17 -0
  30. package/dist/declarations/src/providers/ServiceRagProvider.d.ts.map +1 -0
  31. package/dist/declarations/src/providers/index.d.ts +14 -0
  32. package/dist/declarations/src/providers/index.d.ts.map +1 -0
  33. package/dist/declarations/src/providers/types.d.ts +45 -0
  34. package/dist/declarations/src/providers/types.d.ts.map +1 -0
  35. package/dist/declarations/src/similarity.d.ts +12 -0
  36. package/dist/declarations/src/similarity.d.ts.map +1 -0
  37. package/dist/declarations/src/types.d.ts +319 -0
  38. package/dist/declarations/src/types.d.ts.map +1 -0
  39. package/dist/declarations/src/vector-store.d.ts +34 -0
  40. package/dist/declarations/src/vector-store.d.ts.map +1 -0
  41. package/dist/nixxie-cms-ai-rag.cjs.d.ts +2 -0
  42. package/dist/nixxie-cms-ai-rag.cjs.js +2507 -0
  43. package/dist/nixxie-cms-ai-rag.esm.js +2481 -0
  44. package/package.json +37 -0
  45. package/src/AiRagService.ts +640 -0
  46. package/src/admin-page.ts +135 -0
  47. package/src/chunking.ts +78 -0
  48. package/src/collection.ts +79 -0
  49. package/src/express.ts +212 -0
  50. package/src/graphql.ts +196 -0
  51. package/src/guard.ts +75 -0
  52. package/src/index.ts +102 -0
  53. package/src/plugin.ts +162 -0
  54. package/src/prompt.ts +62 -0
  55. package/src/providers/AnthropicRagProvider.ts +91 -0
  56. package/src/providers/GeminiRagProvider.ts +147 -0
  57. package/src/providers/OllamaRagProvider.ts +157 -0
  58. package/src/providers/OpenAiRagProvider.ts +108 -0
  59. package/src/providers/ServiceRagProvider.ts +44 -0
  60. package/src/providers/index.ts +67 -0
  61. package/src/providers/types.ts +44 -0
  62. package/src/semaphore.ts +26 -0
  63. package/src/similarity.ts +31 -0
  64. package/src/types.ts +346 -0
  65. 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
+ }
@@ -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
+ }