From 87efcdd2962d432d79504f0e59d60e9d850be4de Mon Sep 17 00:00:00 2001 From: MT-Fire <798521692@qq.com> Date: Thu, 5 Mar 2026 10:51:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(oss):=20=E5=AE=8C=E6=88=90=E9=99=84?= =?UTF-8?q?=E4=BB=B6=E4=B8=8A=E4=BC=A0=E5=8A=9F=E8=83=BD=E5=B9=B6=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=20doxc=20=E6=96=87=E4=BB=B6=EF=BC=8CGLM=20=E6=88=90?= =?UTF-8?q?=E5=8A=9F=E8=AF=86=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/utils/glm_adapter.py | 58 ++++-- server/utils/test_oss_doc_glm.py | 171 ++++++++++++++++++ src/components/chat/ChatHeader.vue | 23 +-- src/components/input/ChatInput.vue | 23 ++- .../modals/ConversationSettingsModal.vue | 23 ++- src/services/api.ts | 23 +-- src/stores/settings.ts | 60 ++++-- 7 files changed, 312 insertions(+), 69 deletions(-) create mode 100644 server/utils/test_oss_doc_glm.py diff --git a/server/utils/glm_adapter.py b/server/utils/glm_adapter.py index 157c8a9..a4270b7 100644 --- a/server/utils/glm_adapter.py +++ b/server/utils/glm_adapter.py @@ -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() + + # ── 远程 URL(OSS 等)→ 直接透传 ───────────────── + 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) diff --git a/server/utils/test_oss_doc_glm.py b/server/utils/test_oss_doc_glm.py new file mode 100644 index 0000000..6a1d35a --- /dev/null +++ b/server/utils/test_oss_doc_glm.py @@ -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() diff --git a/src/components/chat/ChatHeader.vue b/src/components/chat/ChatHeader.vue index e3afcf6..105001b 100644 --- a/src/components/chat/ChatHeader.vue +++ b/src/components/chat/ChatHeader.vue @@ -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; } diff --git a/src/components/input/ChatInput.vue b/src/components/input/ChatInput.vue index 255d3d6..bfbb964 100644 --- a/src/components/input/ChatInput.vue +++ b/src/components/input/ChatInput.vue @@ -71,12 +71,13 @@ @@ -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(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; diff --git a/src/components/modals/ConversationSettingsModal.vue b/src/components/modals/ConversationSettingsModal.vue index e53b24f..3658c47 100644 --- a/src/components/modals/ConversationSettingsModal.vue +++ b/src/components/modals/ConversationSettingsModal.vue @@ -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); + } } // 格式化日期 diff --git a/src/services/api.ts b/src/services/api.ts index 4f7998a..e15aa29 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -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 { 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", + // }, ]; } diff --git a/src/stores/settings.ts b/src/stores/settings.ts index 512f604..be9b3f5 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -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, } }) \ No newline at end of file