Browse Source

feat: add OpenAI integration and new chat API endpoints

- Added @ai-sdk/openai dependency to package.json and bun.lock
- Implemented chat index endpoint to handle user messages and stream responses from OpenAI
- Created models endpoint to retrieve active LLM providers and their models for the authenticated user
- Updated drizzle-pkg database with a new SQLite file
acas
npmrun 1 week ago
parent
commit
f574567820
  1. 7
      app/pages/settings.vue
  2. 843
      app/pages/settings/llm-test/index.vue
  3. 44
      bun.lock
  4. 4
      package.json
  5. BIN
      packages/drizzle-pkg/db.sqlite
  6. 54
      server/api/llm/chat/index.post.ts
  7. 43
      server/api/llm/chat/models.get.ts

7
app/pages/settings.vue

@ -12,6 +12,11 @@ const settingsNav: NavItem[] = [
to: '/settings/llm-config',
icon: 'lucide:brain-circuit',
},
{
label: '模型测试',
to: '/settings/llm-test',
icon: 'lucide:message-square-text',
},
]
const logout = async () => {
@ -56,7 +61,7 @@ const logout = async () => {
</aside>
<main class="settings-main">
<NuxtPage />
<NuxtPage :keepalive="true" />
</main>
</div>
</div>

843
app/pages/settings/llm-test/index.vue

@ -0,0 +1,843 @@
<script setup lang="ts">
import { processDataStream } from 'ai'
interface ModelOption {
id: number
name: string
modelId: string
type: string
maxTokens: number | null
}
interface ProviderWithModels {
id: number
name: string
parseMode: string
status: string
models: ModelOption[]
}
interface ChatMessage {
id: string
role: 'user' | 'assistant'
content: string
}
const { data: modelsData, refresh: refreshModels } = await useHttpFetch('/api/llm/chat/models', {
getCachedData: () => undefined,
})
onActivated(() => {
refreshModels()
})
const providers = computed<ProviderWithModels[]>(() => (modelsData.value as any) ?? [])
const allModels = computed(() => {
const result: (ModelOption & { providerName: string; providerId: number })[] = []
for (const p of providers.value) {
for (const m of p.models) {
result.push({ ...m, providerName: p.name, providerId: p.id })
}
}
return result
})
const selectedModelId = ref<number | null>(null)
const chatContainer = ref<HTMLElement | null>(null)
const messages = ref<ChatMessage[]>([])
const inputMessage = ref('')
const isLoading = ref(false)
const errorMessage = ref('')
let abortController: AbortController | null = null
const selectedModel = computed(() => {
if (!selectedModelId.value) return null
return allModels.value.find(m => m.id === selectedModelId.value) ?? null
})
function showToast(message: string, type: 'success' | 'error' = 'success') {
if (import.meta.client) {
const event = new CustomEvent('toast', { detail: { message, type } })
window.dispatchEvent(event)
}
}
function generateId() {
return Date.now().toString(36) + Math.random().toString(36).slice(2)
}
watch(messages, () => {
nextTick(() => {
if (chatContainer.value) {
chatContainer.value.scrollTop = chatContainer.value.scrollHeight
}
})
}, { deep: true })
async function sendMessage() {
const text = inputMessage.value.trim()
if (!text || !selectedModelId.value || isLoading.value) return
errorMessage.value = ''
inputMessage.value = ''
const userMsg: ChatMessage = { id: generateId(), role: 'user', content: text }
messages.value.push(userMsg)
const assistantMsg: ChatMessage = { id: generateId(), role: 'assistant', content: '' }
messages.value.push(assistantMsg)
const assistantIdx = messages.value.length - 1
isLoading.value = true
abortController = new AbortController()
try {
const res = await fetch('/api/llm/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
modelId: selectedModelId.value,
messages: messages.value
.filter(m => m.content)
.map(m => ({ role: m.role, content: m.content })),
}),
signal: abortController.signal,
})
if (!res.ok) {
const errText = await res.text()
throw new Error(errText || `请求失败 (${res.status})`)
}
if (!res.body) {
throw new Error('响应体为空')
}
await processDataStream({
stream: res.body,
onTextPart: (text) => {
messages.value[assistantIdx].content += text
},
onErrorPart: (error) => {
errorMessage.value = error || '流式响应出错'
showToast(errorMessage.value, 'error')
},
})
} catch (err: any) {
if (err.name === 'AbortError') {
// user stopped
} else {
errorMessage.value = err.message || '请求失败'
showToast(err.message || '请求失败', 'error')
if (!messages.value[assistantIdx].content) {
messages.value.splice(assistantIdx, 1)
}
}
} finally {
isLoading.value = false
abortController = null
}
}
function stopGeneration() {
if (abortController) {
abortController.abort()
abortController = null
}
}
function clearChat() {
messages.value = []
errorMessage.value = ''
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendMessage()
}
}
function modelTypeLabel(type: string) {
const map: Record<string, string> = { text: '文本', vision: '视觉', multimodal: '多模态' }
return map[type] ?? type
}
function modelTypeClass(type: string) {
const map: Record<string, string> = { text: 'type-text', vision: 'type-vision', multimodal: 'type-multimodal' }
return map[type] ?? 'type-text'
}
function renderContent(content: string): string {
if (!content) return ''
return content
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
}
</script>
<template>
<div class="llm-test-page">
<header class="page-header">
<div class="header-left">
<h1 class="page-title">模型测试</h1>
<p class="page-desc">选择已配置的模型进行对话测试验证模型连接和响应效果</p>
</div>
</header>
<div class="test-layout">
<div class="sidebar-panel">
<div class="panel-section">
<label class="panel-label">选择模型</label>
<select v-model="selectedModelId" class="model-select">
<option :value="null" disabled>请选择模型</option>
<optgroup v-for="p in providers" :key="p.id" :label="p.name">
<option v-for="m in p.models" :key="m.id" :value="m.id">
{{ m.name }} ({{ m.modelId }})
</option>
</optgroup>
</select>
</div>
<div v-if="selectedModel" class="model-info-card">
<div class="model-info-header">
<span class="model-info-name">{{ selectedModel.name }}</span>
<span :class="['model-type-badge', modelTypeClass(selectedModel.type)]">{{ modelTypeLabel(selectedModel.type) }}</span>
</div>
<div class="model-info-meta">
<span class="meta-item">
<Icon name="lucide:cpu" />
{{ selectedModel.modelId }}
</span>
<span class="meta-item">
<Icon name="lucide:server" />
{{ selectedModel.providerName }}
</span>
<span v-if="selectedModel.maxTokens" class="meta-item">
<Icon name="lucide:arrow-up-right" />
{{ selectedModel.maxTokens.toLocaleString() }} tokens
</span>
</div>
</div>
<div v-if="allModels.length === 0" class="empty-hint">
暂无可用模型请先在<a href="/settings/llm-config" class="link">模型配置</a>中添加
</div>
<div class="panel-section panel-actions">
<button class="btn-secondary" :disabled="messages.length === 0" @click="clearChat">
<Icon name="lucide:trash-2" />
清空对话
</button>
</div>
</div>
<div class="chat-panel">
<div v-if="!selectedModelId" class="chat-empty">
<div class="chat-empty-icon">
<Icon name="lucide:message-square-text" />
</div>
<p>选择一个模型开始对话测试</p>
</div>
<template v-else>
<div ref="chatContainer" class="chat-messages">
<div v-if="messages.length === 0" class="chat-welcome">
<div class="welcome-icon">
<Icon name="lucide:sparkles" />
</div>
<p class="welcome-text">开始与 {{ selectedModel?.name ?? '模型' }} 对话</p>
<p class="welcome-hint">输入消息测试模型连接和响应效果</p>
</div>
<template v-for="msg in messages" :key="msg.id">
<div
v-if="msg.content || msg.role === 'user'"
class="chat-message"
:class="[`message-${msg.role}`]"
>
<div class="message-avatar">
<Icon v-if="msg.role === 'user'" name="lucide:user" />
<Icon v-else name="lucide:bot" />
</div>
<div class="message-body">
<div class="message-role">{{ msg.role === 'user' ? '你' : '助手' }}</div>
<div class="message-content" v-html="renderContent(msg.content)"></div>
</div>
</div>
</template>
<div v-if="isLoading && messages.length > 0 && messages[messages.length - 1]?.content === ''" class="chat-message message-assistant">
<div class="message-avatar">
<Icon name="lucide:bot" />
</div>
<div class="message-body">
<div class="message-role">助手</div>
<div class="message-content typing-indicator">
<span></span><span></span><span></span>
</div>
</div>
</div>
</div>
<div v-if="errorMessage" class="chat-error">
<Icon name="lucide:alert-circle" />
{{ errorMessage }}
</div>
<div class="chat-input-area">
<div class="input-wrapper">
<textarea
v-model="inputMessage"
class="chat-input"
placeholder="输入消息... (Enter 发送, Shift+Enter 换行)"
rows="1"
:disabled="isLoading"
@keydown="handleKeydown"
></textarea>
<div class="input-actions">
<button
v-if="isLoading"
class="btn-stop"
@click="stopGeneration"
>
<Icon name="lucide:square" />
停止
</button>
<button
v-else
class="btn-send"
:disabled="!inputMessage.trim() || !selectedModelId"
@click="sendMessage"
>
<Icon name="lucide:send" />
</button>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
<style scoped>
.llm-test-page {
padding: 40px;
height: 100%;
display: flex;
flex-direction: column;
}
.page-header {
margin-bottom: 24px;
}
.page-title {
font-family: var(--font-display);
font-size: 32px;
font-weight: 400;
color: var(--color-ink);
letter-spacing: -0.3px;
margin: 0 0 8px 0;
}
.page-desc {
font-size: 14px;
color: var(--color-muted);
margin: 0;
}
.test-layout {
display: flex;
gap: 20px;
flex: 1;
min-height: 0;
max-height: calc(100vh - 200px);
}
.sidebar-panel {
width: 280px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.panel-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.panel-label {
font-size: 13px;
font-weight: 500;
color: var(--color-body-strong);
}
.model-select {
width: 100%;
padding: 10px 14px;
background: var(--color-surface-card);
border: 1px solid var(--color-hairline);
border-radius: 8px;
font-size: 14px;
color: var(--color-ink);
cursor: pointer;
transition: all 0.15s ease;
}
.model-select:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(204, 120, 92, 0.15);
}
.model-info-card {
background: var(--color-surface-card);
border-radius: 10px;
padding: 14px 16px;
}
.model-info-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.model-info-name {
font-size: 14px;
font-weight: 500;
color: var(--color-ink);
}
.model-type-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 9999px;
font-size: 11px;
font-weight: 500;
}
.type-text {
background: rgba(93, 184, 166, 0.15);
color: var(--color-accent-teal);
}
.type-vision {
background: rgba(232, 165, 90, 0.15);
color: var(--color-accent-amber);
}
.type-multimodal {
background: rgba(204, 120, 92, 0.15);
color: var(--color-primary);
}
.model-info-meta {
display: flex;
flex-direction: column;
gap: 4px;
}
.meta-item {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--color-muted);
}
.meta-item :deep(svg) {
width: 13px;
height: 13px;
}
.empty-hint {
font-size: 13px;
color: var(--color-muted);
line-height: 1.5;
}
.link {
color: var(--color-primary);
text-decoration: none;
}
.link:hover {
text-decoration: underline;
}
.panel-actions {
margin-top: auto;
}
.btn-secondary {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
color: var(--color-body-strong);
background: var(--color-surface-soft);
border: 1px solid var(--color-hairline);
border-radius: 8px;
cursor: pointer;
transition: all 0.15s ease;
width: 100%;
justify-content: center;
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-hairline);
}
.btn-secondary:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-secondary :deep(svg) {
width: 14px;
height: 14px;
}
.chat-panel {
flex: 1;
display: flex;
flex-direction: column;
background: var(--color-surface-card);
border-radius: 12px;
overflow: hidden;
min-width: 0;
}
.chat-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--color-muted);
}
.chat-empty-icon :deep(svg) {
width: 48px;
height: 48px;
opacity: 0.3;
}
.chat-empty p {
font-size: 14px;
margin: 0;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.chat-welcome {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
gap: 8px;
}
.welcome-icon :deep(svg) {
width: 40px;
height: 40px;
color: var(--color-primary);
opacity: 0.6;
}
.welcome-text {
font-size: 16px;
font-weight: 500;
color: var(--color-ink);
margin: 0;
}
.welcome-hint {
font-size: 13px;
color: var(--color-muted);
margin: 0;
}
.chat-message {
display: flex;
gap: 12px;
max-width: 85%;
}
.message-user {
align-self: flex-end;
flex-direction: row-reverse;
}
.message-assistant {
align-self: flex-start;
}
.message-avatar {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.message-user .message-avatar {
background: var(--color-primary);
color: var(--color-on-primary);
}
.message-assistant .message-avatar {
background: var(--color-surface-soft);
color: var(--color-body-strong);
}
.message-avatar :deep(svg) {
width: 16px;
height: 16px;
}
.message-body {
display: flex;
flex-direction: column;
gap: 4px;
}
.message-role {
font-size: 11px;
font-weight: 500;
color: var(--color-muted);
}
.message-user .message-role {
text-align: right;
}
.message-content {
font-size: 14px;
line-height: 1.6;
color: var(--color-body);
word-break: break-word;
}
.message-user .message-content {
background: var(--color-primary);
color: var(--color-on-primary);
padding: 10px 14px;
border-radius: 12px 12px 4px 12px;
}
.message-assistant .message-content {
background: var(--color-canvas);
padding: 10px 14px;
border-radius: 12px 12px 12px 4px;
border: 1px solid var(--color-hairline);
}
.message-content :deep(code) {
background: rgba(0, 0, 0, 0.06);
padding: 1px 5px;
border-radius: 4px;
font-size: 13px;
font-family: monospace;
}
.message-user .message-content :deep(code) {
background: rgba(255, 255, 255, 0.2);
}
.typing-indicator {
display: flex;
gap: 4px;
align-items: center;
padding: 10px 14px;
}
.typing-indicator span {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color-muted);
animation: typing 1.4s infinite;
}
.typing-indicator span:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typing {
0%, 60%, 100% {
opacity: 0.3;
transform: scale(0.8);
}
30% {
opacity: 1;
transform: scale(1);
}
}
.chat-error {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: rgba(198, 69, 69, 0.1);
color: var(--color-error);
font-size: 13px;
}
.chat-error :deep(svg) {
width: 14px;
height: 14px;
}
.chat-input-area {
padding: 16px 20px;
border-top: 1px solid var(--color-hairline);
background: var(--color-canvas);
}
.input-wrapper {
display: flex;
align-items: flex-end;
gap: 8px;
background: var(--color-surface-card);
border: 1px solid var(--color-hairline);
border-radius: 10px;
padding: 8px 12px;
transition: all 0.15s ease;
}
.input-wrapper:focus-within {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(204, 120, 92, 0.15);
}
.chat-input {
flex: 1;
border: none;
background: transparent;
font-size: 14px;
color: var(--color-ink);
resize: none;
outline: none;
min-height: 24px;
max-height: 120px;
line-height: 1.5;
font-family: inherit;
}
.chat-input::placeholder {
color: var(--color-muted);
}
.chat-input:disabled {
opacity: 0.5;
}
.input-actions {
display: flex;
align-items: center;
flex-shrink: 0;
}
.btn-send {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-primary);
color: var(--color-on-primary);
border: none;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s ease;
}
.btn-send:hover:not(:disabled) {
background: var(--color-primary-active);
}
.btn-send:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-send :deep(svg) {
width: 16px;
height: 16px;
}
.btn-stop {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
font-size: 12px;
font-weight: 500;
color: var(--color-error);
background: rgba(198, 69, 69, 0.1);
border: 1px solid rgba(198, 69, 69, 0.2);
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-stop:hover {
background: rgba(198, 69, 69, 0.2);
}
.btn-stop :deep(svg) {
width: 12px;
height: 12px;
}
@media (max-width: 768px) {
.llm-test-page {
padding: 24px;
}
.test-layout {
flex-direction: column;
max-height: none;
}
.sidebar-panel {
width: 100%;
}
.chat-panel {
min-height: 500px;
}
.chat-message {
max-width: 95%;
}
}
</style>

44
bun.lock

@ -5,12 +5,14 @@
"": {
"name": "person-panel",
"dependencies": {
"@ai-sdk/openai": "1.3.16",
"@bytemd/plugin-gfm": "1.22.0",
"@bytemd/vue-next": "1.22.0",
"@libsql/client": "0.17.3",
"@module-federation/vite": "1.15.5",
"@nuxt/icon": "2.2.2",
"@types/lodash-es": "4.17.12",
"ai": "4.3.16",
"bcryptjs": "3.0.3",
"bolt-ui": "workspace:*",
"bytemd": "1.22.0",
@ -87,6 +89,16 @@
},
},
"packages": {
"@ai-sdk/openai": ["@ai-sdk/openai@1.3.16", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.7" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-pjtiBKt1GgaSKZryTbM3tqgoegJwgAUlp1+X5uN6T+VPnI4FLSymV65tyloWzDlyqZmi9HXnnSRPu76VoL5D5g=="],
"@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.7", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-kM0xS3GWg3aMChh9zfeM+80vEZfXzR3JEUBdycZLtbRZ2TRT8xOj3WodGHPb06sUK5yD7pAXC/P7ctsi2fvUGQ=="],
"@ai-sdk/react": ["@ai-sdk/react@1.2.12", "", { "dependencies": { "@ai-sdk/provider-utils": "2.2.8", "@ai-sdk/ui-utils": "1.2.11", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.23.8" }, "optionalPeers": ["zod"] }, "sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g=="],
"@ai-sdk/ui-utils": ["@ai-sdk/ui-utils@1.2.11", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w=="],
"@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "https://registry.npmmirror.com/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="],
"@babel/code-frame": ["@babel/code-frame@7.29.0", "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.0.tgz", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
@ -331,6 +343,8 @@
"@nuxt/vite-builder": ["@nuxt/vite-builder@4.4.6", "https://registry.npmmirror.com/@nuxt/vite-builder/-/vite-builder-4.4.6.tgz", { "dependencies": { "@nuxt/kit": "4.4.6", "@rollup/plugin-replace": "^6.0.3", "@vitejs/plugin-vue": "^6.0.7", "@vitejs/plugin-vue-jsx": "^5.1.5", "autoprefixer": "^10.5.0", "consola": "^3.4.2", "cssnano": "^8.0.1", "defu": "^6.1.7", "escape-string-regexp": "^5.0.0", "exsolve": "^1.0.8", "get-port-please": "^3.2.0", "jiti": "^2.7.0", "knitwork": "^1.3.0", "magic-string": "^0.30.21", "mlly": "^1.8.2", "mocked-exports": "^0.1.1", "nypm": "^0.6.6", "pathe": "^2.0.3", "pkg-types": "^2.3.1", "postcss": "^8.5.14", "seroval": "^1.5.4", "std-env": "^4.1.0", "ufo": "^1.6.4", "unenv": "^2.0.0-rc.24", "vite": "^7.3.3", "vite-node": "^5.3.0", "vite-plugin-checker": "^0.13.0", "vue-bundle-renderer": "^2.2.0" }, "peerDependencies": { "@babel/plugin-proposal-decorators": "^7.25.0", "@babel/plugin-syntax-jsx": "^7.25.0", "nuxt": "4.4.6", "rolldown": "^1.0.0-beta.38", "rollup-plugin-visualizer": "^6.0.0 || ^7.0.1", "vue": "^3.3.4" }, "optionalPeers": ["@babel/plugin-proposal-decorators", "@babel/plugin-syntax-jsx", "rolldown", "rollup-plugin-visualizer"] }, "sha512-q/JDHLy/tBJodyqu75GBrFWcOkkj9alGH8Qh/Wpir/xD6/MAMvnQNOHewC3KH40jMHxdETSglEmFaAkEIHzmLQ=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@oxc-minify/binding-android-arm-eabi": ["@oxc-minify/binding-android-arm-eabi@0.131.0", "https://registry.npmmirror.com/@oxc-minify/binding-android-arm-eabi/-/binding-android-arm-eabi-0.131.0.tgz", { "os": "android", "cpu": "arm" }, "sha512-yLa7y9jjJgUeUUMm6AtjmBIQzK1YU5sYcNJnVVtr6WtoWu5SpuNDZ8u6cl/dhn0g/oQgVlf+E+8WJfsExt8R+Q=="],
"@oxc-minify/binding-android-arm64": ["@oxc-minify/binding-android-arm64@0.131.0", "https://registry.npmmirror.com/@oxc-minify/binding-android-arm64/-/binding-android-arm64-0.131.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-ShZDYFEVd46qCc9L0D3ZTPLXe/DezTedEj7g6x1Bdlm1WwgQ1pQJgWkqpMGlQhUet5wq4WUpQB/P6afK470Ydg=="],
@ -615,6 +629,8 @@
"@types/debug": ["@types/debug@4.1.13", "https://registry.npmmirror.com/@types/debug/-/debug-4.1.13.tgz", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
"@types/diff-match-patch": ["@types/diff-match-patch@1.0.36", "", {}, "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg=="],
"@types/estree": ["@types/estree@1.0.8", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/express": ["@types/express@5.0.6", "https://registry.npmmirror.com/@types/express/-/express-5.0.6.tgz", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "^2" } }, "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA=="],
@ -715,6 +731,8 @@
"agent-base": ["agent-base@7.1.4", "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"ai": ["ai@4.3.16", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "@ai-sdk/react": "1.2.12", "@ai-sdk/ui-utils": "1.2.11", "@opentelemetry/api": "1.9.0", "jsondiffpatch": "0.6.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.23.8" }, "optionalPeers": ["react"] }, "sha512-KUDwlThJ5tr2Vw0A1ZkbDKNME3wzWhuVfAOwIvFUzl1TPVDFAXDFTXio3p+jaKneB+dKNCvFFlolYmmgHttG1g=="],
"ansi-colors": ["ansi-colors@4.1.3", "https://registry.npmmirror.com/ansi-colors/-/ansi-colors-4.1.3.tgz", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
"ansi-regex": ["ansi-regex@6.2.2", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.2.2.tgz", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
@ -809,6 +827,8 @@
"ccount": ["ccount@2.0.1", "https://registry.npmmirror.com/ccount/-/ccount-2.0.1.tgz", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"character-entities": ["character-entities@2.0.2", "https://registry.npmmirror.com/character-entities/-/character-entities-2.0.2.tgz", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
"character-entities-html4": ["character-entities-html4@2.1.0", "https://registry.npmmirror.com/character-entities-html4/-/character-entities-html4-2.1.0.tgz", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
@ -923,6 +943,8 @@
"diff": ["diff@8.0.4", "https://registry.npmmirror.com/diff/-/diff-8.0.4.tgz", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
"diff-match-patch": ["diff-match-patch@1.0.5", "", {}, "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="],
"dom-serializer": ["dom-serializer@2.0.0", "https://registry.npmmirror.com/dom-serializer/-/dom-serializer-2.0.0.tgz", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
"domelementtype": ["domelementtype@2.3.0", "https://registry.npmmirror.com/domelementtype/-/domelementtype-2.3.0.tgz", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
@ -1189,8 +1211,12 @@
"jsesc": ["jsesc@3.1.0", "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"json5": ["json5@2.2.3", "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"jsondiffpatch": ["jsondiffpatch@0.6.0", "", { "dependencies": { "@types/diff-match-patch": "^1.0.36", "chalk": "^5.3.0", "diff-match-patch": "^1.0.5" }, "bin": { "jsondiffpatch": "bin/jsondiffpatch.js" } }, "sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ=="],
"jsonfile": ["jsonfile@4.0.0", "https://registry.npmmirror.com/jsonfile/-/jsonfile-4.0.0.tgz", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
"kleur": ["kleur@4.1.5", "https://registry.npmmirror.com/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
@ -1613,6 +1639,8 @@
"rc9": ["rc9@3.0.1", "https://registry.npmmirror.com/rc9/-/rc9-3.0.1.tgz", { "dependencies": { "defu": "^6.1.6", "destr": "^2.0.5" } }, "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ=="],
"react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
"readable-stream": ["readable-stream@3.6.2", "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"readdir-glob": ["readdir-glob@1.1.3", "https://registry.npmmirror.com/readdir-glob/-/readdir-glob-1.1.3.tgz", { "dependencies": { "minimatch": "^5.1.0" } }, "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA=="],
@ -1713,6 +1741,8 @@
"scule": ["scule@1.3.0", "https://registry.npmmirror.com/scule/-/scule-1.3.0.tgz", {}, "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g=="],
"secure-json-parse": ["secure-json-parse@2.7.0", "", {}, "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="],
"select-files": ["select-files@1.0.1", "https://registry.npmmirror.com/select-files/-/select-files-1.0.1.tgz", {}, "sha512-8h4DSpjfFa0hyMP3z3ye4SxyhdaE5RgaXeScRpH7xl4YblnZSHwexmLdLNdSKwTO8H9ccDKj7Votz0io+18+BQ=="],
"semver": ["semver@7.8.0", "https://registry.npmmirror.com/semver/-/semver-7.8.0.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
@ -1809,6 +1839,8 @@
"svgo": ["svgo@4.0.1", "https://registry.npmmirror.com/svgo/-/svgo-4.0.1.tgz", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w=="],
"swr": ["swr@2.4.2", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-ej644Y2bvkIajfR32KGeSSdBXQW+ScjGjkybZgSE7kFpk9eGnV44XY9FJylXi+W75pavSX1PVNB57W5EbhGIYw=="],
"sync-child-process": ["sync-child-process@1.0.2", "https://registry.npmmirror.com/sync-child-process/-/sync-child-process-1.0.2.tgz", { "dependencies": { "sync-message-port": "^1.0.0" } }, "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA=="],
"sync-message-port": ["sync-message-port@1.2.0", "https://registry.npmmirror.com/sync-message-port/-/sync-message-port-1.2.0.tgz", {}, "sha512-gAQ9qrUN/UCypHtGFbbe7Rc/f9bzO88IwrG8TDo/aMKAApKyD6E3W4Cm0EfhfBb6Z6SKt59tTCTfD+n1xmAvMg=="],
@ -1831,6 +1863,8 @@
"text-decoder": ["text-decoder@1.2.7", "https://registry.npmmirror.com/text-decoder/-/text-decoder-1.2.7.tgz", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="],
"throttleit": ["throttleit@2.1.0", "", {}, "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw=="],
"tiny-inflate": ["tiny-inflate@1.0.3", "https://registry.npmmirror.com/tiny-inflate/-/tiny-inflate-1.0.3.tgz", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
@ -1927,6 +1961,8 @@
"uqr": ["uqr@0.1.3", "https://registry.npmmirror.com/uqr/-/uqr-0.1.3.tgz", {}, "sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"util-deprecate": ["util-deprecate@1.0.2", "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"uvu": ["uvu@0.5.6", "https://registry.npmmirror.com/uvu/-/uvu-0.5.6.tgz", { "dependencies": { "dequal": "^2.0.0", "diff": "^5.0.0", "kleur": "^4.0.3", "sade": "^1.7.3" }, "bin": { "uvu": "bin.js" } }, "sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA=="],
@ -2007,8 +2043,14 @@
"zod": ["zod@4.3.6", "https://registry.npmmirror.com/zod/-/zod-4.3.6.tgz", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
"zwitch": ["zwitch@2.0.4", "https://registry.npmmirror.com/zwitch/-/zwitch-2.0.4.tgz", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@ai-sdk/react/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="],
"@ai-sdk/ui-utils/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="],
"@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"@babel/core/semver": ["semver@6.3.1", "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
@ -2151,6 +2193,8 @@
"@vue/server-renderer/@vue/shared": ["@vue/shared@3.5.32", "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.32.tgz", {}, "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg=="],
"ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="],
"anymatch/picomatch": ["picomatch@2.3.2", "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"archiver/readable-stream": ["readable-stream@4.7.0", "https://registry.npmmirror.com/readable-stream/-/readable-stream-4.7.0.tgz", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],

4
package.json

@ -19,12 +19,14 @@
"check": "bunx taze major -x drizzle-pkg -x logger -r -f"
},
"dependencies": {
"@ai-sdk/openai": "1.3.16",
"@bytemd/plugin-gfm": "1.22.0",
"@bytemd/vue-next": "1.22.0",
"@libsql/client": "0.17.3",
"@module-federation/vite": "1.15.5",
"@nuxt/icon": "2.2.2",
"@types/lodash-es": "4.17.12",
"ai": "4.3.16",
"bcryptjs": "3.0.3",
"bolt-ui": "workspace:*",
"bytemd": "1.22.0",
@ -42,8 +44,8 @@
"logger": "workspace:*",
"marked": "12.0.2",
"mime": "4.1.0",
"nodemailer": "6.9.16",
"multer": "2.1.1",
"nodemailer": "6.9.16",
"nuxt": "4.4.6",
"svg-captcha": "1.4.0",
"tailwindcss": "4.3.0",

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.

54
server/api/llm/chat/index.post.ts

@ -0,0 +1,54 @@
import { requireUser } from "#server/utils/context";
import { getProviderById, getModelById } from "#server/service/llm";
import { createOpenAI } from "@ai-sdk/openai";
import { streamText } from "ai";
export default defineEventHandler(async (event) => {
const user = await requireUser(event);
if (!user) {
throw createError({ statusCode: 401, statusMessage: "未登录" });
}
const body = await readBody(event);
const { modelId: llmModelId, messages } = body as {
modelId: number;
messages: { role: "user" | "assistant" | "system"; content: string }[];
};
if (!llmModelId || !messages || !Array.isArray(messages) || messages.length === 0) {
throw createError({ statusCode: 400, statusMessage: "参数无效" });
}
const model = await getModelById(llmModelId, user.id);
if (!model) {
throw createError({ statusCode: 404, statusMessage: "模型不存在" });
}
const provider = await getProviderById(model.providerId, user.id);
if (!provider) {
throw createError({ statusCode: 404, statusMessage: "供应商不存在" });
}
if (provider.status !== "active") {
throw createError({ statusCode: 400, statusMessage: "供应商已禁用" });
}
if (!provider.apiKey) {
throw createError({ statusCode: 400, statusMessage: "供应商未配置 API Key" });
}
const baseUrl = provider.baseUrl?.replace(/\/+$/, "") || undefined;
const openai = createOpenAI({
apiKey: provider.apiKey,
baseURL: baseUrl,
});
const result = streamText({
model: openai(model.modelId),
messages,
maxTokens: model.maxTokens || undefined,
});
return result.toDataStreamResponse();
});

43
server/api/llm/chat/models.get.ts

@ -0,0 +1,43 @@
import { requireUser } from "#server/utils/context";
import { getProviderById } from "#server/service/llm";
import { dbGlobal } from "drizzle-pkg/lib/db";
import { llmProviders, llmModels } from "drizzle-pkg/lib/schema/llm";
import { eq, and } from "drizzle-orm";
export default defineWrappedResponseHandler(async (event) => {
const user = await requireUser(event);
const providers = await dbGlobal
.select({
id: llmProviders.id,
name: llmProviders.name,
parseMode: llmProviders.parseMode,
status: llmProviders.status,
})
.from(llmProviders)
.where(and(eq(llmProviders.userId, user.id), eq(llmProviders.status, "active")));
const result = [];
for (const provider of providers) {
const models = await dbGlobal
.select({
id: llmModels.id,
name: llmModels.name,
modelId: llmModels.modelId,
type: llmModels.type,
maxTokens: llmModels.maxTokens,
})
.from(llmModels)
.where(and(eq(llmModels.providerId, provider.id), eq(llmModels.enabled, 1)));
if (models.length > 0) {
result.push({
...provider,
models,
});
}
}
return R.success(result);
});
Loading…
Cancel
Save