feat(oss): 完成附件上传功能并测试 doxc 文件,GLM 成功识别

This commit is contained in:
肖应宇 2026-03-05 10:51:18 +08:00
parent cb80d5cee7
commit 87efcdd296
7 changed files with 312 additions and 69 deletions

View File

@ -46,27 +46,18 @@ def get_client():
# ── 模型映射 ──────────────────────────────────────────────────────────
DEFAULT_TEXT_MODEL = "glm-4.5-Air" # glm-4.6 文本统一模型
DEFAULT_VISION_MODEL = "glm-4.6v"
MODEL_MAP = {
"qwen-max": "glm-4.5-Air",
"qwen-plus": "glm-4.5-Air",
"qwen-turbo": "glm-4.5-Air",
"qwen-vl-max": "glm-4.5-Air",
"qwen-vl-plus": "glm-4.5-Air",
}
DEFAULT_TEXT_MODEL = "glm-4-flash" # 默认文本模型
DEFAULT_VISION_MODEL = "glm-4.6v" # 图片/附件识别用 glm-4.6v
def resolve_model(model: str, has_vision: bool = False) -> str:
if model.startswith("glm"):
return model
mapped = MODEL_MAP.get(model, DEFAULT_TEXT_MODEL)
# 当消息包含图片时,强制使用视觉模型
if has_vision and mapped != DEFAULT_VISION_MODEL:
print(f"[GLM] 检测到图片,模型从 {mapped} 切换为 {DEFAULT_VISION_MODEL}")
# 当消息包含图片或附件时,使用视觉模型
if has_vision:
print(f"[GLM] 检测到图片/附件,使用视觉模型:{model}{DEFAULT_VISION_MODEL}")
return DEFAULT_VISION_MODEL
return mapped
# 普通文本对话,保持原模型不变
print(f"[GLM] 使用模型:{model}")
return model
# ── 文件上传(含 file_id 缓存)───────────────────────────────────────
@ -156,6 +147,10 @@ def build_glm_messages(messages: list, files: list | None = None) -> tuple[list,
img_val.get("url", "") if isinstance(img_val, dict) else img_val
)
new_content.append(encode_image(img_src))
elif t == "file_url":
# file_url 类型PDF/DOCX/TXT 等文档链接)原样透传
has_vision = True
new_content.append(item)
else:
new_content.append({"type": "text", "text": str(item)})
glm_messages.append({"role": role, "content": new_content})
@ -164,7 +159,20 @@ def build_glm_messages(messages: list, files: list | None = None) -> tuple[list,
# 处理独立附件列表
if files:
doc_exts = {".pdf", ".doc", ".docx", ".xlsx", ".xls", ".pptx", ".ppt"}
doc_exts = {
".pdf",
".doc",
".docx",
".xlsx",
".xls",
".pptx",
".ppt",
".txt",
".md",
".csv",
".json",
".log",
}
img_exts = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"}
inserts = []
@ -172,6 +180,20 @@ def build_glm_messages(messages: list, files: list | None = None) -> tuple[list,
parsed = urlparse(file_url)
filename = parsed.path.split("/")[-1]
suffix = Path(filename).suffix.lower()
# ── 远程 URLOSS 等)→ 直接透传 ─────────────────
if file_url.startswith(("http://", "https://")):
has_vision = True
if suffix in img_exts:
inserts.append(
{"type": "image_url", "image_url": {"url": file_url}}
)
else:
# 文档/文本类统一走 file_url
inserts.append({"type": "file_url", "file_url": {"url": file_url}})
continue
# ── 本地文件回退逻辑 ──────────────────────────────
rel = parsed.path.lstrip("/")
local = Path(rel)

View File

@ -0,0 +1,171 @@
"""
测试脚本上传 PDF / DOCX / TXT 文件到阿里云 OSS 获取 URL 发送给 GLM-4.6V 识别
支持的文件类型:
- .pdf 上传 OSS 后以 file_url 类型发送 URL GLM
- .docx 上传 OSS 后以 file_url 类型发送 URL GLM
- .txt 上传 OSS 后以 file_url 类型发送 URL GLM
用法:
cd server
source ~/.bashrc && source .venv/bin/activate
python -m utils.test_oss_doc_glm --file <本地文件路径> [--prompt "请总结这份文件"]
"""
import argparse
import sys
from pathlib import Path
# 确保 server 目录在 sys.path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from utils.oss_uploader import upload_file
from utils.glm_adapter import glm_chat_sync
# 文件类型分组
DOC_EXTS = {".pdf", ".doc", ".docx", ".xlsx", ".xls", ".pptx", ".ppt"}
TXT_EXTS = {".txt", ".md", ".csv", ".json", ".log"}
IMG_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"}
# 所有可通过 file_url 发送的类型
FILE_URL_EXTS = DOC_EXTS | TXT_EXTS
def detect_file_type(suffix: str) -> str:
"""根据后缀判断文件类别: 'file_url' / 'image' / 'unknown'"""
suffix = suffix.lower()
if suffix in FILE_URL_EXTS:
return "file_url"
elif suffix in IMG_EXTS:
return "image"
return "unknown"
def build_messages_for_file_url(file_url: str, prompt: str) -> list:
"""
为文档/文本文件构建消息
使用 file_url 类型直接传递 OSS URL GLM
"""
return [
{
"role": "user",
"content": [
{
"type": "file_url",
"file_url": {"url": file_url},
},
{
"type": "text",
"text": prompt,
},
],
}
]
def build_messages_for_image(file_url: str, prompt: str) -> list:
"""为图片文件构建消息,使用 image_url 类型。"""
return [
{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": file_url}},
{"type": "text", "text": prompt},
],
}
]
def main():
parser = argparse.ArgumentParser(
description="上传 PDF/DOCX/TXT 文件到 OSS 并让 GLM-4.6V 识别"
)
parser.add_argument("--file", required=True, help="要上传的本地文件路径")
parser.add_argument(
"--prompt", default="请总结这份文件的主要内容", help="发给 GLM 的提示词"
)
parser.add_argument(
"--model", default="glm-4.6v", help="GLM 模型名称(默认: glm-4.6v"
)
args = parser.parse_args()
file_path = Path(args.file).resolve()
if not file_path.exists():
print(f"❌ 文件不存在: {file_path}")
sys.exit(1)
suffix = file_path.suffix.lower()
file_type = detect_file_type(suffix)
print(f"📂 文件信息:")
print(f" 路径: {file_path}")
print(f" 后缀: {suffix}")
print(f" 类型: {file_type}")
print(f" 大小: {file_path.stat().st_size / 1024:.1f} KB")
print()
# ── 第一步:上传文件到 OSS ────────────────────────────────
print(f"📤 正在上传文件到阿里云 OSS...")
oss_result = upload_file(str(file_path))
file_url = oss_result["url"]
print(f"✅ OSS 上传成功!")
print(f" URL: {file_url}")
print(f" ETag: {oss_result['etag']}")
print()
# ── 第二步:根据文件类型构建消息 ──────────────────────────
print(f"🔧 正在构建 GLM 消息...")
if file_type == "file_url":
# PDF / DOCX / TXT 等:使用 file_url 类型发送 OSS URL
print(f" 策略: 使用 file_url 发送 OSS 链接")
messages = build_messages_for_file_url(file_url, args.prompt)
elif file_type == "image":
# 图片:使用 image_url
print(f" 策略: 使用 image_url 发送 OSS 链接")
messages = build_messages_for_image(file_url, args.prompt)
else:
print(f"❌ 不支持的文件类型: {suffix}")
print(f" 支持: {', '.join(sorted(FILE_URL_EXTS | IMG_EXTS))}")
sys.exit(1)
print()
# ── 第三步:发送给 GLM 识别 ──────────────────────────────
print(f"🤖 正在请求 GLM ({args.model}) 识别文件...")
print(f" 提示词: {args.prompt}")
print()
try:
result = glm_chat_sync(
messages=messages,
model=args.model,
temperature=0.7,
max_tokens=4096,
)
print("" * 60)
print("📝 GLM 回复:")
print("" * 60)
print(result["content"])
print("" * 60)
if result.get("usage"):
usage = result["usage"]
print(
f"\n📊 Token 用量: 输入 {usage['promptTokens']} | "
f"输出 {usage['completionTokens']} | "
f"总计 {usage['totalTokens']}"
)
print(f"\n✅ 测试完成! 使用模型: {result.get('model', args.model)}")
except Exception as e:
print(f"\n❌ GLM 请求失败: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
main()

View File

@ -143,7 +143,6 @@ const props = withDefaults(
showSidebarToggle?: boolean;
isWideMode?: boolean;
isPinned?: boolean;
currentModelId?: string;
}>(),
{
title: "新对话",
@ -151,7 +150,6 @@ const props = withDefaults(
showSidebarToggle: true,
isWideMode: true,
isPinned: false,
currentModelId: "gpt-4",
},
);
@ -164,7 +162,6 @@ const emit = defineEmits<{
pin: [];
archive: [];
settings: [];
"select-model": [modelId: string];
"conversation-settings": [];
}>();
@ -173,25 +170,29 @@ const showMoreMenu = ref(false);
const settingsStore = useSettingsStore();
const currentModel = ref(localStorage.getItem("modelSelect") || "");
const currentModelId = ref(settingsStore.getSelectedModelId());
const models: any = ref([]);
onMounted(() => {
chatApi.getModels().then((res: any) => {
models.value = res;
if (!localStorage.getItem("modelSelect")) {
currentModel.value = models.value[0]["name"] || "";
localStorage.setItem("modelSelect", currentModel.value);
//
const model = models.value?.find((m: any) => m.id === currentModelId.value);
if (model) {
currentModel.value = model.name;
} else if (models.value.length > 0) {
currentModel.value = models.value[0].name;
currentModelId.value = models.value[0].id;
}
localStorage.setItem("modelSelect", currentModel.value);
});
});
function selectModel(modelId: string, modelName: string) {
currentModel.value = modelName;
currentModelId.value = modelId;
localStorage.setItem("modelSelect", modelName);
const model = models.value?.find((m: any) => m.id === modelId);
if (model) {
currentModel.value = model.name;
emit("select-model", modelId);
}
settingsStore.setSelectedModelId(modelId); // ID
showModelMenu.value = false;
}

View File

@ -71,12 +71,13 @@
<button
v-else
class="action-btn send"
:class="{ active: canSend }"
:class="{ active: canSend, loading: isUploading }"
:disabled="!canSend"
title="发送消息 (Ctrl+Enter)"
:title="isUploading ? '上传中...' : '发送消息 (Ctrl+Enter)'"
@click="handleSend"
>
<Send :size="20" />
<Loader2 v-if="isUploading" :size="20" class="animate-spin" />
<Send v-else :size="20" />
</button>
</div>
</div>
@ -151,6 +152,7 @@ import {
Maximize2,
Minimize2,
Brain,
Loader2,
} from "@/components/icons";
import AttachmentPreview from "./AttachmentPreview.vue";
import { generateId } from "@/utils/helpers";
@ -209,11 +211,15 @@ const imageInputRef = ref<HTMLInputElement | null>(null);
//
const charCount = computed(() => inputText.value.length);
const isUploading = computed(() =>
attachments.value.some((a) => a.uploading),
);
const canSend = computed(() => {
return (
(inputText.value.trim().length > 0 || attachments.value.length > 0) &&
!props.disabled &&
charCount.value <= props.maxChars
charCount.value <= props.maxChars &&
!isUploading.value
);
});
@ -355,6 +361,9 @@ async function uploadFileToServer(id: string, file: File) {
attachment.uploading = false;
attachment.progress = 100;
}
//
window.$toast && window.$toast('文件上传成功', 'success');
} catch (error) {
console.error('文件上传失败:', error);
@ -521,6 +530,12 @@ onMounted(() => {
}
}
&.loading {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
cursor: wait;
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;

View File

@ -212,14 +212,20 @@ const { showConversationSettingsModal: visible, settings } =
storeToRefs(settingsStore);
const availableModels: any = ref([]);
const modelSelect = ref(localStorage.getItem("modelSelect") || "");
const currentModelId = ref(settingsStore.getSelectedModelId());
onMounted(() => {
chatApi.getModels().then((res: any) => {
availableModels.value = res;
if (!localStorage.getItem("modelSelect")) {
modelSelect.value = availableModels.value[0]["name"] || "";
localStorage.setItem("modelSelect", modelSelect.value);
//
const model = availableModels.value?.find((m: any) => m.id === currentModelId.value);
if (model) {
modelSelect.value = model.name;
} else if (availableModels.value.length > 0) {
modelSelect.value = availableModels.value[0].name;
currentModelId.value = availableModels.value[0].id;
}
localStorage.setItem("modelSelect", modelSelect.value);
});
});
@ -273,8 +279,15 @@ const presetPrompts = [
},
];
function updateSelect(data: any) {
localStorage.setItem("modelSelect", data);
function updateSelect(modelName: any) {
modelSelect.value = modelName;
localStorage.setItem("modelSelect", modelName);
// ID
const model = availableModels.value?.find((m: any) => m.name === modelName);
if (model) {
currentModelId.value = model.id;
settingsStore.setSelectedModelId(model.id);
}
}
//

View File

@ -108,7 +108,7 @@ class ChatApi {
// 将前端简化的请求翻译为 OpenAI 兼容的规范请求体
const openAiRequest = {
model: request.model || "qwen-plus", // 可能需要指定支持视觉的模型
model: request.model || "glm-4-flash", // 可能需要指定支持视觉的模型
messages: [
{
role: "system",
@ -248,19 +248,20 @@ class ChatApi {
async getModels(): Promise<ModelInfo[]> {
return [
{
id: "qwen-max",
name: "通义千问 Max",
id: "glm-4.6",
name: "智普 GLM-4.6",
description: "最强大的模型",
maxTokens: 8192,
provider: "Aliyun",
},
{
id: "qwen-plus",
name: "通义千问 Plus",
description: "能力均衡",
maxTokens: 8192,
provider: "Aliyun",
provider: "Zhipu",
},
// GLM-4.5,联网搜索功能有问题
// {
// id: "glm-4.5",
// name: "智普 GLM-4.5",
// description: "能力均衡",
// maxTokens: 8192,
// provider: "Zhipu",
// },
];
}

View File

@ -16,7 +16,7 @@ export const useSettingsStore = defineStore('settings', () => {
compactMode: false,
// AI 默认设置
defaultModel: 'qwen-plus',
defaultModel: 'glm-4.6',
defaultTemperature: 0.7,
defaultMaxTokens: 4096,
defaultSystemPrompt: '你是一个有帮助的 AI 助手。',
@ -34,32 +34,32 @@ export const useSettingsStore = defineStore('settings', () => {
// 可用的 AI 模型
const availableModels: AIModel[] = [
{
id: 'qwen-max',
name: '通义千问 Max',
description: '最强大的模型,适合复杂任务',
maxTokens: 8192,
provider: 'Aliyun',
},
id: "glm-4.6",
name: "智普 GLM-4.6",
description: "最强大的模型",
maxTokens: 8192,
provider: "Zhipu",
},
{
id: "glm-4.5",
name: "智普 GLM-4.5",
description: "能力均衡",
maxTokens: 8192,
provider: "Zhipu",
},
{
id: 'qwen-plus',
name: '通义千问 Plus',
description: '能力均衡,更快的响应速度',
maxTokens: 8192,
provider: 'Aliyun',
},
{
id: 'qwen-turbo',
name: '通义千问 Turbo',
id: 'glm-4-flash',
name: '智普 GLM-4-Flash',
description: '快速高效,适合日常对话',
maxTokens: 8192,
provider: 'Aliyun',
provider: 'Zhipu',
},
{
id: 'qwen-vl-max',
name: '通义千问 VL-Max',
id: 'glm-4v-plus',
name: '智普 GLM-4V-Plus',
description: '强大的视觉理解模型',
maxTokens: 8192,
provider: 'Aliyun',
provider: 'Zhipu',
},
]
@ -212,6 +212,24 @@ export const useSettingsStore = defineStore('settings', () => {
}
}
// 存储选中模型 ID 的 localStorage key
const MODEL_ID_KEY = 'modelSelectId'
// 获取当前选择的模型 ID
function getSelectedModelId(): string {
return defaultSettings.defaultModel
}
// 设置当前选择的模型 ID
function setSelectedModelId(modelId: string) {
localStorage.setItem(MODEL_ID_KEY, modelId)
// 同时更新 settings 中的 defaultModel
settings.value.defaultModel = modelId
saveToStorage()
}
function loadFromStorage() {
try {
const stored = localStorage.getItem('chat-settings')
@ -280,5 +298,7 @@ export const useSettingsStore = defineStore('settings', () => {
exportSettings,
importSettings,
loadFromStorage,
getSelectedModelId,
setSelectedModelId,
}
})