diff --git a/.gitignore b/.gitignore index 5b9e276..94c29f6 100644 --- a/.gitignore +++ b/.gitignore @@ -16,13 +16,10 @@ uploads .venv __pycache__ .claude -<<<<<<< HEAD *.db -======= .trae .agent .agents ->>>>>>> feat/database # Editor directories and files .vscode/* @@ -34,7 +31,6 @@ __pycache__ *.njsproj *.sln *.sw? -<<<<<<< HEAD # Skills .skills @@ -42,9 +38,7 @@ __pycache__ .agents .trae skills-lock.json -======= *.db server/data/*.db -skills-lock.json ->>>>>>> feat/database +tsconfig.tsbuildinfo \ No newline at end of file diff --git a/server/adapters/dashscope_adapter.py b/server/adapters/dashscope_adapter.py index 1e4cb02..3bbdbf7 100644 --- a/server/adapters/dashscope_adapter.py +++ b/server/adapters/dashscope_adapter.py @@ -10,6 +10,7 @@ from typing import Dict, List from fastapi.responses import JSONResponse, StreamingResponse from .base import BaseAdapter, ChatCompletionRequest, ModelInfo +from .plugins import get_web_search_mode from core import get_logger logger = get_logger() @@ -29,7 +30,7 @@ DASHSCOPE_MODELS = [ max_tokens=8192, provider="Aliyun", supports_thinking=True, - supports_web_search=False, + supports_web_search=True, supports_vision=False, supports_files=False, ), @@ -40,7 +41,7 @@ DASHSCOPE_MODELS = [ max_tokens=8192, provider="Aliyun", supports_thinking=True, - supports_web_search=False, + supports_web_search=True, supports_vision=True, supports_files=False, ), @@ -51,7 +52,7 @@ DASHSCOPE_MODELS = [ max_tokens=8192, provider="Aliyun", supports_thinking=False, - supports_web_search=False, + supports_web_search=True, supports_vision=False, supports_files=False, ), @@ -188,7 +189,7 @@ class DashScopeAdapter(BaseAdapter): chunk_count = 0 error_occurred = False - # 构建 API 调用参数 + # 打印 API 调用参数 api_params = { "model": request.model, "messages": messages, @@ -197,6 +198,13 @@ class DashScopeAdapter(BaseAdapter): "max_tokens": request.max_tokens, "result_format": "message", } + + # 使用统一网络搜索配置 + web_search_mode = get_web_search_mode(request) + if web_search_mode: + api_params["enable_search"] = True + if web_search_mode == "deep": + api_params["search_options"] = {"enable_search_extension": True} # 添加深度思考参数 if thinking_enabled: @@ -330,6 +338,13 @@ class DashScopeAdapter(BaseAdapter): "max_tokens": request.max_tokens, "result_format": "message", } + + # 使用统一网络搜索配置 + web_search_mode = get_web_search_mode(request) + if web_search_mode: + api_params["enable_search"] = True + if web_search_mode == "deep": + api_params["search_options"] = {"enable_search_extension": True} # 添加深度思考参数 if thinking_enabled: @@ -531,6 +546,13 @@ class DashScopeAdapter(BaseAdapter): "max_tokens": request.max_tokens, "temperature": request.temperature, } + + # 使用统一网络搜索配置 + web_search_mode = get_web_search_mode(request) + if web_search_mode: + api_params["enable_search"] = True + if web_search_mode == "deep": + api_params["search_options"] = {"enable_search_extension": True} # 添加深度思考参数 if thinking_enabled: @@ -679,6 +701,13 @@ class DashScopeAdapter(BaseAdapter): "enable_thinking": False, "temperature": request.temperature, } + + # 使用统一网络搜索配置 + web_search_mode = get_web_search_mode(request) + if web_search_mode: + api_params["enable_search"] = True + if web_search_mode == "deep": + api_params["search_options"] = {"enable_search_extension": True} # 添加深度思考参数 if thinking_enabled: diff --git a/server/adapters/glm_adapter.py b/server/adapters/glm_adapter.py index 305eda4..0169594 100644 --- a/server/adapters/glm_adapter.py +++ b/server/adapters/glm_adapter.py @@ -11,6 +11,7 @@ from typing import Dict, List, Optional from fastapi.responses import JSONResponse, StreamingResponse from .base import BaseAdapter, ChatCompletionRequest, ModelInfo +from .plugins import get_web_search_mode, build_glm_search_tool from core import get_logger logger = get_logger() @@ -24,7 +25,7 @@ GLM_MODELS = [ max_tokens=128000, provider="ZhipuAI", supports_thinking=True, - supports_web_search=False, + supports_web_search=True, supports_vision=False, supports_files=False, ), @@ -64,11 +65,11 @@ GLM_MODELS = [ ModelInfo( id="glm-z1-flash", name="GLM-Z1 Flash", - description="深度思考推理模型", + description="深度思考推理模型,默认开启深度思考", max_tokens=128000, provider="ZhipuAI", supports_thinking=True, - supports_web_search=False, + supports_web_search=True, supports_vision=False, supports_files=False, ), @@ -129,10 +130,10 @@ class GLMAdapter(BaseAdapter): # 构建额外参数 extra_kwargs = {} - web_search = self._get_web_search_mode(request) + web_search_mode = get_web_search_mode(request) - if web_search: - extra_kwargs["tools"] = [self._build_web_search_tool(web_search)] + if web_search_mode: + extra_kwargs["tools"] = [build_glm_search_tool(web_search_mode)] extra_kwargs["tool_choice"] = "auto" # 深度思考:正向选择(True 时启用,False 时禁用) @@ -260,46 +261,6 @@ class GLMAdapter(BaseAdapter): """检查模型是否支持深度思考""" return model.lower() in THINKING_MODELS - def _get_web_search_mode(self, request: ChatCompletionRequest) -> str: - """获取联网搜索模式""" - if request.deep_search: - return "deep" - elif request.web_search: - return "simple" - return "" - - def _build_web_search_tool(self, mode: str) -> Dict: - """构建联网搜索工具""" - from datetime import datetime - - today = datetime.now().strftime("%Y年%m月%d日") - - if mode == "deep": - # 深度搜索:返回搜索结果详情 - return { - "type": "web_search", - "web_search": { - "enable": True, - "search_engine": "search_pro", - "search_result": True, - "search_prompt": f"你是一位智能助手。请用简洁的语言总结网络搜索{{search_result}}中的关键信息,按重要性排序并引用来源日期。今天的日期是{today}。", - "count": 5, - "search_recency_filter": "noLimit", - "content_size": "high", - }, - } - else: - # 简单搜索 - return { - "type": "web_search", - "web_search": { - "enable": True, - "search_engine": "search_pro", - "search_result": True, - "count": 5, - }, - } - def _stream_chat( self, client, messages, model, request, extra_kwargs ) -> StreamingResponse: diff --git a/server/adapters/openai_adapter.py b/server/adapters/openai_adapter.py index 066131e..5421a10 100644 --- a/server/adapters/openai_adapter.py +++ b/server/adapters/openai_adapter.py @@ -10,6 +10,7 @@ from typing import Dict, List, Optional from fastapi.responses import JSONResponse, StreamingResponse from .base import BaseAdapter, ChatCompletionRequest, ModelInfo +from .plugins import get_web_search_mode, build_openai_search_tool, execute_tavily_search, get_current_time_info from core import get_logger logger = get_logger() @@ -155,6 +156,21 @@ class OpenAIAdapter(BaseAdapter): # 构建消息 messages = self._build_messages(request) + + # 统一添加联网搜索插件参数 + web_search_mode = get_web_search_mode(request) + if web_search_mode: + # 注入当前时间信息到 System Prompt 中,以便模型拥有时间感知能力 + time_info = get_current_time_info() + has_system = False + for msg in messages: + if msg.get("role") == "system": + msg["content"] = f"当前系统时间:{time_info}\n" + str(msg.get("content", "")) + has_system = True + break + if not has_system: + messages.insert(0, {"role": "system", "content": f"当前系统时间:{time_info}"}) + logger.info( f" - messages: {json.dumps(messages, ensure_ascii=False, indent=2)}" ) @@ -167,6 +183,10 @@ class OpenAIAdapter(BaseAdapter): "max_tokens": request.max_tokens, "stream": request.stream, } + + if web_search_mode: + search_tool = build_openai_search_tool(web_search_mode) + kwargs["tools"] = [search_tool] # DeepSeek 深度思考支持 extra_body = None @@ -219,17 +239,27 @@ class OpenAIAdapter(BaseAdapter): def generator(): from utils.helpers import generate_unique_id, get_current_timestamp + + nonlocal kwargs - resp = client.chat.completions.create(**kwargs) - - full_content = "" - full_reasoning = "" - chunk_count = 0 - for chunk in resp: - if chunk.choices: + # 可能需要执行多轮对话(当发生工具调用时) + while True: + resp = client.chat.completions.create(**kwargs) + full_content = "" + full_reasoning = "" + chunk_count = 0 + + tool_calls = [] + current_tool_call = None + + for chunk in resp: + if not chunk.choices: + continue + chunk_count += 1 delta = chunk.choices[0].delta + # 1. 收集可能有内容/推理 delta_content = {} if hasattr(delta, "content") and delta.content: delta_content["content"] = delta.content @@ -238,7 +268,27 @@ class OpenAIAdapter(BaseAdapter): delta_content["reasoning_content"] = delta.reasoning_content full_reasoning += delta.reasoning_content - if delta_content: + # 2. 收集可能产生的 tool_calls (流式) + if hasattr(delta, "tool_calls") and delta.tool_calls: + for tool_call_chunk in delta.tool_calls: + idx = tool_call_chunk.index + # 确保 tool_calls 列表足够长 + while len(tool_calls) <= idx: + tool_calls.append({"id": "", "type": "function", "function": {"name": "", "arguments": ""}}) + + if tool_call_chunk.id: + tool_calls[idx]["id"] += tool_call_chunk.id + if tool_call_chunk.type: + # 对于 type, 因为 OpenAI 可能会传 chunks, 但通常只在第一块或者每块传, 为了避免 functionfunction, 使用赋值而非累加 + tool_calls[idx]["type"] = tool_call_chunk.type + if tool_call_chunk.function: + if tool_call_chunk.function.name: + tool_calls[idx]["function"]["name"] += tool_call_chunk.function.name + if tool_call_chunk.function.arguments: + tool_calls[idx]["function"]["arguments"] += tool_call_chunk.function.arguments + + # 3. 输出给前端普通文本 + if delta_content and not tool_calls: data = { "id": f"chatcmpl-{generate_unique_id()}", "object": "chat.completion.chunk", @@ -253,28 +303,72 @@ class OpenAIAdapter(BaseAdapter): ], } yield f"data: {json.dumps(data, ensure_ascii=False)}\n\n" + + # 检查此轮请求是否收到了完整工具调用,若是则执行搜索逻辑并追加继续请求,不再让外部函数退出 + if tool_calls: + logger.info(f"[{provider_name}] 检测到流式中包含了工具调用进行拦截并处理: {json.dumps(tool_calls, ensure_ascii=False)}") + + # 把大模型的工具调用请求也追加进去 + assistant_msg = { + "role": "assistant", + "content": full_content or None, # 如果工具和普通内容同时存在也保留 + "tool_calls": tool_calls + } + if full_reasoning: + assistant_msg["reasoning_content"] = full_reasoning + elif self._provider_type == "deepseek" and self._supports_thinking(kwargs["model"]): + # DeepSeek 推理模型在有工具调用时必须有 reasoning_content 字段 + assistant_msg["reasoning_content"] = "" + kwargs["messages"].append(assistant_msg) + + for tc in tool_calls: + if tc["function"]["name"] == "web_search": + try: + args = json.loads(tc["function"]["arguments"]) + query = args.get("query", "") + mode = "deep" if "advanced" in str(kwargs.get("tools", [])) else "simple" + logger.info(f"[{provider_name}] 执行搜索插件: {query}") + search_result = execute_tavily_search(query, mode=mode) + except Exception as e: + search_result = f"获取搜索参数或执行搜索失败: {str(e)}" + logger.error(search_result) + + # 把执行结果告诉大模型 + kwargs["messages"].append({ + "role": "tool", + "tool_call_id": tc["id"], + "name": "web_search", + "content": search_result + }) + + # 工具执行完毕,继续发起下一轮请求大模型归纳总结输出 + continue - finish = { - "id": f"chatcmpl-{generate_unique_id()}", - "object": "chat.completion.chunk", - "created": get_current_timestamp(), - "model": kwargs["model"], - "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], - } - yield f"data: {json.dumps(finish, ensure_ascii=False)}\n\n" - yield "data: [DONE]\n\n" + # 如果没有工具调用或者全部分发完毕,正常结束给前端 + finish = { + "id": f"chatcmpl-{generate_unique_id()}", + "object": "chat.completion.chunk", + "created": get_current_timestamp(), + "model": kwargs["model"], + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], + } + yield f"data: {json.dumps(finish, ensure_ascii=False)}\n\n" + yield "data: [DONE]\n\n" - # 打印流式响应结果 - logger.info(f"[{provider_name}] 流式响应完成:") - logger.info(f" - chunks: {chunk_count}") - logger.info(f" - content_length: {len(full_content)} 字符") - if full_reasoning: - logger.info(f" - reasoning_length: {len(full_reasoning)} 字符") - logger.info( - f" - content_preview: {full_content[:200]}..." - if len(full_content) > 200 - else f" - content: {full_content}" - ) + # 打印流式响应结果 + logger.info(f"[{provider_name}] 流式响应完成:") + logger.info(f" - chunks: {chunk_count}") + logger.info(f" - content_length: {len(full_content)} 字符") + if full_reasoning: + logger.info(f" - reasoning_length: {len(full_reasoning)} 字符") + logger.info( + f" - content_preview: {full_content[:200]}..." + if len(full_content) > 200 + else f" - content: {full_content}" + ) + + # 结束外层循环退出生成器 + break return StreamingResponse(generator(), media_type="text/event-stream") @@ -284,10 +378,58 @@ class OpenAIAdapter(BaseAdapter): """非流式聊天""" from utils.helpers import generate_unique_id, get_current_timestamp - resp = client.chat.completions.create(**kwargs) + while True: + resp = client.chat.completions.create(**kwargs) - message = resp.choices[0].message - content = message.content or "" + message = resp.choices[0].message + + # 判断是否涉及工具调用 + if hasattr(message, "tool_calls") and message.tool_calls: + # 记录这轮的助手回复 + assistant_msg = {"role": "assistant", "content": message.content or None} + # openai sdk 对象转 dict 存储 tool_calls + tool_calls_dict = [] + for tc in message.tool_calls: + tc_dict = { + "id": tc.id, + "type": tc.type, + "function": { + "name": tc.function.name, + "arguments": tc.function.arguments + } + } + tool_calls_dict.append(tc_dict) + assistant_msg["tool_calls"] = tool_calls_dict + if hasattr(message, "reasoning_content") and message.reasoning_content: + assistant_msg["reasoning_content"] = message.reasoning_content + elif self._provider_type == "deepseek" and self._supports_thinking(kwargs["model"]): + # DeepSeek 推理模型在有工具调用时必须有 reasoning_content 字段 + assistant_msg["reasoning_content"] = "" + kwargs["messages"].append(assistant_msg) + + # 执行所有的工具调用 + for tc in tool_calls_dict: + if tc["function"]["name"] == "web_search": + try: + args = json.loads(tc["function"]["arguments"]) + query = args.get("query", "") + mode = "deep" if "advanced" in str(kwargs.get("tools", [])) else "simple" + search_result = execute_tavily_search(query, mode=mode) + except Exception as e: + search_result = f"执行搜索失败: {str(e)}" + + # 把执行结果追加到消息中 + kwargs["messages"].append({ + "role": "tool", + "tool_call_id": tc["id"], + "name": "web_search", + "content": search_result + }) + # 工具调用完成,发起下一轮请求获取归纳答案 + continue + + # 处理普通的文本回复 + content = message.content or "" response = { "id": f"chatcmpl-{generate_unique_id()}", "object": "chat.completion", diff --git a/server/adapters/plugins.py b/server/adapters/plugins.py new file mode 100644 index 0000000..4a5fab1 --- /dev/null +++ b/server/adapters/plugins.py @@ -0,0 +1,119 @@ +import os +import urllib.request +import json +from typing import Dict +from datetime import datetime +from .base import ChatCompletionRequest + +def get_current_time_info() -> str: + """获取当前时间信息""" + now = datetime.now() + weekdays = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"] + return f"{now.strftime('%Y年%m月%d日 %H:%M:%S')} {weekdays[now.weekday()]}" + +def get_web_search_mode(request: ChatCompletionRequest) -> str: + """获取联网搜索模式""" + if getattr(request, 'deep_search', False): + return "deep" + elif getattr(request, 'web_search', False): + return "simple" + return "" + +def execute_tavily_search(query: str, mode: str = "simple") -> str: + """真实调用 Tavily 搜索 API""" + api_key = os.getenv("TAVILY_API_KEY") + if not api_key: + return "本地环境变量 TAVILY_API_KEY 未配置,无法进行搜索。" + + url = "https://api.tavily.com/search" + headers = {"Content-Type": "application/json"} + data = { + "api_key": api_key, + "query": query, + "search_depth": "advanced" if mode == "deep" else "basic", + "include_answer": False, + "max_results": 5 if mode == "deep" else 3 + } + + req = urllib.request.Request(url, data=json.dumps(data).encode('utf-8'), headers=headers, method='POST') + try: + with urllib.request.urlopen(req) as response: + result = json.loads(response.read().decode('utf-8')) + results = result.get("results", []) + + if not results: + return "搜索未返回结果。" + + formatted_res = [] + for i, res in enumerate(results): + formatted_res.append(f"[{i+1}] {res.get('title')}\n{res.get('content')}\n链接: {res.get('url')}") + + return "\n\n".join(formatted_res) + except Exception as e: + return f"搜索请求失败,错误: {str(e)}" + + + +def build_openai_search_tool(mode: str) -> Dict: + """ + 构建兼容型联网搜索插件工具结构 (供 DeepSeek / OpenAI SDK 使用) + 注意:此类提供标准的 Tool/Function Function calling 模板。 + 深度思考通常结合内置联网或者其他外挂流程实现。 + """ + if mode == "deep": + return { + "type": "function", + "function": { + "name": "web_search", + "description": "深度互联网搜索插件(查找并阅读网页内容)", + "parameters": {"type": "object", "properties": {"query": {"type": "string"}}}, + } + } + else: + return { + "type": "function", + "function": { + "name": "web_search", + "description": "进行互联网搜索并获取实时信息或资料以辅助回答。", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "要搜索的准确关键词或短语" + } + }, + "required": ["query"] + }, + } + } + +def build_glm_search_tool(mode: str) -> Dict: + """构建 GLM 联网搜索工具""" + today = get_current_time_info() + + if mode == "deep": + # 深度搜索:返回搜索结果详情 + return { + "type": "web_search", + "web_search": { + "enable": True, + "search_engine": "search_pro", + "search_result": True, + "search_prompt": f"你是一位智能助手。请用简洁的语言总结网络搜索{{search_result}}中的关键信息,按重要性排序并引用来源日期。今天的日期是{today}。", + "count": 5, + "search_recency_filter": "noLimit", + "content_size": "high", + }, + } + else: + # 简单搜索 + return { + "type": "web_search", + "web_search": { + "enable": True, + "search_engine": "search_pro", + "search_result": True, + "count": 5, + }, + } diff --git a/server/utils/test_tavily.py b/server/utils/test_tavily.py new file mode 100644 index 0000000..558a8f3 --- /dev/null +++ b/server/utils/test_tavily.py @@ -0,0 +1,46 @@ +import os +import sys +import urllib.request +import json + +def test_tavily(api_key: str): + url = "https://api.tavily.com/search" + headers = { + "Content-Type": "application/json" + } + data = { + "api_key": api_key, + "query": "武汉明天的天气", + "search_depth": "basic", + "include_answer": False, + "max_results": 3 + } + + # 模拟请求 + req = urllib.request.Request(url, data=json.dumps(data).encode('utf-8'), headers=headers, method='POST') + try: + with urllib.request.urlopen(req) as response: + result = json.loads(response.read().decode('utf-8')) + print("✅ Tavily API Key 测试成功!成功获取以下搜索结果:\n") + + for i, res in enumerate(result.get("results", [])): + print(f"[{i+1}] 标题: {res.get('title')}") + print(f" 内容: {res.get('content')}") + print(f" 链接: {res.get('url')}\n") + + except urllib.error.HTTPError as e: + print(f"❌ 请求失败,HTTP 错误代码: {e.code}") + print("这通常意味着您的 API Key 错误或无效。详细信息:") + error_msg = e.read().decode('utf-8') + print(error_msg) + except Exception as e: + print(f"❌ 发生其他错误: {str(e)}") + +if __name__ == "__main__": + key = input("请输入您的 Tavily API Key (以 tvly- 开头): ").strip() + if not key: + print("未输入 Key,程序退出。") + sys.exit(1) + + print("\n正在连接 Tavily 进行测试搜索...") + test_tavily(key) diff --git a/src/components/chat/ChatMain.vue b/src/components/chat/ChatMain.vue index eaa8baf..3c2bfba 100644 --- a/src/components/chat/ChatMain.vue +++ b/src/components/chat/ChatMain.vue @@ -180,6 +180,8 @@ async function handleSend( webSearch?: boolean; deepThinking?: boolean; systemPrompt?: string; + skipUserMessage?: boolean; + conversationTitle?: string; }, ) { // 检查认证状态 @@ -220,7 +222,13 @@ async function handleSend( // 如果没有当前对话,创建新对话 if (!currentConversation.value) { - await chatStore.createConversation(); + await chatStore.createConversation(options?.conversationTitle || text); + } else if (currentConversation.value.title === "新对话") { + // 如果当前对话是"新对话",用传入的标题或用户输入重命名 + chatStore.renameConversation( + currentConversation.value.id, + options?.conversationTitle || text + ); } // 获取系统提示词(优先使用传入的,否则使用会话设置) @@ -245,13 +253,15 @@ async function handleSend( .slice(-(MAX_HISTORY_ROUNDS * 2)) .map((m: any) => ({ role: m.role, content: m.content.text })); - // 添加用户消息 - await chatStore.addMessage(MessageRole.USER, { - type: MessageType.TEXT, - text, - images: attachments.filter((a) => a.type === "image"), - files: attachments.filter((a) => a.type === "file"), - }); + // 添加用户消息(如果不需要跳过) + if (!options?.skipUserMessage) { + await chatStore.addMessage(MessageRole.USER, { + type: MessageType.TEXT, + text, + images: attachments.filter((a) => a.type === "image"), + files: attachments.filter((a) => a.type === "file"), + }); + } // 添加 AI 消息占位符 const aiMessage = await chatStore.addMessage(MessageRole.ASSISTANT, { @@ -279,7 +289,7 @@ async function handleSend( const stream = chatApi.streamChat( { - message: text, + message: options?.skipUserMessage ? "直接输出系统提示词要求你的回答" : text, conversationId: currentConversation.value?.id || "", images: imageUrls, files: fileUrls, @@ -485,7 +495,11 @@ function handleRegenerate(messageId: string) { } function handleSuggestion(suggestion: Suggestion) { - handleSend(suggestion.text, [], { systemPrompt: suggestion.systemPrompt }); + handleSend(suggestion.text, [], { + systemPrompt: suggestion.systemPrompt, + skipUserMessage: true, + conversationTitle: suggestion.text, + }); } function focusInput() { diff --git a/src/components/chat/MessageList.vue b/src/components/chat/MessageList.vue index 040534f..28572ae 100644 --- a/src/components/chat/MessageList.vue +++ b/src/components/chat/MessageList.vue @@ -229,7 +229,10 @@ defineExpose({ }); onMounted(() => { - scrollToBottom(false); + // 只有当有消息时才滚动到底部,否则保持在顶部显示欢迎界面 + if (visibleMessages.value.length > 0) { + scrollToBottom(false); + } }); diff --git a/src/components/chat/WelcomeScreen.vue b/src/components/chat/WelcomeScreen.vue index dbd72b0..f414f45 100644 --- a/src/components/chat/WelcomeScreen.vue +++ b/src/components/chat/WelcomeScreen.vue @@ -2,7 +2,7 @@
-

