fix: 1. 增加 ollama vl 模型配置 2. 优化简易后台

This commit is contained in:
fuzhongyun 2026-01-19 15:26:23 +08:00
parent ccf8bd6902
commit eaa6fc6fe7
4 changed files with 105 additions and 72 deletions

View File

@ -11,6 +11,7 @@ LLM_MODEL=qwen2.5-7b-awq
LLM_KEY=EMPTY # vLLM default key LLM_KEY=EMPTY # vLLM default key
# LLMVision Configuration # LLMVision Configuration
VL_BINDING=vllm # ollama, vllm, openai
VL_BINDING_HOST=http://192.168.6.115:8001/v1 VL_BINDING_HOST=http://192.168.6.115:8001/v1
VL_MODEL=qwen2.5-vl-3b-awq VL_MODEL=qwen2.5-vl-3b-awq
VL_KEY=EMPTY VL_KEY=EMPTY

View File

@ -18,6 +18,7 @@ class Settings(BaseSettings):
LLM_KEY: str = "EMPTY" # vLLM default key LLM_KEY: str = "EMPTY" # vLLM default key
# LLM (Vision) - vLLM # LLM (Vision) - vLLM
VL_BINDING: str = "vllm" # ollama, vllm, openai
VL_BINDING_HOST: str = "http://192.168.6.115:8001/v1" VL_BINDING_HOST: str = "http://192.168.6.115:8001/v1"
VL_MODEL: str = "qwen2.5-vl-3b-awq" VL_MODEL: str = "qwen2.5-vl-3b-awq"
VL_KEY: str = "EMPTY" VL_KEY: str = "EMPTY"

View File

@ -6,7 +6,8 @@ from app.config import settings
async def vl_image_caption_func(image_data: bytes, prompt: str = "请详细描述这张图片") -> str: async def vl_image_caption_func(image_data: bytes, prompt: str = "请详细描述这张图片") -> str:
""" """
使用 VL 模型 (vLLM OpenAI API) 生成图片描述 使用 VL 模型生成图片描述
支持 ollama openai/vllm 协议
""" """
if not settings.VL_BINDING_HOST: if not settings.VL_BINDING_HOST:
return "[Image Processing Skipped: No VL Model Configured]" return "[Image Processing Skipped: No VL Model Configured]"
@ -15,38 +16,53 @@ async def vl_image_caption_func(image_data: bytes, prompt: str = "请详细描
# 1. 编码图片为 Base64 # 1. 编码图片为 Base64
base64_image = base64.b64encode(image_data).decode('utf-8') base64_image = base64.b64encode(image_data).decode('utf-8')
# 2. 构造 OpenAI 格式请求
# vLLM 支持 OpenAI Vision API
url = f"{settings.VL_BINDING_HOST}/chat/completions"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {settings.VL_KEY}"
}
payload = {
"model": settings.VL_MODEL,
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{base64_image}"
}
}
]
}
],
"max_tokens": 300
}
async with httpx.AsyncClient(timeout=30.0) as client: async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(url, headers=headers, json=payload) if settings.VL_BINDING == "ollama":
response.raise_for_status() # Ollama 协议
result = response.json() url = f"{settings.VL_BINDING_HOST}/api/generate"
description = result['choices'][0]['message']['content'] payload = {
"model": settings.VL_MODEL,
"prompt": prompt,
"images": [base64_image],
"stream": False
}
response = await client.post(url, json=payload)
response.raise_for_status()
result = response.json()
description = result.get('response', '')
else:
# OpenAI / vLLM 协议
url = f"{settings.VL_BINDING_HOST}/chat/completions"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {settings.VL_KEY}"
}
payload = {
"model": settings.VL_MODEL,
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{base64_image}"
}
}
]
}
],
"max_tokens": 300
}
response = await client.post(url, headers=headers, json=payload)
response.raise_for_status()
result = response.json()
description = result['choices'][0]['message']['content']
return f"[Image Description: {description}]" return f"[Image Description: {description}]"
except Exception as e: except Exception as e:
@ -70,14 +86,14 @@ async def process_pdf_with_images(file_bytes: bytes) -> str:
text_content += f"--- Page {page_num + 1} Text ---\n{page_text}\n\n" text_content += f"--- Page {page_num + 1} Text ---\n{page_text}\n\n"
# 2. 提取图片 # 2. 提取图片
if False and settings.VL_BINDING_HOST: if settings.VL_BINDING_HOST:
for count, image_file_object in enumerate(page.images): for count, image_file_object in enumerate(page.images):
try: try:
# 获取图片数据 # 获取图片数据
image_data = image_file_object.data image_data = image_file_object.data
# 简单验证图片有效性 # 简单验证图片有效性
# Image.open(BytesIO(image_data)).verify() Image.open(BytesIO(image_data)).verify()
# 调用 VL 模型 # 调用 VL 模型
caption = await vl_image_caption_func(image_data) caption = await vl_image_caption_func(image_data)

