Compare commits

...

5 Commits

Author SHA1 Message Date
e633462ace style: 优化 Settings 页面样式
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 15:43:24 +08:00
9ca267244d chore: 更新前端依赖和侧边栏导航
- 添加必要的前端依赖
- 优化侧边栏交互
- 更新 AI-Core API 文档

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 15:43:07 +08:00
49433f1681 feat: 增强 Knowledge 页面功能
- 优化文档预览功能
- 添加 CSV 文件解析支持
- 增强知识库详情展示
- 优化样式和交互

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 15:42:55 +08:00
4a7199de93 feat: 完善后端知识库服务和配置
- 优化 AI-Core 客户端调用
- 添加更多知识库配置选项
- 完善文档解析逻辑

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 15:42:42 +08:00
5012a25f99 feat: 增强 AI-Core 文档解析器
- 添加 VLM 客户端支持
- 优化解析器配置
- 添加配置示例文件
- 生成新的 gRPC protobuf 文件

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 15:42:35 +08:00
41 changed files with 5627 additions and 235 deletions

View File

@@ -0,0 +1,18 @@
# AI-Core 配置文件示例
# 复制此文件为 config.yaml 并填入实际配置
# VLM 配置(可选)
# 如果配置了 VLM图片文件会自动使用 VLM 解析
vlm:
enabled: false # 是否启用 VLM
provider: "openai" # openai / anthropic / qwen
model: "gpt-4o" # 模型名称
api_key: "" # API Key
base_url: "" # 自定义 API 地址(可选)
prompt: "" # 自定义提示词(可选)
# 服务配置
server:
port: 50051
max_workers: 10
log_level: INFO

84
ai-core/parser/config.py Normal file
View File

@@ -0,0 +1,84 @@
"""
配置管理模块
"""
import os
import yaml
import logging
from typing import Optional, Dict, Any
logger = logging.getLogger(__name__)
# 默认配置
DEFAULT_CONFIG = {
"vlm": {
"enabled": False,
"provider": "openai",
"model": "gpt-4o",
"api_key": "",
"base_url": "",
"prompt": ""
},
"server": {
"port": 50051,
"max_workers": 10,
"log_level": "INFO"
}
}
def load_config(config_path: Optional[str] = None) -> Dict[str, Any]:
"""加载配置文件"""
if config_path is None:
# 默认查找 config.yaml
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
config_path = os.path.join(base_dir, "config.yaml")
# 环境变量覆盖
vlm_api_key = os.environ.get("VLM_API_KEY", "")
if vlm_api_key:
DEFAULT_CONFIG["vlm"]["api_key"] = vlm_api_key
DEFAULT_CONFIG["vlm"]["enabled"] = True
logger.info("VLM enabled via environment variable")
vlm_provider = os.environ.get("VLM_PROVIDER", "")
if vlm_provider:
DEFAULT_CONFIG["vlm"]["provider"] = vlm_provider
vlm_model = os.environ.get("VLM_MODEL", "")
if vlm_model:
DEFAULT_CONFIG["vlm"]["model"] = vlm_model
# 尝试加载配置文件
if os.path.exists(config_path):
try:
with open(config_path, 'r', encoding='utf-8') as f:
file_config = yaml.safe_load(f)
if file_config:
# 合并配置
for key in file_config:
if key in DEFAULT_CONFIG:
DEFAULT_CONFIG[key].update(file_config[key])
logger.info(f"Loaded config from {config_path}")
except Exception as e:
logger.warning(f"Failed to load config: {e}")
# 检查 VLM 是否有效
if DEFAULT_CONFIG["vlm"]["enabled"] and not DEFAULT_CONFIG["vlm"]["api_key"]:
logger.warning("VLM enabled but API key is empty, disabling VLM")
DEFAULT_CONFIG["vlm"]["enabled"] = False
return DEFAULT_CONFIG
def get_vlm_config() -> Optional[Dict[str, Any]]:
"""获取 VLM 配置"""
config = load_config()
if config.get("vlm", {}).get("enabled") and config["vlm"].get("api_key"):
return config["vlm"]
return None
def get_server_config() -> Dict[str, Any]:
"""获取服务器配置"""
config = load_config()
return config.get("server", DEFAULT_CONFIG["server"])

View File