教研聊天助手

+

学习、教学、科研聊天助手

diff --git a/src/components/input/ChatInput.vue b/src/components/input/ChatInput.vue index d0d4d8f..9f195e8 100644 --- a/src/components/input/ChatInput.vue +++ b/src/components/input/ChatInput.vue @@ -54,6 +54,7 @@ v-model="inputText" :placeholder="placeholder" :rows="1" + @beforeinput="handleBeforeInput" @input="autoResize" @focus="isFocused = true" @blur="isFocused = false" @@ -90,13 +91,18 @@
+ + - - - -
- -
- - {{ charCount }} / {{ maxChars }} - - - {{ sendOnEnter ? "Enter 发送, Shift+Enter 换行" : "Ctrl+Enter 发送" }} -
@@ -190,7 +178,7 @@ const props = withDefaults( placeholder: "输入你的问题...", isStreaming: false, sendOnEnter: false, - maxChars: 4000, + maxChars: 10000, disabled: false, // 默认全部支持 supports_thinking: true, @@ -200,6 +188,14 @@ const props = withDefaults( }, ); +// 强制深度思考的模型列表(这些模型默认开启深度思考且不可关闭) +const FORCE_DEEP_THINKING_MODELS = ["deepseek-reasoner", "glm-z1-flash"]; + +// 判断当前模型是否强制开启深度思考 +const isForceDeepThinkingModel = computed(() => { + return FORCE_DEEP_THINKING_MODELS.includes(modelName.value.toLowerCase()); +}); + const emit = defineEmits<{ send: [ text: string, @@ -212,6 +208,7 @@ const emit = defineEmits<{ // 响应式状态 const authStore = useAuthStore(); const settingsStore = useSettingsStore(); +const modelName = computed(() => settingsStore.settings.defaultModel); const inputText = ref(""); const attachments = ref([]); @@ -231,6 +228,18 @@ const textareaRef = ref(null); const fileInputRef = ref(null); const imageInputRef = ref(null); +// toast 节流 +let lastToastTime = 0; +const toastThrottleMs = 2000; + +function showThrottledToast(message: string, type: "error" = "error") { + const now = Date.now(); + if (now - lastToastTime >= toastThrottleMs) { + lastToastTime = now; + window.$toast?.(message, type); + } +} + // 计算属性 const charCount = computed(() => inputText.value.length); const isUploading = computed(() => attachments.value.some((a) => a.uploading)); @@ -251,7 +260,29 @@ function autoResize() { textarea.style.height = "auto"; const maxHeight = isExpanded.value ? 400 : 160; // 增加1px是为了避免滚动条出现 - textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight)+1}px`; + textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight) + 1}px`; +} + +// 处理输入前事件,限制字数 +function handleBeforeInput(event: InputEvent) { + // 如果不是插入文本的操作(如删除、退格等),允许 + if (!event.data) return; + + // 检查输入后是否会超过限制 + const currentLength = inputText.value.length; + const insertLength = event.data?.length || 0; + const selectionStart = + (event.target as HTMLTextAreaElement).selectionStart || 0; + const selectionEnd = (event.target as HTMLTextAreaElement).selectionEnd || 0; + const selectedLength = selectionEnd - selectionStart; + + // 计算输入后的长度 + const newLength = currentLength - selectedLength + insertLength; + + if (newLength > props.maxChars) { + event.preventDefault(); + showThrottledToast(`已超${props.maxChars}字上限,请删除部分内容`); + } } // 处理键盘事件 @@ -298,6 +329,22 @@ async function handlePaste(event: ClipboardEvent) { const items = event.clipboardData?.items; if (!items) return; + // 检查粘贴文本是否会超过字数限制 + const text = event.clipboardData?.getData("text"); + if (text) { + const textarea = event.target as HTMLTextAreaElement; + const selectionStart = textarea.selectionStart || 0; + const selectionEnd = textarea.selectionEnd || 0; + const selectedLength = selectionEnd - selectionStart; + const newLength = inputText.value.length - selectedLength + text.length; + + if (newLength > props.maxChars) { + event.preventDefault(); + showThrottledToast(`已超${props.maxChars}字上限,请删除部分内容`); + return; + } + } + for (const item of items) { if (item.type.startsWith("image/")) { event.preventDefault(); @@ -350,7 +397,7 @@ async function addFileAsAttachment( ) { // 检查认证状态 if (!authStore.isAuthenticated) { - window.$toast?.('请先登录', 'error'); + window.$toast?.("请先登录", "error"); return; } @@ -414,17 +461,17 @@ async function removeAttachment(id: string) { const attachment = attachments.value[index]; // 如果已上传到 OSS(不是本地 blob URL),则从 OSS 删除 - if (attachment.url && !attachment.url.startsWith('blob:')) { + if (attachment.url && !attachment.url.startsWith("blob:")) { try { await chatApi.deleteAttachment(attachment.url); } catch (error) { - console.error('删除 OSS 文件失败:', error); + console.error("删除 OSS 文件失败:", error); // 即使删除失败也继续移除本地引用 } } // 释放 blob URL(如果是本地的) - if (attachment.url.startsWith('blob:')) { + if (attachment.url.startsWith("blob:")) { URL.revokeObjectURL(attachment.url); } @@ -491,10 +538,20 @@ watch(inputText, () => { // 监听模型变化,重置选项功能 watch( () => settingsStore.settings.defaultModel, - () => { + (newModel) => { + // 如果是强制深度思考的模型,自动开启深度思考 + if (FORCE_DEEP_THINKING_MODELS.includes(newModel.toLowerCase())) { + isDeepThinking.value = true; + localStorage.setItem("isDeepThinking", "true"); + } else { + isDeepThinking.value = false; + localStorage.setItem("isDeepThinking", "false"); + } + // 重置搜索相关选项 isDeepSearch.value = false; - isDeepThinking.value = false; isWebSearch.value = false; + localStorage.setItem("isDeepSearch", "false"); + localStorage.setItem("isWebSearch", "false"); }, ); @@ -718,26 +775,6 @@ onMounted(() => { } } -.toolbar-right { - display: flex; - align-items: center; - gap: 16px; -} - -.char-count { - font-size: 12px; - color: #9ca3af; - - &.warning { - color: #f59e0b; - } -} - -.send-hint { - font-size: 12px; - color: #9ca3af; -} - @keyframes pulse { 0%, 100% { diff --git a/src/components/message/MessageBubble.vue b/src/components/message/MessageBubble.vue index 54c4a7d..a5a9ea3 100644 --- a/src/components/message/MessageBubble.vue +++ b/src/components/message/MessageBubble.vue @@ -24,14 +24,14 @@
-
+
@@ -183,7 +183,7 @@