View File

@ -38,7 +38,7 @@
<h1 @click="goHome" style="cursor: pointer;">LightRAG Admin</h1> <h1 @click="goHome" style="cursor: pointer;">LightRAG Admin</h1>
<div v-if="currentPage === 'tenant'"> <div v-if="currentPage === 'tenant'">
<el-tag type="success" size="large">当前租户: {{ currentTenantId }}</el-tag> <el-tag type="success" size="large">当前租户: {{ currentTenantId }}</el-tag>
<el-button type="primary" link @click="goHome">返回首页</el-button> <el-button v-if="isAdmin" type="primary" link @click="goHome">返回首页</el-button>
</div> </div>
</div> </div>
@ -113,41 +113,44 @@
</div> </div>
<!-- 导入弹窗 --> <!-- 导入弹窗 -->
<el-dialog v-model="showImportDialog" title="导入知识" width="600px"> <el-dialog v-model="showImportDialog" title="导入知识" width="600px" :close-on-click-modal="!importing" :close-on-press-escape="!importing" :show-close="!importing">
<el-tabs v-model="importType"> <div v-loading="importing" element-loading-text="正在导入中,请稍候...(大文件可能需要较长时间)">
<el-tab-pane label="文件上传" name="file"> <el-tabs v-model="importType">
<el-upload <el-tab-pane label="文件上传" name="file">
class="upload-demo" <el-upload
drag class="upload-demo"
action="#" drag
:http-request="uploadFile" action="#"
:show-file-list="false" :http-request="uploadFile"
> :show-file-list="false"
<el-icon class="el-icon--upload"><upload-filled /></el-icon> :disabled="importing"
<div class="el-upload__text">拖拽文件到此处或 <em>点击上传</em></div> >
<template #tip> <el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__tip">支持 .txt, .md, .pdf</div> <div class="el-upload__text">拖拽文件到此处或 <em>点击上传</em></div>
</template> <template #tip>
</el-upload> <div class="el-upload__tip">支持 .txt, .md, .pdf</div>
</el-tab-pane> </template>
<el-tab-pane label="纯文本" name="text"> </el-upload>
<el-input v-model="importText" type="textarea" rows="10" placeholder="请输入文本内容..."></el-input> </el-tab-pane>
<div style="margin-top: 10px; text-align: right;"> <el-tab-pane label="纯文本" name="text">
<el-button type="primary" @click="uploadText">提交</el-button> <el-input v-model="importText" type="textarea" rows="10" placeholder="请输入文本内容..." :disabled="importing"></el-input>
</div> <div style="margin-top: 10px; text-align: right;">
</el-tab-pane> <el-button type="primary" @click="uploadText" :loading="importing">提交</el-button>
<el-tab-pane label="QA 问答" name="qa"> </div>
<div v-for="(qa, index) in qaList" :key="index" style="margin-bottom: 15px; border-bottom: 1px solid #eee; padding-bottom: 15px;"> </el-tab-pane>
<el-input v-model="qa.question" placeholder="问题 (Question)" style="margin-bottom: 5px;"></el-input> <el-tab-pane label="QA 问答" name="qa">
<el-input v-model="qa.answer" type="textarea" placeholder="回答 (Answer)"></el-input> <div v-for="(qa, index) in qaList" :key="index" style="margin-bottom: 15px; border-bottom: 1px solid #eee; padding-bottom: 15px;">
<el-button type="danger" link size="small" @click="removeQA(index)" v-if="qaList.length > 1">删除</el-button> <el-input v-model="qa.question" placeholder="问题 (Question)" style="margin-bottom: 5px;" :disabled="importing"></el-input>
</div> <el-input v-model="qa.answer" type="textarea" placeholder="回答 (Answer)" :disabled="importing"></el-input>
<el-button type="primary" plain style="width: 100%; margin-bottom: 10px;" @click="addQA">添加一组 QA</el-button> <el-button type="danger" link size="small" @click="removeQA(index)" v-if="qaList.length > 1" :disabled="importing">删除</el-button>
<div style="text-align: right;"> </div>
<el-button type="primary" @click="uploadQA">提交所有 QA</el-button> <el-button type="primary" plain style="width: 100%; margin-bottom: 10px;" @click="addQA" :disabled="importing">添加一组 QA</el-button>
</div> <div style="text-align: right;">
</el-tab-pane> <el-button type="primary" @click="uploadQA" :loading="importing">提交所有 QA</el-button>
</el-tabs> </div>
</el-tab-pane>
</el-tabs>
</div>
</el-dialog> </el-dialog>
<!-- 文档详情弹窗 --> <!-- 文档详情弹窗 -->
@ -185,6 +188,7 @@
const documents = ref([]); const documents = ref([]);
const loadingDocs = ref(false); const loadingDocs = ref(false);
const showImportDialog = ref(false); const showImportDialog = ref(false);
const importing = ref(false);
const importType = ref('file'); const importType = ref('file');
const importText = ref(''); const importText = ref('');
const qaList = ref([{ question: '', answer: '' }]); const qaList = ref([{ question: '', answer: '' }]);
@ -208,6 +212,7 @@
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const tokenParam = urlParams.get('token'); const tokenParam = urlParams.get('token');
const tenantParam = urlParams.get('tenant_id'); const tenantParam = urlParams.get('tenant_id');
const isAdmin = ref(false);
// 初始化 // 初始化
onMounted(() => { onMounted(() => {
@ -221,6 +226,7 @@
} else if (tokenParam === 'fzy') { } else if (tokenParam === 'fzy') {
// 管理员模式 // 管理员模式
currentPage.value = 'home'; currentPage.value = 'home';
isAdmin.value = true;
refreshTenants(); refreshTenants();
} else { } else {
ElMessage.warning('请在 URL 中提供有效的 token'); ElMessage.warning('请在 URL 中提供有效的 token');
@ -300,6 +306,7 @@
// 导入逻辑 // 导入逻辑
const uploadFile = async (param) => { const uploadFile = async (param) => {
importing.value = true;
const formData = new FormData(); const formData = new FormData();
formData.append('file', param.file); formData.append('file', param.file);
try { try {
@ -309,11 +316,14 @@
fetchDocuments(); fetchDocuments();
} catch (e) { } catch (e) {
ElMessage.error('上传失败'); ElMessage.error('上传失败');
} finally {
importing.value = false;
} }
}; };
const uploadText = async () => { const uploadText = async () => {
if (!importText.value) return; if (!importText.value) return;
importing.value = true;
try { try {
await api.post('/ingest/text', { text: importText.value }); await api.post('/ingest/text', { text: importText.value });
ElMessage.success('导入成功'); ElMessage.success('导入成功');
@ -322,6 +332,8 @@
fetchDocuments(); fetchDocuments();
} catch (e) { } catch (e) {
ElMessage.error('导入失败'); ElMessage.error('导入失败');
} finally {
importing.value = false;
} }
}; };
@ -332,6 +344,7 @@
const validQA = qaList.value.filter(q => q.question && q.answer); const validQA = qaList.value.filter(q => q.question && q.answer);
if (validQA.length === 0) return ElMessage.warning('请填写 QA'); if (validQA.length === 0) return ElMessage.warning('请填写 QA');
importing.value = true;
try { try {
await api.post('/ingest/batch_qa', validQA); await api.post('/ingest/batch_qa', validQA);
ElMessage.success(`成功导入 ${validQA.length} 条 QA`); ElMessage.success(`成功导入 ${validQA.length} 条 QA`);
@ -340,6 +353,8 @@
fetchDocuments(); fetchDocuments();
} catch (e) { } catch (e) {
ElMessage.error('导入失败'); ElMessage.error('导入失败');
} finally {
importing.value = false;
} }
}; };
@ -447,13 +462,13 @@
return { return {
currentPage, tenants, currentTenantId, currentPage, tenants, currentTenantId,
activeTab, documents, loadingDocs, activeTab, documents, loadingDocs,
showImportDialog, importType, importText, qaList, showImportDialog, importType, importText, qaList, importing,
showDocDialog, currentDocId, currentDocContent, docDetailLoading, showDocDialog, currentDocId, currentDocContent, docDetailLoading,
queryInput, chatHistory, chatLoading, chatBox, queryInput, chatHistory, chatLoading, chatBox,
goHome, refreshTenants, enterTenant, fetchDocuments, goHome, refreshTenants, enterTenant, fetchDocuments,
viewDocument, deleteDocument, deleteCurrentDoc, viewDocument, deleteDocument, deleteCurrentDoc,
uploadFile, uploadText, addQA, removeQA, uploadQA, uploadFile, uploadText, addQA, removeQA, uploadQA,
sendQuery, renderMarkdown, formatDate sendQuery, renderMarkdown, formatDate, isAdmin
}; };
} }
}); });