diff --git a/ai_engines/ollama_engine.py b/ai_engines/ollama_engine.py index 1cfe2bb..978ed09 100644 --- a/ai_engines/ollama_engine.py +++ b/ai_engines/ollama_engine.py @@ -1,4 +1,5 @@ import os +import re import sys import json import requests @@ -21,6 +22,13 @@ current_dir = os.path.dirname(os.path.abspath(__file__)) project_root = os.path.dirname(current_dir) sys.path.insert(0, project_root) +# 特殊语法解析(配置模型时可交互式询问用户) +try: + from xcli_core.config import resolve_input_value +except Exception: + def resolve_input_value(value, field_name="", default_prompt=None): + return value + class OllamaAI: """基于Ollama的本地AI引擎""" @@ -62,6 +70,9 @@ Ollama AI引擎插件帮助信息 self.conversation_history = [] # 直接从 config.json 读取配置 + # 模型最大 token 大小(单位 K,如 32 = 32K = 32768 token) + # 支持 语法:配置里写 会在配置模型时询问用户 + max_token_k = "" config_file = os.path.join(project_root, "config.json") try: with open(config_file, 'r', encoding='utf-8') as f: @@ -71,12 +82,17 @@ Ollama AI引擎插件帮助信息 self.base_url = engine_config.get("base_url", "") self.model = engine_config.get("model", "") self.max_history = config.get("system", {}).get("max_history", 10) + max_token_k = engine_config.get("max_token_k", max_token_k) except Exception as e: # 如果读取失败,使用空值 self.api_key = "" self.base_url = "" self.model = "" self.max_history = 10 + + # 解析 语法(询问用户)并换算成 num_ctx(token 数) + resolved_k = resolve_input_value(max_token_k, field_name="模型最大token大小(K)") + self.num_ctx = self._parse_num_ctx(resolved_k) # 共享对话历史引用(由CLI设置) self.shared_conversation_history = None @@ -93,6 +109,23 @@ Ollama AI引擎插件帮助信息 else: print(f"{Fore.RED}Ollama服务未运行,引擎将不可用{Style.RESET_ALL}") + @staticmethod + def _parse_num_ctx(value): + """把用户输入的 K 值换算成 token 数:32 / 32K → 32768。非法/空 → 4096""" + if value is None: + return 4096 + s = str(value).strip().lower() + digits = re.sub(r"[^\d.]", "", s) + if not digits: + return 4096 + try: + k = float(digits) + except ValueError: + return 4096 + if k <= 0: + return 4096 + return max(1024, int(k * 1024)) + def check_ollama_service(self): """检测Ollama服务是否运行""" # 检查 ollama 库是否可用 @@ -136,11 +169,12 @@ Ollama AI引擎插件帮助信息 else: messages = self._build_messages_with_history(system_prompt, user_input) - # 构建调用参数 + # 构建调用参数(num_ctx: 模型上下文窗口大小,由配置的 max_token_k 决定) kwargs = { "model": self.model, "messages": messages, - "stream": False + "stream": False, + "options": {"num_ctx": self.num_ctx} } # ── Function Calling: 注入工具定义 ── @@ -217,13 +251,11 @@ Ollama AI引擎插件帮助信息 role = msg["role"] - # tool 角色消息(FC 工具结果) + # tool 角色消息(FC 工具结果)—— 官方格式不含 name 字段 if role == "tool": tool_msg = {"role": "tool", "content": msg.get("content", "")} if "tool_call_id" in msg: tool_msg["tool_call_id"] = msg["tool_call_id"] - if "name" in msg: - tool_msg["name"] = msg["name"] messages.append(tool_msg) continue diff --git a/ai_engines/openai_engine.py b/ai_engines/openai_engine.py index 36c72dd..26dc9b2 100644 --- a/ai_engines/openai_engine.py +++ b/ai_engines/openai_engine.py @@ -22,6 +22,9 @@ except Exception: # ── 常见模型的上下文窗口(token 数)── # OpenAI /chat/completions 协议本身不返回模型窗口,需本地维护。 MODEL_CONTEXT_WINDOWS = { + # 讯飞星火(Spark,经 one-api/new-api 转发时模型名常为 spark-* / general*) + "spark-4.0": 128000, "spark-max": 8192, "spark-pro": 8192, "spark-lite": 8192, + "spark": 8192, "generalv3.5": 8192, "generalv3": 8192, "general": 4096, "gpt-4o": 128000, "gpt-4o-mini": 128000, "gpt-4": 8192, "gpt-4-turbo": 128000, "gpt-3.5-turbo": 16385, "deepseek-chat": 64000, "deepseek-reasoner": 64000, @@ -181,10 +184,19 @@ OpenAI 兼容格式引擎帮助信息 "stream": False } - # ── Function Calling: 注入工具定义 ── + # ── Function Calling: 注入工具定义(统一纯官方格式)── if tools: - body["tools"] = tools - body["tool_choice"] = "auto" + pure_tools = [] + for t in tools: + if not isinstance(t, dict): + continue + if t.get("type") == "function" and isinstance(t.get("function"), dict): + pure_tools.append(t) + elif isinstance(t.get("function"), dict): + pure_tools.append({"type": "function", "function": t["function"]}) + if pure_tools: + body["tools"] = pure_tools + body["tool_choice"] = "auto" # 发送请求 api_url = self._get_api_url() @@ -247,9 +259,29 @@ OpenAI 兼容格式引擎帮助信息 if "choices" in rd and rd["choices"]: ai_response = rd["choices"][0].get("message", {}).get("content", "") return ai_response.encode('utf-8', 'replace').decode('utf-8') if ai_response else "" - return f"API 调用失败: {response.status_code} - {response.text}" + return self._friendly_error(response.status_code, response) else: - return f"API 调用失败: {response.status_code} - {response.text}" + # 500 等错误:部分服务(如讯飞星火经 one-api 转发)不支持 FC 工具, + # 会直接报 500 Invalid Params —— 尝试去掉 tools 降级重试一次 + lower = str(response.text).lower() + if tools and ("xunfei" in lower or "invalid params" in lower or "requestparamserror" in lower): + print(f"{Fore.YELLOW}该接口可能不支持 FC 工具,降级为普通模式重试...{Style.RESET_ALL}") + body.pop("tools", None) + body.pop("tool_choice", None) + retry = requests.post(url=api_url, json=body, headers=self._get_headers(), timeout=None) + if retry.status_code == 200: + rd = retry.json() + retry_usage = rd.get("usage", {}) + if isinstance(retry_usage, dict) and retry_usage.get("prompt_tokens"): + try: + self.last_prompt_tokens = int(retry_usage["prompt_tokens"]) + except (TypeError, ValueError): + pass + if "choices" in rd and rd["choices"]: + ai_response = rd["choices"][0].get("message", {}).get("content", "") + ai_response = self._process_thinking_content(ai_response) + return ai_response.encode('utf-8', 'replace').decode('utf-8') if ai_response else "" + return self._friendly_error(response.status_code, response) except requests.exceptions.ConnectionError: return f"无法连接到 API 服务: {self.base_url},请检查服务是否运行" @@ -276,22 +308,20 @@ OpenAI 兼容格式引擎帮助信息 role = msg["role"] - # tool 角色消息(FC 工具结果) + # tool 角色消息(FC 工具结果)—— 官方 OpenAI 格式不含 name 字段 if role == "tool": tool_msg = {"role": "tool", "content": msg.get("content", "")} if "tool_call_id" in msg: tool_msg["tool_call_id"] = msg["tool_call_id"] - if "name" in msg: - tool_msg["name"] = msg["name"] messages.append(tool_msg) continue - # assistant 消息可能包含 tool_calls + # assistant 消息可能包含 tool_calls —— 重建成纯官方结构 if role == "assistant" and "tool_calls" in msg: assistant_msg = { "role": "assistant", - "content": msg.get("content"), - "tool_calls": msg["tool_calls"] + "content": msg.get("content") or "", + "tool_calls": self._pure_tool_calls(msg["tool_calls"]) } messages.append(assistant_msg) continue @@ -372,6 +402,58 @@ OpenAI 兼容格式引擎帮助信息 return response + @staticmethod + def _friendly_error(status_code, response): + """把错误响应里的可读信息提取出来,附带常见原因提示""" + try: + data = response.json() + err = data.get("error", {}) + msg = err.get("message", "") if isinstance(err, dict) else "" + except Exception: + msg = "" + if not msg: + return f"API 调用失败: {status_code} - {response.text}" + + hint = "" + low = str(msg).lower() + if "invalid params" in low or "requestparamserror" in low or "xunfei" in low: + hint = ("\n提示: 讯飞星火接口报'参数错误',常见原因:" + "\n 1) model 名与 one-api/new-api 渠道里的模型映射不一致(如 spark-4.0-ultra / spark-max / generalv3.5)" + "\n 2) 该模型/渠道不支持 function calling(引擎已尝试去掉 tools 重试)" + "\n 3) base_url 需为 OpenAI 兼容转发地址(如 one-api 的 https://域名/v1)") + return f"API 调用失败: {status_code} - {msg}{hint}" + + @staticmethod + def _pure_tool_calls(tool_calls): + """把历史里的 tool_calls 重建成官方 OpenAI 格式: + [{"id": ..., "type": "function", "function": {"name": ..., "arguments": "json串"}}] + 剔除内部字段(action/tool/args 等),arguments 统一为字符串。 + """ + pure = [] + for tc in (tool_calls or []): + if not isinstance(tc, dict): + continue + func = tc.get("function") + if isinstance(func, dict): + name = func.get("name", tc.get("name", "")) + args = func.get("arguments", "{}") + else: + name = tc.get("name", "") + args = tc.get("arguments", "{}") + if isinstance(args, str): + args_str = args + else: + try: + args_str = json.dumps(args, ensure_ascii=False) + except (TypeError, ValueError): + args_str = "{}" + pure.append({ + "id": tc.get("id", f"call_{name}"), + "type": "function", + "function": {"name": name, "arguments": args_str} + }) + return pure + def set_model(self, model_name): """设置模型""" self.model = model_name diff --git a/config.json.example b/config.json.example index ccd07ef..a529ef4 100644 --- a/config.json.example +++ b/config.json.example @@ -4,7 +4,8 @@ "ollama": { "api_key": "", "base_url": "http://localhost:11434", - "model": "gemma4:31b" + "model": "gemma4:31b", + "max_token_k": "" }, "openai": { "api_key": "your-api-key-here", diff --git a/launcher.py b/launcher.py index a0348c3..56b8e91 100644 --- a/launcher.py +++ b/launcher.py @@ -16,6 +16,13 @@ from pathlib import Path from datetime import datetime import platform +# 特殊语法:配置模型时可用它交互式询问用户并获取输入 +try: + from xcli_core.config import resolve_input_value +except Exception: + def resolve_input_value(value, field_name="", default_prompt=None): + return value + # ── 编码设置 ── if sys.stdout.encoding != 'utf-8': sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8') @@ -471,6 +478,12 @@ class Launcher: entry['base_url'] = 'https://api.deepseek.com/v1' entry['model'] = 'deepseek-chat' config['api']['engines'][name] = entry + if name == 'ollama': + # 模型最大 token 大小(单位 K,如 32 = 32K = 32768 token) + # 默认用 语法:配置模型时交互式询问用户 + config['api']['engines'][name].setdefault( + 'max_token_k', '' + ) self.config_file.write_text(json.dumps(config, ensure_ascii=False, indent=2), 'utf-8') return config @@ -498,7 +511,7 @@ class Launcher: info(f'当前密钥: {masked}') if prompt_yn(f'配置 {name} 的 API 密钥?', 'n'): key = self.secret_input(f'{name} API 密钥') - cfg['api_key'] = key + cfg['api_key'] = resolve_input_value(key, field_name='API 密钥') # URL current_url = cfg.get('base_url', '') @@ -506,7 +519,7 @@ class Launcher: info(f'当前 URL: {current_url}') if prompt_yn(f'配置 {name} 的 Base URL?', 'n'): url = prompt('Base URL', current_url) - cfg['base_url'] = url + cfg['base_url'] = resolve_input_value(url, field_name='Base URL') # Model current_model = cfg.get('model', '') @@ -514,7 +527,26 @@ class Launcher: info(f'当前模型: {current_model}') if prompt_yn(f'配置 {name} 的模型?', 'n'): model = prompt('模型名称', current_model) - cfg['model'] = model + cfg['model'] = resolve_input_value(model, field_name='模型') + + # 最大 token 大小(仅 ollama;单位 K,如 32 = 32K = 32768 token;支持 语法) + if name == 'ollama': + current_ctx = cfg.get('max_token_k', '') + is_syntax = isinstance(current_ctx, str) and ' 特殊语法:配置模型时可用它交互式询问用户并获取输入 +# → 用默认提示询问 +# → 用自定义提示语询问 +_INPUT_PATTERN = re.compile(r"]*))?>") + + +def resolve_input_value(value, field_name="", default_prompt=None): + """解析配置值中的 特殊语法,交互式询问用户并返回其输入。 + + 用法(写在 config.json 的字段值里): + "model": "" → 启动时提示"请输入model: " + "model": "" → 启动时提示自定义文案 + 也可以夹在字符串中间: "prefixsuffix" + + 不含 的值原样返回;用户直接回车/中断时返回空串,由调用方兜底。 + """ + if not isinstance(value, str) or " Dict Returns: {"role": "tool", "tool_call_id": ..., "content": ...} + 注意: 官方 OpenAI 的 tool 消息不含 name 字段(严格服务端如讯飞会拒),故不写入。 """ return { "role": "tool", "tool_call_id": tool_call_id, - "name": tool_name, "content": result[:10000] if result else "" # 截断过长结果 }