feat(oss): 完成附件上传功能并测试 doxc 文件,GLM 成功识别
This commit is contained in:
parent
cb80d5cee7
commit
87efcdd296
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
// },
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
Loading…
Reference in New Issue