@@ -1,39 +1,68 @@
import logging
import os
import tempfile
from typing import Optional
from typing import Optional, Dict, Any
from markitdown import MarkItDown
from .vlm_client import VLMClient
from .config import get_vlm_config
logger = logging.getLogger(__name__)
class Parser:
"""基于 MarkItDown 的统一文档解析器
"""基于 MarkItDown + VLM 的统一文档解析器
支持格式PDF、DOCX、DOC、PPTX、PPT、XLSX、XLS、CSV、图片、网页、Markdown 等
VLM 解析:
- 方式一启动时配置config.yaml 或环境变量)
- 方式二gRPC 请求时传入 VLM 配置(优先级更高)
"""
def __init__(self):
self.markitdown = MarkItDown()
logger.info("Parser initialized with MarkItDown")
self.vlm_client: Optional[VLMClient] = None
def parse(self, file_path: str, file_type: Optional[str] = None) -> dict:
# 尝试加载配置的 VLM
vlm_config = get_vlm_config()
if vlm_config:
self.vlm_client = VLMClient(vlm_config)
logger.info(f"VLM enabled: provider={vlm_config.get('provider')}, model={vlm_config.get('model')}")
else:
logger.info("VLM not configured, using MarkItDown only")
def set_vlm_config(self, config: Dict[str, Any]) -> None:
"""手动设置 VLM 配置(优先级高于全局配置)"""
if config and config.get("enabled") and config.get("api_key"):
self.vlm_client = VLMClient(config)
logger.info(f"VLM enabled: provider={config.get('provider')}, model={config.get('model')}")
else:
self.vlm_client = None
logger.info("VLM disabled")
def parse(self, file_path: str, file_type: Optional[str] = None, vlm_config: Optional[Dict[str, Any]] = None) -> dict:
"""解析文档为 Markdown
Args:
file_path: 文件路径或 URL
file_type: 文件类型可选MarkItDown 会自动检测)
vlm_config: VLM 配置(可选,优先级高于全局配置)
Returns:
dict: 包含 markdown 内容和元数据
"""
# 如果有 VLM 配置,覆盖全局配置
if vlm_config:
self.set_vlm_config(vlm_config)
try:
logger.info(f"Parsing file: {file_path}")
result = self.markitdown.convert(file_path)
logger.info(f"Parse successful: {len(result.text_content)} characters")
return {
"success": True,
"content": result.text_content,
@@ -49,29 +78,40 @@ class Parser:
"error": str(e)
}
def parse_bytes(self, content: bytes, file_name: str, file_type: Optional[str] = None) -> dict:
def parse_bytes(self, content: bytes, file_name: str, file_type: Optional[str] = None, vlm_config: Optional[Dict[str, Any]] = None) -> dict:
"""解析字节内容为 Markdown
Args:
content: 文件字节内容
file_name: 文件名
file_type: 文件类型(可选)
vlm_config: VLM 配置(可选,优先级高于全局配置)
Returns:
dict: 包含 markdown 内容和元数据
"""
# 如果有 VLM 配置,覆盖全局配置
if vlm_config:
self.set_vlm_config(vlm_config)
try:
logger.info(f"Parsing bytes: {file_name}, size: {len(content)} bytes")
# 检查是否应该使用 VLM根据文件名自动判断
if self._should_use_vlm(file_name):
logger.info("Using VLM for parsing")
return self._parse_with_vlm(content, file_name)
# 否则使用 MarkItDown
with tempfile.NamedTemporaryFile(delete=False, suffix=os.path.splitext(file_name)[1] or '') as temp_file:
temp_file.write(content)
temp_path = temp_file.name
try:
result = self.markitdown.convert(temp_path)
logger.info(f"Parse successful: {len(result.text_content)} characters")
return {
"success": True,
"content": result.text_content,
@@ -89,10 +129,69 @@ class Parser:
"error": str(e)
}
def _should_use_vlm(self, file_name: str) -> bool:
"""判断是否应该使用 VLM"""
if not self.vlm_client:
return False
# 图片文件使用 VLM
image_exts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff']
ext = os.path.splitext(file_name)[1].lower()
return ext in image_exts
def _parse_with_vlm(self, content: bytes, file_name: str) -> dict:
"""使用 VLM 解析"""
if not self.vlm_client:
return {
"success": False,
"content": "",
"content_length": 0,
"error": "VLM not configured"
}
# 确定 MIME 类型
ext = os.path.splitext(file_name)[1].lower()
mime_types = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.bmp': 'image/bmp',
'.webp': 'image/webp',
'.tiff': 'image/tiff',
}
mime_type = mime_types.get(ext, 'image/png')
try:
result = self.vlm_client.analyze_image(content, mime_type)
if result.get("success"):
return {
"success": True,
"content": result["content"],
"content_length": len(result["content"]),
"metadata": {"vlm_used": True}
}
else:
return {
"success": False,
"content": "",
"content_length": 0,
"error": result.get("error", "VLM parsing failed")
}
except Exception as e:
logger.error(f"VLM parsing error: {e}")
return {
"success": False,
"content": "",
"content_length": 0,
"error": str(e)
}
if __name__ == "__main__":
parser = Parser()
# 测试
test_url = "https://example.com"
result = parser.parse(test_url)

View File

@@ -0,0 +1,209 @@
"""
VLM 客户端 - 用于调用 VLM 模型进行文档理解
"""
import logging
import base64
import requests
from typing import Optional, Dict, Any
logger = logging.getLogger(__name__)
class VLMClient:
"""VLM 客户端,支持多种提供商"""
def __init__(self, config: Dict[str, Any]):
"""
初始化 VLM 客户端
Args:
config: VLM 配置,包含 provider, model, api_key, base_url, prompt 等
"""
self.config = config
self.provider = config.get("provider", "openai")
self.model = config.get("model", "gpt-4o")
self.api_key = config.get("api_key", "")
self.base_url = config.get("base_url", "")
self.prompt = config.get("prompt", "") or self._default_prompt()
logger.info(f"VLMClient initialized: provider={self.provider}, model={self.model}")
def _default_prompt(self) -> str:
"""默认提示词"""
return """请分析这张图片中的文档内容,并将其转换为 Markdown 格式。
要求:
1. 保持原文的格式和结构
2. 表格用 Markdown 表格格式
3. 标题用 # ## ### 标记
4. 代码块用 ``` 标记
5. 尽量保留原文的所有信息"""
def analyze_image(self, image_data: bytes, mime_type: str = "image/png") -> Dict[str, Any]:
"""
使用 VLM 分析图片
Args:
image_data: 图片二进制数据
mime_type: 图片 MIME 类型
Returns:
包含分析结果的字典
"""
if self.provider == "openai":
return self._call_openai(image_data, mime_type)
elif self.provider == "anthropic":
return self._call_anthropic(image_data, mime_type)
elif self.provider == "qwen":
return self._call_qwen(image_data, mime_type)
else:
return {
"success": False,
"content": "",
"error": f"Unsupported provider: {self.provider}"
}
def _call_openai(self, image_data: bytes, mime_type: str) -> Dict[str, Any]:
"""调用 OpenAI GPT-4o API"""
try:
url = (self.base_url or "https://api.openai.com/v1") + "/chat/completions"
# Base64 编码图片
image_base64 = base64.b64encode(image_data).decode("utf-8")
data_url = f"data:{mime_type};base64,{image_base64}"
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json"
}
payload = {
"model": self.model,
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": self.prompt},
{"type": "image_url", "image_url": {"url": data_url}}
]
}
],
"max_tokens": 4096
}
response = requests.post(url, headers=headers, json=payload, timeout=120)
response.raise_for_status()
result = response.json()
content = result["choices"][0]["message"]["content"]
return {
"success": True,
"content": content,
"usage": result.get("usage", {})
}
except Exception as e:
logger.error(f"OpenAI API error: {e}")
return {
"success": False,
"content": "",
"error": str(e)
}
def _call_anthropic(self, image_data: bytes, mime_type: str) -> Dict[str, Any]:
"""调用 Anthropic Claude API"""
try:
url = (self.base_url or "https://api.anthropic.com/v1") + "/messages"
image_base64 = base64.b64encode(image_data).decode("utf-8")
headers = {
"x-api-key": self.api_key,
"anthropic-version": "2023-06-01",
"Content-Type": "application/json"
}
# Anthropic 支持 image 类型
payload = {
"model": self.model,
"max_tokens": 4096,
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": self.prompt},
{
"type": "image",
"source": {
"type": "base64",
"media_type": mime_type,
"data": image_base64
}
}
]
}
]
}
response = requests.post(url, headers=headers, json=payload, timeout=120)
response.raise_for_status()
result = response.json()
content = result["content"][0]["text"]
return {
"success": True,
"content": content,
"usage": result.get("usage", {})
}
except Exception as e:
logger.error(f"Anthropic API error: {e}")
return {
"success": False,
"content": "",
"error": str(e)
}
def _call_qwen(self, image_data: bytes, mime_type: str) -> Dict[str, Any]:
"""调用阿里 Qwen VL API"""
try:
url = (self.base_url or "https://dashscope.aliyuncs.com/compatible-mode/v1") + "/chat/completions"
image_base64 = base64.b64encode(image_data).decode("utf-8")
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json"
}
# Qwen 格式
payload = {
"model": self.model,
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": self.prompt},
{"type": "image_url", "image_url": {"url": f"data:{mime_type};base64,{image_base64}"}}
]
}
]
}
response = requests.post(url, headers=headers, json=payload, timeout=120)
response.raise_for_status()
result = response.json()
content = result["choices"][0]["message"]["content"]
return {
"success": True,
"content": content,
"usage": {}
}
except Exception as e:
logger.error(f"Qwen API error: {e}")
return {
"success": False,
"content": "",
"error": str(e)
}

View File

@@ -16,6 +16,18 @@ message ParseRequest {
string file_type = 3;
string parser_engine = 4;
map<string, string> engine_overrides = 5;
// VLM 配置(可选)
VLMConfig vlm_config = 6;
}
message VLMConfig {
bool enabled = 1; // 是否启用 VLM
string provider = 2; // VLM 提供商: openai, anthropic, local 等
string model = 3; // 模型名称
string api_key = 4; // API Key
string base_url = 5; // 自定义 API 地址
string prompt = 6; // 自定义提示词
}
message ParseResponse {
@@ -44,4 +56,4 @@ message EngineInfo {
repeated string supported_file_types = 3;
bool available = 4;
string unavailable_reason = 5;
}
}

View File

@@ -24,7 +24,7 @@ _sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x15\x64ocument_parser.proto\x12\tdocparser\"\xdd\x01\n\x0cParseRequest\x12\x10\n\x08\x66ile_url\x18\x01 \x01(\t\x12\x11\n\tfile_name\x18\x02 \x01(\t\x12\x11\n\tfile_type\x18\x03 \x01(\t\x12\x15\n\rparser_engine\x18\x04 \x01(\t\x12\x46\n\x10\x65ngine_overrides\x18\x05 \x03(\x0b\x32,.docparser.ParseRequest.EngineOverridesEntry\x1a\x36\n\x14\x45ngineOverridesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x84\x01\n\rParseResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07\x63ontent\x18\x02 \x01(\t\x12\x0f\n\x07message\x18\x03 \x01(\t\x12\x16\n\x0e\x63ontent_length\x18\x04 \x01(\x05\x12\x11\n\tfile_type\x18\x05 \x01(\t\x12\x15\n\rparser_engine\x18\x06 \x01(\t\"\x07\n\x05\x45mpty\"\xca\x01\n\x18SupportedFormatsResponse\x12\x12\n\nfile_types\x18\x01 \x03(\t\x12]\n\x16\x66ile_type_descriptions\x18\x02 \x03(\x0b\x32=.docparser.SupportedFormatsResponse.FileTypeDescriptionsEntry\x1a;\n\x19\x46ileTypeDescriptionsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"9\n\x0f\x45nginesResponse\x12&\n\x07\x65ngines\x18\x01 \x03(\x0b\x32\x15.docparser.EngineInfo\"|\n\nEngineInfo\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x1c\n\x14supported_file_types\x18\x03 \x03(\t\x12\x11\n\tavailable\x18\x04 \x01(\x08\x12\x1a\n\x12unavailable_reason\x18\x05 \x01(\t2\xde\x01\n\x0e\x44ocumentParser\x12\x42\n\rParseDocument\x12\x17.docparser.ParseRequest\x1a\x18.docparser.ParseResponse\x12L\n\x13GetSupportedFormats\x12\x10.docparser.Empty\x1a#.docparser.SupportedFormatsResponse\x12:\n\nGetEngines\x12\x10.docparser.Empty\x1a\x1a.docparser.EnginesResponseB\x1aZ\x18x-agents/proto/docparserb\x06proto3')
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x15\x64ocument_parser.proto\x12\tdocparser\"\x87\x02\n\x0cParseRequest\x12\x10\n\x08\x66ile_url\x18\x01 \x01(\t\x12\x11\n\tfile_name\x18\x02 \x01(\t\x12\x11\n\tfile_type\x18\x03 \x01(\t\x12\x15\n\rparser_engine\x18\x04 \x01(\t\x12\x46\n\x10\x65ngine_overrides\x18\x05 \x03(\x0b\x32,.docparser.ParseRequest.EngineOverridesEntry\x12(\n\nvlm_config\x18\x06 \x01(\x0b\x32\x14.docparser.VLMConfig\x1a\x36\n\x14\x45ngineOverridesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"p\n\tVLMConfig\x12\x0f\n\x07\x65nabled\x18\x01 \x01(\x08\x12\x10\n\x08provider\x18\x02 \x01(\t\x12\r\n\x05model\x18\x03 \x01(\t\x12\x0f\n\x07\x61pi_key\x18\x04 \x01(\t\x12\x10\n\x08\x62\x61se_url\x18\x05 \x01(\t\x12\x0e\n\x06prompt\x18\x06 \x01(\t\"\x84\x01\n\rParseResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07\x63ontent\x18\x02 \x01(\t\x12\x0f\n\x07message\x18\x03 \x01(\t\x12\x16\n\x0e\x63ontent_length\x18\x04 \x01(\x05\x12\x11\n\tfile_type\x18\x05 \x01(\t\x12\x15\n\rparser_engine\x18\x06 \x01(\t\"\x07\n\x05\x45mpty\"\xca\x01\n\x18SupportedFormatsResponse\x12\x12\n\nfile_types\x18\x01 \x03(\t\x12]\n\x16\x66ile_type_descriptions\x18\x02 \x03(\x0b\x32=.docparser.SupportedFormatsResponse.FileTypeDescriptionsEntry\x1a;\n\x19\x46ileTypeDescriptionsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"9\n\x0f\x45nginesResponse\x12&\n\x07\x65ngines\x18\x01 \x03(\x0b\x32\x15.docparser.EngineInfo\"|\n\nEngineInfo\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x1c\n\x14supported_file_types\x18\x03 \x03(\t\x12\x11\n\tavailable\x18\x04 \x01(\x08\x12\x1a\n\x12unavailable_reason\x18\x05 \x01(\t2\xde\x01\n\x0e\x44ocumentParser\x12\x42\n\rParseDocument\x12\x17.docparser.ParseRequest\x1a\x18.docparser.ParseResponse\x12L\n\x13GetSupportedFormats\x12\x10.docparser.Empty\x1a#.docparser.SupportedFormatsResponse\x12:\n\nGetEngines\x12\x10.docparser.Empty\x1a\x1a.docparser.EnginesResponseB\x1aZ\x18x-agents/proto/docparserb\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
@@ -37,21 +37,23 @@ if not _descriptor._USE_C_DESCRIPTORS:
_globals['_SUPPORTEDFORMATSRESPONSE_FILETYPEDESCRIPTIONSENTRY']._loaded_options = None
_globals['_SUPPORTEDFORMATSRESPONSE_FILETYPEDESCRIPTIONSENTRY']._serialized_options = b'8\001'
_globals['_PARSEREQUEST']._serialized_start=37
_globals['_PARSEREQUEST']._serialized_end=258
_globals['_PARSEREQUEST_ENGINEOVERRIDESENTRY']._serialized_start=204
_globals['_PARSEREQUEST_ENGINEOVERRIDESENTRY']._serialized_end=258
_globals['_PARSERESPONSE']._serialized_start=261
_globals['_PARSERESPONSE']._serialized_end=393
_globals['_EMPTY']._serialized_start=395
_globals['_EMPTY']._serialized_end=402
_globals['_SUPPORTEDFORMATSRESPONSE']._serialized_start=405
_globals['_SUPPORTEDFORMATSRESPONSE']._serialized_end=607
_globals['_SUPPORTEDFORMATSRESPONSE_FILETYPEDESCRIPTIONSENTRY']._serialized_start=548
_globals['_SUPPORTEDFORMATSRESPONSE_FILETYPEDESCRIPTIONSENTRY']._serialized_end=607
_globals['_ENGINESRESPONSE']._serialized_start=609
_globals['_ENGINESRESPONSE']._serialized_end=666
_globals['_ENGINEINFO']._serialized_start=668
_globals['_ENGINEINFO']._serialized_end=792
_globals['_DOCUMENTPARSER']._serialized_start=795
_globals['_DOCUMENTPARSER']._serialized_end=1017
_globals['_PARSEREQUEST']._serialized_end=300
_globals['_PARSEREQUEST_ENGINEOVERRIDESENTRY']._serialized_start=246
_globals['_PARSEREQUEST_ENGINEOVERRIDESENTRY']._serialized_end=300
_globals['_VLMCONFIG']._serialized_start=302
_globals['_VLMCONFIG']._serialized_end=414
_globals['_PARSERESPONSE']._serialized_start=417
_globals['_PARSERESPONSE']._serialized_end=549
_globals['_EMPTY']._serialized_start=551
_globals['_EMPTY']._serialized_end=558
_globals['_SUPPORTEDFORMATSRESPONSE']._serialized_start=561
_globals['_SUPPORTEDFORMATSRESPONSE']._serialized_end=763
_globals['_SUPPORTEDFORMATSRESPONSE_FILETYPEDESCRIPTIONSENTRY']._serialized_start=704
_globals['_SUPPORTEDFORMATSRESPONSE_FILETYPEDESCRIPTIONSENTRY']._serialized_end=763
_globals['_ENGINESRESPONSE']._serialized_start=765
_globals['_ENGINESRESPONSE']._serialized_end=822
_globals['_ENGINEINFO']._serialized_start=824
_globals['_ENGINEINFO']._serialized_end=948
_globals['_DOCUMENTPARSER']._serialized_start=951
_globals['_DOCUMENTPARSER']._serialized_end=1173
# @@protoc_insertion_point(module_scope)

View File

@@ -6,8 +6,9 @@ grpcio-tools>=1.60.0
grpcio-reflection>=1.60.0
protobuf>=4.25.0
# HTTP 请求
# 配置文件解析
pyyaml>=6.0
requests>=2.31.0
# 文档解析
markitdown>=0.0.1
# 文档解析 - markitdown 及其所有依赖
markitdown[pdf,docx,pptx,xlsx,all]>=0.0.1

View File

@@ -75,6 +75,21 @@ class DocumentParserServicer:
content_length=0,
)
# 提取 VLM 配置
vlm_config = None
if hasattr(request, 'vlm_config') and request.vlm_config:
vlm_cfg = request.vlm_config
if vlm_cfg.enabled:
vlm_config = {
"enabled": vlm_cfg.enabled,
"provider": vlm_cfg.provider,
"model": vlm_cfg.model,
"api_key": vlm_cfg.api_key,
"base_url": vlm_cfg.base_url,
"prompt": vlm_cfg.prompt,
}
logger.info(f"VLM config: provider={vlm_cfg.provider}, model={vlm_cfg.model}")
logger.info("Downloading file from URL: %s", file_url)
try:
@@ -95,9 +110,9 @@ class DocumentParserServicer:
content_length=0,
)
logger.info("Parsing file with MarkItDown")
logger.info("Parsing file with MarkItDown + VLM")
result = self.parser.parse_bytes(content, file_name)
result = self.parser.parse_bytes(content, file_name, vlm_config=vlm_config)
if not result.get("success", False):
logger.warning("Parser returned failure: %s", result.get("error", "Unknown error"))

View File

@@ -87,7 +87,7 @@ func main() {
if err != nil {
log.Printf("Warning: Failed to initialize upload service: %v (files will not be available)", err)
}
knowledgeService := service.NewKnowledgeService(knowledgeRepo, modelRepo, uploadService, cfg.PythonServiceURL, cfg.AICoreServiceAddr)
knowledgeService := service.NewKnowledgeService(knowledgeRepo, modelRepo, uploadService, cfg.PythonServiceURL, cfg.AICoreServiceAddr, cfg.MarkdownLocalPath)
// 6. 初始化 Handler
dbHandler := handler.NewDatabaseHandler(dbService)

View File

@@ -0,0 +1,498 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v5.29.3
// source: document_parser.proto
package docparser
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type ParseRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
FileUrl string `protobuf:"bytes,1,opt,name=file_url,json=fileUrl,proto3" json:"file_url,omitempty"`
FileName string `protobuf:"bytes,2,opt,name=file_name,json=fileName,proto3" json:"file_name,omitempty"`
FileType string `protobuf:"bytes,3,opt,name=file_type,json=fileType,proto3" json:"file_type,omitempty"`
ParserEngine string `protobuf:"bytes,4,opt,name=parser_engine,json=parserEngine,proto3" json:"parser_engine,omitempty"`
EngineOverrides map[string]string `protobuf:"bytes,5,rep,name=engine_overrides,json=engineOverrides,proto3" json:"engine_overrides,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ParseRequest) Reset() {
*x = ParseRequest{}
mi := &file_document_parser_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ParseRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ParseRequest) ProtoMessage() {}
func (x *ParseRequest) ProtoReflect() protoreflect.Message {
mi := &file_document_parser_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ParseRequest.ProtoReflect.Descriptor instead.
func (*ParseRequest) Descriptor() ([]byte, []int) {
return file_document_parser_proto_rawDescGZIP(), []int{0}
}
func (x *ParseRequest) GetFileUrl() string {
if x != nil {
return x.FileUrl
}
return ""
}
func (x *ParseRequest) GetFileName() string {
if x != nil {
return x.FileName
}
return ""
}
func (x *ParseRequest) GetFileType() string {
if x != nil {
return x.FileType
}
return ""
}
func (x *ParseRequest) GetParserEngine() string {
if x != nil {
return x.ParserEngine
}
return ""
}
func (x *ParseRequest) GetEngineOverrides() map[string]string {
if x != nil {
return x.EngineOverrides
}
return nil
}
type ParseResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"`
Content string `protobuf:"bytes,2,opt,name=content,proto3" json:"content,omitempty"`
Message string `protobuf:"bytes,3,opt,name=message,proto3" json:"message,omitempty"`
ContentLength int32 `protobuf:"varint,4,opt,name=content_length,json=contentLength,proto3" json:"content_length,omitempty"`
FileType string `protobuf:"bytes,5,opt,name=file_type,json=fileType,proto3" json:"file_type,omitempty"`
ParserEngine string `protobuf:"bytes,6,opt,name=parser_engine,json=parserEngine,proto3" json:"parser_engine,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ParseResponse) Reset() {
*x = ParseResponse{}
mi := &file_document_parser_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ParseResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ParseResponse) ProtoMessage() {}
func (x *ParseResponse) ProtoReflect() protoreflect.Message {
mi := &file_document_parser_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ParseResponse.ProtoReflect.Descriptor instead.
func (*ParseResponse) Descriptor() ([]byte, []int) {
return file_document_parser_proto_rawDescGZIP(), []int{1}
}
func (x *ParseResponse) GetSuccess() bool {
if x != nil {
return x.Success
}
return false
}
func (x *ParseResponse) GetContent() string {
if x != nil {
return x.Content
}
return ""
}
func (x *ParseResponse) GetMessage() string {
if x != nil {
return x.Message
}
return ""
}
func (x *ParseResponse) GetContentLength() int32 {
if x != nil {
return x.ContentLength
}
return 0
}
func (x *ParseResponse) GetFileType() string {
if x != nil {
return x.FileType
}
return ""
}
func (x *ParseResponse) GetParserEngine() string {
if x != nil {
return x.ParserEngine
}
return ""
}
type Empty struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Empty) Reset() {
*x = Empty{}
mi := &file_document_parser_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Empty) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Empty) ProtoMessage() {}
func (x *Empty) ProtoReflect() protoreflect.Message {
mi := &file_document_parser_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Empty.ProtoReflect.Descriptor instead.
func (*Empty) Descriptor() ([]byte, []int) {
return file_document_parser_proto_rawDescGZIP(), []int{2}
}
type SupportedFormatsResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
FileTypes []string `protobuf:"bytes,1,rep,name=file_types,json=fileTypes,proto3" json:"file_types,omitempty"`
FileTypeDescriptions map[string]string `protobuf:"bytes,2,rep,name=file_type_descriptions,json=fileTypeDescriptions,proto3" json:"file_type_descriptions,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SupportedFormatsResponse) Reset() {
*x = SupportedFormatsResponse{}
mi := &file_document_parser_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *SupportedFormatsResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SupportedFormatsResponse) ProtoMessage() {}
func (x *SupportedFormatsResponse) ProtoReflect() protoreflect.Message {
mi := &file_document_parser_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use SupportedFormatsResponse.ProtoReflect.Descriptor instead.
func (*SupportedFormatsResponse) Descriptor() ([]byte, []int) {
return file_document_parser_proto_rawDescGZIP(), []int{3}
}
func (x *SupportedFormatsResponse) GetFileTypes() []string {
if x != nil {
return x.FileTypes
}
return nil
}
func (x *SupportedFormatsResponse) GetFileTypeDescriptions() map[string]string {
if x != nil {
return x.FileTypeDescriptions
}
return nil
}
type EnginesResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Engines []*EngineInfo `protobuf:"bytes,1,rep,name=engines,proto3" json:"engines,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *EnginesResponse) Reset() {
*x = EnginesResponse{}
mi := &file_document_parser_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *EnginesResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*EnginesResponse) ProtoMessage() {}
func (x *EnginesResponse) ProtoReflect() protoreflect.Message {
mi := &file_document_parser_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use EnginesResponse.ProtoReflect.Descriptor instead.
func (*EnginesResponse) Descriptor() ([]byte, []int) {
return file_document_parser_proto_rawDescGZIP(), []int{4}
}
func (x *EnginesResponse) GetEngines() []*EngineInfo {
if x != nil {
return x.Engines
}
return nil
}
type EngineInfo struct {
state protoimpl.MessageState `protogen:"open.v1"`
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"`
SupportedFileTypes []string `protobuf:"bytes,3,rep,name=supported_file_types,json=supportedFileTypes,proto3" json:"supported_file_types,omitempty"`
Available bool `protobuf:"varint,4,opt,name=available,proto3" json:"available,omitempty"`
UnavailableReason string `protobuf:"bytes,5,opt,name=unavailable_reason,json=unavailableReason,proto3" json:"unavailable_reason,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *EngineInfo) Reset() {
*x = EngineInfo{}
mi := &file_document_parser_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *EngineInfo) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*EngineInfo) ProtoMessage() {}
func (x *EngineInfo) ProtoReflect() protoreflect.Message {
mi := &file_document_parser_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use EngineInfo.ProtoReflect.Descriptor instead.
func (*EngineInfo) Descriptor() ([]byte, []int) {
return file_document_parser_proto_rawDescGZIP(), []int{5}
}
func (x *EngineInfo) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *EngineInfo) GetDescription() string {
if x != nil {
return x.Description
}
return ""
}
func (x *EngineInfo) GetSupportedFileTypes() []string {
if x != nil {
return x.SupportedFileTypes
}
return nil
}
func (x *EngineInfo) GetAvailable() bool {
if x != nil {
return x.Available
}
return false
}
func (x *EngineInfo) GetUnavailableReason() string {
if x != nil {
return x.UnavailableReason
}
return ""
}
var File_document_parser_proto protoreflect.FileDescriptor
const file_document_parser_proto_rawDesc = "" +
"\n" +
"\x15document_parser.proto\x12\tdocparser\"\xa5\x02\n" +
"\fParseRequest\x12\x19\n" +
"\bfile_url\x18\x01 \x01(\tR\afileUrl\x12\x1b\n" +
"\tfile_name\x18\x02 \x01(\tR\bfileName\x12\x1b\n" +
"\tfile_type\x18\x03 \x01(\tR\bfileType\x12#\n" +
"\rparser_engine\x18\x04 \x01(\tR\fparserEngine\x12W\n" +
"\x10engine_overrides\x18\x05 \x03(\v2,.docparser.ParseRequest.EngineOverridesEntryR\x0fengineOverrides\x1aB\n" +
"\x14EngineOverridesEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xc6\x01\n" +
"\rParseResponse\x12\x18\n" +
"\asuccess\x18\x01 \x01(\bR\asuccess\x12\x18\n" +
"\acontent\x18\x02 \x01(\tR\acontent\x12\x18\n" +
"\amessage\x18\x03 \x01(\tR\amessage\x12%\n" +
"\x0econtent_length\x18\x04 \x01(\x05R\rcontentLength\x12\x1b\n" +
"\tfile_type\x18\x05 \x01(\tR\bfileType\x12#\n" +
"\rparser_engine\x18\x06 \x01(\tR\fparserEngine\"\a\n" +
"\x05Empty\"\xf7\x01\n" +
"\x18SupportedFormatsResponse\x12\x1d\n" +
"\n" +
"file_types\x18\x01 \x03(\tR\tfileTypes\x12s\n" +
"\x16file_type_descriptions\x18\x02 \x03(\v2=.docparser.SupportedFormatsResponse.FileTypeDescriptionsEntryR\x14fileTypeDescriptions\x1aG\n" +
"\x19FileTypeDescriptionsEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"B\n" +
"\x0fEnginesResponse\x12/\n" +
"\aengines\x18\x01 \x03(\v2\x15.docparser.EngineInfoR\aengines\"\xc1\x01\n" +
"\n" +
"EngineInfo\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\x12 \n" +
"\vdescription\x18\x02 \x01(\tR\vdescription\x120\n" +
"\x14supported_file_types\x18\x03 \x03(\tR\x12supportedFileTypes\x12\x1c\n" +
"\tavailable\x18\x04 \x01(\bR\tavailable\x12-\n" +
"\x12unavailable_reason\x18\x05 \x01(\tR\x11unavailableReason2\xde\x01\n" +
"\x0eDocumentParser\x12B\n" +
"\rParseDocument\x12\x17.docparser.ParseRequest\x1a\x18.docparser.ParseResponse\x12L\n" +
"\x13GetSupportedFormats\x12\x10.docparser.Empty\x1a#.docparser.SupportedFormatsResponse\x12:\n" +
"\n" +
"GetEngines\x12\x10.docparser.Empty\x1a\x1a.docparser.EnginesResponseB\x1aZ\x18x-agents/proto/docparserb\x06proto3"
var (
file_document_parser_proto_rawDescOnce sync.Once
file_document_parser_proto_rawDescData []byte
)
func file_document_parser_proto_rawDescGZIP() []byte {
file_document_parser_proto_rawDescOnce.Do(func() {
file_document_parser_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_document_parser_proto_rawDesc), len(file_document_parser_proto_rawDesc)))
})
return file_document_parser_proto_rawDescData
}
var file_document_parser_proto_msgTypes = make([]protoimpl.MessageInfo, 8)
var file_document_parser_proto_goTypes = []any{
(*ParseRequest)(nil), // 0: docparser.ParseRequest
(*ParseResponse)(nil), // 1: docparser.ParseResponse
(*Empty)(nil), // 2: docparser.Empty
(*SupportedFormatsResponse)(nil), // 3: docparser.SupportedFormatsResponse
(*EnginesResponse)(nil), // 4: docparser.EnginesResponse
(*EngineInfo)(nil), // 5: docparser.EngineInfo
nil, // 6: docparser.ParseRequest.EngineOverridesEntry
nil, // 7: docparser.SupportedFormatsResponse.FileTypeDescriptionsEntry
}
var file_document_parser_proto_depIdxs = []int32{
6, // 0: docparser.ParseRequest.engine_overrides:type_name -> docparser.ParseRequest.EngineOverridesEntry
7, // 1: docparser.SupportedFormatsResponse.file_type_descriptions:type_name -> docparser.SupportedFormatsResponse.FileTypeDescriptionsEntry
5, // 2: docparser.EnginesResponse.engines:type_name -> docparser.EngineInfo
0, // 3: docparser.DocumentParser.ParseDocument:input_type -> docparser.ParseRequest
2, // 4: docparser.DocumentParser.GetSupportedFormats:input_type -> docparser.Empty
2, // 5: docparser.DocumentParser.GetEngines:input_type -> docparser.Empty
1, // 6: docparser.DocumentParser.ParseDocument:output_type -> docparser.ParseResponse
3, // 7: docparser.DocumentParser.GetSupportedFormats:output_type -> docparser.SupportedFormatsResponse
4, // 8: docparser.DocumentParser.GetEngines:output_type -> docparser.EnginesResponse
6, // [6:9] is the sub-list for method output_type
3, // [3:6] is the sub-list for method input_type
3, // [3:3] is the sub-list for extension type_name
3, // [3:3] is the sub-list for extension extendee
0, // [0:3] is the sub-list for field type_name
}
func init() { file_document_parser_proto_init() }
func file_document_parser_proto_init() {
if File_document_parser_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_document_parser_proto_rawDesc), len(file_document_parser_proto_rawDesc)),
NumEnums: 0,
NumMessages: 8,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_document_parser_proto_goTypes,
DependencyIndexes: file_document_parser_proto_depIdxs,
MessageInfos: file_document_parser_proto_msgTypes,
}.Build()
File_document_parser_proto = out.File
file_document_parser_proto_goTypes = nil
file_document_parser_proto_depIdxs = nil
}

View File

@@ -0,0 +1,197 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc v5.29.3
// source: document_parser.proto
package docparser
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
DocumentParser_ParseDocument_FullMethodName = "/docparser.DocumentParser/ParseDocument"
DocumentParser_GetSupportedFormats_FullMethodName = "/docparser.DocumentParser/GetSupportedFormats"
DocumentParser_GetEngines_FullMethodName = "/docparser.DocumentParser/GetEngines"
)
// DocumentParserClient is the client API for DocumentParser service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type DocumentParserClient interface {
ParseDocument(ctx context.Context, in *ParseRequest, opts ...grpc.CallOption) (*ParseResponse, error)
GetSupportedFormats(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*SupportedFormatsResponse, error)
GetEngines(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*EnginesResponse, error)
}
type documentParserClient struct {
cc grpc.ClientConnInterface
}
func NewDocumentParserClient(cc grpc.ClientConnInterface) DocumentParserClient {
return &documentParserClient{cc}
}
func (c *documentParserClient) ParseDocument(ctx context.Context, in *ParseRequest, opts ...grpc.CallOption) (*ParseResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ParseResponse)
err := c.cc.Invoke(ctx, DocumentParser_ParseDocument_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *documentParserClient) GetSupportedFormats(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*SupportedFormatsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SupportedFormatsResponse)
err := c.cc.Invoke(ctx, DocumentParser_GetSupportedFormats_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *documentParserClient) GetEngines(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*EnginesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(EnginesResponse)
err := c.cc.Invoke(ctx, DocumentParser_GetEngines_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// DocumentParserServer is the server API for DocumentParser service.
// All implementations must embed UnimplementedDocumentParserServer
// for forward compatibility.
type DocumentParserServer interface {
ParseDocument(context.Context, *ParseRequest) (*ParseResponse, error)
GetSupportedFormats(context.Context, *Empty) (*SupportedFormatsResponse, error)
GetEngines(context.Context, *Empty) (*EnginesResponse, error)
mustEmbedUnimplementedDocumentParserServer()
}
// UnimplementedDocumentParserServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedDocumentParserServer struct{}
func (UnimplementedDocumentParserServer) ParseDocument(context.Context, *ParseRequest) (*ParseResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ParseDocument not implemented")
}
func (UnimplementedDocumentParserServer) GetSupportedFormats(context.Context, *Empty) (*SupportedFormatsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetSupportedFormats not implemented")
}
func (UnimplementedDocumentParserServer) GetEngines(context.Context, *Empty) (*EnginesResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetEngines not implemented")
}
func (UnimplementedDocumentParserServer) mustEmbedUnimplementedDocumentParserServer() {}
func (UnimplementedDocumentParserServer) testEmbeddedByValue() {}
// UnsafeDocumentParserServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to DocumentParserServer will
// result in compilation errors.
type UnsafeDocumentParserServer interface {
mustEmbedUnimplementedDocumentParserServer()
}
func RegisterDocumentParserServer(s grpc.ServiceRegistrar, srv DocumentParserServer) {
// If the following call panics, it indicates UnimplementedDocumentParserServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&DocumentParser_ServiceDesc, srv)
}
func _DocumentParser_ParseDocument_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ParseRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DocumentParserServer).ParseDocument(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: DocumentParser_ParseDocument_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DocumentParserServer).ParseDocument(ctx, req.(*ParseRequest))
}
return interceptor(ctx, in, info, handler)
}
func _DocumentParser_GetSupportedFormats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(Empty)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DocumentParserServer).GetSupportedFormats(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: DocumentParser_GetSupportedFormats_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DocumentParserServer).GetSupportedFormats(ctx, req.(*Empty))
}
return interceptor(ctx, in, info, handler)
}
func _DocumentParser_GetEngines_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(Empty)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DocumentParserServer).GetEngines(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: DocumentParser_GetEngines_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DocumentParserServer).GetEngines(ctx, req.(*Empty))
}
return interceptor(ctx, in, info, handler)
}
// DocumentParser_ServiceDesc is the grpc.ServiceDesc for DocumentParser service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var DocumentParser_ServiceDesc = grpc.ServiceDesc{
ServiceName: "docparser.DocumentParser",
HandlerType: (*DocumentParserServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "ParseDocument",
Handler: _DocumentParser_ParseDocument_Handler,
},
{
MethodName: "GetSupportedFormats",
Handler: _DocumentParser_GetSupportedFormats_Handler,
},
{
MethodName: "GetEngines",
Handler: _DocumentParser_GetEngines_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "document_parser.proto",
}

View File

@@ -23,9 +23,10 @@ type Config struct {
PythonServiceURL string
AICoreServiceAddr string // AI-Core gRPC 服务地址,如 "localhost:50051"
// 文件上传配置
UploadMode string // "local" 或 "minio"
UploadLocalPath string // 本地存储路径,如 "resource/files"
ServerBaseURL string // 服务器基础 URL用于生成本地文件 URL
UploadMode string // "local" 或 "minio"
UploadLocalPath string // 本地存储路径,如 "resource/files"
ServerBaseURL string // 服务器基础 URL用于生成本地文件 URL
MarkdownLocalPath string // Markdown 文件存储路径,如 "resource/markdown"
// MinIO 配置
MinIOEndpoint string
MinIOAccessKey string
@@ -56,6 +57,7 @@ func Load() *Config {
viper.SetDefault("upload_mode", "local")
viper.SetDefault("upload_local_path", "resource/files")
viper.SetDefault("server_base_url", "http://localhost:8080")
viper.SetDefault("markdown_local_path", "resource/markdown")
viper.SetDefault("minio_endpoint", "localhost:9000")
viper.SetDefault("minio_access_key", "")
viper.SetDefault("minio_secret_key", "")
@@ -87,9 +89,10 @@ func Load() *Config {
PythonServiceURL: viper.GetString("python_service_url"),
AICoreServiceAddr: viper.GetString("ai_core_service_addr"),
// 文件上传配置
UploadMode: viper.GetString("upload_mode"),
UploadLocalPath: viper.GetString("upload_local_path"),
ServerBaseURL: viper.GetString("server_base_url"),
UploadMode: viper.GetString("upload_mode"),
UploadLocalPath: viper.GetString("upload_local_path"),
ServerBaseURL: viper.GetString("server_base_url"),
MarkdownLocalPath: viper.GetString("markdown_local_path"),
// MinIO 配置
MinIOEndpoint: viper.GetString("minio_endpoint"),
MinIOAccessKey: viper.GetString("minio_access_key"),

View File

@@ -145,7 +145,8 @@ type UploadDocumentResponse struct {
// DocumentPreviewResponse 文档预览响应
type DocumentPreviewResponse struct {
TotalPages int `json:"total_pages"`
CurrentPage int `json:"current_page"`
Content string `json:"content"`
TotalPages int `json:"total_pages"`
CurrentPage int `json:"current_page"`
Content string `json:"content"`
ContentType string `json:"content_type"` // url: 文件URL, html: HTML内容
}

View File

@@ -7,6 +7,8 @@ import (
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
docparser "x-agents/server"
)
// AICoreClient AI-Core 文档解析服务客户端
@@ -53,7 +55,7 @@ func (c *AICoreClient) Close() {
}
}
// ParseDocument 解析文档
// ParseDocument 解析文档 - 使用生成的 protobuf 代码
func (c *AICoreClient) ParseDocument(fileURL, fileName, fileType string) (*ParseResult, error) {
if c.conn == nil {
if err := c.Connect(); err != nil {
@@ -61,17 +63,16 @@ func (c *AICoreClient) ParseDocument(fileURL, fileName, fileType string) (*Parse
}
}
// 使用 gRPC raw bytes 调用
// 由于没有生成 protobuf 代码,使用 raw bytes 方式调用
client := NewDocumentParserClient(c.conn)
// 使用生成的 protobuf 客户端
client := docparser.NewDocumentParserClient(c.conn)
req := &ParseRequest{
req := &docparser.ParseRequest{
FileUrl: fileURL,
FileName: fileName,
FileType: fileType,
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
resp, err := client.ParseDocument(ctx, req)
@@ -80,53 +81,11 @@ func (c *AICoreClient) ParseDocument(fileURL, fileName, fileType string) (*Parse
}
return &ParseResult{
Success: resp.Success,
Content: resp.Content,
Message: resp.Message,
ContentLength: resp.ContentLength,
FileType: resp.FileType,
ParserEngine: resp.ParserEngine,
Success: resp.GetSuccess(),
Content: resp.GetContent(),
Message: resp.GetMessage(),
ContentLength: resp.GetContentLength(),
FileType: resp.GetFileType(),
ParserEngine: resp.GetParserEngine(),
}, nil
}
// 以下是手动定义的 protobuf messages与 proto 文件一致)
// 不需要生成 .pb.go 文件,直接手动定义
type ParseRequest struct {
FileUrl string
FileName string
FileType string
ParserEngine string
}
type ParseResponse struct {
Success bool
Content string
Message string
ContentLength int32
FileType string
ParserEngine string
}
// DocumentParserClient gRPC 客户端接口(手动实现)
type DocumentParserClient interface {
ParseDocument(ctx context.Context, in *ParseRequest, opts ...grpc.CallOption) (*ParseResponse, error)
}
type documentParserClient struct {
cc grpc.ClientConnInterface
}
// NewDocumentParserClient 创建 DocumentParser 客户端
func NewDocumentParserClient(cc grpc.ClientConnInterface) DocumentParserClient {
return &documentParserClient{cc: cc}
}
func (c *documentParserClient) ParseDocument(ctx context.Context, in *ParseRequest, opts ...grpc.CallOption) (*ParseResponse, error) {
out := new(ParseResponse)
err := c.cc.Invoke(ctx, "/docparser.DocumentParser/ParseDocument", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}

View File

@@ -29,9 +29,10 @@ type KnowledgeService struct {
uploadService *UploadService
pythonServiceURL string
aiCoreClient *AICoreClient
markdownLocalPath string // Markdown 本地存储路径
}
func NewKnowledgeService(repo *repository.KnowledgeRepository, modelRepo *repository.ModelRepository, uploadService *UploadService, pythonServiceURL, aiCoreServiceAddr string) *KnowledgeService {
func NewKnowledgeService(repo *repository.KnowledgeRepository, modelRepo *repository.ModelRepository, uploadService *UploadService, pythonServiceURL, aiCoreServiceAddr, markdownLocalPath string) *KnowledgeService {
aiCoreClient, _ := NewAICoreClient(aiCoreServiceAddr)
return &KnowledgeService{
repo: repo,
@@ -39,6 +40,7 @@ func NewKnowledgeService(repo *repository.KnowledgeRepository, modelRepo *reposi
uploadService: uploadService,
pythonServiceURL: pythonServiceURL,
aiCoreClient: aiCoreClient,
markdownLocalPath: markdownLocalPath,
}
}
@@ -307,6 +309,13 @@ func (s *KnowledgeService) parseDocumentWithAICore(docID, fileURL, fileName stri
if result.Success && result.Content != "" {
knowledgeDebugLog.Printf("[AICore] 解析成功: docID=%s, contentLength=%d", docID, len(result.Content))
// 保存到本地文件
markdownPath := s.saveMarkdownToFile(docID, fileName, result.Content)
if markdownPath != "" {
knowledgeDebugLog.Printf("[AICore] Markdown 保存到本地: docID=%s, path=%s", docID, markdownPath)
}
// 更新文档的 Content 字段
s.repo.UpdateDocument(docID, map[string]interface{}{
"content": result.Content,
@@ -316,6 +325,31 @@ func (s *KnowledgeService) parseDocumentWithAICore(docID, fileURL, fileName stri
}
}
// saveMarkdownToFile 保存 Markdown 内容到本地文件
func (s *KnowledgeService) saveMarkdownToFile(docID, fileName, content string) string {
if s.markdownLocalPath == "" {
s.markdownLocalPath = "resource/markdown"
}
// 创建目录
if err := os.MkdirAll(s.markdownLocalPath, 0755); err != nil {
knowledgeDebugLog.Printf("[AICore] 创建目录失败: path=%s, err=%v", s.markdownLocalPath, err)
return ""
}
// 生成文件名(用 docID + .md
markdownFileName := docID + ".md"
markdownPath := s.markdownLocalPath + "/" + markdownFileName
// 写入文件
if err := os.WriteFile(markdownPath, []byte(content), 0644); err != nil {
knowledgeDebugLog.Printf("[AICore] 保存 Markdown 失败: path=%s, err=%v", markdownPath, err)
return ""
}
return markdownPath
}
// DeleteDocument 删除文档
func (s *KnowledgeService) DeleteDocument(kbID, docID string) error {
// 验证文档存在
@@ -400,24 +434,66 @@ func (s *KnowledgeService) GetDocumentPreview(kbID, docID string, page int) (*mo
return nil, nil
}
// 如果已解析,返回解析内容;否则返回文件 URL
if doc.Status == "parsed" {
// TODO: 从存储中读取解析内容(可以存到数据库或文件)
// 暂时返回文件 URL
fileURL, _ := s.uploadService.GetFileURL(doc.FileKey)
// 获取文件URL
fileURL, _ := s.uploadService.GetFileURL(doc.FileKey)
// 根据文件类型决定预览方式
fileName := doc.Name
isPDF := strings.HasSuffix(strings.ToLower(fileName), ".pdf")
isOffice := false
officeExts := []string{".csv", ".xlsx", ".xls", ".docx", ".doc", ".pptx", ".ppt", ".txt", ".md"}
for _, ext := range officeExts {
if strings.HasSuffix(strings.ToLower(fileName), ext) {
isOffice = true
break
}
}
// PDF文件返回文件URL
if isPDF {
return &model.DocumentPreviewResponse{
TotalPages: 1,
CurrentPage: page,
Content: fileURL,
ContentType: "url",
}, nil
}
// 未解析,返回文件 URL
fileURL, _ := s.uploadService.GetFileURL(doc.FileKey)
// Office文件调用解析服务转换为HTML
if isOffice && s.aiCoreClient != nil {
knowledgeDebugLog.Printf("[Preview] Parsing office file: %s, URL: %s", fileName, fileURL)
result, err := s.aiCoreClient.ParseDocument(fileURL, fileName, "")
if err != nil {
// 解析失败返回文件URL
knowledgeDebugLog.Printf("[Preview] Parse document failed: %v", err)
return &model.DocumentPreviewResponse{
TotalPages: 1,
CurrentPage: page,
Content: fileURL,
ContentType: "url",
}, nil
}
knowledgeDebugLog.Printf("[Preview] Parse result: success=%v, content_length=%d", result.Success, len(result.Content))
// 返回HTML内容
if result.Success && result.Content != "" {
knowledgeDebugLog.Printf("[Preview] Returning HTML content, length: %d", len(result.Content))
return &model.DocumentPreviewResponse{
TotalPages: 1,
CurrentPage: page,
Content: result.Content,
ContentType: "html",
}, nil
}
}
// 其他情况返回文件URL
return &model.DocumentPreviewResponse{
TotalPages: 1,
CurrentPage: page,
Content: fileURL,
ContentType: "url",
}, nil
}

View File

@@ -0,0 +1,5 @@
sk-5706307e3e3a4eb09452dbf0bb87fe31
https://dashscope.aliyuncs.com/compatible-mode/v1
qwen3.5-flash

View File

@@ -0,0 +1,5 @@
sk-5706307e3e3a4eb09452dbf0bb87fe31
https://dashscope.aliyuncs.com/compatible-mode/v1
qwen3.5-flash

View File

@@ -0,0 +1,4 @@
## students
| 班级 | 姓名 | 年龄 | 性别 |
| --- | --- | --- | --- |
| 1 | 曹 | 123 | 男 |

View File

@@ -0,0 +1,13 @@
# 1 . 你好么?
### 表哥啊啊
大叔大婶打扫的暗示打扫暗示
> 太好了
```python
print("hello world")
```

View File

@@ -0,0 +1,13 @@
# 1 . 你好么?
### 表哥啊啊
大叔大婶打扫的暗示打扫暗示
> 太好了
```python
print("hello world")
```

View File

@@ -0,0 +1,13 @@
# 1 . 你好么?
### 表哥啊啊
大叔大婶打扫的暗示打扫暗示
> 太好了
```python
print("hello world")
```

View File

@@ -0,0 +1,13 @@
# 1 . 你好么?
### 表哥啊啊
大叔大婶打扫的暗示打扫暗示
> 太好了
```python
print("hello world")
```

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
# 1 . 你好么?
### 表哥啊啊
大叔大婶打扫的暗示打扫暗示
> 太好了
```python
print("hello world")
```

View File

@@ -0,0 +1,5 @@
sk-5706307e3e3a4eb09452dbf0bb87fe31
https://dashscope.aliyuncs.com/compatible-mode/v1
qwen3.5-flash

View File

@@ -0,0 +1,3 @@
| 班级 | 姓名 | 年龄 | 性别 |
| --- | --- | --- | --- |
| 1 | 曹 | 123 | 男 |

View File

@@ -0,0 +1,5 @@
sk-5706307e3e3a4eb09452dbf0bb87fe31
https://dashscope.aliyuncs.com/compatible-mode/v1
qwen3.5-flash

View File

@@ -0,0 +1,13 @@
# 1 . 你好么?
### 表哥啊啊
大叔大婶打扫的暗示打扫暗示
> 太好了
```python
print("hello world")
```

View File

@@ -0,0 +1,3 @@
| 겯섬 | 檎츰 | 쾨쥑 | 昑깎 |
| --- | --- | --- | --- |
| 1 | 꿀 | 123 | 켕 |

View File

@@ -0,0 +1,5 @@
sk-5706307e3e3a4eb09452dbf0bb87fe31
https://dashscope.aliyuncs.com/compatible-mode/v1
qwen3.5-flash

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,43 @@
localhost:50051
```
## VLM 配置(可选)
VLM 用于提升图片文件的解析效果。如果不配置 VLM则使用默认的 MarkItDown 解析。
### 方式一:环境变量
```bash
# 设置环境变量
export VLM_API_KEY="your-api-key"
export VLM_PROVIDER="openai" # openai / anthropic / qwen
export VLM_MODEL="gpt-4o"
```
### 方式二:配置文件
`ai-core/config.yaml` 中配置:
```yaml
vlm:
enabled: true
provider: "openai" # openai / anthropic / qwen
model: "gpt-4o" # 模型名称
api_key: "sk-xxx" # API Key
base_url: "" # 自定义 API 地址(可选)
prompt: "" # 自定义提示词(可选)
```
### 支持的 VLM 提供商
| 提供商 | 示例模型 |
|--------|----------|
| openai | gpt-4o, gpt-4o-mini |
| anthropic | claude-3-opus, claude-3-sonnet |
| qwen | qwen-vl-max, qwen2-vl-72b |
---
## gRPC API 定义
### 1. ParseDocument - 解析文档
@@ -16,128 +53,85 @@ message ParseRequest {
string file_url = 1; // 文件 URL必填
string file_name = 2; // 文件名,带扩展名(必填)
string file_type = 3; // 文件类型(可选,自动检测)
map<string, string> engine_overrides = 4; // 引擎配置
string parser_engine = 4; // 解析引擎(可选)
map<string, string> engine_overrides = 5; // 引擎配置
// VLM 配置(可选,优先级高于全局配置)
VLMConfig vlm_config = 6;
}
message VLMConfig {
bool enabled = 1;
string provider = 2;
string model = 3;
string api_key = 4;
string base_url = 5;
string prompt = 6;
}
```
**响应 (ParseResponse)**
```protobuf
message ParseResponse {
bool success = 1; // 是否成功
bool success = 1;
string content = 2; // Markdown 内容
string message = 3; // 状态消息
int32 content_length = 4; // 内容长度
string file_type = 5; // 文件类型
string parser_engine = 6; // 解析引擎 (markitdown)
string message = 3;
int32 content_length = 4;
string file_type = 5;
string parser_engine = 6;
}
```
### 2. GetSupportedFormats - 获取支持的格式
**请求**: 空消息
**响应**
- `file_types`: string[] - 支持的扩展名列表
- `file_type_descriptions`: map<string, string> - 格式描述
---
## Golang 对接示例
### 1. 安装依赖
```bash
go get google.golang.org/grpc
go get google.golang.org/grpc/credentials/insecure
```
### 2. 生成 Go Proto 代码
需要先将 `proto/document_parser.proto` 生成 Go 代码:
```bash
# 方法一:使用 grpc_tools
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
protoc --go_out=. --go_opt=paths=source_relative \
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
proto/document_parser.proto
```
### 3. 完整调用代码
### 基础调用(无 VLM 配置时使用 MarkItDown
```go
package main
import (
"context"
"fmt"
"log"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
pb "your-project/proto" // 替换为你的 proto 包路径
)
func main() {
// 连接 gRPC 服务
conn, err := grpc.Dial(
"localhost:50051",
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithBlock(),
)
if err != nil {
log.Fatalf("连接失败: %v", err)
}
defer conn.Close()
client := pb.NewDocumentParserClient(conn)
ctx := context.Background()
// 调用 ParseDocument
req := &pb.ParseRequest{
FileUrl: "https://example.com/document.pdf",
FileName: "document.pdf",
}
resp, err := client.ParseDocument(ctx, req)
if err != nil {
log.Fatalf("解析失败: %v", err)
}
// 处理响应
if resp.Success {
fmt.Printf("解析成功!\n")
fmt.Printf("内容长度: %d 字符\n", resp.ContentLength)
fmt.Printf("Markdown 内容:\n%s\n", resp.Content)
} else {
fmt.Printf("解析失败: %s\n", resp.Message)
}
req := &pb.ParseRequest{
FileUrl: "https://example.com/document.pdf",
FileName: "document.pdf",
}
resp, client.ParseDocument(ctx, req)
```
### 4. 获取支持的格式
### 带 VLM 配置调用
```go
// 获取支持的文件格式
formatsReq := &pb.Empty{}
formatsResp, err := client.GetSupportedFormats(ctx, formatsReq)
if err != nil {
log.Fatal(err)
req := &pb.ParseRequest{
FileUrl: "https://example.com/image.png",
FileName: "image.png",
VlmConfig: &pb.VLMConfig{
Enabled: Provider: "open true,
ai",
Model: "gpt-4o",
ApiKey: "sk-xxx",
},
}
fmt.Println("支持的格式:")
for _, ft := range formatsResp.FileTypes {
desc := formatsResp.FileTypeDescriptions[ft]
fmt.Printf(" - %s: %s\n", ft, desc)
}
resp, err := client.ParseDocument(ctx, req)
```
---
## 解析逻辑
1. **图片文件** (jpg, png, webp 等)
- 如果配置了 VLM → 使用 VLM 解析
- 如果没有配置 VLM → 使用 MarkItDown 解析
2. **PDF/DOCX/PPTX 等文档**
- 使用 MarkItDown 解析
3. **VLM 优先级**
- gRPC 请求中的 vlm_config > 全局配置config.yaml/环境变量)
---
## 注意事项
1. **文件 URL**: 必须是可直接访问的 URL,服务会下载文件到内存解析
2. **文件名**: 必须带扩展名(如 `.pdf`, `.docx`),用于自动识别文件类型
3. **返回内容**: 直接返回 Markdown 格式文本,可用于向量检索或 LLM 处理
1. **文件 URL**: 必须是可直接访问的 URL
2. **文件名**: 必须带扩展名(如 `.pdf`, `.png`
3. **返回内容**: Markdown 格式文本

View File

@@ -6,10 +6,15 @@
- [ ] **AI-Core 文档解析服务对接**
- 服务ai-core (gRPC, 端口 50051)
- 功能将文档PDF/DOCX/PPTX 等)转换为 Markdown
- 功能将文档PDF/DOCX/PPTX/图片等)转换为 Markdown
- 对接方式gRPC 调用
- 详细需求:[ai-core-api.md](./ai-core-api.md)
- [ ] **VLM 调用支持**
- 支持 OpenAI GPT-4o、Anthropic Claude、阿里 Qwen VL
- 通过 vlm_config 配置启用
- 适用场景图片文件jpg, png 等)自动使用 VLM 解析
---
> 需求完成后请完成者打 ✔

58
web/package-lock.json generated
View File

@@ -8,10 +8,15 @@
"name": "x-agent-dashboard",
"version": "0.0.1",
"dependencies": {
"@vue-office/docx": "^1.6.3",
"@vue-office/excel": "^1.7.14",
"echarts": "^6.0.0",
"element-plus": "^2.13.3",
"marked": "^17.0.4",
"papaparse": "^5.5.3",
"pinia": "^2.1.7",
"vue": "^3.4.21",
"vue-demi": "^0.14.10",
"vue-router": "^4.3.0"
},
"devDependencies": {
@@ -1026,6 +1031,40 @@
"vscode-uri": "^3.0.8"
}
},
"node_modules/@vue-office/docx": {
"version": "1.6.3",
"resolved": "https://registry.npmmirror.com/@vue-office/docx/-/docx-1.6.3.tgz",
"integrity": "sha512-Cs+3CAaRBOWOiW4XAhTwwxJ0dy8cPIf6DqfNvYcD3YACiLwO4kuawLF2IAXxyijhbuOeoFsfvoVbOc16A/4bZA==",
"hasInstallScript": true,
"license": "MIT",
"peerDependencies": {
"@vue/composition-api": "^1.7.1",
"vue": "^2.0.0 || >=3.0.0",
"vue-demi": "^0.14.6"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/@vue-office/excel": {
"version": "1.7.14",
"resolved": "https://registry.npmmirror.com/@vue-office/excel/-/excel-1.7.14.tgz",
"integrity": "sha512-pVUgt+emDQUnW7q22CfnQ+jl43mM/7IFwYzOg7lwOwPEbiVB4K4qEQf+y/bc4xGXz75w1/e3Kz3G6wAafmFBFg==",
"hasInstallScript": true,
"license": "MIT",
"peerDependencies": {
"@vue/composition-api": "^1.7.1",
"vue": "^2.0.0 || >=3.0.0",
"vue-demi": "^0.14.6"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/@vue/compiler-core": {
"version": "3.5.29",
"resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.29.tgz",
@@ -1862,6 +1901,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/marked": {
"version": "17.0.4",
"resolved": "https://registry.npmmirror.com/marked/-/marked-17.0.4.tgz",
"integrity": "sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz",
@@ -1988,6 +2039,12 @@
"node": ">= 6"
}
},
"node_modules/papaparse": {
"version": "5.5.3",
"resolved": "https://registry.npmmirror.com/papaparse/-/papaparse-5.5.3.tgz",
"integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==",
"license": "MIT"
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
@@ -2701,6 +2758,7 @@
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"

View File

@@ -9,10 +9,15 @@
"preview": "vite preview"
},
"dependencies": {
"@vue-office/docx": "^1.6.3",
"@vue-office/excel": "^1.7.14",
"echarts": "^6.0.0",
"element-plus": "^2.13.3",
"marked": "^17.0.4",
"papaparse": "^5.5.3",
"pinia": "^2.1.7",
"vue": "^3.4.21",
"vue-demi": "^0.14.10",
"vue-router": "^4.3.0"
},
"devDependencies": {

View File

@@ -5,6 +5,7 @@ defineProps<{
description?: string
icon?: string
iconClass?: string
class?: string
}>()
const emit = defineEmits<{
@@ -18,7 +19,7 @@ const close = () => {
<template>
<Teleport to="body">
<div v-if="modelValue" class="dialog-overlay" @click.self="close">
<div v-if="modelValue" class="dialog-overlay" :class="$props.class" @click.self="close">
<div class="dialog-container">
<!-- 头部 -->
<div class="dialog-header">
@@ -61,7 +62,7 @@ const close = () => {
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
z-index: 2000;
}
.dialog-container {

View File

@@ -21,12 +21,15 @@ const mainMenu: MenuItem[] = [
{ name: 'Dashboard', icon: 'fa-gauge', path: '/dashboard' },
{ name: 'Agents', icon: 'fa-robot', badge: 3, path: '/agents' },
{ name: 'Team', icon: 'fa-users', path: '/team' },
{ name: 'Skills', icon: 'fa-wand-magic-sparkles', badge: 21, path: '/mcp' },
{ name: 'Tools', icon: 'fa-tools', badge: 13, path: '/model-apis' },
{ name: 'Database', icon: 'fa-database', path: '/database' },
{ name: 'Knowledge', icon: 'fa-brain', path: '/knowledge' },
]
const middleMenu: MenuItem[] = [
{ name: 'Skills', icon: 'fa-wand-magic-sparkles', badge: 21, path: '/mcp' },
{ name: 'Tools', icon: 'fa-tools', badge: 13, path: '/model-apis' },
]
const bottomMenu: MenuItem[] = [
{ name: 'Settings', icon: 'fa-gear', path: '/settings' },
]
@@ -117,6 +120,25 @@ const handleUserCommand = (command: string) => {
<!-- 分隔线 -->
<li class="my-4 border-t border-dark-500"></li>
<!-- Skills & Tools -->
<li v-for="item in middleMenu" :key="item.name">
<a
href="#"
class="flex items-center justify-between px-3 py-2.5 rounded-lg transition-colors text-sm"
:class="activeMenu === item.name ? 'bg-dark-600 text-white' : 'text-gray-400 hover:bg-dark-600 hover:text-white'"
@click="navigateTo(item)"
>
<div class="flex items-center gap-3">
<i :class="['fa-solid', item.icon, 'w-5', 'text-center']"></i>
<span>{{ item.name }}</span>
</div>
<span v-if="item.badge" class="bg-dark-500 text-xs px-2 py-0.5 rounded-full">{{ item.badge }}</span>
</a>
</li>
<!-- 分隔线 -->
<li class="my-4 border-t border-dark-500"></li>
<!-- Settings -->
<li v-for="item in bottomMenu" :key="item.name">
<a

View File

@@ -3,6 +3,10 @@ import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { useModelSettings } from './settings/useModelSettings'
import { fetchKnowledgeBases, createKnowledgeBase as apiCreateKnowledgeBase, deleteKnowledgeBase as apiDeleteKnowledgeBase, fetchKnowledgeDocuments } from './knowledge/useKnowledge'
import VueOfficeDocx from '@vue-office/docx'
import VueOfficeExcel from '@vue-office/excel'
import Papa from 'papaparse'
import { marked } from 'marked'
import './knowledge/knowledge.css'
// 获取已配置的模型列表
@@ -135,7 +139,12 @@ const loadingDocuments = ref(false)
const fileInput = ref<HTMLInputElement | null>(null)
const uploading = ref(false)
const previewUrl = ref('') // 文档预览URL (blob URL)
const previewHtml = ref('') // HTML内容预览
const previewContentType = ref('') // content_type: url 或 html
const previewDownloadUrl = ref('') // 原始下载链接
const previewFileType = ref('') // 文件类型: pdf, docx, xlsx, csv
const previewCsvData = ref<any[]>([]) // CSV数据
const previewCsvHeaders = ref<string[]>([]) // CSV表头
const loadingPreview = ref(false)
const previewPage = ref(1) // 当前页码
const previewTotalPages = ref(1) // 总页数
@@ -157,6 +166,20 @@ const loadPdfWithProxy = async (doc: any): Promise<string> => {
}
}
// 将URL转换为blob URL (用于Excel组件)
const loadFileAsBlob = async (url: string): Promise<Blob> => {
try {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status}`)
}
return await response.blob()
} catch (error) {
console.error('Failed to load file as blob:', error)
throw error
}
}
const newKbForm = ref({
name: '',
description: '',
@@ -337,6 +360,11 @@ const enterKnowledge = async (kb: any) => {
selectedKnowledge.value = kb
selectedFile.value = null
previewUrl.value = ''
previewHtml.value = ''
previewContentType.value = ''
previewFileType.value = ''
previewCsvData.value = []
previewCsvHeaders.value = []
previewDownloadUrl.value = ''
// 获取文档列表
@@ -375,13 +403,126 @@ const selectDocument = async (doc: any) => {
selectedFile.value = doc.id
selectedDocument.value = doc
previewUrl.value = ''
previewHtml.value = ''
previewContentType.value = ''
previewDownloadUrl.value = ''
previewFileType.value = ''
previewCsvData.value = []
previewCsvHeaders.value = []
previewPage.value = 1
previewTotalPages.value = 1
// 优先使用代理接口加载PDF
// 检测文件类型
const fileName = doc.name || ''
const ext = fileName.split('.').pop()?.toLowerCase() || ''
console.log('File name:', fileName, 'Ext:', ext)
if (ext === 'pdf') {
previewFileType.value = 'pdf'
} else if (ext === 'docx' || ext === 'doc') {
previewFileType.value = 'docx'
} else if (ext === 'xlsx' || ext === 'xls') {
previewFileType.value = 'xlsx'
} else if (ext === 'csv') {
previewFileType.value = 'csv'
} else if (ext === 'txt' || ext === 'md') {
previewFileType.value = 'text'
}
console.log('Preview file type:', previewFileType.value)
// 纯文本文件 (txt, md) 直接读取内容显示
if (previewFileType.value === 'text') {
// 优先使用 file_url上传时后端返回的
let url = doc.file_url || doc.fileUrl || doc.url || doc.FileURL || ''
// 如果没有 file_url尝试用 file_key 通过代理
if (!url && doc.file_key && selectedKnowledge.value) {
url = await loadPdfWithProxy(doc)
}
console.log('Text file URL:', url)
if (url) {
try {
const response = await fetch(url)
console.log('Response status:', response.status)
const arrayBuffer = await response.arrayBuffer()
const decoder = new TextDecoder('utf-8')
const content = decoder.decode(arrayBuffer)
console.log('Content length:', content.length, 'ext:', ext)
// .md 文件用 marked 渲染
if (ext === 'md') {
// marked.parse 返回 Promise需要 await
const html = await marked.parse(content)
previewHtml.value = html as string
} else {
// .txt 文件直接显示
previewHtml.value = '<pre style="white-space: pre-wrap; word-wrap: break-word; color: #e8eaed;">' + content + '</pre>'
}
console.log('PreviewHtml set, length:', previewHtml.value.length)
return
} catch (error) {
console.error('Failed to load text file:', error)
}
} else {
console.log('No URL for text file, file_key:', doc.file_key, 'file_url:', doc.file_url)
}
}
// CSV需要特殊处理用PapaParse解析
if (previewFileType.value === 'csv' && (doc.file_key || doc.file_url)) {
const url = doc.file_key && selectedKnowledge.value
? await loadPdfWithProxy(doc)
: doc.file_url || doc.fileUrl || doc.url || ''
if (url) {
try {
const response = await fetch(url)
const arrayBuffer = await response.arrayBuffer()
// 尝试多种编码解码
let csvText = ''
const encodings = ['utf-8', 'gbk', 'gb2312', 'big5']
for (const encoding of encodings) {
try {
const decoder = new TextDecoder(encoding)
csvText = decoder.decode(arrayBuffer)
// 检查是否成功解码(如果没有乱码字符)
if (!csvText.includes('\uFFFD')) {
console.log(`CSV decoded with: ${encoding}`)
break
}
} catch (e) {
continue
}
}
const result = Papa.parse(csvText, { header: false, skipEmptyLines: true })
if (result.data && result.data.length > 0) {
previewCsvHeaders.value = result.data[0] as string[]
previewCsvData.value = result.data.slice(1) as any[][]
}
return
} catch (error) {
console.error('Failed to parse CSV:', error)
}
}
}
// 使用代理接口加载文件
if (doc.file_key && selectedKnowledge.value) {
previewUrl.value = await loadPdfWithProxy(doc)
const proxyUrl = await loadPdfWithProxy(doc)
// Excel/Word需要blob URL
if ((previewFileType.value === 'xlsx' || previewFileType.value === 'docx') && proxyUrl) {
try {
const blob = await loadFileAsBlob(proxyUrl)
const blobUrl = URL.createObjectURL(blob)
previewUrl.value = blobUrl
return
} catch (error) {
console.error('Failed to convert to blob:', error)
}
}
previewUrl.value = proxyUrl
if (previewUrl.value) {
return
}
@@ -397,11 +538,19 @@ const selectDocument = async (doc: any) => {
// 如果没有file_url调用预览API获取
if (selectedKnowledge.value) {
loadingPreview.value = true
previewUrl.value = ''
previewHtml.value = ''
previewContentType.value = ''
try {
const { getDocumentPreview } = await import('./knowledge/useKnowledge')
const result = await getDocumentPreview(selectedKnowledge.value.id, doc.id)
if (result.success && result.data?.content) {
previewUrl.value = result.data.content
previewContentType.value = result.data.content_type || 'url'
if (previewContentType.value === 'html') {
previewHtml.value = result.data.content
} else {
previewUrl.value = result.data.content
}
previewTotalPages.value = result.data.total_pages || 1
previewPage.value = result.data.current_page || 1
}
@@ -416,6 +565,8 @@ const selectDocument = async (doc: any) => {
// 翻页
const changePreviewPage = async (page: number) => {
if (!selectedKnowledge.value || !selectedFile.value || page < 1 || page > previewTotalPages.value) return
// HTML内容不支持翻页
if (previewContentType.value === 'html') return
loadingPreview.value = true
previewPage.value = page
@@ -423,7 +574,12 @@ const changePreviewPage = async (page: number) => {
const { getDocumentPreview } = await import('./knowledge/useKnowledge')
const result = await getDocumentPreview(selectedKnowledge.value.id, selectedFile.value, page)
if (result.success && result.data?.content) {
previewUrl.value = result.data.content
previewContentType.value = result.data.content_type || 'url'
if (previewContentType.value === 'html') {
previewHtml.value = result.data.content
} else {
previewUrl.value = result.data.content
}
previewTotalPages.value = result.data.total_pages || 1
previewPage.value = result.data.current_page || page
}
@@ -459,16 +615,8 @@ const handleFileSelect = async (event: Event) => {
// 获取刚上传的文档
const uploadedDoc = knowledgeDocuments.value.find(d => d.id === result.id)
if (uploadedDoc) {
// 选中新上传的文档
selectedFile.value = result.id
selectedDocument.value = uploadedDoc
// 使用代理接口加载PDF
if (uploadedDoc.file_key) {
previewUrl.value = await loadPdfWithProxy(uploadedDoc)
} else if (uploadedDoc.file_url) {
previewUrl.value = uploadedDoc.file_url
}
// 选中新上传的文档,调用 selectDocument 处理预览
await selectDocument(uploadedDoc)
}
} else {
ElMessage.error(result.message || 'Failed to upload file')
@@ -497,7 +645,12 @@ const deleteDocument = async (docId: string) => {
selectedFile.value = null
selectedDocument.value = null
previewUrl.value = ''
previewDownloadUrl.value = ''
previewHtml.value = ''
previewContentType.value = ''
previewFileType.value = ''
previewCsvData.value = []
previewCsvHeaders.value = []
previewDownloadUrl.value = ''
}
// 刷新文档列表
await changeFileFilter(fileFilter.value)
@@ -873,7 +1026,7 @@ const deleteDocument = async (docId: string) => {
type="file"
ref="fileInput"
style="display: none"
accept=".pdf,.doc,.docx,.txt,.md"
accept=".pdf,.doc,.docx,.docx,.txt,.md,.csv,.xlsx,.xls,.pptx,.ppt"
@change="handleFileSelect"
/>
<button class="btn-primary" @click="triggerFileUpload">
@@ -987,7 +1140,40 @@ const deleteDocument = async (docId: string) => {
<i class="fa-solid fa-spinner fa-spin"></i>
<span>Loading preview...</span>
</div>
<!-- 有blob预览URL时显示PDF (使用iframe) -->
<!-- Word文件预览 -->
<VueOfficeDocx
v-else-if="previewUrl && previewFileType === 'docx'"
:src="previewUrl"
class="office-embed"
@rendered="console.log('Docx rendered')"
@error="console.error('Docx error', $event)"
/>
<!-- Excel文件预览 -->
<VueOfficeExcel
v-else-if="previewUrl && previewFileType === 'xlsx'"
:src="previewUrl"
class="office-embed"
@rendered="console.log('Excel rendered')"
@error="console.error('Excel error', $event)"
/>
<!-- CSV文件预览 -->
<div v-else-if="previewFileType === 'csv' && previewCsvData.length > 0" class="csv-preview">
<table>
<thead>
<tr>
<th v-for="(header, index) in previewCsvHeaders" :key="index">{{ header }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIndex) in previewCsvData" :key="rowIndex">
<td v-for="(cell, cellIndex) in row" :key="cellIndex">{{ cell }}</td>
</tr>
</tbody>
</table>
</div>
<!-- 文本文件预览 (txt, md) -->
<div v-else-if="previewHtml" class="text-preview" v-html="previewHtml"></div>
<!-- PDF/其他文件预览 (使用iframe) -->
<iframe
v-else-if="previewUrl"
:src="previewUrl"
@@ -996,9 +1182,9 @@ const deleteDocument = async (docId: string) => {
<!-- 无预览但有下载链接时显示下载按钮 -->
<div v-else-if="previewDownloadUrl" class="preview-no-file">
<i class="fa-solid fa-file-pdf"></i>
<span>Cannot preview PDF directly</span>
<span>Cannot preview file directly</span>
<a :href="previewDownloadUrl" target="_blank" class="download-link">
<i class="fa-solid fa-download"></i> Download PDF
<i class="fa-solid fa-download"></i> Download File
</a>
</div>
<!-- 无预览也无下载链接时显示提示 -->

View File

@@ -241,6 +241,7 @@ const showChangePassword = () => {
description="Configure your model settings"
icon="fa-solid fa-brain"
icon-class="bg-gradient-to-br from-primary-orange to-red-500"
class="add-model-dialog"
>
<div class="space-y-4">
<div>

View File

@@ -943,6 +943,166 @@
border: none;
}
/* HTML内容预览 */
.html-preview {
width: 100%;
height: 100%;
overflow: auto;
padding: 20px;
background-color: #1e1e24;
color: #e8eaed;
line-height: 1.6;
}
.html-preview table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
}
.html-preview table th,
.html-preview table td {
border: 1px solid #3a3a4a;
padding: 8px 12px;
text-align: left;
}
.html-preview table th {
background-color: #2a2a3a;
}
.html-preview h1, .html-preview h2, .html-preview h3 {
margin-top: 16px;
margin-bottom: 8px;
color: #ffffff;
}
.html-preview pre {
background-color: #2a2a3a;
padding: 12px;
border-radius: 6px;
overflow-x: auto;
}
.html-preview code {
background-color: #2a2a3a;
padding: 2px 6px;
border-radius: 4px;
font-family: monospace;
}
/* Office 组件预览 */
.office-embed {
width: 100%;
height: 100%;
overflow: auto;
background-color: #ffffff;
}
/* 修复 vue-office 表格黑色背景问题 */
.office-embed :deep(table) {
background-color: #ffffff !important;
color: #333333 !important;
}
.office-embed :deep(td),
.office-embed :deep(th) {
background-color: #ffffff !important;
color: #333333 !important;
border: 1px solid #dddddd !important;
}
/* CSV 表格预览 */
.csv-preview {
width: 100%;
height: 100%;
overflow: auto;
background-color: #ffffff;
padding: 16px;
}
.csv-preview table {
width: 100%;
border-collapse: collapse;
}
.csv-preview th,
.csv-preview td {
border: 1px solid #dddddd;
padding: 8px 12px;
text-align: left;
color: #333333;
}
.csv-preview th {
background-color: #f5f5f5;
font-weight: 600;
}
.csv-preview tr:nth-child(even) {
background-color: #fafafa;
}
/* 文本文件预览 */
.text-preview {
width: 100%;
height: 100%;
overflow: auto;
padding: 16px;
background-color: #1e1e24;
color: #e8eaed;
}
.text-preview pre {
white-space: pre-wrap;
word-wrap: break-word;
color: #e8eaed;
font-family: monospace;
font-size: 14px;
line-height: 1.6;
}
/* Markdown 渲染样式 */
.text-preview h1, .text-preview h2, .text-preview h3,
.text-preview h4, .text-preview h5, .text-preview h6 {
color: #ffffff;
margin-top: 16px;
margin-bottom: 8px;
}
.text-preview code {
background-color: #2a2a3a;
padding: 2px 6px;
border-radius: 4px;
font-family: monospace;
}
.text-preview pre {
background-color: #2a2a3a;
padding: 12px;
border-radius: 6px;
overflow-x: auto;
}
.text-preview ul, .text-preview ol {
padding-left: 24px;
}
.text-preview li {
margin-bottom: 4px;
}
.text-preview a {
color: #f97316;
}
.text-preview blockquote {
border-left: 4px solid #f97316;
padding-left: 16px;
margin-left: 0;
color: #9ca3af;
}
.preview-info {
display: grid;
grid-template-columns: repeat(2, 1fr);

View File

@@ -256,10 +256,14 @@
color: #6b7280;
}
.add-model-dialog :deep(.el-select) {
.add-model-dialog .el-select {
width: 100%;
}
.add-model-dialog .el-select .el-input {
pointer-events: auto;
}
.add-model-dialog :deep(.el-select .el-input__wrapper) {
background-color: #171922;
border: 1px solid #4b5563;
@@ -276,6 +280,7 @@
.add-model-dialog :deep(.el-select-dropdown) {
background-color: #1a1a24;
border: 1px solid #2a2a3a;
z-index: 10000;
}
.add-model-dialog :deep(.el-select-dropdown__item) {