@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,147 @@
|
|
|
1
|
+
import type { RagProviderConfig } from '../types'
|
|
2
|
+
import type {
|
|
3
|
+
EmbeddingProvider,
|
|
4
|
+
GenerationProvider,
|
|
5
|
+
RagGenerateOptions,
|
|
6
|
+
RagGenerateResult,
|
|
7
|
+
RagMessage,
|
|
8
|
+
RagStreamChunk,
|
|
9
|
+
} from './types'
|
|
10
|
+
|
|
11
|
+
const DEFAULT_MODEL = 'gemini-2.0-flash'
|
|
12
|
+
const DEFAULT_EMBEDDING_MODEL = 'text-embedding-004'
|
|
13
|
+
const DEFAULT_BASE = 'https://generativelanguage.googleapis.com/v1beta'
|
|
14
|
+
|
|
15
|
+
/** Google Gemini provider over the public REST API — generation, streaming and embeddings. */
|
|
16
|
+
export class GeminiRagProvider implements GenerationProvider, EmbeddingProvider {
|
|
17
|
+
readonly name = 'gemini'
|
|
18
|
+
readonly defaultModel = DEFAULT_MODEL
|
|
19
|
+
private apiKey: string
|
|
20
|
+
private model: string
|
|
21
|
+
private embeddingModel: string
|
|
22
|
+
private base: string
|
|
23
|
+
private extra: Record<string, unknown>
|
|
24
|
+
|
|
25
|
+
constructor(config: RagProviderConfig) {
|
|
26
|
+
if (!config.apiKey) throw new Error('[@nixxie-cms/ai-rag] Gemini requires `apiKey`.')
|
|
27
|
+
this.apiKey = config.apiKey
|
|
28
|
+
this.model = config.model ?? DEFAULT_MODEL
|
|
29
|
+
this.embeddingModel = config.model ?? DEFAULT_EMBEDDING_MODEL
|
|
30
|
+
this.base = (config.baseUrl ?? DEFAULT_BASE).replace(/\/$/, '')
|
|
31
|
+
this.extra = config.extra ?? {}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private buildBody(messages: RagMessage[], options?: RagGenerateOptions) {
|
|
35
|
+
return {
|
|
36
|
+
contents: messages.map(m => ({
|
|
37
|
+
role: m.role === 'assistant' ? 'model' : 'user',
|
|
38
|
+
parts: [{ text: m.content }],
|
|
39
|
+
})),
|
|
40
|
+
...(options?.system
|
|
41
|
+
? { systemInstruction: { parts: [{ text: options.system }] } }
|
|
42
|
+
: {}),
|
|
43
|
+
generationConfig: {
|
|
44
|
+
...(options?.temperature !== undefined ? { temperature: options.temperature } : {}),
|
|
45
|
+
...(options?.maxTokens !== undefined ? { maxOutputTokens: options.maxTokens } : {}),
|
|
46
|
+
...(options?.topP !== undefined ? { topP: options.topP } : {}),
|
|
47
|
+
},
|
|
48
|
+
...this.extra,
|
|
49
|
+
...(options?.extra ?? {}),
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private extractText(data: any): string {
|
|
54
|
+
const parts = data?.candidates?.[0]?.content?.parts ?? []
|
|
55
|
+
return parts.map((p: any) => p.text ?? '').join('')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async generate(messages: RagMessage[], options?: RagGenerateOptions): Promise<RagGenerateResult> {
|
|
59
|
+
const model = options?.model ?? this.model
|
|
60
|
+
const res = await fetch(
|
|
61
|
+
`${this.base}/models/${model}:generateContent?key=${this.apiKey}`,
|
|
62
|
+
{
|
|
63
|
+
method: 'POST',
|
|
64
|
+
headers: { 'Content-Type': 'application/json' },
|
|
65
|
+
body: JSON.stringify(this.buildBody(messages, options)),
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
if (!res.ok) throw new Error(`[@nixxie-cms/ai-rag] Gemini generate failed (${res.status}): ${await res.text()}`)
|
|
69
|
+
const data: any = await res.json()
|
|
70
|
+
return {
|
|
71
|
+
text: this.extractText(data),
|
|
72
|
+
model,
|
|
73
|
+
usage: {
|
|
74
|
+
inputTokens: data?.usageMetadata?.promptTokenCount,
|
|
75
|
+
outputTokens: data?.usageMetadata?.candidatesTokenCount,
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async *stream(
|
|
81
|
+
messages: RagMessage[],
|
|
82
|
+
options?: RagGenerateOptions
|
|
83
|
+
): AsyncIterable<RagStreamChunk> {
|
|
84
|
+
const model = options?.model ?? this.model
|
|
85
|
+
const res = await fetch(
|
|
86
|
+
`${this.base}/models/${model}:streamGenerateContent?alt=sse&key=${this.apiKey}`,
|
|
87
|
+
{
|
|
88
|
+
method: 'POST',
|
|
89
|
+
headers: { 'Content-Type': 'application/json' },
|
|
90
|
+
body: JSON.stringify(this.buildBody(messages, options)),
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
if (!res.ok || !res.body) {
|
|
94
|
+
throw new Error(`[@nixxie-cms/ai-rag] Gemini stream failed (${res.status}): ${await res.text()}`)
|
|
95
|
+
}
|
|
96
|
+
let usage: any
|
|
97
|
+
for await (const data of parseSseJson(res.body)) {
|
|
98
|
+
const text = this.extractText(data)
|
|
99
|
+
if (text) yield { delta: text }
|
|
100
|
+
if (data?.usageMetadata) usage = data.usageMetadata
|
|
101
|
+
}
|
|
102
|
+
yield {
|
|
103
|
+
done: true,
|
|
104
|
+
model,
|
|
105
|
+
usage: { inputTokens: usage?.promptTokenCount, outputTokens: usage?.candidatesTokenCount },
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async embed(texts: string[], model?: string): Promise<number[][]> {
|
|
110
|
+
const m = model ?? this.embeddingModel
|
|
111
|
+
const res = await fetch(`${this.base}/models/${m}:batchEmbedContents?key=${this.apiKey}`, {
|
|
112
|
+
method: 'POST',
|
|
113
|
+
headers: { 'Content-Type': 'application/json' },
|
|
114
|
+
body: JSON.stringify({
|
|
115
|
+
requests: texts.map(text => ({
|
|
116
|
+
model: `models/${m}`,
|
|
117
|
+
content: { parts: [{ text }] },
|
|
118
|
+
})),
|
|
119
|
+
}),
|
|
120
|
+
})
|
|
121
|
+
if (!res.ok) throw new Error(`[@nixxie-cms/ai-rag] Gemini embed failed (${res.status}): ${await res.text()}`)
|
|
122
|
+
const data: any = await res.json()
|
|
123
|
+
return (data.embeddings ?? []).map((e: any) => e.values as number[])
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Parse a `text/event-stream` of JSON `data:` lines into objects. */
|
|
128
|
+
async function* parseSseJson(body: any): AsyncIterable<any> {
|
|
129
|
+
const decoder = new TextDecoder()
|
|
130
|
+
let buffer = ''
|
|
131
|
+
for await (const piece of body as AsyncIterable<Uint8Array>) {
|
|
132
|
+
buffer += typeof piece === 'string' ? piece : decoder.decode(piece, { stream: true })
|
|
133
|
+
let idx
|
|
134
|
+
while ((idx = buffer.indexOf('\n')) !== -1) {
|
|
135
|
+
const line = buffer.slice(0, idx).trim()
|
|
136
|
+
buffer = buffer.slice(idx + 1)
|
|
137
|
+
if (!line.startsWith('data:')) continue
|
|
138
|
+
const payload = line.slice(5).trim()
|
|
139
|
+
if (!payload || payload === '[DONE]') continue
|
|
140
|
+
try {
|
|
141
|
+
yield JSON.parse(payload)
|
|
142
|
+
} catch {
|
|
143
|
+
// Partial frame — ignore; the next chunk will complete it.
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type { RagProviderConfig } from '../types'
|
|
2
|
+
import type {
|
|
3
|
+
EmbeddingProvider,
|
|
4
|
+
GenerationProvider,
|
|
5
|
+
RagGenerateOptions,
|
|
6
|
+
RagGenerateResult,
|
|
7
|
+
RagMessage,
|
|
8
|
+
RagStreamChunk,
|
|
9
|
+
} from './types'
|
|
10
|
+
|
|
11
|
+
const DEFAULT_MODEL = 'llama3.1'
|
|
12
|
+
const DEFAULT_EMBEDDING_MODEL = 'nomic-embed-text'
|
|
13
|
+
const DEFAULT_BASE = 'http://localhost:11434'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Ollama provider for local (or remote) open models — generation, streaming and embeddings.
|
|
17
|
+
* Runs any Ollama model family (Llama, Mistral, Phi, Gemma, Qwen, …). No API key needed for a
|
|
18
|
+
* local server; point `baseUrl` at your Ollama host if it isn't on localhost.
|
|
19
|
+
*/
|
|
20
|
+
export class OllamaRagProvider implements GenerationProvider, EmbeddingProvider {
|
|
21
|
+
readonly name = 'ollama'
|
|
22
|
+
readonly defaultModel = DEFAULT_MODEL
|
|
23
|
+
private base: string
|
|
24
|
+
private model: string
|
|
25
|
+
private embeddingModel: string
|
|
26
|
+
private apiKey?: string
|
|
27
|
+
private extra: Record<string, unknown>
|
|
28
|
+
|
|
29
|
+
constructor(config: RagProviderConfig) {
|
|
30
|
+
this.base = (config.baseUrl ?? DEFAULT_BASE).replace(/\/$/, '')
|
|
31
|
+
this.model = config.model ?? DEFAULT_MODEL
|
|
32
|
+
this.embeddingModel = config.model ?? DEFAULT_EMBEDDING_MODEL
|
|
33
|
+
this.apiKey = config.apiKey
|
|
34
|
+
this.extra = config.extra ?? {}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private headers() {
|
|
38
|
+
return {
|
|
39
|
+
'Content-Type': 'application/json',
|
|
40
|
+
...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}),
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private buildBody(messages: RagMessage[], options: RagGenerateOptions | undefined, stream: boolean) {
|
|
45
|
+
return {
|
|
46
|
+
model: options?.model ?? this.model,
|
|
47
|
+
stream,
|
|
48
|
+
messages: [
|
|
49
|
+
...(options?.system ? [{ role: 'system', content: options.system }] : []),
|
|
50
|
+
...messages.map(m => ({ role: m.role, content: m.content })),
|
|
51
|
+
],
|
|
52
|
+
options: {
|
|
53
|
+
...(options?.temperature !== undefined ? { temperature: options.temperature } : {}),
|
|
54
|
+
...(options?.maxTokens !== undefined ? { num_predict: options.maxTokens } : {}),
|
|
55
|
+
...(options?.topP !== undefined ? { top_p: options.topP } : {}),
|
|
56
|
+
},
|
|
57
|
+
...this.extra,
|
|
58
|
+
...(options?.extra ?? {}),
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async generate(messages: RagMessage[], options?: RagGenerateOptions): Promise<RagGenerateResult> {
|
|
63
|
+
const res = await fetch(`${this.base}/api/chat`, {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: this.headers(),
|
|
66
|
+
body: JSON.stringify(this.buildBody(messages, options, false)),
|
|
67
|
+
})
|
|
68
|
+
if (!res.ok) throw new Error(`[@nixxie-cms/ai-rag] Ollama generate failed (${res.status}): ${await res.text()}`)
|
|
69
|
+
const data: any = await res.json()
|
|
70
|
+
return {
|
|
71
|
+
text: data?.message?.content ?? '',
|
|
72
|
+
model: data?.model ?? options?.model ?? this.model,
|
|
73
|
+
usage: { inputTokens: data?.prompt_eval_count, outputTokens: data?.eval_count },
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async *stream(
|
|
78
|
+
messages: RagMessage[],
|
|
79
|
+
options?: RagGenerateOptions
|
|
80
|
+
): AsyncIterable<RagStreamChunk> {
|
|
81
|
+
const res = await fetch(`${this.base}/api/chat`, {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers: this.headers(),
|
|
84
|
+
body: JSON.stringify(this.buildBody(messages, options, true)),
|
|
85
|
+
})
|
|
86
|
+
if (!res.ok || !res.body) {
|
|
87
|
+
throw new Error(`[@nixxie-cms/ai-rag] Ollama stream failed (${res.status}): ${await res.text()}`)
|
|
88
|
+
}
|
|
89
|
+
let usage: any
|
|
90
|
+
// Ollama streams newline-delimited JSON objects.
|
|
91
|
+
for await (const obj of parseNdJson(res.body)) {
|
|
92
|
+
const delta = obj?.message?.content
|
|
93
|
+
if (delta) yield { delta }
|
|
94
|
+
if (obj?.done) usage = obj
|
|
95
|
+
}
|
|
96
|
+
yield {
|
|
97
|
+
done: true,
|
|
98
|
+
model: options?.model ?? this.model,
|
|
99
|
+
usage: { inputTokens: usage?.prompt_eval_count, outputTokens: usage?.eval_count },
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async embed(texts: string[], model?: string): Promise<number[][]> {
|
|
104
|
+
const m = model ?? this.embeddingModel
|
|
105
|
+
// /api/embed accepts a batch; older servers only support /api/embeddings (single).
|
|
106
|
+
const res = await fetch(`${this.base}/api/embed`, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: this.headers(),
|
|
109
|
+
body: JSON.stringify({ model: m, input: texts }),
|
|
110
|
+
})
|
|
111
|
+
if (res.ok) {
|
|
112
|
+
const data: any = await res.json()
|
|
113
|
+
if (Array.isArray(data?.embeddings)) return data.embeddings as number[][]
|
|
114
|
+
}
|
|
115
|
+
// Fallback: one request per text against the legacy endpoint.
|
|
116
|
+
const out: number[][] = []
|
|
117
|
+
for (const text of texts) {
|
|
118
|
+
const r = await fetch(`${this.base}/api/embeddings`, {
|
|
119
|
+
method: 'POST',
|
|
120
|
+
headers: this.headers(),
|
|
121
|
+
body: JSON.stringify({ model: m, prompt: text }),
|
|
122
|
+
})
|
|
123
|
+
if (!r.ok) throw new Error(`[@nixxie-cms/ai-rag] Ollama embed failed (${r.status}): ${await r.text()}`)
|
|
124
|
+
const d: any = await r.json()
|
|
125
|
+
out.push(d.embedding as number[])
|
|
126
|
+
}
|
|
127
|
+
return out
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Parse newline-delimited JSON from a stream body. */
|
|
132
|
+
async function* parseNdJson(body: any): AsyncIterable<any> {
|
|
133
|
+
const decoder = new TextDecoder()
|
|
134
|
+
let buffer = ''
|
|
135
|
+
for await (const piece of body as AsyncIterable<Uint8Array>) {
|
|
136
|
+
buffer += typeof piece === 'string' ? piece : decoder.decode(piece, { stream: true })
|
|
137
|
+
let idx
|
|
138
|
+
while ((idx = buffer.indexOf('\n')) !== -1) {
|
|
139
|
+
const line = buffer.slice(0, idx).trim()
|
|
140
|
+
buffer = buffer.slice(idx + 1)
|
|
141
|
+
if (!line) continue
|
|
142
|
+
try {
|
|
143
|
+
yield JSON.parse(line)
|
|
144
|
+
} catch {
|
|
145
|
+
// Ignore partial lines.
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const tail = buffer.trim()
|
|
150
|
+
if (tail) {
|
|
151
|
+
try {
|
|
152
|
+
yield JSON.parse(tail)
|
|
153
|
+
} catch {
|
|
154
|
+
/* ignore */
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { RagProviderConfig } from '../types'
|
|
2
|
+
import type {
|
|
3
|
+
EmbeddingProvider,
|
|
4
|
+
GenerationProvider,
|
|
5
|
+
RagGenerateOptions,
|
|
6
|
+
RagGenerateResult,
|
|
7
|
+
RagMessage,
|
|
8
|
+
RagStreamChunk,
|
|
9
|
+
} from './types'
|
|
10
|
+
|
|
11
|
+
const DEFAULT_MODEL = 'gpt-4o'
|
|
12
|
+
const DEFAULT_EMBEDDING_MODEL = 'text-embedding-3-small'
|
|
13
|
+
|
|
14
|
+
function loadOpenAi(): any {
|
|
15
|
+
try {
|
|
16
|
+
return require('openai').default ?? require('openai')
|
|
17
|
+
} catch {
|
|
18
|
+
throw new Error(
|
|
19
|
+
'[@nixxie-cms/ai-rag] The OpenAI provider requires the openai package. Run: npm install openai'
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** OpenAI (GPT) provider — generation, streaming and embeddings. */
|
|
25
|
+
export class OpenAiRagProvider implements GenerationProvider, EmbeddingProvider {
|
|
26
|
+
readonly name = 'openai'
|
|
27
|
+
readonly defaultModel = DEFAULT_MODEL
|
|
28
|
+
private client: any
|
|
29
|
+
private model: string
|
|
30
|
+
private embeddingModel: string
|
|
31
|
+
private extra: Record<string, unknown>
|
|
32
|
+
|
|
33
|
+
constructor(config: RagProviderConfig) {
|
|
34
|
+
if (!config.apiKey) throw new Error('[@nixxie-cms/ai-rag] OpenAI requires `apiKey`.')
|
|
35
|
+
const OpenAI = loadOpenAi()
|
|
36
|
+
this.client = new OpenAI({ apiKey: config.apiKey, baseURL: config.baseUrl })
|
|
37
|
+
this.model = config.model ?? DEFAULT_MODEL
|
|
38
|
+
this.embeddingModel = config.model ?? DEFAULT_EMBEDDING_MODEL
|
|
39
|
+
this.extra = config.extra ?? {}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private buildMessages(messages: RagMessage[], system?: string) {
|
|
43
|
+
return [
|
|
44
|
+
...(system ? [{ role: 'system' as const, content: system }] : []),
|
|
45
|
+
...messages.map(m => ({ role: m.role, content: m.content })),
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async generate(messages: RagMessage[], options?: RagGenerateOptions): Promise<RagGenerateResult> {
|
|
50
|
+
const res = await this.client.chat.completions.create({
|
|
51
|
+
model: options?.model ?? this.model,
|
|
52
|
+
messages: this.buildMessages(messages, options?.system),
|
|
53
|
+
...(options?.maxTokens !== undefined ? { max_tokens: options.maxTokens } : {}),
|
|
54
|
+
...(options?.temperature !== undefined ? { temperature: options.temperature } : {}),
|
|
55
|
+
...(options?.topP !== undefined ? { top_p: options.topP } : {}),
|
|
56
|
+
...this.extra,
|
|
57
|
+
...(options?.extra ?? {}),
|
|
58
|
+
})
|
|
59
|
+
return {
|
|
60
|
+
text: res.choices?.[0]?.message?.content ?? '',
|
|
61
|
+
model: res.model ?? options?.model ?? this.model,
|
|
62
|
+
usage: {
|
|
63
|
+
inputTokens: res.usage?.prompt_tokens,
|
|
64
|
+
outputTokens: res.usage?.completion_tokens,
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async *stream(
|
|
70
|
+
messages: RagMessage[],
|
|
71
|
+
options?: RagGenerateOptions
|
|
72
|
+
): AsyncIterable<RagStreamChunk> {
|
|
73
|
+
const stream = await this.client.chat.completions.create({
|
|
74
|
+
model: options?.model ?? this.model,
|
|
75
|
+
messages: this.buildMessages(messages, options?.system),
|
|
76
|
+
stream: true,
|
|
77
|
+
stream_options: { include_usage: true },
|
|
78
|
+
...(options?.maxTokens !== undefined ? { max_tokens: options.maxTokens } : {}),
|
|
79
|
+
...(options?.temperature !== undefined ? { temperature: options.temperature } : {}),
|
|
80
|
+
...(options?.topP !== undefined ? { top_p: options.topP } : {}),
|
|
81
|
+
...this.extra,
|
|
82
|
+
...(options?.extra ?? {}),
|
|
83
|
+
})
|
|
84
|
+
let usage: any
|
|
85
|
+
for await (const part of stream) {
|
|
86
|
+
const delta = part.choices?.[0]?.delta?.content
|
|
87
|
+
if (delta) yield { delta }
|
|
88
|
+
if (part.usage) usage = part.usage
|
|
89
|
+
}
|
|
90
|
+
yield {
|
|
91
|
+
done: true,
|
|
92
|
+
model: options?.model ?? this.model,
|
|
93
|
+
usage: { inputTokens: usage?.prompt_tokens, outputTokens: usage?.completion_tokens },
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async embed(texts: string[], model?: string): Promise<number[][]> {
|
|
98
|
+
const res = await this.client.embeddings.create({
|
|
99
|
+
model: model ?? this.embeddingModel,
|
|
100
|
+
input: texts,
|
|
101
|
+
})
|
|
102
|
+
const items = (res.data ?? []) as Array<{ index?: number; embedding: number[] }>
|
|
103
|
+
return items
|
|
104
|
+
.slice()
|
|
105
|
+
.sort((a, b) => (a.index ?? 0) - (b.index ?? 0))
|
|
106
|
+
.map(d => d.embedding)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { NixxieAiService } from '@nixxie-cms/core'
|
|
2
|
+
import type {
|
|
3
|
+
EmbeddingProvider,
|
|
4
|
+
GenerationProvider,
|
|
5
|
+
RagGenerateOptions,
|
|
6
|
+
RagGenerateResult,
|
|
7
|
+
RagMessage,
|
|
8
|
+
RagStreamChunk,
|
|
9
|
+
} from './types'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Adapts an existing `NixxieAiService` (e.g. `context.services.ai` from @nixxie-cms/ai)
|
|
13
|
+
* into a RAG provider. Streaming is emulated by emitting the full answer as one delta,
|
|
14
|
+
* since `NixxieAiService` has no streaming surface.
|
|
15
|
+
*/
|
|
16
|
+
export class ServiceRagProvider implements GenerationProvider, EmbeddingProvider {
|
|
17
|
+
readonly name = 'service'
|
|
18
|
+
readonly defaultModel = 'service'
|
|
19
|
+
|
|
20
|
+
constructor(private service: NixxieAiService) {}
|
|
21
|
+
|
|
22
|
+
async generate(messages: RagMessage[], options?: RagGenerateOptions): Promise<RagGenerateResult> {
|
|
23
|
+
const res = await this.service.chat(messages, {
|
|
24
|
+
model: options?.model,
|
|
25
|
+
system: options?.system,
|
|
26
|
+
temperature: options?.temperature,
|
|
27
|
+
maxTokens: options?.maxTokens,
|
|
28
|
+
})
|
|
29
|
+
return { text: res.text, model: res.model, usage: res.usage }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async *stream(
|
|
33
|
+
messages: RagMessage[],
|
|
34
|
+
options?: RagGenerateOptions
|
|
35
|
+
): AsyncIterable<RagStreamChunk> {
|
|
36
|
+
const res = await this.generate(messages, options)
|
|
37
|
+
if (res.text) yield { delta: res.text }
|
|
38
|
+
yield { done: true, model: res.model, usage: res.usage }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async embed(texts: string[]): Promise<number[][]> {
|
|
42
|
+
return this.service.embedMany(texts)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { RagEmbeddingConfig, RagGenerationConfig, RagProviderName } from '../types'
|
|
2
|
+
import { AnthropicRagProvider } from './AnthropicRagProvider'
|
|
3
|
+
import { GeminiRagProvider } from './GeminiRagProvider'
|
|
4
|
+
import { OllamaRagProvider } from './OllamaRagProvider'
|
|
5
|
+
import { OpenAiRagProvider } from './OpenAiRagProvider'
|
|
6
|
+
import { ServiceRagProvider } from './ServiceRagProvider'
|
|
7
|
+
import type { EmbeddingProvider, GenerationProvider } from './types'
|
|
8
|
+
|
|
9
|
+
/** Build the generation provider from config (default provider: anthropic). */
|
|
10
|
+
export function resolveGenerationProvider(config: RagGenerationConfig = {}): GenerationProvider {
|
|
11
|
+
if (config.service) return new ServiceRagProvider(config.service)
|
|
12
|
+
const provider: RagProviderName = config.provider ?? 'anthropic'
|
|
13
|
+
switch (provider) {
|
|
14
|
+
case 'anthropic':
|
|
15
|
+
return new AnthropicRagProvider(config)
|
|
16
|
+
case 'openai':
|
|
17
|
+
return new OpenAiRagProvider(config)
|
|
18
|
+
case 'gemini':
|
|
19
|
+
return new GeminiRagProvider(config)
|
|
20
|
+
case 'ollama':
|
|
21
|
+
return new OllamaRagProvider(config)
|
|
22
|
+
default: {
|
|
23
|
+
const exhaustive: never = provider
|
|
24
|
+
throw new Error(`[@nixxie-cms/ai-rag] Unknown generation provider: ${exhaustive}`)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Build the embedding provider from config (default provider: openai). */
|
|
30
|
+
export function resolveEmbeddingProvider(config: RagEmbeddingConfig = {}): EmbeddingProvider {
|
|
31
|
+
if (config.service) return new ServiceRagProvider(config.service)
|
|
32
|
+
const provider: RagProviderName = config.provider ?? 'openai'
|
|
33
|
+
switch (provider) {
|
|
34
|
+
case 'openai':
|
|
35
|
+
return new OpenAiRagProvider(config)
|
|
36
|
+
case 'gemini':
|
|
37
|
+
return new GeminiRagProvider(config)
|
|
38
|
+
case 'ollama':
|
|
39
|
+
return new OllamaRagProvider(config)
|
|
40
|
+
case 'anthropic':
|
|
41
|
+
throw new Error(
|
|
42
|
+
'[@nixxie-cms/ai-rag] Anthropic has no native embeddings endpoint. Use `openai`, ' +
|
|
43
|
+
'`gemini` or `ollama` for the `embedding` provider (you can still use Anthropic for generation).'
|
|
44
|
+
)
|
|
45
|
+
default: {
|
|
46
|
+
const exhaustive: never = provider
|
|
47
|
+
throw new Error(`[@nixxie-cms/ai-rag] Unknown embedding provider: ${exhaustive}`)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export {
|
|
53
|
+
AnthropicRagProvider,
|
|
54
|
+
OpenAiRagProvider,
|
|
55
|
+
GeminiRagProvider,
|
|
56
|
+
OllamaRagProvider,
|
|
57
|
+
ServiceRagProvider,
|
|
58
|
+
}
|
|
59
|
+
export type {
|
|
60
|
+
GenerationProvider,
|
|
61
|
+
EmbeddingProvider,
|
|
62
|
+
RagMessage,
|
|
63
|
+
RagGenerateOptions,
|
|
64
|
+
RagGenerateResult,
|
|
65
|
+
RagStreamChunk,
|
|
66
|
+
RagUsage,
|
|
67
|
+
} from './types'
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** A single chat turn handed to a provider. */
|
|
2
|
+
export type RagMessage = { role: 'user' | 'assistant'; content: string }
|
|
3
|
+
|
|
4
|
+
export type RagGenerateOptions = {
|
|
5
|
+
model?: string
|
|
6
|
+
system?: string
|
|
7
|
+
temperature?: number
|
|
8
|
+
maxTokens?: number
|
|
9
|
+
topP?: number
|
|
10
|
+
/** Provider-specific extras merged into the request body. */
|
|
11
|
+
extra?: Record<string, unknown>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type RagUsage = { inputTokens?: number; outputTokens?: number }
|
|
15
|
+
|
|
16
|
+
export type RagGenerateResult = {
|
|
17
|
+
text: string
|
|
18
|
+
model: string
|
|
19
|
+
usage?: RagUsage
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** One streamed delta. The final chunk carries `done: true` and any usage. */
|
|
23
|
+
export type RagStreamChunk = {
|
|
24
|
+
delta?: string
|
|
25
|
+
done?: boolean
|
|
26
|
+
usage?: RagUsage
|
|
27
|
+
model?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A provider that can answer (and ideally stream) a chat. */
|
|
31
|
+
export interface GenerationProvider {
|
|
32
|
+
readonly name: string
|
|
33
|
+
readonly defaultModel: string
|
|
34
|
+
generate(messages: RagMessage[], options?: RagGenerateOptions): Promise<RagGenerateResult>
|
|
35
|
+
/** Optional native streaming. When absent, the service emulates it from `generate`. */
|
|
36
|
+
stream?(messages: RagMessage[], options?: RagGenerateOptions): AsyncIterable<RagStreamChunk>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A provider that can turn text into embedding vectors. */
|
|
40
|
+
export interface EmbeddingProvider {
|
|
41
|
+
readonly name: string
|
|
42
|
+
readonly defaultModel: string
|
|
43
|
+
embed(texts: string[], model?: string): Promise<number[][]>
|
|
44
|
+
}
|
package/src/semaphore.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** A minimal counting semaphore used to cap concurrent generations. */
|
|
2
|
+
export class Semaphore {
|
|
3
|
+
private available: number
|
|
4
|
+
private waiters: Array<() => void> = []
|
|
5
|
+
|
|
6
|
+
constructor(permits: number) {
|
|
7
|
+
this.available = Math.max(1, permits)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Acquire a permit, resolving to a `release` function to call when done. */
|
|
11
|
+
async acquire(): Promise<() => void> {
|
|
12
|
+
if (this.available > 0) {
|
|
13
|
+
this.available--
|
|
14
|
+
return () => this.release()
|
|
15
|
+
}
|
|
16
|
+
await new Promise<void>(resolve => this.waiters.push(resolve))
|
|
17
|
+
this.available--
|
|
18
|
+
return () => this.release()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
private release(): void {
|
|
22
|
+
this.available++
|
|
23
|
+
const next = this.waiters.shift()
|
|
24
|
+
if (next) next()
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Dot product of two equal-length vectors. */
|
|
2
|
+
export function dot(a: number[], b: number[]): number {
|
|
3
|
+
let sum = 0
|
|
4
|
+
const n = Math.min(a.length, b.length)
|
|
5
|
+
for (let i = 0; i < n; i++) sum += a[i]! * b[i]!
|
|
6
|
+
return sum
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Euclidean norm of a vector. */
|
|
10
|
+
export function norm(a: number[]): number {
|
|
11
|
+
return Math.sqrt(dot(a, a))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Cosine similarity mapped from [-1, 1] into [0, 1] so it can be used as a relevance
|
|
16
|
+
* score and compared against a `minScore` threshold. Returns 0 for a zero vector.
|
|
17
|
+
*/
|
|
18
|
+
export function cosineSimilarity(a: number[], b: number[]): number {
|
|
19
|
+
const denom = norm(a) * norm(b)
|
|
20
|
+
if (denom === 0) return 0
|
|
21
|
+
const cos = dot(a, b) / denom
|
|
22
|
+
// Clamp for floating-point drift, then rescale to [0, 1].
|
|
23
|
+
return (Math.max(-1, Math.min(1, cos)) + 1) / 2
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Pre-normalise a vector to unit length (lets retrieval use a plain dot product). */
|
|
27
|
+
export function normalize(a: number[]): number[] {
|
|
28
|
+
const n = norm(a)
|
|
29
|
+
if (n === 0) return a.slice()
|
|
30
|
+
return a.map(x => x / n)
|
|
31
|
+
}
|