Files

1 line
225 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "cangyeone/sage", "files": {"web_app/routes/llm.py": {"5ef458cab746280eb97d2dfb27bd545c578d5400": {"t": 1785746376.4386458, "n": 24876, "h": "5fc0a19131931741", "c": "\"\"\"LLM 配置路由\"\"\"\nfrom flask import Blueprint, request, jsonify\nimport sys\nimport json\nimport threading\nimport time as _time\nimport urllib.request as _ur\nfrom pathlib import Path\nfrom state import _pull_status\n\nbp = Blueprint('llm', __name__)\n\ntry:\n from backend_manager import ONLINE_PROVIDERS\nexcept Exception:\n ONLINE_PROVIDERS = {\n \"openai\": {\"display\": \"OpenAI\", \"api_base\": \"https://api.openai.com/v1\", \"default_model\": \"gpt-4o-mini\", \"need_key\": True},\n \"deepseek\": {\"display\": \"DeepSeek\", \"api_base\": \"https://api.deepseek.com/v1\", \"default_model\": \"deepseek-v4-flash\", \"need_key\": True},\n \"siliconflow\": {\"display\": \"SiliconFlow\", \"api_base\": \"https://api.siliconflow.cn/v1\", \"default_model\": \"Qwen/Qwen2.5-7B-Instruct\", \"need_key\": True},\n \"moonshot\": {\"display\": \"Moonshot / Kimi\", \"api_base\": \"https://api.moonshot.cn/v1\", \"default_model\": \"moonshot-v1-8k\", \"need_key\": True},\n \"dashscope\": {\"display\": \"DashScope\", \"api_base\": \"https://dashscope.aliyuncs.com/compatible-mode/v1\", \"default_model\": \"qwen-turbo\", \"need_key\": True},\n \"zhipu\": {\"display\": \"Zhipu AI\", \"api_base\": \"https://open.bigmodel.cn/api/paas/v4\", \"default_model\": \"glm-4-flash\", \"need_key\": True},\n \"custom\": {\"display\": \"Custom OpenAI-compatible\", \"api_base\": \"\", \"default_model\": \"\", \"need_key\": True},\n }\n\n\ndef _public_online_providers():\n \"\"\"Provider metadata safe to send to the browser.\"\"\"\n return {\n key: {\n 'display': value.get('display', key),\n 'api_base': value.get('api_base', ''),\n 'default_model': value.get('default_model', ''),\n 'need_key': value.get('need_key', True),\n }\n for key, value in ONLINE_PROVIDERS.items()\n if key != 'anthropic'\n }\n\n\ndef _mask_api_key(config):\n config = dict(config or {})\n if config.get('api_key'):\n key = config['api_key']\n config['api_key_masked'] = '****' + key[-4:] if len(key) > 4 else '****'\n config['api_key'] = ''\n return config\n\n\ndef _mask_key(value):\n if not value:\n return ''\n value = str(value)\n return '****' + value[-4:] if len(value) > 4 else '****'\n\n\ndef _public_search_config(raw_search):\n from config_manager import SEARCH_PROVIDER_DEFAULTS\n raw_search = raw_search or {}\n providers = raw_search.get('providers') or {}\n out = {}\n for key, meta in SEARCH_PROVIDER_DEFAULTS.items():\n cfg = providers.get(key) or {}\n out[key] = {\n 'display': meta.get('display', key),\n 'description': meta.get('description', ''),\n 'requires_key': bool(meta.get('requires_key')),\n 'enabled': bool(cfg.get('enabled', meta.get('enabled', False))),\n 'api_key_masked': _mask_key(cfg.get('api_key', '')),\n 'api_base': cfg.get('api_base', ''),\n }\n return {\n 'providers': out,\n 'default_sources': raw_search.get('default_sources') or ['openalex', 'semantic_scholar'],\n }\n\n\ndef _parse_model_list(data):\n models = []\n if isinstance(data, dict) and isinstance(data.get('data'), list):\n models = [m['id'] for m in data['data'] if isinstance(m, dict) and m.get('id')]\n elif isinstance(data, dict) and isinstance(data.get('models'), list):\n models = [m.get('id') or m.get('name', '') for m in data['models'] if isinstance(m, dict)]\n elif isinstance(data, list):\n models = [\n m.get('id') or m.get('name', '')\n for m in data\n if isinstance(m, dict)\n ]\n return sorted({m for m in models if m})\n\n\ndef _fallback_model_candidates(provider, api_base, current_model):\n candidates = []\n if current_model:\n candidates.append(current_model)\n preset = ONLINE_PROVIDERS.get(provider, {})\n if preset.get('default_model'):\n candidates.append(preset['default_model'])\n base_l = (api_base or '').lower()\n if 'aliyuncs.com' in base_l or 'dashscope' in base_l:\n candidates.extend(['qwen3.6-plus', 'qwen-plus', 'qwen-turbo'])\n return list(dict.fromkeys(candidates))\n\n\ndef _chat_model_available(api_base, api_key, model):\n payload = json.dumps({\n 'model': model,\n 'messages': [{'role': 'user', 'content': 'hi'}],\n 'max_tokens': 1,\n 'temperature': 0,\n }).encode('utf-8')\n req = _ur.Request(\n f'{api_base.rstrip(\"/\")}/chat/completions',\n data=payload,\n headers={\n 'Content-Type': 'application/json',\n 'Authorization': f'Bearer {api_key}',\n },\n method='POST',\n )\n with _ur.urlopen(req, timeout=20) as resp:\n data = json.loads(resp.read().decode('utf-8'))\n return bool(data.get('choices'))\n\n\n@bp.route('/api/llm/config', methods=['GET'])\ndef llm_config_get():\n \"\"\"Get current LLM configuration (reads directly from config.json)\"\"\"\n from config_manager import LLMConfigManager\n cfg_mgr = LLMConfigManager()\n raw_config = cfg_mgr.config\n llm_config = dict(raw_config.get('llm', {}))\n\n if raw_config.get('active_backend') == 'online' and raw_config.get('online'):\n online = raw_config.get('online', {})\n llm_config.update({\n 'provider': online.get('provider', llm_config.get('provider', 'custom')),\n 'model': online.get('model', llm_config.get('model', '')),\n 'api_base': online.get('api_base', llm_config.get('api_base', '')),\n 'api_key': online.get('api_key', llm_config.get('api_key', '')),\n })\n elif raw_config.get('active_backend') == 'ollama' and raw_config.get('ollama'):\n ollama = raw_config.get('ollama', {})\n llm_config.update({\n 'provider': 'ollama',\n 'model': ollama.get('model', llm_config.get('model', '')),\n 'api_base': ollama.get('api_base', llm_config.get('api_base', 'http://localhost:11434')),\n 'api_key': '',\n })\n\n return jsonify({\n 'config': _mask_api_key(llm_config),\n 'search': _public_search_config(cfg_mgr.get_search_config()),\n 'app_paths': cfg_mgr.get_app_paths(),\n 'coding_agent': cfg_mgr.get_coding_agent_config(),\n 'online_providers': _public_online_providers(),\n 'first_run': cfg_mgr.is_first_run(),\n 'ollama_available': cfg_mgr.check_ollama_available()\n })\n\n\n@bp.route('/api/llm/config', methods=['POST'])\ndef update_llm_config():\n \"\"\"Update LLM configuration\"\"\"\n from config_manager import get_config_manager\n data = request.get_json(silent=True) or {}\n config = get_config_manager()\n\n try:\n cfg = config.config\n llm = cfg.setdefault('llm', {})\n provider = (data.get('provider') or llm.get('provider') or 'ollama').strip()\n if provider not in ['ollama', 'openai', 'deepseek', 'siliconflow', 'moonshot', 'dashscope', 'zhipu', 'custom']:\n raise ValueError(f'Invalid provider: {provider}')\n\n if provider == 'ollama':\n api_base = (data.get('api_base') or 'http://localhost:11434').strip().rstrip('/')\n model = (data.get('model') or llm.get('model') or '').strip()\n llm.update({\n 'provider': 'ollama',\n 'model': model,\n 'api_base': api_base,\n 'api_key': '',\n })\n cfg['active_backend'] = 'ollama'\n cfg.setdefault('ollama', {}).update({\n 'model': model,\n 'api_base': api_base,\n })\n else:\n preset = ONLINE_PROVIDERS.get(provider, {})\n online = cfg.setdefault('online', {})\n api_base = (data.get('api_base') or preset.get('api_base') or online.get('api_base') or llm.get('api_base') or '').strip().rstrip('/')\n model = (data.get('model') or preset.get('default_model') or online.get('model') or llm.get('model') or '').strip()\n api_key = data.get('api_key')\n if api_key is None or api_key == '':\n api_key = online.get('api_key') or llm.get('api_key', '')\n else:\n api_key = api_key.strip()\n\n llm.update({\n 'provider': provider,\n 'model': model,\n 'api_base': api_base,\n 'api_key': api_key,\n })\n cfg['active_backend'] = 'online'\n online.update({\n 'provider': provider,\n 'api_base': api_base,\n 'api_key': api_key,\n 'model': model,\n })\n\n if 'temperature' in data:\n llm['temperature'] = data['temperature']\n if 'max_tokens' in data:\n llm['max_tokens'] = data['max_tokens']\n\n # Mark first run complete if this is the first setup\n if config.is_first_run():\n cfg['first_run'] = False\n\n config.save_config()\n return jsonify({'message': 'Configuration updated successfully', 'config': _mask_api_key(llm)})\n except Exception as e:\n return jsonify({'error': str(e)}), 400\n\n\n@bp.route('/api/config/search', methods=['GET', 'POST'])\ndef search_config():\n \"\"\"Get/update web/literature search provider configuration.\"\"\"\n from config_manager import get_config_manager, SEARCH_PROVIDER_DEFAULTS\n mgr = get_config_manager()\n project_cfg = mgr.get_project_config()\n search = project_cfg.setdefault('search', {})\n providers = search.setdefault('providers', {})\n for key, meta in SEARCH_PROVIDER_DEFAULTS.items():\n providers.setdefault(key, {'enabled': meta['enabled'], 'api_key': '', 'api_base': ''})\n\n if request.method == 'GET':\n return jsonify({'ok': True, 'search': _public_search_config(search)})\n\n data = request.get_json(silent=True) or {}\n incoming = data.get('providers') or {}\n for key, item in incoming.items():\n if key not in SEARCH_PROVIDER_DEFAULTS or not isinstance(item, dict):\n continue\n cur = providers.setdefault(key, {'enabled': False, 'api_key': '', 'api_base': ''})\n if 'enabled' in item:\n cur['enabled'] = bool(item.get('enabled'))\n if item.get('api_key'):\n cur['api_key'] = str(item.get('api_key')).strip()\n if 'api_base' in item:\n cur['api_base'] = str(item.get('api_base') or '').strip().rstrip('/')\n if isinstance(data.get('default_sources'), list):\n search['default_sources'] = [\n s for s in data['default_sources']\n if s in SEARCH_PROVIDER_DEFAULTS\n ] or ['openalex', 'semantic_scholar']\n mgr.save_project_config(project_cfg)\n return jsonify({'ok': True, 'search': _public_search_config(search)})\n\n\n@bp.route('/api/config/app_paths', methods=['GET', 'POST'])\ndef app_paths_config():\n \"\"\"Get/update shared Chat and Parameter Optimizer path defaults.\"\"\"\n from config_manager import get_config_manager\n mgr = get_config_manager()\n project_cfg = mgr.get_project_config()\n paths = project_cfg.setdefault('app_paths', mgr.get_app_paths())\n\n if request.method == 'GET':\n return jsonify({'ok': True, 'app_paths': paths})\n\n data = request.get_json(silent=True) or {}\n if 'chat_workspace_enabled' in data:\n paths['chat_workspace_enabled'] = bool(data.get('chat_workspace_enabled'))\n if 'chat_workspace_paths' in data:\n raw = data.get('chat_workspace_paths')\n if isinstance(raw, str):\n vals = [x.strip() for x in raw.replace(';', '\\n').replace(',', '\\n').splitlines() if x.strip()]\n elif isinstance(raw, list):\n vals = [str(x).strip() for x in raw if str(x).strip()]\n else:\n vals = []\n paths['chat_workspace_paths'] = vals\n for key in ('geo_workspace_root', 'geo_literature_root', 'geo_output_dir'):\n if key in data:\n paths[key] = str(data.get(key) or '').strip()\n mgr.save_project_config(project_cfg)\n return jsonify({'ok': True, 'app_paths': paths})\n\n\n@bp.route('/api/config/coding_agent', methods=['GET', 'POST'])\ndef coding_agent_config():\n \"\"\"Get/update the project-scoped coding backend configuration.\"\"\"\n from config_manager import get_config_manager\n mgr = get_config_manager()\n project_cfg = mgr.get_project_config()\n cfg = project_cfg.setdefault('coding_agent', mgr.get_coding_agent_config())\n\n if request.method == 'GET':\n status = {}\n try:\n from seismo_code.external_coding_agent import external_agent_status\n status = external_agent_status(cfg)\n except Exception as exc:\n status = {'available': False, 'error': str(exc)}\n return jsonify({'ok': True, 'coding_agent': cfg, 'status': status})\n\n data = request.get_json(silent=True) or {}\n backend = str(data.get('backend') or cfg.get('backend') or 'builtin').strip()\n if backend not in {'builtin', 'aider', 'openhands'}:\n return jsonify({'ok': False, 'error': f'Invalid coding agent backend: {backend}'}), 400\n cfg['backend'] = backend\n # Backend selection is the source of truth. Keep `enabled` only for older clients.\n cfg['enabled'] = backend != 'builtin'\n for key in ('command', 'model', 'extra_args'):\n if key in data:\n cfg[key] = str(data.get(key) or '').strip()\n if 'timeout_s' in data:\n try:\n cfg['timeout_s'] = max(30, min(7200, int(data.get('timeout_s') or 900)))\n except Exception:\n cfg['timeout_s'] = 900\n if 'auto_approve' in data:\n cfg['auto_approve'] = bool(data.get('auto_approve'))\n if 'use_current_llm_env' in data:\n cfg['use_current_llm_env'] = bool(data.get('use_current_llm_env'))\n mgr.save_project_config(project_cfg)\n return jsonify({'ok': True, 'coding_agent': cfg})\n\n\n@bp.route('/api/llm/ollama/models', methods=['GET'])\ndef get_ollama_models():\n \"\"\"Get available Ollama models with a single short HTTP request.\"\"\"\n from config_manager import get_config_manager\n config = get_config_manager()\n recommended = config.get_recommended_models().get('ollama', [])\n ollama_cfg = config.config.get('ollama', {}) if isinstance(config.config.get('ollama', {}), dict) else {}\n llm_cfg = config.config.get('llm', {}) if isinstance(config.config.get('llm', {}), dict) else {}\n saved_llm_base = llm_cfg.get('api_base') if llm_cfg.get('provider') == 'ollama' else ''\n api_base = (\n request.args.get('api_base')\n or ollama_cfg.get('api_base')\n or saved_llm_base\n or 'http://localhost:11434'\n ).strip().rstrip('/')\n if not api_base.startswith(('http://localhost', 'http://127.0.0.1', 'http://0.0.0.0', 'http://[::1]')):\n api_base = 'http://localhost:11434'\n\n models = []\n available = False\n error = ''\n try:\n req = _ur.Request(f'{api_base}/api/tags')\n with _ur.urlopen(req, timeout=2.5) as resp:\n available = resp.status == 200\n if available:\n data = json.loads(resp.read().decode('utf-8'))\n models = _parse_model_list(data)\n except Exception as exc:\n error = str(exc)\n\n return jsonify({\n 'installed': models,\n 'recommended': recommended,\n 'ollama_available': available,\n 'api_base': api_base,\n 'error': error,\n })\n\n\n@bp.route('/api/llm/ollama/pull', methods=['POST'])\ndef pull_ollama_model():\n \"\"\"Pull an Ollama model and track progress.\"\"\"\n data = request.get_json(silent=True) or {}\n model_name = data.get('model')\n\n if not model_name:\n return jsonify({'error': 'Model name required'}), 400\n\n _pull_status[model_name] = {'status': 'pulling', 'progress': 0, 'detail': '', 'error': ''}\n\n def pull_in_background():\n try:\n # Use Ollama streaming pull API to track progress\n url = 'http://localhost:11434/api/pull'\n payload = json.dumps({'name': model_name, 'stream': True}).encode()\n req = _ur.Request(url, data=payload, method='POST',\n headers={'Content-Type': 'application/json'})\n with _ur.urlopen(req, timeout=1800) as resp:\n for raw_line in resp:\n line = raw_line.decode().strip()\n if not line:\n continue\n try:\n obj = json.loads(line)\n except Exception:\n continue\n status_msg = obj.get('status', '')\n total = obj.get('total', 0)\n completed = obj.get('completed', 0)\n pct = int(completed * 100 / total) if total else 0\n detail = f\"{completed/1e9:.1f} GB / {total/1e9:.1f} GB\" if total else status_msg\n _pull_status[model_name] = {\n 'status': 'pulling', 'progress': pct,\n 'detail': detail, 'error': ''\n }\n _pull_status[model_name] = {'status': 'done', 'progress': 100, 'detail': '', 'error': ''}\n except Exception as ex:\n _pull_status[model_name] = {'status': 'error', 'progress': 0,\n 'detail': '', 'error': str(ex)}\n\n thread = threading.Thread(target=pull_in_background, daemon=True)\n thread.start()\n return jsonify({'message': f'Started pulling model: {model_name}'})\n\n\n@bp.route('/api/llm/online/models', methods=['GET'])\ndef get_online_api_models():\n \"\"\"从在线 API 获取可用模型列表。支持 GET 参数: provider, api_base, api_key, model。\"\"\"\n from config_manager import get_config_manager\n\n provider = request.args.get('provider', '').strip()\n api_base = request.args.get('api_base', '').strip()\n api_key = request.args.get('api_key', '').strip()\n current_model = request.args.get('model', '').strip()\n\n if not api_base and provider in ONLINE_PROVIDERS:\n api_base = ONLINE_PROVIDERS[provider].get('api_base', '')\n\n # 如果请求没带 api_key,使用已保存的 online/llm key\n if not api_key:\n cfg = get_config_manager()\n if not current_model:\n current_model = (\n cfg.config.get('online', {}).get('model')\n or cfg.config.get('llm', {}).get('model', '')\n )\n api_key = (\n cfg.config.get('online', {}).get('api_key')\n or cfg.config.get('llm', {}).get('api_key', '')\n )\n\n if not api_base:\n return jsonify({'error': '请提供 api_base 参数', 'models': []}), 400\n if not api_key:\n return jsonify({'error': '请先填写并保存 API Key,或在参数中提供 api_key', 'models': []}), 400\n\n import urllib.request as _urq\n import urllib.error as _ure\n\n url = api_base.rstrip('/') + '/models'\n try:\n req = _urq.Request(\n url,\n headers={\n 'Authorization': f'Bearer {api_key}',\n 'Content-Type': 'application/json',\n }\n )\n with _urq.urlopen(req, timeout=12) as resp:\n data = json.loads(resp.read().decode('utf-8'))\n\n models = _parse_model_list(data)\n if not models:\n return jsonify({'error': '接口返回了空模型列表', 'models': []}), 502\n\n return jsonify({'models': models, 'source': 'models_endpoint'})\n\n except _ure.HTTPError as e:\n body = ''\n try:\n body = e.read().decode('utf-8', errors='ignore')[:240]\n except Exception:\n pass\n msgs = {\n 401: 'API Key 无效或已过期 (401)',\n 403: '访问被拒绝,请检查权限 (403)',\n 404: '该平台不支持模型列表接口 (404)',\n }\n candidates = _fallback_model_candidates(provider, api_base, current_model)\n if e.code in {400, 404, 405, 501} and candidates:\n verified = []\n for model in candidates[:4]:\n try:\n if _chat_model_available(api_base, api_key, model):\n verified.append(model)\n break\n except Exception:\n continue\n models = verified or candidates\n return jsonify({\n 'models': models,\n 'source': 'chat_probe' if verified else 'fallback',\n **({\n 'notice': (\n f'已验证当前模型可用;该平台的 /models 列表接口返回 HTTP {e.code}。'\n )\n } if verified else {\n 'warning': (\n f'/models 接口不可用或返回 HTTP {e.code},'\n '但聊天接口可能仍可使用;已使用当前/预设模型。'\n )\n }),\n })\n\n detail = msgs.get(e.code, f'HTTP {e.code}: {e.reason}')\n if body:\n detail += f' — {body}'\n return jsonify({'error': detail, 'models': []}), 502\n except (_ure.URLError, OSError) as e:\n return jsonify({'error': f'网络连接失败: {e.reason if hasattr(e, \"reason\") else str(e)}', 'models': []}), 502\n except Exception as e:\n return jsonify({'error': f'获取模型列表失败: {str(e)}', 'models': []}), 502\n\n\n@bp.route('/api/llm/test', methods=['POST'])\ndef test_llm_connection():\n \"\"\"轻量测试 LLM 连接:发送一条最短的 chat 消息,返回详细错误信息。\"\"\"\n from config_manager import get_config_manager\n\n manager = get_config_manager()\n cfg = dict(manager.get_llm_config())\n body = request.get_json(silent=True) or {}\n if body:\n saved_online = manager.config.get('online', {})\n saved_llm = manager.config.get('llm', {})\n cfg.update({\n 'provider': body.get('provider', cfg.get('provider', 'ollama')),\n 'model': body.get('model', cfg.get('model', '')),\n 'api_base': body.get('api_base', cfg.get('api_base', '')),\n })\n if body.get('api_key'):\n cfg['api_key'] = body.get('api_key')\n else:\n cfg['api_key'] = saved_online.get('api_key') or saved_llm.get('api_key') or cfg.get('api_key', '')\n\n provider = cfg.get('provider', 'ollama')\n model = cfg.get('model', '')\n api_base = cfg.get('api_base', 'http://localhost:11434')\n api_key = cfg.get('api_key', '')\n\n if not model:\n return jsonify({'ok': False, 'error': '未配置模型,请先保存配置'})\n\n import urllib.request as _urq\n import urllib.error as _ure\n\n # ── Ollama 分支 ─────────────────────────────────────────────────\n if provider == 'ollama':\n try:\n req = _urq.Request(f'{api_base.rstrip(\"/\")}/api/tags')\n with _urq.urlopen(req, timeout=5) as resp:\n ok = resp.status == 200\n if ok:\n return jsonify({'ok': True, 'message': f'Ollama 连接正常,当前模型: {model}'})\n return jsonify({'ok': False, 'error': 'Ollama 服务未响应,请运行 ollama serve'})\n except Exception as e:\n return jsonify({'ok': False, 'error': f'Ollama 连接失败: {str(e)}'})\n\n # ── 在线 API 分支 ────────────────────────────────────────────────\n if not api_key:\n return jsonify({'ok': False, 'error': '未配置 API Key,请填写后保存'})\n\n payload = json.dumps({\n 'model': model,\n 'messages': [{'role': 'user', 'content': 'hi'}],\n 'max_tokens': 5,\n 'temperature': 0,\n }).encode('utf-8')\n\n try:\n req = _urq.Request(\n f'{api_base.rstrip(\"/\")}/chat/completions',\n data=payload,\n headers={\n 'Content-Type': 'application/json',\n 'Authorization': f'Bearer {api_key}',\n },\n method='POST',\n )\n with _urq.urlopen(req, timeout=30) as resp:\n result = json.loads(resp.read().decode('utf-8'))\n\n if result.get('choices'):\n return jsonify({'ok': True, 'message': f'✓ {model} 响应正常'})\n return jsonify({'ok': False, 'error': '模型返回了空结果,请检查模型名称'})\n\n except _ure.HTTPError as e:\n body = ''\n try: body = e.read().decode('utf-8', errors='ignore')[:200]\n except Exception: pass\n msgs = {\n 401: f'API Key 无效或已过期 (401)',\n 403: f'访问被拒绝,请检查权限 (403)',\n 404: f'模型 \"{model}\" 不存在,请确认模型名称 (404)',\n 429: '请求频率超限,稍后再试 (429)',\n }\n err = msgs.get(e.code, f'HTTP {e.code}')\n if body: err += f' — {body}'\n return jsonify({'ok': False, 'error': err})\n except (_ure.URLError, OSError) as e:\n reason = e.reason if hasattr(e, 'reason') else str(e)\n if 'timed out' in str(reason).lower():\n return jsonify({'ok': False, 'error': '连接超时,请检查网络或 API 地址是否正确'})\n return jsonify({'ok': False, 'error': f'网络连接失败: {reason}'})\n except Exception as e:\n return jsonify({'ok': False, 'error': f'测试失败: {str(e)}'})\n\n\n@bp.route('/api/llm/ollama/pull/status', methods=['GET'])\ndef pull_ollama_status():\n \"\"\"Return current pull progress for a model.\"\"\"\n model_name = request.args.get('model', '')\n if not model_name:\n return jsonify({'error': 'model param required'}), 400\n info = _pull_status.get(model_name, {'status': 'unknown', 'progress': 0, 'detail': '', 'error': ''})\n return jsonify(info)\n"}, "3dda843da5bdf26f88e70417b3041523a2477544": {"t": 1785763088.8084035, "n": 24876, "h": "5fc0a19131931741", "c": "\"\"\"LLM 配置路由\"\"\"\nfrom flask import Blueprint, request, jsonify\nimport sys\nimport json\nimport threading\nimport time as _time\nimport urllib.request as _ur\nfrom pathlib import Path\nfrom state import _pull_status\n\nbp = Blueprint('llm', __name__)\n\ntry:\n from backend_manager import ONLINE_PROVIDERS\nexcept Exception:\n ONLINE_PROVIDERS = {\n \"openai\": {\"display\": \"OpenAI\", \"api_base\": \"https://api.openai.com/v1\", \"default_model\": \"gpt-4o-mini\", \"need_key\": True},\n \"deepseek\": {\"display\": \"DeepSeek\", \"api_base\": \"https://api.deepseek.com/v1\", \"default_model\": \"deepseek-v4-flash\", \"need_key\": True},\n \"siliconflow\": {\"display\": \"SiliconFlow\", \"api_base\": \"https://api.siliconflow.cn/v1\", \"default_model\": \"Qwen/Qwen2.5-7B-Instruct\", \"need_key\": True},\n \"moonshot\": {\"display\": \"Moonshot / Kimi\", \"api_base\": \"https://api.moonshot.cn/v1\", \"default_model\": \"moonshot-v1-8k\", \"need_key\": True},\n \"dashscope\": {\"display\": \"DashScope\", \"api_base\": \"https://dashscope.aliyuncs.com/compatible-mode/v1\", \"default_model\": \"qwen-turbo\", \"need_key\": True},\n \"zhipu\": {\"display\": \"Zhipu AI\", \"api_base\": \"https://open.bigmodel.cn/api/paas/v4\", \"default_model\": \"glm-4-flash\", \"need_key\": True},\n \"custom\": {\"display\": \"Custom OpenAI-compatible\", \"api_base\": \"\", \"default_model\": \"\", \"need_key\": True},\n }\n\n\ndef _public_online_providers():\n \"\"\"Provider metadata safe to send to the browser.\"\"\"\n return {\n key: {\n 'display': value.get('display', key),\n 'api_base': value.get('api_base', ''),\n 'default_model': value.get('default_model', ''),\n 'need_key': value.get('need_key', True),\n }\n for key, value in ONLINE_PROVIDERS.items()\n if key != 'anthropic'\n }\n\n\ndef _mask_api_key(config):\n config = dict(config or {})\n if config.get('api_key'):\n key = config['api_key']\n config['api_key_masked'] = '****' + key[-4:] if len(key) > 4 else '****'\n config['api_key'] = ''\n return config\n\n\ndef _mask_key(value):\n if not value:\n return ''\n value = str(value)\n return '****' + value[-4:] if len(value) > 4 else '****'\n\n\ndef _public_search_config(raw_search):\n from config_manager import SEARCH_PROVIDER_DEFAULTS\n raw_search = raw_search or {}\n providers = raw_search.get('providers') or {}\n out = {}\n for key, meta in SEARCH_PROVIDER_DEFAULTS.items():\n cfg = providers.get(key) or {}\n out[key] = {\n 'display': meta.get('display', key),\n 'description': meta.get('description', ''),\n 'requires_key': bool(meta.get('requires_key')),\n 'enabled': bool(cfg.get('enabled', meta.get('enabled', False))),\n 'api_key_masked': _mask_key(cfg.get('api_key', '')),\n 'api_base': cfg.get('api_base', ''),\n }\n return {\n 'providers': out,\n 'default_sources': raw_search.get('default_sources') or ['openalex', 'semantic_scholar'],\n }\n\n\ndef _parse_model_list(data):\n models = []\n if isinstance(data, dict) and isinstance(data.get('data'), list):\n models = [m['id'] for m in data['data'] if isinstance(m, dict) and m.get('id')]\n elif isinstance(data, dict) and isinstance(data.get('models'), list):\n models = [m.get('id') or m.get('name', '') for m in data['models'] if isinstance(m, dict)]\n elif isinstance(data, list):\n models = [\n m.get('id') or m.get('name', '')\n for m in data\n if isinstance(m, dict)\n ]\n return sorted({m for m in models if m})\n\n\ndef _fallback_model_candidates(provider, api_base, current_model):\n candidates = []\n if current_model:\n candidates.append(current_model)\n preset = ONLINE_PROVIDERS.get(provider, {})\n if preset.get('default_model'):\n candidates.append(preset['default_model'])\n base_l = (api_base or '').lower()\n if 'aliyuncs.com' in base_l or 'dashscope' in base_l:\n candidates.extend(['qwen3.6-plus', 'qwen-plus', 'qwen-turbo'])\n return list(dict.fromkeys(candidates))\n\n\ndef _chat_model_available(api_base, api_key, model):\n payload = json.dumps({\n 'model': model,\n 'messages': [{'role': 'user', 'content': 'hi'}],\n 'max_tokens': 1,\n 'temperature': 0,\n }).encode('utf-8')\n req = _ur.Request(\n f'{api_base.rstrip(\"/\")}/chat/completions',\n data=payload,\n headers={\n 'Content-Type': 'application/json',\n 'Authorization': f'Bearer {api_key}',\n },\n method='POST',\n )\n with _ur.urlopen(req, timeout=20) as resp:\n data = json.loads(resp.read().decode('utf-8'))\n return bool(data.get('choices'))\n\n\n@bp.route('/api/llm/config', methods=['GET'])\ndef llm_config_get():\n \"\"\"Get current LLM configuration (reads directly from config.json)\"\"\"\n from config_manager import LLMConfigManager\n cfg_mgr = LLMConfigManager()\n raw_config = cfg_mgr.config\n llm_config = dict(raw_config.get('llm', {}))\n\n if raw_config.get('active_backend') == 'online' and raw_config.get('online'):\n online = raw_config.get('online', {})\n llm_config.update({\n 'provider': online.get('provider', llm_config.get('provider', 'custom')),\n 'model': online.get('model', llm_config.get('model', '')),\n 'api_base': online.get('api_base', llm_config.get('api_base', '')),\n 'api_key': online.get('api_key', llm_config.get('api_key', '')),\n })\n elif raw_config.get('active_backend') == 'ollama' and raw_config.get('ollama'):\n ollama = raw_config.get('ollama', {})\n llm_config.update({\n 'provider': 'ollama',\n 'model': ollama.get('model', llm_config.get('model', '')),\n 'api_base': ollama.get('api_base', llm_config.get('api_base', 'http://localhost:11434')),\n 'api_key': '',\n })\n\n return jsonify({\n 'config': _mask_api_key(llm_config),\n 'search': _public_search_config(cfg_mgr.get_search_config()),\n 'app_paths': cfg_mgr.get_app_paths(),\n 'coding_agent': cfg_mgr.get_coding_agent_config(),\n 'online_providers': _public_online_providers(),\n 'first_run': cfg_mgr.is_first_run(),\n 'ollama_available': cfg_mgr.check_ollama_available()\n })\n\n\n@bp.route('/api/llm/config', methods=['POST'])\ndef update_llm_config():\n \"\"\"Update LLM configuration\"\"\"\n from config_manager import get_config_manager\n data = request.get_json(silent=True) or {}\n config = get_config_manager()\n\n try:\n cfg = config.config\n llm = cfg.setdefault('llm', {})\n provider = (data.get('provider') or llm.get('provider') or 'ollama').strip()\n if provider not in ['ollama', 'openai', 'deepseek', 'siliconflow', 'moonshot', 'dashscope', 'zhipu', 'custom']:\n raise ValueError(f'Invalid provider: {provider}')\n\n if provider == 'ollama':\n api_base = (data.get('api_base') or 'http://localhost:11434').strip().rstrip('/')\n model = (data.get('model') or llm.get('model') or '').strip()\n llm.update({\n 'provider': 'ollama',\n 'model': model,\n 'api_base': api_base,\n 'api_key': '',\n })\n cfg['active_backend'] = 'ollama'\n cfg.setdefault('ollama', {}).update({\n 'model': model,\n 'api_base': api_base,\n })\n else:\n preset = ONLINE_PROVIDERS.get(provider, {})\n online = cfg.setdefault('online', {})\n api_base = (data.get('api_base') or preset.get('api_base') or online.get('api_base') or llm.get('api_base') or '').strip().rstrip('/')\n model = (data.get('model') or preset.get('default_model') or online.get('model') or llm.get('model') or '').strip()\n api_key = data.get('api_key')\n if api_key is None or api_key == '':\n api_key = online.get('api_key') or llm.get('api_key', '')\n else:\n api_key = api_key.strip()\n\n llm.update({\n 'provider': provider,\n 'model': model,\n 'api_base': api_base,\n 'api_key': api_key,\n })\n cfg['active_backend'] = 'online'\n online.update({\n 'provider': provider,\n 'api_base': api_base,\n 'api_key': api_key,\n 'model': model,\n })\n\n if 'temperature' in data:\n llm['temperature'] = data['temperature']\n if 'max_tokens' in data:\n llm['max_tokens'] = data['max_tokens']\n\n # Mark first run complete if this is the first setup\n if config.is_first_run():\n cfg['first_run'] = False\n\n config.save_config()\n return jsonify({'message': 'Configuration updated successfully', 'config': _mask_api_key(llm)})\n except Exception as e:\n return jsonify({'error': str(e)}), 400\n\n\n@bp.route('/api/config/search', methods=['GET', 'POST'])\ndef search_config():\n \"\"\"Get/update web/literature search provider configuration.\"\"\"\n from config_manager import get_config_manager, SEARCH_PROVIDER_DEFAULTS\n mgr = get_config_manager()\n project_cfg = mgr.get_project_config()\n search = project_cfg.setdefault('search', {})\n providers = search.setdefault('providers', {})\n for key, meta in SEARCH_PROVIDER_DEFAULTS.items():\n providers.setdefault(key, {'enabled': meta['enabled'], 'api_key': '', 'api_base': ''})\n\n if request.method == 'GET':\n return jsonify({'ok': True, 'search': _public_search_config(search)})\n\n data = request.get_json(silent=True) or {}\n incoming = data.get('providers') or {}\n for key, item in incoming.items():\n if key not in SEARCH_PROVIDER_DEFAULTS or not isinstance(item, dict):\n continue\n cur = providers.setdefault(key, {'enabled': False, 'api_key': '', 'api_base': ''})\n if 'enabled' in item:\n cur['enabled'] = bool(item.get('enabled'))\n if item.get('api_key'):\n cur['api_key'] = str(item.get('api_key')).strip()\n if 'api_base' in item:\n cur['api_base'] = str(item.get('api_base') or '').strip().rstrip('/')\n if isinstance(data.get('default_sources'), list):\n search['default_sources'] = [\n s for s in data['default_sources']\n if s in SEARCH_PROVIDER_DEFAULTS\n ] or ['openalex', 'semantic_scholar']\n mgr.save_project_config(project_cfg)\n return jsonify({'ok': True, 'search': _public_search_config(search)})\n\n\n@bp.route('/api/config/app_paths', methods=['GET', 'POST'])\ndef app_paths_config():\n \"\"\"Get/update shared Chat and Parameter Optimizer path defaults.\"\"\"\n from config_manager import get_config_manager\n mgr = get_config_manager()\n project_cfg = mgr.get_project_config()\n paths = project_cfg.setdefault('app_paths', mgr.get_app_paths())\n\n if request.method == 'GET':\n return jsonify({'ok': True, 'app_paths': paths})\n\n data = request.get_json(silent=True) or {}\n if 'chat_workspace_enabled' in data:\n paths['chat_workspace_enabled'] = bool(data.get('chat_workspace_enabled'))\n if 'chat_workspace_paths' in data:\n raw = data.get('chat_workspace_paths')\n if isinstance(raw, str):\n vals = [x.strip() for x in raw.replace(';', '\\n').replace(',', '\\n').splitlines() if x.strip()]\n elif isinstance(raw, list):\n vals = [str(x).strip() for x in raw if str(x).strip()]\n else:\n vals = []\n paths['chat_workspace_paths'] = vals\n for key in ('geo_workspace_root', 'geo_literature_root', 'geo_output_dir'):\n if key in data:\n paths[key] = str(data.get(key) or '').strip()\n mgr.save_project_config(project_cfg)\n return jsonify({'ok': True, 'app_paths': paths})\n\n\n@bp.route('/api/config/coding_agent', methods=['GET', 'POST'])\ndef coding_agent_config():\n \"\"\"Get/update the project-scoped coding backend configuration.\"\"\"\n from config_manager import get_config_manager\n mgr = get_config_manager()\n project_cfg = mgr.get_project_config()\n cfg = project_cfg.setdefault('coding_agent', mgr.get_coding_agent_config())\n\n if request.method == 'GET':\n status = {}\n try:\n from seismo_code.external_coding_agent import external_agent_status\n status = external_agent_status(cfg)\n except Exception as exc:\n status = {'available': False, 'error': str(exc)}\n return jsonify({'ok': True, 'coding_agent': cfg, 'status': status})\n\n data = request.get_json(silent=True) or {}\n backend = str(data.get('backend') or cfg.get('backend') or 'builtin').strip()\n if backend not in {'builtin', 'aider', 'openhands'}:\n return jsonify({'ok': False, 'error': f'Invalid coding agent backend: {backend}'}), 400\n cfg['backend'] = backend\n # Backend selection is the source of truth. Keep `enabled` only for older clients.\n cfg['enabled'] = backend != 'builtin'\n for key in ('command', 'model', 'extra_args'):\n if key in data:\n cfg[key] = str(data.get(key) or '').strip()\n if 'timeout_s' in data:\n try:\n cfg['timeout_s'] = max(30, min(7200, int(data.get('timeout_s') or 900)))\n except Exception:\n cfg['timeout_s'] = 900\n if 'auto_approve' in data:\n cfg['auto_approve'] = bool(data.get('auto_approve'))\n if 'use_current_llm_env' in data:\n cfg['use_current_llm_env'] = bool(data.get('use_current_llm_env'))\n mgr.save_project_config(project_cfg)\n return jsonify({'ok': True, 'coding_agent': cfg})\n\n\n@bp.route('/api/llm/ollama/models', methods=['GET'])\ndef get_ollama_models():\n \"\"\"Get available Ollama models with a single short HTTP request.\"\"\"\n from config_manager import get_config_manager\n config = get_config_manager()\n recommended = config.get_recommended_models().get('ollama', [])\n ollama_cfg = config.config.get('ollama', {}) if isinstance(config.config.get('ollama', {}), dict) else {}\n llm_cfg = config.config.get('llm', {}) if isinstance(config.config.get('llm', {}), dict) else {}\n saved_llm_base = llm_cfg.get('api_base') if llm_cfg.get('provider') == 'ollama' else ''\n api_base = (\n request.args.get('api_base')\n or ollama_cfg.get('api_base')\n or saved_llm_base\n or 'http://localhost:11434'\n ).strip().rstrip('/')\n if not api_base.startswith(('http://localhost', 'http://127.0.0.1', 'http://0.0.0.0', 'http://[::1]')):\n api_base = 'http://localhost:11434'\n\n models = []\n available = False\n error = ''\n try:\n req = _ur.Request(f'{api_base}/api/tags')\n with _ur.urlopen(req, timeout=2.5) as resp:\n available = resp.status == 200\n if available:\n data = json.loads(resp.read().decode('utf-8'))\n models = _parse_model_list(data)\n except Exception as exc:\n error = str(exc)\n\n return jsonify({\n 'installed': models,\n 'recommended': recommended,\n 'ollama_available': available,\n 'api_base': api_base,\n 'error': error,\n })\n\n\n@bp.route('/api/llm/ollama/pull', methods=['POST'])\ndef pull_ollama_model():\n \"\"\"Pull an Ollama model and track progress.\"\"\"\n data = request.get_json(silent=True) or {}\n model_name = data.get('model')\n\n if not model_name:\n return jsonify({'error': 'Model name required'}), 400\n\n _pull_status[model_name] = {'status': 'pulling', 'progress': 0, 'detail': '', 'error': ''}\n\n def pull_in_background():\n try:\n # Use Ollama streaming pull API to track progress\n url = 'http://localhost:11434/api/pull'\n payload = json.dumps({'name': model_name, 'stream': True}).encode()\n req = _ur.Request(url, data=payload, method='POST',\n headers={'Content-Type': 'application/json'})\n with _ur.urlopen(req, timeout=1800) as resp:\n for raw_line in resp:\n line = raw_line.decode().strip()\n if not line:\n continue\n try:\n obj = json.loads(line)\n except Exception:\n continue\n status_msg = obj.get('status', '')\n total = obj.get('total', 0)\n completed = obj.get('completed', 0)\n pct = int(completed * 100 / total) if total else 0\n detail = f\"{completed/1e9:.1f} GB / {total/1e9:.1f} GB\" if total else status_msg\n _pull_status[model_name] = {\n 'status': 'pulling', 'progress': pct,\n 'detail': detail, 'error': ''\n }\n _pull_status[model_name] = {'status': 'done', 'progress': 100, 'detail': '', 'error': ''}\n except Exception as ex:\n _pull_status[model_name] = {'status': 'error', 'progress': 0,\n 'detail': '', 'error': str(ex)}\n\n thread = threading.Thread(target=pull_in_background, daemon=True)\n thread.start()\n return jsonify({'message': f'Started pulling model: {model_name}'})\n\n\n@bp.route('/api/llm/online/models', methods=['GET'])\ndef get_online_api_models():\n \"\"\"从在线 API 获取可用模型列表。支持 GET 参数: provider, api_base, api_key, model。\"\"\"\n from config_manager import get_config_manager\n\n provider = request.args.get('provider', '').strip()\n api_base = request.args.get('api_base', '').strip()\n api_key = request.args.get('api_key', '').strip()\n current_model = request.args.get('model', '').strip()\n\n if not api_base and provider in ONLINE_PROVIDERS:\n api_base = ONLINE_PROVIDERS[provider].get('api_base', '')\n\n # 如果请求没带 api_key,使用已保存的 online/llm key\n if not api_key:\n cfg = get_config_manager()\n if not current_model:\n current_model = (\n cfg.config.get('online', {}).get('model')\n or cfg.config.get('llm', {}).get('model', '')\n )\n api_key = (\n cfg.config.get('online', {}).get('api_key')\n or cfg.config.get('llm', {}).get('api_key', '')\n )\n\n if not api_base:\n return jsonify({'error': '请提供 api_base 参数', 'models': []}), 400\n if not api_key:\n return jsonify({'error': '请先填写并保存 API Key,或在参数中提供 api_key', 'models': []}), 400\n\n import urllib.request as _urq\n import urllib.error as _ure\n\n url = api_base.rstrip('/') + '/models'\n try:\n req = _urq.Request(\n url,\n headers={\n 'Authorization': f'Bearer {api_key}',\n 'Content-Type': 'application/json',\n }\n )\n with _urq.urlopen(req, timeout=12) as resp:\n data = json.loads(resp.read().decode('utf-8'))\n\n models = _parse_model_list(data)\n if not models:\n return jsonify({'error': '接口返回了空模型列表', 'models': []}), 502\n\n return jsonify({'models': models, 'source': 'models_endpoint'})\n\n except _ure.HTTPError as e:\n body = ''\n try:\n body = e.read().decode('utf-8', errors='ignore')[:240]\n except Exception:\n pass\n msgs = {\n 401: 'API Key 无效或已过期 (401)',\n 403: '访问被拒绝,请检查权限 (403)',\n 404: '该平台不支持模型列表接口 (404)',\n }\n candidates = _fallback_model_candidates(provider, api_base, current_model)\n if e.code in {400, 404, 405, 501} and candidates:\n verified = []\n for model in candidates[:4]:\n try:\n if _chat_model_available(api_base, api_key, model):\n verified.append(model)\n break\n except Exception:\n continue\n models = verified or candidates\n return jsonify({\n 'models': models,\n 'source': 'chat_probe' if verified else 'fallback',\n **({\n 'notice': (\n f'已验证当前模型可用;该平台的 /models 列表接口返回 HTTP {e.code}。'\n )\n } if verified else {\n 'warning': (\n f'/models 接口不可用或返回 HTTP {e.code},'\n '但聊天接口可能仍可使用;已使用当前/预设模型。'\n )\n }),\n })\n\n detail = msgs.get(e.code, f'HTTP {e.code}: {e.reason}')\n if body:\n detail += f' — {body}'\n return jsonify({'error': detail, 'models': []}), 502\n except (_ure.URLError, OSError) as e:\n return jsonify({'error': f'网络连接失败: {e.reason if hasattr(e, \"reason\") else str(e)}', 'models': []}), 502\n except Exception as e:\n return jsonify({'error': f'获取模型列表失败: {str(e)}', 'models': []}), 502\n\n\n@bp.route('/api/llm/test', methods=['POST'])\ndef test_llm_connection():\n \"\"\"轻量测试 LLM 连接:发送一条最短的 chat 消息,返回详细错误信息。\"\"\"\n from config_manager import get_config_manager\n\n manager = get_config_manager()\n cfg = dict(manager.get_llm_config())\n body = request.get_json(silent=True) or {}\n if body:\n saved_online = manager.config.get('online', {})\n saved_llm = manager.config.get('llm', {})\n cfg.update({\n 'provider': body.get('provider', cfg.get('provider', 'ollama')),\n 'model': body.get('model', cfg.get('model', '')),\n 'api_base': body.get('api_base', cfg.get('api_base', '')),\n })\n if body.get('api_key'):\n cfg['api_key'] = body.get('api_key')\n else:\n cfg['api_key'] = saved_online.get('api_key') or saved_llm.get('api_key') or cfg.get('api_key', '')\n\n provider = cfg.get('provider', 'ollama')\n model = cfg.get('model', '')\n api_base = cfg.get('api_base', 'http://localhost:11434')\n api_key = cfg.get('api_key', '')\n\n if not model:\n return jsonify({'ok': False, 'error': '未配置模型,请先保存配置'})\n\n import urllib.request as _urq\n import urllib.error as _ure\n\n # ── Ollama 分支 ─────────────────────────────────────────────────\n if provider == 'ollama':\n try:\n req = _urq.Request(f'{api_base.rstrip(\"/\")}/api/tags')\n with _urq.urlopen(req, timeout=5) as resp:\n ok = resp.status == 200\n if ok:\n return jsonify({'ok': True, 'message': f'Ollama 连接正常,当前模型: {model}'})\n return jsonify({'ok': False, 'error': 'Ollama 服务未响应,请运行 ollama serve'})\n except Exception as e:\n return jsonify({'ok': False, 'error': f'Ollama 连接失败: {str(e)}'})\n\n # ── 在线 API 分支 ────────────────────────────────────────────────\n if not api_key:\n return jsonify({'ok': False, 'error': '未配置 API Key,请填写后保存'})\n\n payload = json.dumps({\n 'model': model,\n 'messages': [{'role': 'user', 'content': 'hi'}],\n 'max_tokens': 5,\n 'temperature': 0,\n }).encode('utf-8')\n\n try:\n req = _urq.Request(\n f'{api_base.rstrip(\"/\")}/chat/completions',\n data=payload,\n headers={\n 'Content-Type': 'application/json',\n 'Authorization': f'Bearer {api_key}',\n },\n method='POST',\n )\n with _urq.urlopen(req, timeout=30) as resp:\n result = json.loads(resp.read().decode('utf-8'))\n\n if result.get('choices'):\n return jsonify({'ok': True, 'message': f'✓ {model} 响应正常'})\n return jsonify({'ok': False, 'error': '模型返回了空结果,请检查模型名称'})\n\n except _ure.HTTPError as e:\n body = ''\n try: body = e.read().decode('utf-8', errors='ignore')[:200]\n except Exception: pass\n msgs = {\n 401: f'API Key 无效或已过期 (401)',\n 403: f'访问被拒绝,请检查权限 (403)',\n 404: f'模型 \"{model}\" 不存在,请确认模型名称 (404)',\n 429: '请求频率超限,稍后再试 (429)',\n }\n err = msgs.get(e.code, f'HTTP {e.code}')\n if body: err += f' — {body}'\n return jsonify({'ok': False, 'error': err})\n except (_ure.URLError, OSError) as e:\n reason = e.reason if hasattr(e, 'reason') else str(e)\n if 'timed out' in str(reason).lower():\n return jsonify({'ok': False, 'error': '连接超时,请检查网络或 API 地址是否正确'})\n return jsonify({'ok': False, 'error': f'网络连接失败: {reason}'})\n except Exception as e:\n return jsonify({'ok': False, 'error': f'测试失败: {str(e)}'})\n\n\n@bp.route('/api/llm/ollama/pull/status', methods=['GET'])\ndef pull_ollama_status():\n \"\"\"Return current pull progress for a model.\"\"\"\n model_name = request.args.get('model', '')\n if not model_name:\n return jsonify({'error': 'model param required'}), 400\n info = _pull_status.get(model_name, {'status': 'unknown', 'progress': 0, 'detail': '', 'error': ''})\n return jsonify(info)\n"}, "6de3bc7d641e6f1823ee83d5ebb94ea714480e89": {"t": 1785763090.6604872, "n": 24866, "h": "7811fc0e3d9371ed", "c": "\"\"\"LLM 配置路由\"\"\"\nfrom flask import Blueprint, request, jsonify\nimport sys\nimport json\nimport threading\nimport time as _time\nimport urllib.request as _ur\nfrom pathlib import Path\nfrom state import _pull_status\n\nbp = Blueprint('llm', __name__)\n\ntry:\n from backend_manager import ONLINE_PROVIDERS\nexcept Exception:\n ONLINE_PROVIDERS = {\n \"openai\": {\"display\": \"OpenAI\", \"api_base\": \"https://api.openai.com/v1\", \"default_model\": \"gpt-4o-mini\", \"need_key\": True},\n \"deepseek\": {\"display\": \"DeepSeek\", \"api_base\": \"https://api.deepseek.com/v1\", \"default_model\": \"deepseek-v4-flash\", \"need_key\": True},\n \"siliconflow\": {\"display\": \"SiliconFlow\", \"api_base\": \"https://api.siliconflow.cn/v1\", \"default_model\": \"Qwen/Qwen2.5-7B-Instruct\", \"need_key\": True},\n \"moonshot\": {\"display\": \"Moonshot / Kimi\", \"api_base\": \"https://api.moonshot.cn/v1\", \"default_model\": \"moonshot-v1-8k\", \"need_key\": True},\n \"dashscope\": {\"display\": \"DashScope\", \"api_base\": \"https://dashscope.aliyuncs.com/compatible-mode/v1\", \"default_model\": \"qwen-turbo\", \"need_key\": True},\n \"zhipu\": {\"display\": \"Zhipu AI\", \"api_base\": \"https://open.bigmodel.cn/api/paas/v4\", \"default_model\": \"glm-4-flash\", \"need_key\": True},\n \"custom\": {\"display\": \"Custom OpenAI-compatible\", \"api_base\": \"\", \"default_model\": \"\", \"need_key\": True},\n }\n\n\ndef _public_online_providers():\n \"\"\"Provider metadata safe to send to the browser.\"\"\"\n return {\n key: {\n 'display': value.get('display', key),\n 'api_base': value.get('api_base', ''),\n 'default_model': value.get('default_model', ''),\n 'need_key': value.get('need_key', True),\n }\n for key, value in ONLINE_PROVIDERS.items()\n if key != 'anthropic'\n }\n\n\ndef _mask_api_key(config):\n config = dict(config or {})\n if config.get('api_key'):\n key = config['api_key']\n config['api_key_masked'] = '****' + key[-4:] if len(key) > 4 else '****'\n config['api_key'] = ''\n return config\n\n\ndef _mask_key(value):\n if not value:\n return ''\n value = str(value)\n return '****' + value[-4:] if len(value) > 4 else '****'\n\n\ndef _public_search_config(raw_search):\n from config_manager import SEARCH_PROVIDER_DEFAULTS\n raw_search = raw_search or {}\n providers = raw_search.get('providers') or {}\n out = {}\n for key, meta in SEARCH_PROVIDER_DEFAULTS.items():\n cfg = providers.get(key) or {}\n out[key] = {\n 'display': meta.get('display', key),\n 'description': meta.get('description', ''),\n 'requires_key': bool(meta.get('requires_key')),\n 'enabled': bool(cfg.get('enabled', meta.get('enabled', False))),\n 'api_key_masked': _mask_key(cfg.get('api_key', '')),\n 'api_base': cfg.get('api_base', ''),\n }\n return {\n 'providers': out,\n 'default_sources': raw_search.get('default_sources') or ['openalex', 'semantic_scholar'],\n }\n\n\ndef _parse_model_list(data):\n models = []\n if isinstance(data, dict) and isinstance(data.get('data'), list):\n models = [m['id'] for m in data['data'] if isinstance(m, dict) and m.get('id')]\n elif isinstance(data, dict) and isinstance(data.get('models'), list):\n models = [m.get('id') or m.get('name', '') for m in data['models'] if isinstance(m, dict)]\n elif isinstance(data, list):\n models = [\n m.get('id') or m.get('name', '')\n for m in data\n if isinstance(m, dict)\n ]\n return sorted({m for m in models if m})\n\n\ndef _fallback_model_candidates(provider, api_base, current_model):\n candidates = []\n if current_model:\n candidates.append(current_model)\n preset = ONLINE_PROVIDERS.get(provider, {})\n if preset.get('default_model'):\n candidates.append(preset['default_model'])\n base_l = (api_base or '').lower()\n if 'aliyuncs.com' in base_l or 'dashscope' in base_l:\n candidates.extend(['qwen3.6-plus', 'qwen-plus', 'qwen-turbo'])\n return list(dict.fromkeys(candidates))\n\n\ndef _chat_model_available(api_base, api_key, model):\n payload = json.dumps({\n 'model': model,\n 'messages': [{'role': 'user', 'content': 'hi'}],\n 'max_tokens': 1,\n 'temperature': 0,\n }).encode('utf-8')\n req = _ur.Request(\n f'{api_base.rstrip(\"/\")}/chat/completions',\n data=payload,\n headers={\n 'Content-Type': 'application/json',\n 'Authorization': f'Bearer {api_key}',\n },\n method='POST',\n )\n with _ur.urlopen(req, timeout=20) as resp:\n data = json.loads(resp.read().decode('utf-8'))\n return bool(data.get('choices'))\n\n\n@bp.route('/api/llm/config', methods=['GET'])\ndef llm_config_get():\n \"\"\"Get current LLM configuration (reads directly from config.json)\"\"\"\n from config_manager import LLMConfigManager\n cfg_mgr = LLMConfigManager()\n raw_config = cfg_mgr.config\n llm_config = dict(raw_config.get('llm', {}))\n\n if raw_config.get('active_backend') == 'online' and raw_config.get('online'):\n online = raw_config.get('online', {})\n llm_config.update({\n 'provider': online.get('provider', llm_config.get('provider', 'custom')),\n 'model': online.get('model', llm_config.get('model', '')),\n 'api_base': online.get('api_base', llm_config.get('api_base', '')),\n 'api_key': online.get('api_key', llm_config.get('api_key', '')),\n })\n elif raw_config.get('active_backend') == 'ollama' and raw_config.get('ollama'):\n ollama = raw_config.get('ollama', {})\n llm_config.update({\n 'provider': 'ollama',\n 'model': ollama.get('model', llm_config.get('model', '')),\n 'api_base': ollama.get('api_base', llm_config.get('api_base', 'http://localhost:11434')),\n 'api_key': '',\n })\n\n return jsonify({\n 'config': _mask_api_key(llm_config),\n 'search': _public_search_config(cfg_mgr.get_search_config()),\n 'app_paths': cfg_mgr.get_app_paths(),\n 'coding_agent': cfg_mgr.get_coding_agent_config(),\n 'online_providers': _public_online_providers(),\n 'first_run': cfg_mgr.is_first_run(),\n 'ollama_available': cfg_mgr.check_ollama_available()\n })\n\n\n@bp.route('/api/llm/config', methods=['POST'])\ndef update_llm_config():\n \"\"\"Update LLM configuration\"\"\"\n from config_manager import get_config_manager\n data = request.get_json(silent=True) or {}\n config = get_config_manager()\n\n try:\n cfg = config.config\n llm = cfg.setdefault('llm', {})\n provider = (data.get('provider') or llm.get('provider') or 'ollama').strip()\n if provider not in ['ollama', 'openai', 'deepseek', 'siliconflow', 'moonshot', 'dashscope', 'zhipu', 'custom']:\n raise ValueError(f'Invalid provider: {provider}')\n\n if provider == 'ollama':\n api_base = (data.get('api_base') or 'http://localhost:11434').strip().rstrip('/')\n model = (data.get('model') or llm.get('model') or '').strip()\n llm.update({\n 'provider': 'ollama',\n 'model': model,\n 'api_base': api_base,\n 'api_key': '',\n })\n cfg['active_backend'] = 'ollama'\n cfg.setdefault('ollama', {}).update({\n 'model': model,\n 'api_base': api_base,\n })\n else:\n preset = ONLINE_PROVIDERS.get(provider, {})\n online = cfg.setdefault('online', {})\n api_base = (data.get('api_base') or preset.get('api_base') or online.get('api_base') or llm.get('api_base') or '').strip().rstrip('/')\n model = (data.get('model') or preset.get('default_model') or online.get('model') or llm.get('model') or '').strip()\n api_key = data.get('api_key')\n if api_key is None or api_key == '':\n api_key = online.get('api_key') or llm.get('api_key', '')\n else:\n api_key = api_key.strip()\n\n llm.update({\n 'provider': provider,\n 'model': model,\n 'api_base': api_base,\n 'api_key': api_key,\n })\n cfg['active_backend'] = 'online'\n online.update({\n 'provider': provider,\n 'api_base': api_base,\n 'api_key': api_key,\n 'model': model,\n })\n\n if 'temperature' in data:\n llm['temperature'] = data['temperature']\n if 'max_tokens' in data:\n llm['max_tokens'] = data['max_tokens']\n\n # Mark first run complete if this is the first setup\n if config.is_first_run():\n cfg['first_run'] = False\n\n config.save_config()\n return jsonify({'message': 'Configuration updated successfully', 'config': _mask_api_key(llm)})\n except Exception as e:\n return jsonify({'error': str(e)}), 400\n\n\n@bp.route('/api/config/search', methods=['GET', 'POST'])\ndef search_config():\n \"\"\"Get/update web/literature search provider configuration.\"\"\"\n from config_manager import get_config_manager, SEARCH_PROVIDER_DEFAULTS\n mgr = get_config_manager()\n project_cfg = mgr.get_project_config()\n search = project_cfg.setdefault('search', {})\n providers = search.setdefault('providers', {})\n for key, meta in SEARCH_PROVIDER_DEFAULTS.items():\n providers.setdefault(key, {'enabled': meta['enabled'], 'api_key': '', 'api_base': ''})\n\n if request.method == 'GET':\n return jsonify({'ok': True, 'search': _public_search_config(search)})\n\n data = request.get_json(silent=True) or {}\n incoming = data.get('providers') or {}\n for key, item in incoming.items():\n if key not in SEARCH_PROVIDER_DEFAULTS or not isinstance(item, dict):\n continue\n cur = providers.setdefault(key, {'enabled': False, 'api_key': '', 'api_base': ''})\n if 'enabled' in item:\n cur['enabled'] = bool(item.get('enabled'))\n if item.get('api_key'):\n cur['api_key'] = str(item.get('api_key')).strip()\n if 'api_base' in item:\n cur['api_base'] = str(item.get('api_base') or '').strip().rstrip('/')\n if isinstance(data.get('default_sources'), list):\n search['default_sources'] = [\n s for s in data['default_sources']\n if s in SEARCH_PROVIDER_DEFAULTS\n ] or ['openalex', 'semantic_scholar']\n mgr.save_project_config(project_cfg)\n return jsonify({'ok': True, 'search': _public_search_config(search)})\n\n\n@bp.route('/api/config/app_paths', methods=['GET', 'POST'])\ndef app_paths_config():\n \"\"\"Get/update shared Chat and Geo Agent path defaults.\"\"\"\n from config_manager import get_config_manager\n mgr = get_config_manager()\n project_cfg = mgr.get_project_config()\n paths = project_cfg.setdefault('app_paths', mgr.get_app_paths())\n\n if request.method == 'GET':\n return jsonify({'ok': True, 'app_paths': paths})\n\n data = request.get_json(silent=True) or {}\n if 'chat_workspace_enabled' in data:\n paths['chat_workspace_enabled'] = bool(data.get('chat_workspace_enabled'))\n if 'chat_workspace_paths' in data:\n raw = data.get('chat_workspace_paths')\n if isinstance(raw, str):\n vals = [x.strip() for x in raw.replace(';', '\\n').replace(',', '\\n').splitlines() if x.strip()]\n elif isinstance(raw, list):\n vals = [str(x).strip() for x in raw if str(x).strip()]\n else:\n vals = []\n paths['chat_workspace_paths'] = vals\n for key in ('geo_workspace_root', 'geo_literature_root', 'geo_output_dir'):\n if key in data:\n paths[key] = str(data.get(key) or '').strip()\n mgr.save_project_config(project_cfg)\n return jsonify({'ok': True, 'app_paths': paths})\n\n\n@bp.route('/api/config/coding_agent', methods=['GET', 'POST'])\ndef coding_agent_config():\n \"\"\"Get/update the project-scoped coding backend configuration.\"\"\"\n from config_manager import get_config_manager\n mgr = get_config_manager()\n project_cfg = mgr.get_project_config()\n cfg = project_cfg.setdefault('coding_agent', mgr.get_coding_agent_config())\n\n if request.method == 'GET':\n status = {}\n try:\n from seismo_code.external_coding_agent import external_agent_status\n status = external_agent_status(cfg)\n except Exception as exc:\n status = {'available': False, 'error': str(exc)}\n return jsonify({'ok': True, 'coding_agent': cfg, 'status': status})\n\n data = request.get_json(silent=True) or {}\n backend = str(data.get('backend') or cfg.get('backend') or 'builtin').strip()\n if backend not in {'builtin', 'aider', 'openhands'}:\n return jsonify({'ok': False, 'error': f'Invalid coding agent backend: {backend}'}), 400\n cfg['backend'] = backend\n # Backend selection is the source of truth. Keep `enabled` only for older clients.\n cfg['enabled'] = backend != 'builtin'\n for key in ('command', 'model', 'extra_args'):\n if key in data:\n cfg[key] = str(data.get(key) or '').strip()\n if 'timeout_s' in data:\n try:\n cfg['timeout_s'] = max(30, min(7200, int(data.get('timeout_s') or 900)))\n except Exception:\n cfg['timeout_s'] = 900\n if 'auto_approve' in data:\n cfg['auto_approve'] = bool(data.get('auto_approve'))\n if 'use_current_llm_env' in data:\n cfg['use_current_llm_env'] = bool(data.get('use_current_llm_env'))\n mgr.save_project_config(project_cfg)\n return jsonify({'ok': True, 'coding_agent': cfg})\n\n\n@bp.route('/api/llm/ollama/models', methods=['GET'])\ndef get_ollama_models():\n \"\"\"Get available Ollama models with a single short HTTP request.\"\"\"\n from config_manager import get_config_manager\n config = get_config_manager()\n recommended = config.get_recommended_models().get('ollama', [])\n ollama_cfg = config.config.get('ollama', {}) if isinstance(config.config.get('ollama', {}), dict) else {}\n llm_cfg = config.config.get('llm', {}) if isinstance(config.config.get('llm', {}), dict) else {}\n saved_llm_base = llm_cfg.get('api_base') if llm_cfg.get('provider') == 'ollama' else ''\n api_base = (\n request.args.get('api_base')\n or ollama_cfg.get('api_base')\n or saved_llm_base\n or 'http://localhost:11434'\n ).strip().rstrip('/')\n if not api_base.startswith(('http://localhost', 'http://127.0.0.1', 'http://0.0.0.0', 'http://[::1]')):\n api_base = 'http://localhost:11434'\n\n models = []\n available = False\n error = ''\n try:\n req = _ur.Request(f'{api_base}/api/tags')\n with _ur.urlopen(req, timeout=2.5) as resp:\n available = resp.status == 200\n if available:\n data = json.loads(resp.read().decode('utf-8'))\n models = _parse_model_list(data)\n except Exception as exc:\n error = str(exc)\n\n return jsonify({\n 'installed': models,\n 'recommended': recommended,\n 'ollama_available': available,\n 'api_base': api_base,\n 'error': error,\n })\n\n\n@bp.route('/api/llm/ollama/pull', methods=['POST'])\ndef pull_ollama_model():\n \"\"\"Pull an Ollama model and track progress.\"\"\"\n data = request.get_json(silent=True) or {}\n model_name = data.get('model')\n\n if not model_name:\n return jsonify({'error': 'Model name required'}), 400\n\n _pull_status[model_name] = {'status': 'pulling', 'progress': 0, 'detail': '', 'error': ''}\n\n def pull_in_background():\n try:\n # Use Ollama streaming pull API to track progress\n url = 'http://localhost:11434/api/pull'\n payload = json.dumps({'name': model_name, 'stream': True}).encode()\n req = _ur.Request(url, data=payload, method='POST',\n headers={'Content-Type': 'application/json'})\n with _ur.urlopen(req, timeout=1800) as resp:\n for raw_line in resp:\n line = raw_line.decode().strip()\n if not line:\n continue\n try:\n obj = json.loads(line)\n except Exception:\n continue\n status_msg = obj.get('status', '')\n total = obj.get('total', 0)\n completed = obj.get('completed', 0)\n pct = int(completed * 100 / total) if total else 0\n detail = f\"{completed/1e9:.1f} GB / {total/1e9:.1f} GB\" if total else status_msg\n _pull_status[model_name] = {\n 'status': 'pulling', 'progress': pct,\n 'detail': detail, 'error': ''\n }\n _pull_status[model_name] = {'status': 'done', 'progress': 100, 'detail': '', 'error': ''}\n except Exception as ex:\n _pull_status[model_name] = {'status': 'error', 'progress': 0,\n 'detail': '', 'error': str(ex)}\n\n thread = threading.Thread(target=pull_in_background, daemon=True)\n thread.start()\n return jsonify({'message': f'Started pulling model: {model_name}'})\n\n\n@bp.route('/api/llm/online/models', methods=['GET'])\ndef get_online_api_models():\n \"\"\"从在线 API 获取可用模型列表。支持 GET 参数: provider, api_base, api_key, model。\"\"\"\n from config_manager import get_config_manager\n\n provider = request.args.get('provider', '').strip()\n api_base = request.args.get('api_base', '').strip()\n api_key = request.args.get('api_key', '').strip()\n current_model = request.args.get('model', '').strip()\n\n if not api_base and provider in ONLINE_PROVIDERS:\n api_base = ONLINE_PROVIDERS[provider].get('api_base', '')\n\n # 如果请求没带 api_key,使用已保存的 online/llm key\n if not api_key:\n cfg = get_config_manager()\n if not current_model:\n current_model = (\n cfg.config.get('online', {}).get('model')\n or cfg.config.get('llm', {}).get('model', '')\n )\n api_key = (\n cfg.config.get('online', {}).get('api_key')\n or cfg.config.get('llm', {}).get('api_key', '')\n )\n\n if not api_base:\n return jsonify({'error': '请提供 api_base 参数', 'models': []}), 400\n if not api_key:\n return jsonify({'error': '请先填写并保存 API Key,或在参数中提供 api_key', 'models': []}), 400\n\n import urllib.request as _urq\n import urllib.error as _ure\n\n url = api_base.rstrip('/') + '/models'\n try:\n req = _urq.Request(\n url,\n headers={\n 'Authorization': f'Bearer {api_key}',\n 'Content-Type': 'application/json',\n }\n )\n with _urq.urlopen(req, timeout=12) as resp:\n data = json.loads(resp.read().decode('utf-8'))\n\n models = _parse_model_list(data)\n if not models:\n return jsonify({'error': '接口返回了空模型列表', 'models': []}), 502\n\n return jsonify({'models': models, 'source': 'models_endpoint'})\n\n except _ure.HTTPError as e:\n body = ''\n try:\n body = e.read().decode('utf-8', errors='ignore')[:240]\n except Exception:\n pass\n msgs = {\n 401: 'API Key 无效或已过期 (401)',\n 403: '访问被拒绝,请检查权限 (403)',\n 404: '该平台不支持模型列表接口 (404)',\n }\n candidates = _fallback_model_candidates(provider, api_base, current_model)\n if e.code in {400, 404, 405, 501} and candidates:\n verified = []\n for model in candidates[:4]:\n try:\n if _chat_model_available(api_base, api_key, model):\n verified.append(model)\n break\n except Exception:\n continue\n models = verified or candidates\n return jsonify({\n 'models': models,\n 'source': 'chat_probe' if verified else 'fallback',\n **({\n 'notice': (\n f'已验证当前模型可用;该平台的 /models 列表接口返回 HTTP {e.code}。'\n )\n } if verified else {\n 'warning': (\n f'/models 接口不可用或返回 HTTP {e.code},'\n '但聊天接口可能仍可使用;已使用当前/预设模型。'\n )\n }),\n })\n\n detail = msgs.get(e.code, f'HTTP {e.code}: {e.reason}')\n if body:\n detail += f' — {body}'\n return jsonify({'error': detail, 'models': []}), 502\n except (_ure.URLError, OSError) as e:\n return jsonify({'error': f'网络连接失败: {e.reason if hasattr(e, \"reason\") else str(e)}', 'models': []}), 502\n except Exception as e:\n return jsonify({'error': f'获取模型列表失败: {str(e)}', 'models': []}), 502\n\n\n@bp.route('/api/llm/test', methods=['POST'])\ndef test_llm_connection():\n \"\"\"轻量测试 LLM 连接:发送一条最短的 chat 消息,返回详细错误信息。\"\"\"\n from config_manager import get_config_manager\n\n manager = get_config_manager()\n cfg = dict(manager.get_llm_config())\n body = request.get_json(silent=True) or {}\n if body:\n saved_online = manager.config.get('online', {})\n saved_llm = manager.config.get('llm', {})\n cfg.update({\n 'provider': body.get('provider', cfg.get('provider', 'ollama')),\n 'model': body.get('model', cfg.get('model', '')),\n 'api_base': body.get('api_base', cfg.get('api_base', '')),\n })\n if body.get('api_key'):\n cfg['api_key'] = body.get('api_key')\n else:\n cfg['api_key'] = saved_online.get('api_key') or saved_llm.get('api_key') or cfg.get('api_key', '')\n\n provider = cfg.get('provider', 'ollama')\n model = cfg.get('model', '')\n api_base = cfg.get('api_base', 'http://localhost:11434')\n api_key = cfg.get('api_key', '')\n\n if not model:\n return jsonify({'ok': False, 'error': '未配置模型,请先保存配置'})\n\n import urllib.request as _urq\n import urllib.error as _ure\n\n # ── Ollama 分支 ─────────────────────────────────────────────────\n if provider == 'ollama':\n try:\n req = _urq.Request(f'{api_base.rstrip(\"/\")}/api/tags')\n with _urq.urlopen(req, timeout=5) as resp:\n ok = resp.status == 200\n if ok:\n return jsonify({'ok': True, 'message': f'Ollama 连接正常,当前模型: {model}'})\n return jsonify({'ok': False, 'error': 'Ollama 服务未响应,请运行 ollama serve'})\n except Exception as e:\n return jsonify({'ok': False, 'error': f'Ollama 连接失败: {str(e)}'})\n\n # ── 在线 API 分支 ────────────────────────────────────────────────\n if not api_key:\n return jsonify({'ok': False, 'error': '未配置 API Key,请填写后保存'})\n\n payload = json.dumps({\n 'model': model,\n 'messages': [{'role': 'user', 'content': 'hi'}],\n 'max_tokens': 5,\n 'temperature': 0,\n }).encode('utf-8')\n\n try:\n req = _urq.Request(\n f'{api_base.rstrip(\"/\")}/chat/completions',\n data=payload,\n headers={\n 'Content-Type': 'application/json',\n 'Authorization': f'Bearer {api_key}',\n },\n method='POST',\n )\n with _urq.urlopen(req, timeout=30) as resp:\n result = json.loads(resp.read().decode('utf-8'))\n\n if result.get('choices'):\n return jsonify({'ok': True, 'message': f'✓ {model} 响应正常'})\n return jsonify({'ok': False, 'error': '模型返回了空结果,请检查模型名称'})\n\n except _ure.HTTPError as e:\n body = ''\n try: body = e.read().decode('utf-8', errors='ignore')[:200]\n except Exception: pass\n msgs = {\n 401: f'API Key 无效或已过期 (401)',\n 403: f'访问被拒绝,请检查权限 (403)',\n 404: f'模型 \"{model}\" 不存在,请确认模型名称 (404)',\n 429: '请求频率超限,稍后再试 (429)',\n }\n err = msgs.get(e.code, f'HTTP {e.code}')\n if body: err += f' — {body}'\n return jsonify({'ok': False, 'error': err})\n except (_ure.URLError, OSError) as e:\n reason = e.reason if hasattr(e, 'reason') else str(e)\n if 'timed out' in str(reason).lower():\n return jsonify({'ok': False, 'error': '连接超时,请检查网络或 API 地址是否正确'})\n return jsonify({'ok': False, 'error': f'网络连接失败: {reason}'})\n except Exception as e:\n return jsonify({'ok': False, 'error': f'测试失败: {str(e)}'})\n\n\n@bp.route('/api/llm/ollama/pull/status', methods=['GET'])\ndef pull_ollama_status():\n \"\"\"Return current pull progress for a model.\"\"\"\n model_name = request.args.get('model', '')\n if not model_name:\n return jsonify({'error': 'model param required'}), 400\n info = _pull_status.get(model_name, {'status': 'unknown', 'progress': 0, 'detail': '', 'error': ''})\n return jsonify(info)\n"}, "bfecc1f48452eb4f1ea84a99369c06dae9f312a9": {"t": 1785763092.1301532, "n": 23018, "h": "f01fb127bc89d078", "c": "\"\"\"LLM 配置路由\"\"\"\nfrom flask import Blueprint, request, jsonify\nimport sys\nimport json\nimport threading\nimport time as _time\nimport urllib.request as _ur\nfrom pathlib import Path\nfrom state import _pull_status\n\nbp = Blueprint('llm', __name__)\n\ntry:\n from backend_manager import ONLINE_PROVIDERS\nexcept Exception:\n ONLINE_PROVIDERS = {\n \"openai\": {\"display\": \"OpenAI\", \"api_base\": \"https://api.openai.com/v1\", \"default_model\": \"gpt-4o-mini\", \"need_key\": True},\n \"deepseek\": {\"display\": \"DeepSeek\", \"api_base\": \"https://api.deepseek.com/v1\", \"default_model\": \"deepseek-v4-flash\", \"need_key\": True},\n \"siliconflow\": {\"display\": \"SiliconFlow\", \"api_base\": \"https://api.siliconflow.cn/v1\", \"default_model\": \"Qwen/Qwen2.5-7B-Instruct\", \"need_key\": True},\n \"moonshot\": {\"display\": \"Moonshot / Kimi\", \"api_base\": \"https://api.moonshot.cn/v1\", \"default_model\": \"moonshot-v1-8k\", \"need_key\": True},\n \"dashscope\": {\"display\": \"DashScope\", \"api_base\": \"https://dashscope.aliyuncs.com/compatible-mode/v1\", \"default_model\": \"qwen-turbo\", \"need_key\": True},\n \"zhipu\": {\"display\": \"Zhipu AI\", \"api_base\": \"https://open.bigmodel.cn/api/paas/v4\", \"default_model\": \"glm-4-flash\", \"need_key\": True},\n \"custom\": {\"display\": \"Custom OpenAI-compatible\", \"api_base\": \"\", \"default_model\": \"\", \"need_key\": True},\n }\n\n\ndef _public_online_providers():\n \"\"\"Provider metadata safe to send to the browser.\"\"\"\n return {\n key: {\n 'display': value.get('display', key),\n 'api_base': value.get('api_base', ''),\n 'default_model': value.get('default_model', ''),\n 'need_key': value.get('need_key', True),\n }\n for key, value in ONLINE_PROVIDERS.items()\n if key != 'anthropic'\n }\n\n\ndef _mask_api_key(config):\n config = dict(config or {})\n if config.get('api_key'):\n key = config['api_key']\n config['api_key_masked'] = '****' + key[-4:] if len(key) > 4 else '****'\n config['api_key'] = ''\n return config\n\n\ndef _mask_key(value):\n if not value:\n return ''\n value = str(value)\n return '****' + value[-4:] if len(value) > 4 else '****'\n\n\ndef _public_search_config(raw_search):\n from config_manager import SEARCH_PROVIDER_DEFAULTS\n raw_search = raw_search or {}\n providers = raw_search.get('providers') or {}\n out = {}\n for key, meta in SEARCH_PROVIDER_DEFAULTS.items():\n cfg = providers.get(key) or {}\n out[key] = {\n 'display': meta.get('display', key),\n 'description': meta.get('description', ''),\n 'requires_key': bool(meta.get('requires_key')),\n 'enabled': bool(cfg.get('enabled', meta.get('enabled', False))),\n 'api_key_masked': _mask_key(cfg.get('api_key', '')),\n 'api_base': cfg.get('api_base', ''),\n }\n return {\n 'providers': out,\n 'default_sources': raw_search.get('default_sources') or ['openalex', 'semantic_scholar'],\n }\n\n\ndef _parse_model_list(data):\n models = []\n if isinstance(data, dict) and isinstance(data.get('data'), list):\n models = [m['id'] for m in data['data'] if isinstance(m, dict) and m.get('id')]\n elif isinstance(data, dict) and isinstance(data.get('models'), list):\n models = [m.get('id') or m.get('name', '') for m in data['models'] if isinstance(m, dict)]\n elif isinstance(data, list):\n models = [\n m.get('id') or m.get('name', '')\n for m in data\n if isinstance(m, dict)\n ]\n return sorted({m for m in models if m})\n\n\ndef _fallback_model_candidates(provider, api_base, current_model):\n candidates = []\n if current_model:\n candidates.append(current_model)\n preset = ONLINE_PROVIDERS.get(provider, {})\n if preset.get('default_model'):\n candidates.append(preset['default_model'])\n base_l = (api_base or '').lower()\n if 'aliyuncs.com' in base_l or 'dashscope' in base_l:\n candidates.extend(['qwen3.6-plus', 'qwen-plus', 'qwen-turbo'])\n return list(dict.fromkeys(candidates))\n\n\ndef _chat_model_available(api_base, api_key, model):\n payload = json.dumps({\n 'model': model,\n 'messages': [{'role': 'user', 'content': 'hi'}],\n 'max_tokens': 1,\n 'temperature': 0,\n }).encode('utf-8')\n req = _ur.Request(\n f'{api_base.rstrip(\"/\")}/chat/completions',\n data=payload,\n headers={\n 'Content-Type': 'application/json',\n 'Authorization': f'Bearer {api_key}',\n },\n method='POST',\n )\n with _ur.urlopen(req, timeout=20) as resp:\n data = json.loads(resp.read().decode('utf-8'))\n return bool(data.get('choices'))\n\n\n@bp.route('/api/llm/config', methods=['GET'])\ndef llm_config_get():\n \"\"\"Get current LLM configuration (reads directly from config.json)\"\"\"\n from config_manager import LLMConfigManager\n cfg_mgr = LLMConfigManager()\n raw_config = cfg_mgr.config\n llm_config = dict(raw_config.get('llm', {}))\n\n if raw_config.get('active_backend') == 'online' and raw_config.get('online'):\n online = raw_config.get('online', {})\n llm_config.update({\n 'provider': online.get('provider', llm_config.get('provider', 'custom')),\n 'model': online.get('model', llm_config.get('model', '')),\n 'api_base': online.get('api_base', llm_config.get('api_base', '')),\n 'api_key': online.get('api_key', llm_config.get('api_key', '')),\n })\n elif raw_config.get('active_backend') == 'ollama' and raw_config.get('ollama'):\n ollama = raw_config.get('ollama', {})\n llm_config.update({\n 'provider': 'ollama',\n 'model': ollama.get('model', llm_config.get('model', '')),\n 'api_base': ollama.get('api_base', llm_config.get('api_base', 'http://localhost:11434')),\n 'api_key': '',\n })\n\n return jsonify({\n 'config': _mask_api_key(llm_config),\n 'search': _public_search_config(cfg_mgr.get_search_config()),\n 'app_paths': cfg_mgr.get_app_paths(),\n 'online_providers': _public_online_providers(),\n 'first_run': cfg_mgr.is_first_run(),\n 'ollama_available': cfg_mgr.check_ollama_available()\n })\n\n\n@bp.route('/api/llm/config', methods=['POST'])\ndef update_llm_config():\n \"\"\"Update LLM configuration\"\"\"\n from config_manager import get_config_manager\n data = request.get_json(silent=True) or {}\n config = get_config_manager()\n\n try:\n cfg = config.config\n llm = cfg.setdefault('llm', {})\n provider = (data.get('provider') or llm.get('provider') or 'ollama').strip()\n if provider not in ['ollama', 'openai', 'deepseek', 'siliconflow', 'moonshot', 'dashscope', 'zhipu', 'custom']:\n raise ValueError(f'Invalid provider: {provider}')\n\n if provider == 'ollama':\n api_base = (data.get('api_base') or 'http://localhost:11434').strip().rstrip('/')\n model = (data.get('model') or llm.get('model') or '').strip()\n llm.update({\n 'provider': 'ollama',\n 'model': model,\n 'api_base': api_base,\n 'api_key': '',\n })\n cfg['active_backend'] = 'ollama'\n cfg.setdefault('ollama', {}).update({\n 'model': model,\n 'api_base': api_base,\n })\n else:\n preset = ONLINE_PROVIDERS.get(provider, {})\n online = cfg.setdefault('online', {})\n api_base = (data.get('api_base') or preset.get('api_base') or online.get('api_base') or llm.get('api_base') or '').strip().rstrip('/')\n model = (data.get('model') or preset.get('default_model') or online.get('model') or llm.get('model') or '').strip()\n api_key = data.get('api_key')\n if api_key is None or api_key == '':\n api_key = online.get('api_key') or llm.get('api_key', '')\n else:\n api_key = api_key.strip()\n\n llm.update({\n 'provider': provider,\n 'model': model,\n 'api_base': api_base,\n 'api_key': api_key,\n })\n cfg['active_backend'] = 'online'\n online.update({\n 'provider': provider,\n 'api_base': api_base,\n 'api_key': api_key,\n 'model': model,\n })\n\n if 'temperature' in data:\n llm['temperature'] = data['temperature']\n if 'max_tokens' in data:\n llm['max_tokens'] = data['max_tokens']\n\n # Mark first run complete if this is the first setup\n if config.is_first_run():\n cfg['first_run'] = False\n\n config.save_config()\n return jsonify({'message': 'Configuration updated successfully', 'config': _mask_api_key(llm)})\n except Exception as e:\n return jsonify({'error': str(e)}), 400\n\n\n@bp.route('/api/config/search', methods=['GET', 'POST'])\ndef search_config():\n \"\"\"Get/update web/literature search provider configuration.\"\"\"\n from config_manager import get_config_manager, SEARCH_PROVIDER_DEFAULTS\n mgr = get_config_manager()\n project_cfg = mgr.get_project_config()\n search = project_cfg.setdefault('search', {})\n providers = search.setdefault('providers', {})\n for key, meta in SEARCH_PROVIDER_DEFAULTS.items():\n providers.setdefault(key, {'enabled': meta['enabled'], 'api_key': '', 'api_base': ''})\n\n if request.method == 'GET':\n return jsonify({'ok': True, 'search': _public_search_config(search)})\n\n data = request.get_json(silent=True) or {}\n incoming = data.get('providers') or {}\n for key, item in incoming.items():\n if key not in SEARCH_PROVIDER_DEFAULTS or not isinstance(item, dict):\n continue\n cur = providers.setdefault(key, {'enabled': False, 'api_key': '', 'api_base': ''})\n if 'enabled' in item:\n cur['enabled'] = bool(item.get('enabled'))\n if item.get('api_key'):\n cur['api_key'] = str(item.get('api_key')).strip()\n if 'api_base' in item:\n cur['api_base'] = str(item.get('api_base') or '').strip().rstrip('/')\n if isinstance(data.get('default_sources'), list):\n search['default_sources'] = [\n s for s in data['default_sources']\n if s in SEARCH_PROVIDER_DEFAULTS\n ] or ['openalex', 'semantic_scholar']\n mgr.save_project_config(project_cfg)\n return jsonify({'ok': True, 'search': _public_search_config(search)})\n\n\n@bp.route('/api/config/app_paths', methods=['GET', 'POST'])\ndef app_paths_config():\n \"\"\"Get/update shared Chat and Geo Agent path defaults.\"\"\"\n from config_manager import get_config_manager\n mgr = get_config_manager()\n project_cfg = mgr.get_project_config()\n paths = project_cfg.setdefault('app_paths', mgr.get_app_paths())\n\n if request.method == 'GET':\n return jsonify({'ok': True, 'app_paths': paths})\n\n data = request.get_json(silent=True) or {}\n if 'chat_workspace_enabled' in data:\n paths['chat_workspace_enabled'] = bool(data.get('chat_workspace_enabled'))\n if 'chat_workspace_paths' in data:\n raw = data.get('chat_workspace_paths')\n if isinstance(raw, str):\n vals = [x.strip() for x in raw.replace(';', '\\n').replace(',', '\\n').splitlines() if x.strip()]\n elif isinstance(raw, list):\n vals = [str(x).strip() for x in raw if str(x).strip()]\n else:\n vals = []\n paths['chat_workspace_paths'] = vals\n for key in ('geo_workspace_root', 'geo_literature_root', 'geo_output_dir'):\n if key in data:\n paths[key] = str(data.get(key) or '').strip()\n mgr.save_project_config(project_cfg)\n return jsonify({'ok': True, 'app_paths': paths})\n\n\n@bp.route('/api/llm/ollama/models', methods=['GET'])\ndef get_ollama_models():\n \"\"\"Get available Ollama models with a single short HTTP request.\"\"\"\n from config_manager import get_config_manager\n config = get_config_manager()\n recommended = config.get_recommended_models().get('ollama', [])\n ollama_cfg = config.config.get('ollama', {}) if isinstance(config.config.get('ollama', {}), dict) else {}\n llm_cfg = config.config.get('llm', {}) if isinstance(config.config.get('llm', {}), dict) else {}\n saved_llm_base = llm_cfg.get('api_base') if llm_cfg.get('provider') == 'ollama' else ''\n api_base = (\n request.args.get('api_base')\n or ollama_cfg.get('api_base')\n or saved_llm_base\n or 'http://localhost:11434'\n ).strip().rstrip('/')\n if not api_base.startswith(('http://localhost', 'http://127.0.0.1', 'http://0.0.0.0', 'http://[::1]')):\n api_base = 'http://localhost:11434'\n\n models = []\n available = False\n error = ''\n try:\n req = _ur.Request(f'{api_base}/api/tags')\n with _ur.urlopen(req, timeout=2.5) as resp:\n available = resp.status == 200\n if available:\n data = json.loads(resp.read().decode('utf-8'))\n models = _parse_model_list(data)\n except Exception as exc:\n error = str(exc)\n\n return jsonify({\n 'installed': models,\n 'recommended': recommended,\n 'ollama_available': available,\n 'api_base': api_base,\n 'error': error,\n })\n\n\n@bp.route('/api/llm/ollama/pull', methods=['POST'])\ndef pull_ollama_model():\n \"\"\"Pull an Ollama model and track progress.\"\"\"\n data = request.get_json(silent=True) or {}\n model_name = data.get('model')\n\n if not model_name:\n return jsonify({'error': 'Model name required'}), 400\n\n _pull_status[model_name] = {'status': 'pulling', 'progress': 0, 'detail': '', 'error': ''}\n\n def pull_in_background():\n try:\n # Use Ollama streaming pull API to track progress\n url = 'http://localhost:11434/api/pull'\n payload = json.dumps({'name': model_name, 'stream': True}).encode()\n req = _ur.Request(url, data=payload, method='POST',\n headers={'Content-Type': 'application/json'})\n with _ur.urlopen(req, timeout=1800) as resp:\n for raw_line in resp:\n line = raw_line.decode().strip()\n if not line:\n continue\n try:\n obj = json.loads(line)\n except Exception:\n continue\n status_msg = obj.get('status', '')\n total = obj.get('total', 0)\n completed = obj.get('completed', 0)\n pct = int(completed * 100 / total) if total else 0\n detail = f\"{completed/1e9:.1f} GB / {total/1e9:.1f} GB\" if total else status_msg\n _pull_status[model_name] = {\n 'status': 'pulling', 'progress': pct,\n 'detail': detail, 'error': ''\n }\n _pull_status[model_name] = {'status': 'done', 'progress': 100, 'detail': '', 'error': ''}\n except Exception as ex:\n _pull_status[model_name] = {'status': 'error', 'progress': 0,\n 'detail': '', 'error': str(ex)}\n\n thread = threading.Thread(target=pull_in_background, daemon=True)\n thread.start()\n return jsonify({'message': f'Started pulling model: {model_name}'})\n\n\n@bp.route('/api/llm/online/models', methods=['GET'])\ndef get_online_api_models():\n \"\"\"从在线 API 获取可用模型列表。支持 GET 参数: provider, api_base, api_key, model。\"\"\"\n from config_manager import get_config_manager\n\n provider = request.args.get('provider', '').strip()\n api_base = request.args.get('api_base', '').strip()\n api_key = request.args.get('api_key', '').strip()\n current_model = request.args.get('model', '').strip()\n\n if not api_base and provider in ONLINE_PROVIDERS:\n api_base = ONLINE_PROVIDERS[provider].get('api_base', '')\n\n # 如果请求没带 api_key,使用已保存的 online/llm key\n if not api_key:\n cfg = get_config_manager()\n if not current_model:\n current_model = (\n cfg.config.get('online', {}).get('model')\n or cfg.config.get('llm', {}).get('model', '')\n )\n api_key = (\n cfg.config.get('online', {}).get('api_key')\n or cfg.config.get('llm', {}).get('api_key', '')\n )\n\n if not api_base:\n return jsonify({'error': '请提供 api_base 参数', 'models': []}), 400\n if not api_key:\n return jsonify({'error': '请先填写并保存 API Key,或在参数中提供 api_key', 'models': []}), 400\n\n import urllib.request as _urq\n import urllib.error as _ure\n\n url = api_base.rstrip('/') + '/models'\n try:\n req = _urq.Request(\n url,\n headers={\n 'Authorization': f'Bearer {api_key}',\n 'Content-Type': 'application/json',\n }\n )\n with _urq.urlopen(req, timeout=12) as resp:\n data = json.loads(resp.read().decode('utf-8'))\n\n models = _parse_model_list(data)\n if not models:\n return jsonify({'error': '接口返回了空模型列表', 'models': []}), 502\n\n return jsonify({'models': models, 'source': 'models_endpoint'})\n\n except _ure.HTTPError as e:\n body = ''\n try:\n body = e.read().decode('utf-8', errors='ignore')[:240]\n except Exception:\n pass\n msgs = {\n 401: 'API Key 无效或已过期 (401)',\n 403: '访问被拒绝,请检查权限 (403)',\n 404: '该平台不支持模型列表接口 (404)',\n }\n candidates = _fallback_model_candidates(provider, api_base, current_model)\n if e.code in {400, 404, 405, 501} and candidates:\n verified = []\n for model in candidates[:4]:\n try:\n if _chat_model_available(api_base, api_key, model):\n verified.append(model)\n break\n except Exception:\n continue\n models = verified or candidates\n return jsonify({\n 'models': models,\n 'source': 'chat_probe' if verified else 'fallback',\n **({\n 'notice': (\n f'已验证当前模型可用;该平台的 /models 列表接口返回 HTTP {e.code}。'\n )\n } if verified else {\n 'warning': (\n f'/models 接口不可用或返回 HTTP {e.code},'\n '但聊天接口可能仍可使用;已使用当前/预设模型。'\n )\n }),\n })\n\n detail = msgs.get(e.code, f'HTTP {e.code}: {e.reason}')\n if body:\n detail += f' — {body}'\n return jsonify({'error': detail, 'models': []}), 502\n except (_ure.URLError, OSError) as e:\n return jsonify({'error': f'网络连接失败: {e.reason if hasattr(e, \"reason\") else str(e)}', 'models': []}), 502\n except Exception as e:\n return jsonify({'error': f'获取模型列表失败: {str(e)}', 'models': []}), 502\n\n\n@bp.route('/api/llm/test', methods=['POST'])\ndef test_llm_connection():\n \"\"\"轻量测试 LLM 连接:发送一条最短的 chat 消息,返回详细错误信息。\"\"\"\n from config_manager import get_config_manager\n\n manager = get_config_manager()\n cfg = dict(manager.get_llm_config())\n body = request.get_json(silent=True) or {}\n if body:\n saved_online = manager.config.get('online', {})\n saved_llm = manager.config.get('llm', {})\n cfg.update({\n 'provider': body.get('provider', cfg.get('provider', 'ollama')),\n 'model': body.get('model', cfg.get('model', '')),\n 'api_base': body.get('api_base', cfg.get('api_base', '')),\n })\n if body.get('api_key'):\n cfg['api_key'] = body.get('api_key')\n else:\n cfg['api_key'] = saved_online.get('api_key') or saved_llm.get('api_key') or cfg.get('api_key', '')\n\n provider = cfg.get('provider', 'ollama')\n model = cfg.get('model', '')\n api_base = cfg.get('api_base', 'http://localhost:11434')\n api_key = cfg.get('api_key', '')\n\n if not model:\n return jsonify({'ok': False, 'error': '未配置模型,请先保存配置'})\n\n import urllib.request as _urq\n import urllib.error as _ure\n\n # ── Ollama 分支 ─────────────────────────────────────────────────\n if provider == 'ollama':\n try:\n req = _urq.Request(f'{api_base.rstrip(\"/\")}/api/tags')\n with _urq.urlopen(req, timeout=5) as resp:\n ok = resp.status == 200\n if ok:\n return jsonify({'ok': True, 'message': f'Ollama 连接正常,当前模型: {model}'})\n return jsonify({'ok': False, 'error': 'Ollama 服务未响应,请运行 ollama serve'})\n except Exception as e:\n return jsonify({'ok': False, 'error': f'Ollama 连接失败: {str(e)}'})\n\n # ── 在线 API 分支 ────────────────────────────────────────────────\n if not api_key:\n return jsonify({'ok': False, 'error': '未配置 API Key,请填写后保存'})\n\n payload = json.dumps({\n 'model': model,\n 'messages': [{'role': 'user', 'content': 'hi'}],\n 'max_tokens': 5,\n 'temperature': 0,\n }).encode('utf-8')\n\n try:\n req = _urq.Request(\n f'{api_base.rstrip(\"/\")}/chat/completions',\n data=payload,\n headers={\n 'Content-Type': 'application/json',\n 'Authorization': f'Bearer {api_key}',\n },\n method='POST',\n )\n with _urq.urlopen(req, timeout=30) as resp:\n result = json.loads(resp.read().decode('utf-8'))\n\n if result.get('choices'):\n return jsonify({'ok': True, 'message': f'✓ {model} 响应正常'})\n return jsonify({'ok': False, 'error': '模型返回了空结果,请检查模型名称'})\n\n except _ure.HTTPError as e:\n body = ''\n try: body = e.read().decode('utf-8', errors='ignore')[:200]\n except Exception: pass\n msgs = {\n 401: f'API Key 无效或已过期 (401)',\n 403: f'访问被拒绝,请检查权限 (403)',\n 404: f'模型 \"{model}\" 不存在,请确认模型名称 (404)',\n 429: '请求频率超限,稍后再试 (429)',\n }\n err = msgs.get(e.code, f'HTTP {e.code}')\n if body: err += f' — {body}'\n return jsonify({'ok': False, 'error': err})\n except (_ure.URLError, OSError) as e:\n reason = e.reason if hasattr(e, 'reason') else str(e)\n if 'timed out' in str(reason).lower():\n return jsonify({'ok': False, 'error': '连接超时,请检查网络或 API 地址是否正确'})\n return jsonify({'ok': False, 'error': f'网络连接失败: {reason}'})\n except Exception as e:\n return jsonify({'ok': False, 'error': f'测试失败: {str(e)}'})\n\n\n@bp.route('/api/llm/ollama/pull/status', methods=['GET'])\ndef pull_ollama_status():\n \"\"\"Return current pull progress for a model.\"\"\"\n model_name = request.args.get('model', '')\n if not model_name:\n return jsonify({'error': 'model param required'}), 400\n info = _pull_status.get(model_name, {'status': 'unknown', 'progress': 0, 'detail': '', 'error': ''})\n return jsonify(info)\n"}, "82a35cf579f18dd06b563a9e2eb347e021da2138": {"t": 1785763095.4462836, "n": 19054, "h": "a233b5ad837d40ea", "c": "\"\"\"LLM 配置路由\"\"\"\nfrom flask import Blueprint, request, jsonify\nimport sys\nimport json\nimport threading\nimport time as _time\nimport urllib.request as _ur\nfrom pathlib import Path\nfrom state import _pull_status\n\nbp = Blueprint('llm', __name__)\n\ntry:\n from backend_manager import ONLINE_PROVIDERS\nexcept Exception:\n ONLINE_PROVIDERS = {\n \"openai\": {\"display\": \"OpenAI\", \"api_base\": \"https://api.openai.com/v1\", \"default_model\": \"gpt-4o-mini\", \"need_key\": True},\n \"deepseek\": {\"display\": \"DeepSeek\", \"api_base\": \"https://api.deepseek.com/v1\", \"default_model\": \"deepseek-v4-flash\", \"need_key\": True},\n \"siliconflow\": {\"display\": \"SiliconFlow\", \"api_base\": \"https://api.siliconflow.cn/v1\", \"default_model\": \"Qwen/Qwen2.5-7B-Instruct\", \"need_key\": True},\n \"moonshot\": {\"display\": \"Moonshot / Kimi\", \"api_base\": \"https://api.moonshot.cn/v1\", \"default_model\": \"moonshot-v1-8k\", \"need_key\": True},\n \"dashscope\": {\"display\": \"DashScope\", \"api_base\": \"https://dashscope.aliyuncs.com/compatible-mode/v1\", \"default_model\": \"qwen-turbo\", \"need_key\": True},\n \"zhipu\": {\"display\": \"Zhipu AI\", \"api_base\": \"https://open.bigmodel.cn/api/paas/v4\", \"default_model\": \"glm-4-flash\", \"need_key\": True},\n \"custom\": {\"display\": \"Custom OpenAI-compatible\", \"api_base\": \"\", \"default_model\": \"\", \"need_key\": True},\n }\n\n\ndef _public_online_providers():\n \"\"\"Provider metadata safe to send to the browser.\"\"\"\n return {\n key: {\n 'display': value.get('display', key),\n 'api_base': value.get('api_base', ''),\n 'default_model': value.get('default_model', ''),\n 'need_key': value.get('need_key', True),\n }\n for key, value in ONLINE_PROVIDERS.items()\n if key != 'anthropic'\n }\n\n\ndef _mask_api_key(config):\n config = dict(config or {})\n if config.get('api_key'):\n key = config['api_key']\n config['api_key_masked'] = '****' + key[-4:] if len(key) > 4 else '****'\n config['api_key'] = ''\n return config\n\n\ndef _parse_model_list(data):\n models = []\n if isinstance(data, dict) and isinstance(data.get('data'), list):\n models = [m['id'] for m in data['data'] if isinstance(m, dict) and m.get('id')]\n elif isinstance(data, dict) and isinstance(data.get('models'), list):\n models = [m.get('id') or m.get('name', '') for m in data['models'] if isinstance(m, dict)]\n elif isinstance(data, list):\n models = [\n m.get('id') or m.get('name', '')\n for m in data\n if isinstance(m, dict)\n ]\n return sorted({m for m in models if m})\n\n\ndef _fallback_model_candidates(provider, api_base, current_model):\n candidates = []\n if current_model:\n candidates.append(current_model)\n preset = ONLINE_PROVIDERS.get(provider, {})\n if preset.get('default_model'):\n candidates.append(preset['default_model'])\n base_l = (api_base or '').lower()\n if 'aliyuncs.com' in base_l or 'dashscope' in base_l:\n candidates.extend(['qwen3.6-plus', 'qwen-plus', 'qwen-turbo'])\n return list(dict.fromkeys(candidates))\n\n\ndef _chat_model_available(api_base, api_key, model):\n payload = json.dumps({\n 'model': model,\n 'messages': [{'role': 'user', 'content': 'hi'}],\n 'max_tokens': 1,\n 'temperature': 0,\n }).encode('utf-8')\n req = _ur.Request(\n f'{api_base.rstrip(\"/\")}/chat/completions',\n data=payload,\n headers={\n 'Content-Type': 'application/json',\n 'Authorization': f'Bearer {api_key}',\n },\n method='POST',\n )\n with _ur.urlopen(req, timeout=20) as resp:\n data = json.loads(resp.read().decode('utf-8'))\n return bool(data.get('choices'))\n\n\n@bp.route('/api/llm/config', methods=['GET'])\ndef llm_config_get():\n \"\"\"Get current LLM configuration (reads directly from config.json)\"\"\"\n from config_manager import LLMConfigManager\n cfg_mgr = LLMConfigManager()\n raw_config = cfg_mgr.config\n llm_config = dict(raw_config.get('llm', {}))\n\n if raw_config.get('active_backend') == 'online' and raw_config.get('online'):\n online = raw_config.get('online', {})\n llm_config.update({\n 'provider': online.get('provider', llm_config.get('provider', 'custom')),\n 'model': online.get('model', llm_config.get('model', '')),\n 'api_base': online.get('api_base', llm_config.get('api_base', '')),\n 'api_key': online.get('api_key', llm_config.get('api_key', '')),\n })\n elif raw_config.get('active_backend') == 'ollama' and raw_config.get('ollama'):\n ollama = raw_config.get('ollama', {})\n llm_config.update({\n 'provider': 'ollama',\n 'model': ollama.get('model', llm_config.get('model', '')),\n 'api_base': ollama.get('api_base', llm_config.get('api_base', 'http://localhost:11434')),\n 'api_key': '',\n })\n\n return jsonify({\n 'config': _mask_api_key(llm_config),\n 'online_providers': _public_online_providers(),\n 'first_run': cfg_mgr.is_first_run(),\n 'ollama_available': cfg_mgr.check_ollama_available()\n })\n\n\n@bp.route('/api/llm/config', methods=['POST'])\ndef update_llm_config():\n \"\"\"Update LLM configuration\"\"\"\n from config_manager import get_config_manager\n data = request.get_json(silent=True) or {}\n config = get_config_manager()\n\n try:\n cfg = config.config\n llm = cfg.setdefault('llm', {})\n provider = (data.get('provider') or llm.get('provider') or 'ollama').strip()\n if provider not in ['ollama', 'openai', 'deepseek', 'siliconflow', 'moonshot', 'dashscope', 'zhipu', 'custom']:\n raise ValueError(f'Invalid provider: {provider}')\n\n if provider == 'ollama':\n api_base = (data.get('api_base') or 'http://localhost:11434').strip().rstrip('/')\n model = (data.get('model') or llm.get('model') or '').strip()\n llm.update({\n 'provider': 'ollama',\n 'model': model,\n 'api_base': api_base,\n 'api_key': '',\n })\n cfg['active_backend'] = 'ollama'\n cfg.setdefault('ollama', {}).update({\n 'model': model,\n 'api_base': api_base,\n })\n else:\n preset = ONLINE_PROVIDERS.get(provider, {})\n online = cfg.setdefault('online', {})\n api_base = (data.get('api_base') or preset.get('api_base') or online.get('api_base') or llm.get('api_base') or '').strip().rstrip('/')\n model = (data.get('model') or preset.get('default_model') or online.get('model') or llm.get('model') or '').strip()\n api_key = data.get('api_key')\n if api_key is None or api_key == '':\n api_key = online.get('api_key') or llm.get('api_key', '')\n else:\n api_key = api_key.strip()\n\n llm.update({\n 'provider': provider,\n 'model': model,\n 'api_base': api_base,\n 'api_key': api_key,\n })\n cfg['active_backend'] = 'online'\n online.update({\n 'provider': provider,\n 'api_base': api_base,\n 'api_key': api_key,\n 'model': model,\n })\n\n if 'temperature' in data:\n llm['temperature'] = data['temperature']\n if 'max_tokens' in data:\n llm['max_tokens'] = data['max_tokens']\n\n # Mark first run complete if this is the first setup\n if config.is_first_run():\n cfg['first_run'] = False\n\n config.save_config()\n return jsonify({'message': 'Configuration updated successfully', 'config': _mask_api_key(llm)})\n except Exception as e:\n return jsonify({'error': str(e)}), 400\n\n\n@bp.route('/api/llm/ollama/models', methods=['GET'])\ndef get_ollama_models():\n \"\"\"Get available Ollama models with a single short HTTP request.\"\"\"\n from config_manager import get_config_manager\n config = get_config_manager()\n recommended = config.get_recommended_models().get('ollama', [])\n ollama_cfg = config.config.get('ollama', {}) if isinstance(config.config.get('ollama', {}), dict) else {}\n llm_cfg = config.config.get('llm', {}) if isinstance(config.config.get('llm', {}), dict) else {}\n saved_llm_base = llm_cfg.get('api_base') if llm_cfg.get('provider') == 'ollama' else ''\n api_base = (\n request.args.get('api_base')\n or ollama_cfg.get('api_base')\n or saved_llm_base\n or 'http://localhost:11434'\n ).strip().rstrip('/')\n if not api_base.startswith(('http://localhost', 'http://127.0.0.1', 'http://0.0.0.0', 'http://[::1]')):\n api_base = 'http://localhost:11434'\n\n models = []\n available = False\n error = ''\n try:\n req = _ur.Request(f'{api_base}/api/tags')\n with _ur.urlopen(req, timeout=2.5) as resp:\n available = resp.status == 200\n if available:\n data = json.loads(resp.read().decode('utf-8'))\n models = _parse_model_list(data)\n except Exception as exc:\n error = str(exc)\n\n return jsonify({\n 'installed': models,\n 'recommended': recommended,\n 'ollama_available': available,\n 'api_base': api_base,\n 'error': error,\n })\n\n\n@bp.route('/api/llm/ollama/pull', methods=['POST'])\ndef pull_ollama_model():\n \"\"\"Pull an Ollama model and track progress.\"\"\"\n data = request.get_json(silent=True) or {}\n model_name = data.get('model')\n\n if not model_name:\n return jsonify({'error': 'Model name required'}), 400\n\n _pull_status[model_name] = {'status': 'pulling', 'progress': 0, 'detail': '', 'error': ''}\n\n def pull_in_background():\n try:\n # Use Ollama streaming pull API to track progress\n url = 'http://localhost:11434/api/pull'\n payload = json.dumps({'name': model_name, 'stream': True}).encode()\n req = _ur.Request(url, data=payload, method='POST',\n headers={'Content-Type': 'application/json'})\n with _ur.urlopen(req, timeout=1800) as resp:\n for raw_line in resp:\n line = raw_line.decode().strip()\n if not line:\n continue\n try:\n obj = json.loads(line)\n except Exception:\n continue\n status_msg = obj.get('status', '')\n total = obj.get('total', 0)\n completed = obj.get('completed', 0)\n pct = int(completed * 100 / total) if total else 0\n detail = f\"{completed/1e9:.1f} GB / {total/1e9:.1f} GB\" if total else status_msg\n _pull_status[model_name] = {\n 'status': 'pulling', 'progress': pct,\n 'detail': detail, 'error': ''\n }\n _pull_status[model_name] = {'status': 'done', 'progress': 100, 'detail': '', 'error': ''}\n except Exception as ex:\n _pull_status[model_name] = {'status': 'error', 'progress': 0,\n 'detail': '', 'error': str(ex)}\n\n thread = threading.Thread(target=pull_in_background, daemon=True)\n thread.start()\n return jsonify({'message': f'Started pulling model: {model_name}'})\n\n\n@bp.route('/api/llm/online/models', methods=['GET'])\ndef get_online_api_models():\n \"\"\"从在线 API 获取可用模型列表。支持 GET 参数: provider, api_base, api_key, model。\"\"\"\n from config_manager import get_config_manager\n\n provider = request.args.get('provider', '').strip()\n api_base = request.args.get('api_base', '').strip()\n api_key = request.args.get('api_key', '').strip()\n current_model = request.args.get('model', '').strip()\n\n if not api_base and provider in ONLINE_PROVIDERS:\n api_base = ONLINE_PROVIDERS[provider].get('api_base', '')\n\n # 如果请求没带 api_key,使用已保存的 online/llm key\n if not api_key:\n cfg = get_config_manager()\n if not current_model:\n current_model = (\n cfg.config.get('online', {}).get('model')\n or cfg.config.get('llm', {}).get('model', '')\n )\n api_key = (\n cfg.config.get('online', {}).get('api_key')\n or cfg.config.get('llm', {}).get('api_key', '')\n )\n\n if not api_base:\n return jsonify({'error': '请提供 api_base 参数', 'models': []}), 400\n if not api_key:\n return jsonify({'error': '请先填写并保存 API Key,或在参数中提供 api_key', 'models': []}), 400\n\n import urllib.request as _urq\n import urllib.error as _ure\n\n url = api_base.rstrip('/') + '/models'\n try:\n req = _urq.Request(\n url,\n headers={\n 'Authorization': f'Bearer {api_key}',\n 'Content-Type': 'application/json',\n }\n )\n with _urq.urlopen(req, timeout=12) as resp:\n data = json.loads(resp.read().decode('utf-8'))\n\n models = _parse_model_list(data)\n if not models:\n return jsonify({'error': '接口返回了空模型列表', 'models': []}), 502\n\n return jsonify({'models': models, 'source': 'models_endpoint'})\n\n except _ure.HTTPError as e:\n body = ''\n try:\n body = e.read().decode('utf-8', errors='ignore')[:240]\n except Exception:\n pass\n msgs = {\n 401: 'API Key 无效或已过期 (401)',\n 403: '访问被拒绝,请检查权限 (403)',\n 404: '该平台不支持模型列表接口 (404)',\n }\n candidates = _fallback_model_candidates(provider, api_base, current_model)\n if e.code in {400, 404, 405, 501} and candidates:\n verified = []\n for model in candidates[:4]:\n try:\n if _chat_model_available(api_base, api_key, model):\n verified.append(model)\n break\n except Exception:\n continue\n models = verified or candidates\n return jsonify({\n 'models': models,\n 'source': 'chat_probe' if verified else 'fallback',\n **({\n 'notice': (\n f'已验证当前模型可用;该平台的 /models 列表接口返回 HTTP {e.code}。'\n )\n } if verified else {\n 'warning': (\n f'/models 接口不可用或返回 HTTP {e.code},'\n '但聊天接口可能仍可使用;已使用当前/预设模型。'\n )\n }),\n })\n\n detail = msgs.get(e.code, f'HTTP {e.code}: {e.reason}')\n if body:\n detail += f' — {body}'\n return jsonify({'error': detail, 'models': []}), 502\n except (_ure.URLError, OSError) as e:\n return jsonify({'error': f'网络连接失败: {e.reason if hasattr(e, \"reason\") else str(e)}', 'models': []}), 502\n except Exception as e:\n return jsonify({'error': f'获取模型列表失败: {str(e)}', 'models': []}), 502\n\n\n@bp.route('/api/llm/test', methods=['POST'])\ndef test_llm_connection():\n \"\"\"轻量测试 LLM 连接:发送一条最短的 chat 消息,返回详细错误信息。\"\"\"\n from config_manager import get_config_manager\n\n manager = get_config_manager()\n cfg = dict(manager.get_llm_config())\n body = request.get_json(silent=True) or {}\n if body:\n saved_online = manager.config.get('online', {})\n saved_llm = manager.config.get('llm', {})\n cfg.update({\n 'provider': body.get('provider', cfg.get('provider', 'ollama')),\n 'model': body.get('model', cfg.get('model', '')),\n 'api_base': body.get('api_base', cfg.get('api_base', '')),\n })\n if body.get('api_key'):\n cfg['api_key'] = body.get('api_key')\n else:\n cfg['api_key'] = saved_online.get('api_key') or saved_llm.get('api_key') or cfg.get('api_key', '')\n\n provider = cfg.get('provider', 'ollama')\n model = cfg.get('model', '')\n api_base = cfg.get('api_base', 'http://localhost:11434')\n api_key = cfg.get('api_key', '')\n\n if not model:\n return jsonify({'ok': False, 'error': '未配置模型,请先保存配置'})\n\n import urllib.request as _urq\n import urllib.error as _ure\n\n # ── Ollama 分支 ─────────────────────────────────────────────────\n if provider == 'ollama':\n try:\n req = _urq.Request(f'{api_base.rstrip(\"/\")}/api/tags')\n with _urq.urlopen(req, timeout=5) as resp:\n ok = resp.status == 200\n if ok:\n return jsonify({'ok': True, 'message': f'Ollama 连接正常,当前模型: {model}'})\n return jsonify({'ok': False, 'error': 'Ollama 服务未响应,请运行 ollama serve'})\n except Exception as e:\n return jsonify({'ok': False, 'error': f'Ollama 连接失败: {str(e)}'})\n\n # ── 在线 API 分支 ────────────────────────────────────────────────\n if not api_key:\n return jsonify({'ok': False, 'error': '未配置 API Key,请填写后保存'})\n\n payload = json.dumps({\n 'model': model,\n 'messages': [{'role': 'user', 'content': 'hi'}],\n 'max_tokens': 5,\n 'temperature': 0,\n }).encode('utf-8')\n\n try:\n req = _urq.Request(\n f'{api_base.rstrip(\"/\")}/chat/completions',\n data=payload,\n headers={\n 'Content-Type': 'application/json',\n 'Authorization': f'Bearer {api_key}',\n },\n method='POST',\n )\n with _urq.urlopen(req, timeout=30) as resp:\n result = json.loads(resp.read().decode('utf-8'))\n\n if result.get('choices'):\n return jsonify({'ok': True, 'message': f'✓ {model} 响应正常'})\n return jsonify({'ok': False, 'error': '模型返回了空结果,请检查模型名称'})\n\n except _ure.HTTPError as e:\n body = ''\n try: body = e.read().decode('utf-8', errors='ignore')[:200]\n except Exception: pass\n msgs = {\n 401: f'API Key 无效或已过期 (401)',\n 403: f'访问被拒绝,请检查权限 (403)',\n 404: f'模型 \"{model}\" 不存在,请确认模型名称 (404)',\n 429: '请求频率超限,稍后再试 (429)',\n }\n err = msgs.get(e.code, f'HTTP {e.code}')\n if body: err += f' — {body}'\n return jsonify({'ok': False, 'error': err})\n except (_ure.URLError, OSError) as e:\n reason = e.reason if hasattr(e, 'reason') else str(e)\n if 'timed out' in str(reason).lower():\n return jsonify({'ok': False, 'error': '连接超时,请检查网络或 API 地址是否正确'})\n return jsonify({'ok': False, 'error': f'网络连接失败: {reason}'})\n except Exception as e:\n return jsonify({'ok': False, 'error': f'测试失败: {str(e)}'})\n\n\n@bp.route('/api/llm/ollama/pull/status', methods=['GET'])\ndef pull_ollama_status():\n \"\"\"Return current pull progress for a model.\"\"\"\n model_name = request.args.get('model', '')\n if not model_name:\n return jsonify({'error': 'model param required'}), 400\n info = _pull_status.get(model_name, {'status': 'unknown', 'progress': 0, 'detail': '', 'error': ''})\n return jsonify(info)\n"}, "056f8ca68adc0b24d483aabf77c8ec70f28f37ab": {"t": 1785763096.9841034, "n": 17992, "h": "bcefe2c07a8c841f", "c": "\"\"\"LLM 配置路由\"\"\"\nfrom flask import Blueprint, request, jsonify\nimport sys\nimport json\nimport threading\nimport time as _time\nimport urllib.request as _ur\nfrom pathlib import Path\nfrom state import _pull_status\n\nbp = Blueprint('llm', __name__)\n\ntry:\n from backend_manager import ONLINE_PROVIDERS\nexcept Exception:\n ONLINE_PROVIDERS = {\n \"openai\": {\"display\": \"OpenAI\", \"api_base\": \"https://api.openai.com/v1\", \"default_model\": \"gpt-4o-mini\", \"need_key\": True},\n \"deepseek\": {\"display\": \"DeepSeek\", \"api_base\": \"https://api.deepseek.com/v1\", \"default_model\": \"deepseek-v4-flash\", \"need_key\": True},\n \"siliconflow\": {\"display\": \"SiliconFlow\", \"api_base\": \"https://api.siliconflow.cn/v1\", \"default_model\": \"Qwen/Qwen2.5-7B-Instruct\", \"need_key\": True},\n \"moonshot\": {\"display\": \"Moonshot / Kimi\", \"api_base\": \"https://api.moonshot.cn/v1\", \"default_model\": \"moonshot-v1-8k\", \"need_key\": True},\n \"dashscope\": {\"display\": \"DashScope\", \"api_base\": \"https://dashscope.aliyuncs.com/compatible-mode/v1\", \"default_model\": \"qwen-turbo\", \"need_key\": True},\n \"zhipu\": {\"display\": \"Zhipu AI\", \"api_base\": \"https://open.bigmodel.cn/api/paas/v4\", \"default_model\": \"glm-4-flash\", \"need_key\": True},\n \"custom\": {\"display\": \"Custom OpenAI-compatible\", \"api_base\": \"\", \"default_model\": \"\", \"need_key\": True},\n }\n\n\ndef _public_online_providers():\n \"\"\"Provider metadata safe to send to the browser.\"\"\"\n return {\n key: {\n 'display': value.get('display', key),\n 'api_base': value.get('api_base', ''),\n 'default_model': value.get('default_model', ''),\n 'need_key': value.get('need_key', True),\n }\n for key, value in ONLINE_PROVIDERS.items()\n if key != 'anthropic'\n }\n\n\ndef _mask_api_key(config):\n config = dict(config or {})\n if config.get('api_key'):\n key = config['api_key']\n config['api_key_masked'] = '****' + key[-4:] if len(key) > 4 else '****'\n config['api_key'] = ''\n return config\n\n\ndef _parse_model_list(data):\n models = []\n if isinstance(data, dict) and isinstance(data.get('data'), list):\n models = [m['id'] for m in data['data'] if isinstance(m, dict) and m.get('id')]\n elif isinstance(data, dict) and isinstance(data.get('models'), list):\n models = [m.get('id') or m.get('name', '') for m in data['models'] if isinstance(m, dict)]\n elif isinstance(data, list):\n models = [\n m.get('id') or m.get('name', '')\n for m in data\n if isinstance(m, dict)\n ]\n return sorted({m for m in models if m})\n\n\ndef _fallback_model_candidates(provider, api_base, current_model):\n candidates = []\n if current_model:\n candidates.append(current_model)\n preset = ONLINE_PROVIDERS.get(provider, {})\n if preset.get('default_model'):\n candidates.append(preset['default_model'])\n base_l = (api_base or '').lower()\n if 'aliyuncs.com' in base_l or 'dashscope' in base_l:\n candidates.extend(['qwen3.6-plus', 'qwen-plus', 'qwen-turbo'])\n return list(dict.fromkeys(candidates))\n\n\ndef _chat_model_available(api_base, api_key, model):\n payload = json.dumps({\n 'model': model,\n 'messages': [{'role': 'user', 'content': 'hi'}],\n 'max_tokens': 1,\n 'temperature': 0,\n }).encode('utf-8')\n req = _ur.Request(\n f'{api_base.rstrip(\"/\")}/chat/completions',\n data=payload,\n headers={\n 'Content-Type': 'application/json',\n 'Authorization': f'Bearer {api_key}',\n },\n method='POST',\n )\n with _ur.urlopen(req, timeout=20) as resp:\n data = json.loads(resp.read().decode('utf-8'))\n return bool(data.get('choices'))\n\n\n@bp.route('/api/llm/config', methods=['GET'])\ndef llm_config_get():\n \"\"\"Get current LLM configuration (reads directly from config.json)\"\"\"\n from config_manager import LLMConfigManager\n cfg_mgr = LLMConfigManager()\n raw_config = cfg_mgr.config\n llm_config = dict(raw_config.get('llm', {}))\n\n if raw_config.get('active_backend') == 'online' and raw_config.get('online'):\n online = raw_config.get('online', {})\n llm_config.update({\n 'provider': online.get('provider', llm_config.get('provider', 'custom')),\n 'model': online.get('model', llm_config.get('model', '')),\n 'api_base': online.get('api_base', llm_config.get('api_base', '')),\n 'api_key': online.get('api_key', llm_config.get('api_key', '')),\n })\n elif raw_config.get('active_backend') == 'ollama' and raw_config.get('ollama'):\n ollama = raw_config.get('ollama', {})\n llm_config.update({\n 'provider': 'ollama',\n 'model': ollama.get('model', llm_config.get('model', '')),\n 'api_base': ollama.get('api_base', llm_config.get('api_base', 'http://localhost:11434')),\n 'api_key': '',\n })\n\n return jsonify({\n 'config': _mask_api_key(llm_config),\n 'online_providers': _public_online_providers(),\n 'first_run': cfg_mgr.is_first_run(),\n 'ollama_available': cfg_mgr.check_ollama_available()\n })\n\n\n@bp.route('/api/llm/config', methods=['POST'])\ndef update_llm_config():\n \"\"\"Update LLM configuration\"\"\"\n from config_manager import get_config_manager\n data = request.get_json(silent=True) or {}\n config = get_config_manager()\n\n try:\n cfg = config.config\n llm = cfg.setdefault('llm', {})\n provider = (data.get('provider') or llm.get('provider') or 'ollama').strip()\n if provider not in ['ollama', 'openai', 'deepseek', 'siliconflow', 'moonshot', 'dashscope', 'zhipu', 'custom']:\n raise ValueError(f'Invalid provider: {provider}')\n\n if provider == 'ollama':\n api_base = (data.get('api_base') or 'http://localhost:11434').strip().rstrip('/')\n model = (data.get('model') or llm.get('model') or '').strip()\n llm.update({\n 'provider': 'ollama',\n 'model': model,\n 'api_base': api_base,\n 'api_key': '',\n })\n cfg['active_backend'] = 'ollama'\n cfg.setdefault('ollama', {}).update({\n 'model': model,\n 'api_base': api_base,\n })\n else:\n preset = ONLINE_PROVIDERS.get(provider, {})\n online = cfg.setdefault('online', {})\n api_base = (data.get('api_base') or preset.get('api_base') or online.get('api_base') or llm.get('api_base') or '').strip().rstrip('/')\n model = (data.get('model') or preset.get('default_model') or online.get('model') or llm.get('model') or '').strip()\n api_key = data.get('api_key')\n if api_key is None or api_key == '':\n api_key = online.get('api_key') or llm.get('api_key', '')\n else:\n api_key = api_key.strip()\n\n llm.update({\n 'provider': provider,\n 'model': model,\n 'api_base': api_base,\n 'api_key': api_key,\n })\n cfg['active_backend'] = 'online'\n online.update({\n 'provider': provider,\n 'api_base': api_base,\n 'api_key': api_key,\n 'model': model,\n })\n\n if 'temperature' in data:\n llm['temperature'] = data['temperature']\n if 'max_tokens' in data:\n llm['max_tokens'] = data['max_tokens']\n\n # Mark first run complete if this is the first setup\n if config.is_first_run():\n cfg['first_run'] = False\n\n config.save_config()\n return jsonify({'message': 'Configuration updated successfully', 'config': _mask_api_key(llm)})\n except Exception as e:\n return jsonify({'error': str(e)}), 400\n\n\n@bp.route('/api/llm/ollama/models', methods=['GET'])\ndef get_ollama_models():\n \"\"\"Get available Ollama models\"\"\"\n from config_manager import get_config_manager\n config = get_config_manager()\n models = config.get_ollama_models()\n recommended = config.get_recommended_models().get('ollama', [])\n\n return jsonify({\n 'installed': models,\n 'recommended': recommended,\n 'ollama_available': config.check_ollama_available()\n })\n\n\n@bp.route('/api/llm/ollama/pull', methods=['POST'])\ndef pull_ollama_model():\n \"\"\"Pull an Ollama model and track progress.\"\"\"\n data = request.get_json(silent=True) or {}\n model_name = data.get('model')\n\n if not model_name:\n return jsonify({'error': 'Model name required'}), 400\n\n _pull_status[model_name] = {'status': 'pulling', 'progress': 0, 'detail': '', 'error': ''}\n\n def pull_in_background():\n try:\n # Use Ollama streaming pull API to track progress\n url = 'http://localhost:11434/api/pull'\n payload = json.dumps({'name': model_name, 'stream': True}).encode()\n req = _ur.Request(url, data=payload, method='POST',\n headers={'Content-Type': 'application/json'})\n with _ur.urlopen(req, timeout=1800) as resp:\n for raw_line in resp:\n line = raw_line.decode().strip()\n if not line:\n continue\n try:\n obj = json.loads(line)\n except Exception:\n continue\n status_msg = obj.get('status', '')\n total = obj.get('total', 0)\n completed = obj.get('completed', 0)\n pct = int(completed * 100 / total) if total else 0\n detail = f\"{completed/1e9:.1f} GB / {total/1e9:.1f} GB\" if total else status_msg\n _pull_status[model_name] = {\n 'status': 'pulling', 'progress': pct,\n 'detail': detail, 'error': ''\n }\n _pull_status[model_name] = {'status': 'done', 'progress': 100, 'detail': '', 'error': ''}\n except Exception as ex:\n _pull_status[model_name] = {'status': 'error', 'progress': 0,\n 'detail': '', 'error': str(ex)}\n\n thread = threading.Thread(target=pull_in_background, daemon=True)\n thread.start()\n return jsonify({'message': f'Started pulling model: {model_name}'})\n\n\n@bp.route('/api/llm/online/models', methods=['GET'])\ndef get_online_api_models():\n \"\"\"从在线 API 获取可用模型列表。支持 GET 参数: provider, api_base, api_key, model。\"\"\"\n from config_manager import get_config_manager\n\n provider = request.args.get('provider', '').strip()\n api_base = request.args.get('api_base', '').strip()\n api_key = request.args.get('api_key', '').strip()\n current_model = request.args.get('model', '').strip()\n\n if not api_base and provider in ONLINE_PROVIDERS:\n api_base = ONLINE_PROVIDERS[provider].get('api_base', '')\n\n # 如果请求没带 api_key,使用已保存的 online/llm key\n if not api_key:\n cfg = get_config_manager()\n if not current_model:\n current_model = (\n cfg.config.get('online', {}).get('model')\n or cfg.config.get('llm', {}).get('model', '')\n )\n api_key = (\n cfg.config.get('online', {}).get('api_key')\n or cfg.config.get('llm', {}).get('api_key', '')\n )\n\n if not api_base:\n return jsonify({'error': '请提供 api_base 参数', 'models': []}), 400\n if not api_key:\n return jsonify({'error': '请先填写并保存 API Key,或在参数中提供 api_key', 'models': []}), 400\n\n import urllib.request as _urq\n import urllib.error as _ure\n\n url = api_base.rstrip('/') + '/models'\n try:\n req = _urq.Request(\n url,\n headers={\n 'Authorization': f'Bearer {api_key}',\n 'Content-Type': 'application/json',\n }\n )\n with _urq.urlopen(req, timeout=12) as resp:\n data = json.loads(resp.read().decode('utf-8'))\n\n models = _parse_model_list(data)\n if not models:\n return jsonify({'error': '接口返回了空模型列表', 'models': []}), 502\n\n return jsonify({'models': models, 'source': 'models_endpoint'})\n\n except _ure.HTTPError as e:\n body = ''\n try:\n body = e.read().decode('utf-8', errors='ignore')[:240]\n except Exception:\n pass\n msgs = {\n 401: 'API Key 无效或已过期 (401)',\n 403: '访问被拒绝,请检查权限 (403)',\n 404: '该平台不支持模型列表接口 (404)',\n }\n candidates = _fallback_model_candidates(provider, api_base, current_model)\n if e.code in {400, 404, 405, 501} and candidates:\n verified = []\n for model in candidates[:4]:\n try:\n if _chat_model_available(api_base, api_key, model):\n verified.append(model)\n break\n except Exception:\n continue\n models = verified or candidates\n return jsonify({\n 'models': models,\n 'source': 'chat_probe' if verified else 'fallback',\n **({\n 'notice': (\n f'已验证当前模型可用;该平台的 /models 列表接口返回 HTTP {e.code}。'\n )\n } if verified else {\n 'warning': (\n f'/models 接口不可用或返回 HTTP {e.code},'\n '但聊天接口可能仍可使用;已使用当前/预设模型。'\n )\n }),\n })\n\n detail = msgs.get(e.code, f'HTTP {e.code}: {e.reason}')\n if body:\n detail += f' — {body}'\n return jsonify({'error': detail, 'models': []}), 502\n except (_ure.URLError, OSError) as e:\n return jsonify({'error': f'网络连接失败: {e.reason if hasattr(e, \"reason\") else str(e)}', 'models': []}), 502\n except Exception as e:\n return jsonify({'error': f'获取模型列表失败: {str(e)}', 'models': []}), 502\n\n\n@bp.route('/api/llm/test', methods=['POST'])\ndef test_llm_connection():\n \"\"\"轻量测试 LLM 连接:发送一条最短的 chat 消息,返回详细错误信息。\"\"\"\n from config_manager import get_config_manager\n\n manager = get_config_manager()\n cfg = dict(manager.get_llm_config())\n body = request.get_json(silent=True) or {}\n if body:\n saved_online = manager.config.get('online', {})\n saved_llm = manager.config.get('llm', {})\n cfg.update({\n 'provider': body.get('provider', cfg.get('provider', 'ollama')),\n 'model': body.get('model', cfg.get('model', '')),\n 'api_base': body.get('api_base', cfg.get('api_base', '')),\n })\n if body.get('api_key'):\n cfg['api_key'] = body.get('api_key')\n else:\n cfg['api_key'] = saved_online.get('api_key') or saved_llm.get('api_key') or cfg.get('api_key', '')\n\n provider = cfg.get('provider', 'ollama')\n model = cfg.get('model', '')\n api_base = cfg.get('api_base', 'http://localhost:11434')\n api_key = cfg.get('api_key', '')\n\n if not model:\n return jsonify({'ok': False, 'error': '未配置模型,请先保存配置'})\n\n import urllib.request as _urq\n import urllib.error as _ure\n\n # ── Ollama 分支 ─────────────────────────────────────────────────\n if provider == 'ollama':\n try:\n req = _urq.Request(f'{api_base.rstrip(\"/\")}/api/tags')\n with _urq.urlopen(req, timeout=5) as resp:\n ok = resp.status == 200\n if ok:\n return jsonify({'ok': True, 'message': f'Ollama 连接正常,当前模型: {model}'})\n return jsonify({'ok': False, 'error': 'Ollama 服务未响应,请运行 ollama serve'})\n except Exception as e:\n return jsonify({'ok': False, 'error': f'Ollama 连接失败: {str(e)}'})\n\n # ── 在线 API 分支 ────────────────────────────────────────────────\n if not api_key:\n return jsonify({'ok': False, 'error': '未配置 API Key,请填写后保存'})\n\n payload = json.dumps({\n 'model': model,\n 'messages': [{'role': 'user', 'content': 'hi'}],\n 'max_tokens': 5,\n 'temperature': 0,\n }).encode('utf-8')\n\n try:\n req = _urq.Request(\n f'{api_base.rstrip(\"/\")}/chat/completions',\n data=payload,\n headers={\n 'Content-Type': 'application/json',\n 'Authorization': f'Bearer {api_key}',\n },\n method='POST',\n )\n with _urq.urlopen(req, timeout=30) as resp:\n result = json.loads(resp.read().decode('utf-8'))\n\n if result.get('choices'):\n return jsonify({'ok': True, 'message': f'✓ {model} 响应正常'})\n return jsonify({'ok': False, 'error': '模型返回了空结果,请检查模型名称'})\n\n except _ure.HTTPError as e:\n body = ''\n try: body = e.read().decode('utf-8', errors='ignore')[:200]\n except Exception: pass\n msgs = {\n 401: f'API Key 无效或已过期 (401)',\n 403: f'访问被拒绝,请检查权限 (403)',\n 404: f'模型 \"{model}\" 不存在,请确认模型名称 (404)',\n 429: '请求频率超限,稍后再试 (429)',\n }\n err = msgs.get(e.code, f'HTTP {e.code}')\n if body: err += f' — {body}'\n return jsonify({'ok': False, 'error': err})\n except (_ure.URLError, OSError) as e:\n reason = e.reason if hasattr(e, 'reason') else str(e)\n if 'timed out' in str(reason).lower():\n return jsonify({'ok': False, 'error': '连接超时,请检查网络或 API 地址是否正确'})\n return jsonify({'ok': False, 'error': f'网络连接失败: {reason}'})\n except Exception as e:\n return jsonify({'ok': False, 'error': f'测试失败: {str(e)}'})\n\n\n@bp.route('/api/llm/ollama/pull/status', methods=['GET'])\ndef pull_ollama_status():\n \"\"\"Return current pull progress for a model.\"\"\"\n model_name = request.args.get('model', '')\n if not model_name:\n return jsonify({'error': 'model param required'}), 400\n info = _pull_status.get(model_name, {'status': 'unknown', 'progress': 0, 'detail': '', 'error': ''})\n return jsonify(info)\n"}, "232f6d443b92ded1e6d11315df2a99a5b18e9bc5": {"t": 1785763098.3570983, "n": 17773, "h": "23318db50c6a642f", "c": "\"\"\"LLM 配置路由\"\"\"\nfrom flask import Blueprint, request, jsonify\nimport sys\nimport json\nimport threading\nimport time as _time\nimport urllib.request as _ur\nfrom pathlib import Path\nfrom state import _pull_status\n\nbp = Blueprint('llm', __name__)\n\ntry:\n from backend_manager import ONLINE_PROVIDERS\nexcept Exception:\n ONLINE_PROVIDERS = {\n \"openai\": {\"display\": \"OpenAI\", \"api_base\": \"https://api.openai.com/v1\", \"default_model\": \"gpt-4o-mini\", \"need_key\": True},\n \"deepseek\": {\"display\": \"DeepSeek\", \"api_base\": \"https://api.deepseek.com/v1\", \"default_model\": \"deepseek-v4-flash\", \"need_key\": True},\n \"siliconflow\": {\"display\": \"SiliconFlow\", \"api_base\": \"https://api.siliconflow.cn/v1\", \"default_model\": \"Qwen/Qwen2.5-7B-Instruct\", \"need_key\": True},\n \"moonshot\": {\"display\": \"Moonshot / Kimi\", \"api_base\": \"https://api.moonshot.cn/v1\", \"default_model\": \"moonshot-v1-8k\", \"need_key\": True},\n \"dashscope\": {\"display\": \"DashScope\", \"api_base\": \"https://dashscope.aliyuncs.com/compatible-mode/v1\", \"default_model\": \"qwen-turbo\", \"need_key\": True},\n \"zhipu\": {\"display\": \"Zhipu AI\", \"api_base\": \"https://open.bigmodel.cn/api/paas/v4\", \"default_model\": \"glm-4-flash\", \"need_key\": True},\n \"custom\": {\"display\": \"Custom OpenAI-compatible\", \"api_base\": \"\", \"default_model\": \"\", \"need_key\": True},\n }\n\n\ndef _public_online_providers():\n \"\"\"Provider metadata safe to send to the browser.\"\"\"\n return {\n key: {\n 'display': value.get('display', key),\n 'api_base': value.get('api_base', ''),\n 'default_model': value.get('default_model', ''),\n 'need_key': value.get('need_key', True),\n }\n for key, value in ONLINE_PROVIDERS.items()\n if key != 'anthropic'\n }\n\n\ndef _mask_api_key(config):\n config = dict(config or {})\n if config.get('api_key'):\n key = config['api_key']\n config['api_key_masked'] = '****' + key[-4:] if len(key) > 4 else '****'\n config['api_key'] = ''\n return config\n\n\ndef _parse_model_list(data):\n models = []\n if isinstance(data, dict) and isinstance(data.get('data'), list):\n models = [m['id'] for m in data['data'] if isinstance(m, dict) and m.get('id')]\n elif isinstance(data, dict) and isinstance(data.get('models'), list):\n models = [m.get('id') or m.get('name', '') for m in data['models'] if isinstance(m, dict)]\n elif isinstance(data, list):\n models = [\n m.get('id') or m.get('name', '')\n for m in data\n if isinstance(m, dict)\n ]\n return sorted({m for m in models if m})\n\n\ndef _fallback_model_candidates(provider, api_base, current_model):\n candidates = []\n if current_model:\n candidates.append(current_model)\n preset = ONLINE_PROVIDERS.get(provider, {})\n if preset.get('default_model'):\n candidates.append(preset['default_model'])\n base_l = (api_base or '').lower()\n if 'aliyuncs.com' in base_l or 'dashscope' in base_l:\n candidates.extend(['qwen3.6-plus', 'qwen-plus', 'qwen-turbo'])\n return list(dict.fromkeys(candidates))\n\n\ndef _chat_model_available(api_base, api_key, model):\n payload = json.dumps({\n 'model': model,\n 'messages': [{'role': 'user', 'content': 'hi'}],\n 'max_tokens': 1,\n 'temperature': 0,\n }).encode('utf-8')\n req = _ur.Request(\n f'{api_base.rstrip(\"/\")}/chat/completions',\n data=payload,\n headers={\n 'Content-Type': 'application/json',\n 'Authorization': f'Bearer {api_key}',\n },\n method='POST',\n )\n with _ur.urlopen(req, timeout=20) as resp:\n data = json.loads(resp.read().decode('utf-8'))\n return bool(data.get('choices'))\n\n\n@bp.route('/api/llm/config', methods=['GET'])\ndef llm_config_get():\n \"\"\"Get current LLM configuration (reads directly from config.json)\"\"\"\n from config_manager import LLMConfigManager\n cfg_mgr = LLMConfigManager()\n raw_config = cfg_mgr.config\n llm_config = dict(raw_config.get('llm', {}))\n\n if raw_config.get('active_backend') == 'online' and raw_config.get('online'):\n online = raw_config.get('online', {})\n llm_config.update({\n 'provider': online.get('provider', llm_config.get('provider', 'custom')),\n 'model': online.get('model', llm_config.get('model', '')),\n 'api_base': online.get('api_base', llm_config.get('api_base', '')),\n 'api_key': online.get('api_key', llm_config.get('api_key', '')),\n })\n elif raw_config.get('active_backend') == 'ollama' and raw_config.get('ollama'):\n ollama = raw_config.get('ollama', {})\n llm_config.update({\n 'provider': 'ollama',\n 'model': ollama.get('model', llm_config.get('model', '')),\n 'api_base': ollama.get('api_base', llm_config.get('api_base', 'http://localhost:11434')),\n 'api_key': '',\n })\n\n return jsonify({\n 'config': _mask_api_key(llm_config),\n 'online_providers': _public_online_providers(),\n 'first_run': cfg_mgr.is_first_run(),\n 'ollama_available': cfg_mgr.check_ollama_available()\n })\n\n\n@bp.route('/api/llm/config', methods=['POST'])\ndef update_llm_config():\n \"\"\"Update LLM configuration\"\"\"\n from config_manager import get_config_manager\n data = request.get_json(silent=True) or {}\n config = get_config_manager()\n\n try:\n cfg = config.config\n llm = cfg.setdefault('llm', {})\n provider = (data.get('provider') or llm.get('provider') or 'ollama').strip()\n if provider not in ['ollama', 'openai', 'deepseek', 'siliconflow', 'moonshot', 'dashscope', 'zhipu', 'custom']:\n raise ValueError(f'Invalid provider: {provider}')\n\n if provider == 'ollama':\n api_base = (data.get('api_base') or 'http://localhost:11434').strip().rstrip('/')\n model = (data.get('model') or llm.get('model') or '').strip()\n llm.update({\n 'provider': 'ollama',\n 'model': model,\n 'api_base': api_base,\n 'api_key': '',\n })\n cfg['active_backend'] = 'ollama'\n cfg.setdefault('ollama', {}).update({\n 'model': model,\n 'api_base': api_base,\n })\n else:\n preset = ONLINE_PROVIDERS.get(provider, {})\n online = cfg.setdefault('online', {})\n api_base = (data.get('api_base') or preset.get('api_base') or online.get('api_base') or llm.get('api_base') or '').strip().rstrip('/')\n model = (data.get('model') or preset.get('default_model') or online.get('model') or llm.get('model') or '').strip()\n api_key = data.get('api_key')\n if api_key is None or api_key == '':\n api_key = online.get('api_key') or llm.get('api_key', '')\n else:\n api_key = api_key.strip()\n\n llm.update({\n 'provider': provider,\n 'model': model,\n 'api_base': api_base,\n 'api_key': api_key,\n })\n cfg['active_backend'] = 'online'\n online.update({\n 'provider': provider,\n 'api_base': api_base,\n 'api_key': api_key,\n 'model': model,\n })\n\n if 'temperature' in data:\n llm['temperature'] = data['temperature']\n if 'max_tokens' in data:\n llm['max_tokens'] = data['max_tokens']\n\n # Mark first run complete if this is the first setup\n if config.is_first_run():\n cfg['first_run'] = False\n\n config.save_config()\n return jsonify({'message': 'Configuration updated successfully', 'config': _mask_api_key(llm)})\n except Exception as e:\n return jsonify({'error': str(e)}), 400\n\n\n@bp.route('/api/llm/ollama/models', methods=['GET'])\ndef get_ollama_models():\n \"\"\"Get available Ollama models\"\"\"\n from config_manager import get_config_manager\n config = get_config_manager()\n models = config.get_ollama_models()\n recommended = config.get_recommended_models().get('ollama', [])\n\n return jsonify({\n 'installed': models,\n 'recommended': recommended,\n 'ollama_available': config.check_ollama_available()\n })\n\n\n@bp.route('/api/llm/ollama/pull', methods=['POST'])\ndef pull_ollama_model():\n \"\"\"Pull an Ollama model and track progress.\"\"\"\n data = request.get_json(silent=True) or {}\n model_name = data.get('model')\n\n if not model_name:\n return jsonify({'error': 'Model name required'}), 400\n\n _pull_status[model_name] = {'status': 'pulling', 'progress': 0, 'detail': '', 'error': ''}\n\n def pull_in_background():\n try:\n # Use Ollama streaming pull API to track progress\n url = 'http://localhost:11434/api/pull'\n payload = json.dumps({'name': model_name, 'stream': True}).encode()\n req = _ur.Request(url, data=payload, method='POST',\n headers={'Content-Type': 'application/json'})\n with _ur.urlopen(req, timeout=1800) as resp:\n for raw_line in resp:\n line = raw_line.decode().strip()\n if not line:\n continue\n try:\n obj = json.loads(line)\n except Exception:\n continue\n status_msg = obj.get('status', '')\n total = obj.get('total', 0)\n completed = obj.get('completed', 0)\n pct = int(completed * 100 / total) if total else 0\n detail = f\"{completed/1e9:.1f} GB / {total/1e9:.1f} GB\" if total else status_msg\n _pull_status[model_name] = {\n 'status': 'pulling', 'progress': pct,\n 'detail': detail, 'error': ''\n }\n _pull_status[model_name] = {'status': 'done', 'progress': 100, 'detail': '', 'error': ''}\n except Exception as ex:\n _pull_status[model_name] = {'status': 'error', 'progress': 0,\n 'detail': '', 'error': str(ex)}\n\n thread = threading.Thread(target=pull_in_background, daemon=True)\n thread.start()\n return jsonify({'message': f'Started pulling model: {model_name}'})\n\n\n@bp.route('/api/llm/online/models', methods=['GET'])\ndef get_online_api_models():\n \"\"\"从在线 API 获取可用模型列表。支持 GET 参数: provider, api_base, api_key, model。\"\"\"\n from config_manager import get_config_manager\n\n provider = request.args.get('provider', '').strip()\n api_base = request.args.get('api_base', '').strip()\n api_key = request.args.get('api_key', '').strip()\n current_model = request.args.get('model', '').strip()\n\n if not api_base and provider in ONLINE_PROVIDERS:\n api_base = ONLINE_PROVIDERS[provider].get('api_base', '')\n\n # 如果请求没带 api_key,使用已保存的 online/llm key\n if not api_key:\n cfg = get_config_manager()\n if not current_model:\n current_model = (\n cfg.config.get('online', {}).get('model')\n or cfg.config.get('llm', {}).get('model', '')\n )\n api_key = (\n cfg.config.get('online', {}).get('api_key')\n or cfg.config.get('llm', {}).get('api_key', '')\n )\n\n if not api_base:\n return jsonify({'error': '请提供 api_base 参数', 'models': []}), 400\n if not api_key:\n return jsonify({'error': '请先填写并保存 API Key,或在参数中提供 api_key', 'models': []}), 400\n\n import urllib.request as _urq\n import urllib.error as _ure\n\n url = api_base.rstrip('/') + '/models'\n try:\n req = _urq.Request(\n url,\n headers={\n 'Authorization': f'Bearer {api_key}',\n 'Content-Type': 'application/json',\n }\n )\n with _urq.urlopen(req, timeout=12) as resp:\n data = json.loads(resp.read().decode('utf-8'))\n\n models = _parse_model_list(data)\n if not models:\n return jsonify({'error': '接口返回了空模型列表', 'models': []}), 502\n\n return jsonify({'models': models, 'source': 'models_endpoint'})\n\n except _ure.HTTPError as e:\n body = ''\n try:\n body = e.read().decode('utf-8', errors='ignore')[:240]\n except Exception:\n pass\n msgs = {\n 401: 'API Key 无效或已过期 (401)',\n 403: '访问被拒绝,请检查权限 (403)',\n 404: '该平台不支持模型列表接口 (404)',\n }\n candidates = _fallback_model_candidates(provider, api_base, current_model)\n if e.code in {400, 404, 405, 501} and candidates:\n verified = []\n for model in candidates[:4]:\n try:\n if _chat_model_available(api_base, api_key, model):\n verified.append(model)\n break\n except Exception:\n continue\n models = verified or candidates\n return jsonify({\n 'models': models,\n 'source': 'chat_probe' if verified else 'fallback',\n 'warning': (\n f'/models 接口不可用或返回 HTTP {e.code},'\n '但聊天接口可以继续使用;已使用当前/预设模型。'\n ),\n })\n\n detail = msgs.get(e.code, f'HTTP {e.code}: {e.reason}')\n if body:\n detail += f' — {body}'\n return jsonify({'error': detail, 'models': []}), 502\n except (_ure.URLError, OSError) as e:\n return jsonify({'error': f'网络连接失败: {e.reason if hasattr(e, \"reason\") else str(e)}', 'models': []}), 502\n except Exception as e:\n return jsonify({'error': f'获取模型列表失败: {str(e)}', 'models': []}), 502\n\n\n@bp.route('/api/llm/test', methods=['POST'])\ndef test_llm_connection():\n \"\"\"轻量测试 LLM 连接:发送一条最短的 chat 消息,返回详细错误信息。\"\"\"\n from config_manager import get_config_manager\n\n manager = get_config_manager()\n cfg = dict(manager.get_llm_config())\n body = request.get_json(silent=True) or {}\n if body:\n saved_online = manager.config.get('online', {})\n saved_llm = manager.config.get('llm', {})\n cfg.update({\n 'provider': body.get('provider', cfg.get('provider', 'ollama')),\n 'model': body.get('model', cfg.get('model', '')),\n 'api_base': body.get('api_base', cfg.get('api_base', '')),\n })\n if body.get('api_key'):\n cfg['api_key'] = body.get('api_key')\n else:\n cfg['api_key'] = saved_online.get('api_key') or saved_llm.get('api_key') or cfg.get('api_key', '')\n\n provider = cfg.get('provider', 'ollama')\n model = cfg.get('model', '')\n api_base = cfg.get('api_base', 'http://localhost:11434')\n api_key = cfg.get('api_key', '')\n\n if not model:\n return jsonify({'ok': False, 'error': '未配置模型,请先保存配置'})\n\n import urllib.request as _urq\n import urllib.error as _ure\n\n # ── Ollama 分支 ─────────────────────────────────────────────────\n if provider == 'ollama':\n try:\n req = _urq.Request(f'{api_base.rstrip(\"/\")}/api/tags')\n with _urq.urlopen(req, timeout=5) as resp:\n ok = resp.status == 200\n if ok:\n return jsonify({'ok': True, 'message': f'Ollama 连接正常,当前模型: {model}'})\n return jsonify({'ok': False, 'error': 'Ollama 服务未响应,请运行 ollama serve'})\n except Exception as e:\n return jsonify({'ok': False, 'error': f'Ollama 连接失败: {str(e)}'})\n\n # ── 在线 API 分支 ────────────────────────────────────────────────\n if not api_key:\n return jsonify({'ok': False, 'error': '未配置 API Key,请填写后保存'})\n\n payload = json.dumps({\n 'model': model,\n 'messages': [{'role': 'user', 'content': 'hi'}],\n 'max_tokens': 5,\n 'temperature': 0,\n }).encode('utf-8')\n\n try:\n req = _urq.Request(\n f'{api_base.rstrip(\"/\")}/chat/completions',\n data=payload,\n headers={\n 'Content-Type': 'application/json',\n 'Authorization': f'Bearer {api_key}',\n },\n method='POST',\n )\n with _urq.urlopen(req, timeout=30) as resp:\n result = json.loads(resp.read().decode('utf-8'))\n\n if result.get('choices'):\n return jsonify({'ok': True, 'message': f'✓ {model} 响应正常'})\n return jsonify({'ok': False, 'error': '模型返回了空结果,请检查模型名称'})\n\n except _ure.HTTPError as e:\n body = ''\n try: body = e.read().decode('utf-8', errors='ignore')[:200]\n except Exception: pass\n msgs = {\n 401: f'API Key 无效或已过期 (401)',\n 403: f'访问被拒绝,请检查权限 (403)',\n 404: f'模型 \"{model}\" 不存在,请确认模型名称 (404)',\n 429: '请求频率超限,稍后再试 (429)',\n }\n err = msgs.get(e.code, f'HTTP {e.code}')\n if body: err += f' — {body}'\n return jsonify({'ok': False, 'error': err})\n except (_ure.URLError, OSError) as e:\n reason = e.reason if hasattr(e, 'reason') else str(e)\n if 'timed out' in str(reason).lower():\n return jsonify({'ok': False, 'error': '连接超时,请检查网络或 API 地址是否正确'})\n return jsonify({'ok': False, 'error': f'网络连接失败: {reason}'})\n except Exception as e:\n return jsonify({'ok': False, 'error': f'测试失败: {str(e)}'})\n\n\n@bp.route('/api/llm/ollama/pull/status', methods=['GET'])\ndef pull_ollama_status():\n \"\"\"Return current pull progress for a model.\"\"\"\n model_name = request.args.get('model', '')\n if not model_name:\n return jsonify({'error': 'model param required'}), 400\n info = _pull_status.get(model_name, {'status': 'unknown', 'progress': 0, 'detail': '', 'error': ''})\n return jsonify(info)\n"}, "2cff0b2b6a61da229ee41f5b73819021f38e3470": {"t": 1785763099.7802978, "n": 15255, "h": "d31e04154907957d", "c": "\"\"\"LLM 配置路由\"\"\"\nfrom flask import Blueprint, request, jsonify\nimport sys\nimport json\nimport threading\nimport time as _time\nimport urllib.request as _ur\nfrom pathlib import Path\nfrom state import _pull_status\n\nbp = Blueprint('llm', __name__)\n\ntry:\n from backend_manager import ONLINE_PROVIDERS\nexcept Exception:\n ONLINE_PROVIDERS = {\n \"openai\": {\"display\": \"OpenAI\", \"api_base\": \"https://api.openai.com/v1\", \"default_model\": \"gpt-4o-mini\", \"need_key\": True},\n \"deepseek\": {\"display\": \"DeepSeek\", \"api_base\": \"https://api.deepseek.com/v1\", \"default_model\": \"deepseek-v4-flash\", \"need_key\": True},\n \"siliconflow\": {\"display\": \"SiliconFlow\", \"api_base\": \"https://api.siliconflow.cn/v1\", \"default_model\": \"Qwen/Qwen2.5-7B-Instruct\", \"need_key\": True},\n \"moonshot\": {\"display\": \"Moonshot / Kimi\", \"api_base\": \"https://api.moonshot.cn/v1\", \"default_model\": \"moonshot-v1-8k\", \"need_key\": True},\n \"dashscope\": {\"display\": \"DashScope\", \"api_base\": \"https://dashscope.aliyuncs.com/compatible-mode/v1\", \"default_model\": \"qwen-turbo\", \"need_key\": True},\n \"zhipu\": {\"display\": \"Zhipu AI\", \"api_base\": \"https://open.bigmodel.cn/api/paas/v4\", \"default_model\": \"glm-4-flash\", \"need_key\": True},\n \"custom\": {\"display\": \"Custom OpenAI-compatible\", \"api_base\": \"\", \"default_model\": \"\", \"need_key\": True},\n }\n\n\ndef _public_online_providers():\n \"\"\"Provider metadata safe to send to the browser.\"\"\"\n return {\n key: {\n 'display': value.get('display', key),\n 'api_base': value.get('api_base', ''),\n 'default_model': value.get('default_model', ''),\n 'need_key': value.get('need_key', True),\n }\n for key, value in ONLINE_PROVIDERS.items()\n if key != 'anthropic'\n }\n\n\ndef _mask_api_key(config):\n config = dict(config or {})\n if config.get('api_key'):\n key = config['api_key']\n config['api_key_masked'] = '****' + key[-4:] if len(key) > 4 else '****'\n config['api_key'] = ''\n return config\n\n\n@bp.route('/api/llm/config', methods=['GET'])\ndef llm_config_get():\n \"\"\"Get current LLM configuration (reads directly from config.json)\"\"\"\n from config_manager import LLMConfigManager\n cfg_mgr = LLMConfigManager()\n raw_config = cfg_mgr.config\n llm_config = dict(raw_config.get('llm', {}))\n\n if raw_config.get('active_backend') == 'online' and raw_config.get('online'):\n online = raw_config.get('online', {})\n llm_config.update({\n 'provider': online.get('provider', llm_config.get('provider', 'custom')),\n 'model': online.get('model', llm_config.get('model', '')),\n 'api_base': online.get('api_base', llm_config.get('api_base', '')),\n 'api_key': online.get('api_key', llm_config.get('api_key', '')),\n })\n elif raw_config.get('active_backend') == 'ollama' and raw_config.get('ollama'):\n ollama = raw_config.get('ollama', {})\n llm_config.update({\n 'provider': 'ollama',\n 'model': ollama.get('model', llm_config.get('model', '')),\n 'api_base': ollama.get('api_base', llm_config.get('api_base', 'http://localhost:11434')),\n 'api_key': '',\n })\n\n return jsonify({\n 'config': _mask_api_key(llm_config),\n 'online_providers': _public_online_providers(),\n 'first_run': cfg_mgr.is_first_run(),\n 'ollama_available': cfg_mgr.check_ollama_available()\n })\n\n\n@bp.route('/api/llm/config', methods=['POST'])\ndef update_llm_config():\n \"\"\"Update LLM configuration\"\"\"\n from config_manager import get_config_manager\n data = request.get_json(silent=True) or {}\n config = get_config_manager()\n\n try:\n cfg = config.config\n llm = cfg.setdefault('llm', {})\n provider = (data.get('provider') or llm.get('provider') or 'ollama').strip()\n if provider not in ['ollama', 'openai', 'deepseek', 'siliconflow', 'moonshot', 'dashscope', 'zhipu', 'custom']:\n raise ValueError(f'Invalid provider: {provider}')\n\n if provider == 'ollama':\n api_base = (data.get('api_base') or 'http://localhost:11434').strip().rstrip('/')\n model = (data.get('model') or llm.get('model') or '').strip()\n llm.update({\n 'provider': 'ollama',\n 'model': model,\n 'api_base': api_base,\n 'api_key': '',\n })\n cfg['active_backend'] = 'ollama'\n cfg.setdefault('ollama', {}).update({\n 'model': model,\n 'api_base': api_base,\n })\n else:\n preset = ONLINE_PROVIDERS.get(provider, {})\n online = cfg.setdefault('online', {})\n api_base = (data.get('api_base') or preset.get('api_base') or online.get('api_base') or llm.get('api_base') or '').strip().rstrip('/')\n model = (data.get('model') or preset.get('default_model') or online.get('model') or llm.get('model') or '').strip()\n api_key = data.get('api_key')\n if api_key is None or api_key == '':\n api_key = online.get('api_key') or llm.get('api_key', '')\n else:\n api_key = api_key.strip()\n\n llm.update({\n 'provider': provider,\n 'model': model,\n 'api_base': api_base,\n 'api_key': api_key,\n })\n cfg['active_backend'] = 'online'\n online.update({\n 'provider': provider,\n 'api_base': api_base,\n 'api_key': api_key,\n 'model': model,\n })\n\n if 'temperature' in data:\n llm['temperature'] = data['temperature']\n if 'max_tokens' in data:\n llm['max_tokens'] = data['max_tokens']\n\n # Mark first run complete if this is the first setup\n if config.is_first_run():\n cfg['first_run'] = False\n\n config.save_config()\n return jsonify({'message': 'Configuration updated successfully', 'config': _mask_api_key(llm)})\n except Exception as e:\n return jsonify({'error': str(e)}), 400\n\n\n@bp.route('/api/llm/ollama/models', methods=['GET'])\ndef get_ollama_models():\n \"\"\"Get available Ollama models\"\"\"\n from config_manager import get_config_manager\n config = get_config_manager()\n models = config.get_ollama_models()\n recommended = config.get_recommended_models().get('ollama', [])\n\n return jsonify({\n 'installed': models,\n 'recommended': recommended,\n 'ollama_available': config.check_ollama_available()\n })\n\n\n@bp.route('/api/llm/ollama/pull', methods=['POST'])\ndef pull_ollama_model():\n \"\"\"Pull an Ollama model and track progress.\"\"\"\n data = request.get_json(silent=True) or {}\n model_name = data.get('model')\n\n if not model_name:\n return jsonify({'error': 'Model name required'}), 400\n\n _pull_status[model_name] = {'status': 'pulling', 'progress': 0, 'detail': '', 'error': ''}\n\n def pull_in_background():\n try:\n # Use Ollama streaming pull API to track progress\n url = 'http://localhost:11434/api/pull'\n payload = json.dumps({'name': model_name, 'stream': True}).encode()\n req = _ur.Request(url, data=payload, method='POST',\n headers={'Content-Type': 'application/json'})\n with _ur.urlopen(req, timeout=1800) as resp:\n for raw_line in resp:\n line = raw_line.decode().strip()\n if not line:\n continue\n try:\n obj = json.loads(line)\n except Exception:\n continue\n status_msg = obj.get('status', '')\n total = obj.get('total', 0)\n completed = obj.get('completed', 0)\n pct = int(completed * 100 / total) if total else 0\n detail = f\"{completed/1e9:.1f} GB / {total/1e9:.1f} GB\" if total else status_msg\n _pull_status[model_name] = {\n 'status': 'pulling', 'progress': pct,\n 'detail': detail, 'error': ''\n }\n _pull_status[model_name] = {'status': 'done', 'progress': 100, 'detail': '', 'error': ''}\n except Exception as ex:\n _pull_status[model_name] = {'status': 'error', 'progress': 0,\n 'detail': '', 'error': str(ex)}\n\n thread = threading.Thread(target=pull_in_background, daemon=True)\n thread.start()\n return jsonify({'message': f'Started pulling model: {model_name}'})\n\n\n@bp.route('/api/llm/online/models', methods=['GET'])\ndef get_online_api_models():\n \"\"\"从在线 API 获取可用模型列表。支持 GET 参数: provider, api_base, api_key。\"\"\"\n from config_manager import get_config_manager\n\n provider = request.args.get('provider', '').strip()\n api_base = request.args.get('api_base', '').strip()\n api_key = request.args.get('api_key', '').strip()\n\n if not api_base and provider in ONLINE_PROVIDERS:\n api_base = ONLINE_PROVIDERS[provider].get('api_base', '')\n\n # 如果请求没带 api_key,使用已保存的 online/llm key\n if not api_key:\n cfg = get_config_manager()\n api_key = (\n cfg.config.get('online', {}).get('api_key')\n or cfg.config.get('llm', {}).get('api_key', '')\n )\n\n if not api_base:\n return jsonify({'error': '请提供 api_base 参数', 'models': []}), 400\n if not api_key:\n return jsonify({'error': '请先填写并保存 API Key,或在参数中提供 api_key', 'models': []}), 400\n\n import urllib.request as _urq\n import urllib.error as _ure\n\n url = api_base.rstrip('/') + '/models'\n try:\n req = _urq.Request(\n url,\n headers={\n 'Authorization': f'Bearer {api_key}',\n 'Content-Type': 'application/json',\n }\n )\n with _urq.urlopen(req, timeout=12) as resp:\n data = json.loads(resp.read().decode('utf-8'))\n\n models = []\n if isinstance(data.get('data'), list):\n models = [m['id'] for m in data['data'] if isinstance(m, dict) and m.get('id')]\n elif isinstance(data.get('models'), list):\n models = [m.get('id') or m.get('name', '') for m in data['models'] if isinstance(m, dict)]\n elif isinstance(data, list):\n models = [\n m.get('id') or m.get('name', '')\n for m in data\n if isinstance(m, dict)\n ]\n\n models = sorted([m for m in models if m])\n if not models:\n return jsonify({'error': '接口返回了空模型列表', 'models': []}), 502\n\n return jsonify({'models': models})\n\n except _ure.HTTPError as e:\n msgs = {401: 'API Key 无效或已过期 (401)', 403: '访问被拒绝,请检查权限 (403)',\n 404: '该平台不支持模型列表接口 (404)'}\n return jsonify({'error': msgs.get(e.code, f'HTTP {e.code}: {e.reason}'), 'models': []}), 502\n except (_ure.URLError, OSError) as e:\n return jsonify({'error': f'网络连接失败: {e.reason if hasattr(e, \"reason\") else str(e)}', 'models': []}), 502\n except Exception as e:\n return jsonify({'error': f'获取模型列表失败: {str(e)}', 'models': []}), 502\n\n\n@bp.route('/api/llm/test', methods=['POST'])\ndef test_llm_connection():\n \"\"\"轻量测试 LLM 连接:发送一条最短的 chat 消息,返回详细错误信息。\"\"\"\n from config_manager import get_config_manager\n\n manager = get_config_manager()\n cfg = dict(manager.get_llm_config())\n body = request.get_json(silent=True) or {}\n if body:\n saved_online = manager.config.get('online', {})\n saved_llm = manager.config.get('llm', {})\n cfg.update({\n 'provider': body.get('provider', cfg.get('provider', 'ollama')),\n 'model': body.get('model', cfg.get('model', '')),\n 'api_base': body.get('api_base', cfg.get('api_base', '')),\n })\n if body.get('api_key'):\n cfg['api_key'] = body.get('api_key')\n else:\n cfg['api_key'] = saved_online.get('api_key') or saved_llm.get('api_key') or cfg.get('api_key', '')\n\n provider = cfg.get('provider', 'ollama')\n model = cfg.get('model', '')\n api_base = cfg.get('api_base', 'http://localhost:11434')\n api_key = cfg.get('api_key', '')\n\n if not model:\n return jsonify({'ok': False, 'error': '未配置模型,请先保存配置'})\n\n import urllib.request as _urq\n import urllib.error as _ure\n\n # ── Ollama 分支 ─────────────────────────────────────────────────\n if provider == 'ollama':\n try:\n req = _urq.Request(f'{api_base.rstrip(\"/\")}/api/tags')\n with _urq.urlopen(req, timeout=5) as resp:\n ok = resp.status == 200\n if ok:\n return jsonify({'ok': True, 'message': f'Ollama 连接正常,当前模型: {model}'})\n return jsonify({'ok': False, 'error': 'Ollama 服务未响应,请运行 ollama serve'})\n except Exception as e:\n return jsonify({'ok': False, 'error': f'Ollama 连接失败: {str(e)}'})\n\n # ── 在线 API 分支 ────────────────────────────────────────────────\n if not api_key:\n return jsonify({'ok': False, 'error': '未配置 API Key,请填写后保存'})\n\n payload = json.dumps({\n 'model': model,\n 'messages': [{'role': 'user', 'content': 'hi'}],\n 'max_tokens': 5,\n 'temperature': 0,\n }).encode('utf-8')\n\n try:\n req = _urq.Request(\n f'{api_base.rstrip(\"/\")}/chat/completions',\n data=payload,\n headers={\n 'Content-Type': 'application/json',\n 'Authorization': f'Bearer {api_key}',\n },\n method='POST',\n )\n with _urq.urlopen(req, timeout=30) as resp:\n result = json.loads(resp.read().decode('utf-8'))\n\n if result.get('choices'):\n return jsonify({'ok': True, 'message': f'✓ {model} 响应正常'})\n return jsonify({'ok': False, 'error': '模型返回了空结果,请检查模型名称'})\n\n except _ure.HTTPError as e:\n body = ''\n try: body = e.read().decode('utf-8', errors='ignore')[:200]\n except Exception: pass\n msgs = {\n 401: f'API Key 无效或已过期 (401)',\n 403: f'访问被拒绝,请检查权限 (403)',\n 404: f'模型 \"{model}\" 不存在,请确认模型名称 (404)',\n 429: '请求频率超限,稍后再试 (429)',\n }\n err = msgs.get(e.code, f'HTTP {e.code}')\n if body: err += f' — {body}'\n return jsonify({'ok': False, 'error': err})\n except (_ure.URLError, OSError) as e:\n reason = e.reason if hasattr(e, 'reason') else str(e)\n if 'timed out' in str(reason).lower():\n return jsonify({'ok': False, 'error': '连接超时,请检查网络或 API 地址是否正确'})\n return jsonify({'ok': False, 'error': f'网络连接失败: {reason}'})\n except Exception as e:\n return jsonify({'ok': False, 'error': f'测试失败: {str(e)}'})\n\n\n@bp.route('/api/llm/ollama/pull/status', methods=['GET'])\ndef pull_ollama_status():\n \"\"\"Return current pull progress for a model.\"\"\"\n model_name = request.args.get('model', '')\n if not model_name:\n return jsonify({'error': 'model param required'}), 400\n info = _pull_status.get(model_name, {'status': 'unknown', 'progress': 0, 'detail': '', 'error': ''})\n return jsonify(info)\n"}, "43aa9e4e69b71bb056bce2af01ec97a1243c332f": {"t": 1785763101.163703, "n": 15215, "h": "c07f261d97126ef1", "c": "\"\"\"LLM 配置路由\"\"\"\nfrom flask import Blueprint, request, jsonify\nimport sys\nimport json\nimport threading\nimport time as _time\nimport urllib.request as _ur\nfrom pathlib import Path\nfrom state import _pull_status\n\nbp = Blueprint('llm', __name__)\n\ntry:\n from backend_manager import ONLINE_PROVIDERS\nexcept Exception:\n ONLINE_PROVIDERS = {\n \"openai\": {\"display\": \"OpenAI\", \"api_base\": \"https://api.openai.com/v1\", \"default_model\": \"gpt-4o-mini\", \"need_key\": True},\n \"deepseek\": {\"display\": \"DeepSeek\", \"api_base\": \"https://api.deepseek.com/v1\", \"default_model\": \"deepseek-v4-flash\", \"need_key\": True},\n \"siliconflow\": {\"display\": \"SiliconFlow\", \"api_base\": \"https://api.siliconflow.cn/v1\", \"default_model\": \"Qwen/Qwen2.5-7B-Instruct\", \"need_key\": True},\n \"moonshot\": {\"display\": \"Moonshot / Kimi\", \"api_base\": \"https://api.moonshot.cn/v1\", \"default_model\": \"moonshot-v1-8k\", \"need_key\": True},\n \"dashscope\": {\"display\": \"DashScope\", \"api_base\": \"https://dashscope.aliyuncs.com/compatible-mode/v1\", \"default_model\": \"qwen-turbo\", \"need_key\": True},\n \"zhipu\": {\"display\": \"Zhipu AI\", \"api_base\": \"https://open.bigmodel.cn/api/paas/v4\", \"default_model\": \"glm-4-flash\", \"need_key\": True},\n \"custom\": {\"display\": \"Custom OpenAI-compatible\", \"api_base\": \"\", \"default_model\": \"\", \"need_key\": True},\n }\n\n\ndef _public_online_providers():\n \"\"\"Provider metadata safe to send to the browser.\"\"\"\n return {\n key: {\n 'display': value.get('display', key),\n 'api_base': value.get('api_base', ''),\n 'default_model': value.get('default_model', ''),\n 'need_key': value.get('need_key', True),\n }\n for key, value in ONLINE_PROVIDERS.items()\n if key != 'anthropic'\n }\n\n\ndef _mask_api_key(config):\n config = dict(config or {})\n if config.get('api_key'):\n key = config['api_key']\n config['api_key_masked'] = '****' + key[-4:] if len(key) > 4 else '****'\n config['api_key'] = ''\n return config\n\n\n@bp.route('/api/llm/config', methods=['GET'])\ndef llm_config_get():\n \"\"\"Get current LLM configuration (reads directly from config.json)\"\"\"\n from config_manager import LLMConfigManager\n cfg_mgr = LLMConfigManager()\n raw_config = cfg_mgr.config\n llm_config = dict(raw_config.get('llm', {}))\n\n if raw_config.get('active_backend') == 'online' and raw_config.get('online'):\n online = raw_config.get('online', {})\n llm_config.update({\n 'provider': online.get('provider', llm_config.get('provider', 'custom')),\n 'model': online.get('model', llm_config.get('model', '')),\n 'api_base': online.get('api_base', llm_config.get('api_base', '')),\n 'api_key': online.get('api_key', llm_config.get('api_key', '')),\n })\n elif raw_config.get('active_backend') == 'ollama' and raw_config.get('ollama'):\n ollama = raw_config.get('ollama', {})\n llm_config.update({\n 'provider': 'ollama',\n 'model': ollama.get('model', llm_config.get('model', '')),\n 'api_base': ollama.get('api_base', llm_config.get('api_base', 'http://localhost:11434')),\n 'api_key': '',\n })\n\n return jsonify({\n 'config': _mask_api_key(llm_config),\n 'online_providers': _public_online_providers(),\n 'first_run': cfg_mgr.is_first_run(),\n 'ollama_available': cfg_mgr.check_ollama_available()\n })\n\n\n@bp.route('/api/llm/config', methods=['POST'])\ndef update_llm_config():\n \"\"\"Update LLM configuration\"\"\"\n from config_manager import get_config_manager\n data = request.json or {}\n config = get_config_manager()\n\n try:\n cfg = config.config\n llm = cfg.setdefault('llm', {})\n provider = (data.get('provider') or llm.get('provider') or 'ollama').strip()\n if provider not in ['ollama', 'openai', 'deepseek', 'siliconflow', 'moonshot', 'dashscope', 'zhipu', 'custom']:\n raise ValueError(f'Invalid provider: {provider}')\n\n if provider == 'ollama':\n api_base = (data.get('api_base') or 'http://localhost:11434').strip().rstrip('/')\n model = (data.get('model') or llm.get('model') or '').strip()\n llm.update({\n 'provider': 'ollama',\n 'model': model,\n 'api_base': api_base,\n 'api_key': '',\n })\n cfg['active_backend'] = 'ollama'\n cfg.setdefault('ollama', {}).update({\n 'model': model,\n 'api_base': api_base,\n })\n else:\n preset = ONLINE_PROVIDERS.get(provider, {})\n online = cfg.setdefault('online', {})\n api_base = (data.get('api_base') or preset.get('api_base') or online.get('api_base') or llm.get('api_base') or '').strip().rstrip('/')\n model = (data.get('model') or preset.get('default_model') or online.get('model') or llm.get('model') or '').strip()\n api_key = data.get('api_key')\n if api_key is None or api_key == '':\n api_key = online.get('api_key') or llm.get('api_key', '')\n else:\n api_key = api_key.strip()\n\n llm.update({\n 'provider': provider,\n 'model': model,\n 'api_base': api_base,\n 'api_key': api_key,\n })\n cfg['active_backend'] = 'online'\n online.update({\n 'provider': provider,\n 'api_base': api_base,\n 'api_key': api_key,\n 'model': model,\n })\n\n if 'temperature' in data:\n llm['temperature'] = data['temperature']\n if 'max_tokens' in data:\n llm['max_tokens'] = data['max_tokens']\n\n # Mark first run complete if this is the first setup\n if config.is_first_run():\n cfg['first_run'] = False\n\n config.save_config()\n return jsonify({'message': 'Configuration updated successfully', 'config': _mask_api_key(llm)})\n except Exception as e:\n return jsonify({'error': str(e)}), 400\n\n\n@bp.route('/api/llm/ollama/models', methods=['GET'])\ndef get_ollama_models():\n \"\"\"Get available Ollama models\"\"\"\n from config_manager import get_config_manager\n config = get_config_manager()\n models = config.get_ollama_models()\n recommended = config.get_recommended_models().get('ollama', [])\n\n return jsonify({\n 'installed': models,\n 'recommended': recommended,\n 'ollama_available': config.check_ollama_available()\n })\n\n\n@bp.route('/api/llm/ollama/pull', methods=['POST'])\ndef pull_ollama_model():\n \"\"\"Pull an Ollama model and track progress.\"\"\"\n data = request.json\n model_name = data.get('model')\n\n if not model_name:\n return jsonify({'error': 'Model name required'}), 400\n\n _pull_status[model_name] = {'status': 'pulling', 'progress': 0, 'detail': '', 'error': ''}\n\n def pull_in_background():\n try:\n # Use Ollama streaming pull API to track progress\n url = 'http://localhost:11434/api/pull'\n payload = json.dumps({'name': model_name, 'stream': True}).encode()\n req = _ur.Request(url, data=payload, method='POST',\n headers={'Content-Type': 'application/json'})\n with _ur.urlopen(req, timeout=1800) as resp:\n for raw_line in resp:\n line = raw_line.decode().strip()\n if not line:\n continue\n try:\n obj = json.loads(line)\n except Exception:\n continue\n status_msg = obj.get('status', '')\n total = obj.get('total', 0)\n completed = obj.get('completed', 0)\n pct = int(completed * 100 / total) if total else 0\n detail = f\"{completed/1e9:.1f} GB / {total/1e9:.1f} GB\" if total else status_msg\n _pull_status[model_name] = {\n 'status': 'pulling', 'progress': pct,\n 'detail': detail, 'error': ''\n }\n _pull_status[model_name] = {'status': 'done', 'progress': 100, 'detail': '', 'error': ''}\n except Exception as ex:\n _pull_status[model_name] = {'status': 'error', 'progress': 0,\n 'detail': '', 'error': str(ex)}\n\n thread = threading.Thread(target=pull_in_background, daemon=True)\n thread.start()\n return jsonify({'message': f'Started pulling model: {model_name}'})\n\n\n@bp.route('/api/llm/online/models', methods=['GET'])\ndef get_online_api_models():\n \"\"\"从在线 API 获取可用模型列表。支持 GET 参数: provider, api_base, api_key。\"\"\"\n from config_manager import get_config_manager\n\n provider = request.args.get('provider', '').strip()\n api_base = request.args.get('api_base', '').strip()\n api_key = request.args.get('api_key', '').strip()\n\n if not api_base and provider in ONLINE_PROVIDERS:\n api_base = ONLINE_PROVIDERS[provider].get('api_base', '')\n\n # 如果请求没带 api_key,使用已保存的 online/llm key\n if not api_key:\n cfg = get_config_manager()\n api_key = (\n cfg.config.get('online', {}).get('api_key')\n or cfg.config.get('llm', {}).get('api_key', '')\n )\n\n if not api_base:\n return jsonify({'error': '请提供 api_base 参数', 'models': []}), 400\n if not api_key:\n return jsonify({'error': '请先填写并保存 API Key,或在参数中提供 api_key', 'models': []}), 400\n\n import urllib.request as _urq\n import urllib.error as _ure\n\n url = api_base.rstrip('/') + '/models'\n try:\n req = _urq.Request(\n url,\n headers={\n 'Authorization': f'Bearer {api_key}',\n 'Content-Type': 'application/json',\n }\n )\n with _urq.urlopen(req, timeout=12) as resp:\n data = json.loads(resp.read().decode('utf-8'))\n\n models = []\n if isinstance(data.get('data'), list):\n models = [m['id'] for m in data['data'] if isinstance(m, dict) and m.get('id')]\n elif isinstance(data.get('models'), list):\n models = [m.get('id') or m.get('name', '') for m in data['models'] if isinstance(m, dict)]\n elif isinstance(data, list):\n models = [\n m.get('id') or m.get('name', '')\n for m in data\n if isinstance(m, dict)\n ]\n\n models = sorted([m for m in models if m])\n if not models:\n return jsonify({'error': '接口返回了空模型列表', 'models': []}), 502\n\n return jsonify({'models': models})\n\n except _ure.HTTPError as e:\n msgs = {401: 'API Key 无效或已过期 (401)', 403: '访问被拒绝,请检查权限 (403)',\n 404: '该平台不支持模型列表接口 (404)'}\n return jsonify({'error': msgs.get(e.code, f'HTTP {e.code}: {e.reason}'), 'models': []}), 502\n except (_ure.URLError, OSError) as e:\n return jsonify({'error': f'网络连接失败: {e.reason if hasattr(e, \"reason\") else str(e)}', 'models': []}), 502\n except Exception as e:\n return jsonify({'error': f'获取模型列表失败: {str(e)}', 'models': []}), 502\n\n\n@bp.route('/api/llm/test', methods=['POST'])\ndef test_llm_connection():\n \"\"\"轻量测试 LLM 连接:发送一条最短的 chat 消息,返回详细错误信息。\"\"\"\n from config_manager import get_config_manager\n\n manager = get_config_manager()\n cfg = dict(manager.get_llm_config())\n body = request.get_json(silent=True) or {}\n if body:\n saved_online = manager.config.get('online', {})\n saved_llm = manager.config.get('llm', {})\n cfg.update({\n 'provider': body.get('provider', cfg.get('provider', 'ollama')),\n 'model': body.get('model', cfg.get('model', '')),\n 'api_base': body.get('api_base', cfg.get('api_base', '')),\n })\n if body.get('api_key'):\n cfg['api_key'] = body.get('api_key')\n else:\n cfg['api_key'] = saved_online.get('api_key') or saved_llm.get('api_key') or cfg.get('api_key', '')\n\n provider = cfg.get('provider', 'ollama')\n model = cfg.get('model', '')\n api_base = cfg.get('api_base', 'http://localhost:11434')\n api_key = cfg.get('api_key', '')\n\n if not model:\n return jsonify({'ok': False, 'error': '未配置模型,请先保存配置'})\n\n import urllib.request as _urq\n import urllib.error as _ure\n\n # ── Ollama 分支 ─────────────────────────────────────────────────\n if provider == 'ollama':\n try:\n req = _urq.Request(f'{api_base.rstrip(\"/\")}/api/tags')\n with _urq.urlopen(req, timeout=5) as resp:\n ok = resp.status == 200\n if ok:\n return jsonify({'ok': True, 'message': f'Ollama 连接正常,当前模型: {model}'})\n return jsonify({'ok': False, 'error': 'Ollama 服务未响应,请运行 ollama serve'})\n except Exception as e:\n return jsonify({'ok': False, 'error': f'Ollama 连接失败: {str(e)}'})\n\n # ── 在线 API 分支 ────────────────────────────────────────────────\n if not api_key:\n return jsonify({'ok': False, 'error': '未配置 API Key,请填写后保存'})\n\n payload = json.dumps({\n 'model': model,\n 'messages': [{'role': 'user', 'content': 'hi'}],\n 'max_tokens': 5,\n 'temperature': 0,\n }).encode('utf-8')\n\n try:\n req = _urq.Request(\n f'{api_base.rstrip(\"/\")}/chat/completions',\n data=payload,\n headers={\n 'Content-Type': 'application/json',\n 'Authorization': f'Bearer {api_key}',\n },\n method='POST',\n )\n with _urq.urlopen(req, timeout=30) as resp:\n result = json.loads(resp.read().decode('utf-8'))\n\n if result.get('choices'):\n return jsonify({'ok': True, 'message': f'✓ {model} 响应正常'})\n return jsonify({'ok': False, 'error': '模型返回了空结果,请检查模型名称'})\n\n except _ure.HTTPError as e:\n body = ''\n try: body = e.read().decode('utf-8', errors='ignore')[:200]\n except Exception: pass\n msgs = {\n 401: f'API Key 无效或已过期 (401)',\n 403: f'访问被拒绝,请检查权限 (403)',\n 404: f'模型 \"{model}\" 不存在,请确认模型名称 (404)',\n 429: '请求频率超限,稍后再试 (429)',\n }\n err = msgs.get(e.code, f'HTTP {e.code}')\n if body: err += f' — {body}'\n return jsonify({'ok': False, 'error': err})\n except (_ure.URLError, OSError) as e:\n reason = e.reason if hasattr(e, 'reason') else str(e)\n if 'timed out' in str(reason).lower():\n return jsonify({'ok': False, 'error': '连接超时,请检查网络或 API 地址是否正确'})\n return jsonify({'ok': False, 'error': f'网络连接失败: {reason}'})\n except Exception as e:\n return jsonify({'ok': False, 'error': f'测试失败: {str(e)}'})\n\n\n@bp.route('/api/llm/ollama/pull/status', methods=['GET'])\ndef pull_ollama_status():\n \"\"\"Return current pull progress for a model.\"\"\"\n model_name = request.args.get('model', '')\n if not model_name:\n return jsonify({'error': 'model param required'}), 400\n info = _pull_status.get(model_name, {'status': 'unknown', 'progress': 0, 'detail': '', 'error': ''})\n return jsonify(info)\n"}, "4e0caf42daab295ef7c847f9fbd2c98b89993812": {"t": 1785763103.125807, "n": 10043, "h": "99df870b54b2d93d", "c": "\"\"\"LLM 配置路由\"\"\"\nfrom flask import Blueprint, request, jsonify\nimport sys\nimport json\nimport threading\nimport time as _time\nimport urllib.request as _ur\nfrom pathlib import Path\nfrom state import _pull_status\n\nbp = Blueprint('llm', __name__)\n\n\n@bp.route('/api/llm/config', methods=['GET'])\ndef llm_config_get():\n \"\"\"Get current LLM configuration (reads directly from config.json)\"\"\"\n from config_manager import LLMConfigManager\n cfg_mgr = LLMConfigManager()\n llm_config = dict(cfg_mgr.config.get('llm', {}))\n\n # Hide API key for security\n if llm_config.get('api_key'):\n k = llm_config['api_key']\n llm_config['api_key_masked'] = '****' + k[-4:] if len(k) > 4 else '****'\n llm_config['api_key'] = '' # don't send raw key to browser\n\n return jsonify({\n 'config': llm_config,\n 'first_run': cfg_mgr.is_first_run(),\n 'ollama_available': cfg_mgr.check_ollama_available()\n })\n\n\n@bp.route('/api/llm/config', methods=['POST'])\ndef update_llm_config():\n \"\"\"Update LLM configuration\"\"\"\n from config_manager import get_config_manager\n data = request.json\n config = get_config_manager()\n\n try:\n if 'provider' in data:\n config.set_llm_provider(data['provider'])\n\n if 'model' in data:\n config.set_llm_model(data['model'])\n\n if 'api_key' in data and data['api_key']:\n config.set_api_key(data['api_key'])\n\n if 'api_base' in data:\n config.set_api_base(data['api_base'])\n\n if 'temperature' in data:\n config.config['llm']['temperature'] = data['temperature']\n config.save_config()\n\n if 'max_tokens' in data:\n config.config['llm']['max_tokens'] = data['max_tokens']\n config.save_config()\n\n # Mark first run complete if this is the first setup\n if config.is_first_run():\n config.mark_first_run_complete()\n\n return jsonify({'message': 'Configuration updated successfully'})\n except Exception as e:\n return jsonify({'error': str(e)}), 400\n\n\n@bp.route('/api/llm/ollama/models', methods=['GET'])\ndef get_ollama_models():\n \"\"\"Get available Ollama models\"\"\"\n from config_manager import get_config_manager\n config = get_config_manager()\n models = config.get_ollama_models()\n recommended = config.get_recommended_models().get('ollama', [])\n\n return jsonify({\n 'installed': models,\n 'recommended': recommended,\n 'ollama_available': config.check_ollama_available()\n })\n\n\n@bp.route('/api/llm/ollama/pull', methods=['POST'])\ndef pull_ollama_model():\n \"\"\"Pull an Ollama model and track progress.\"\"\"\n data = request.json\n model_name = data.get('model')\n\n if not model_name:\n return jsonify({'error': 'Model name required'}), 400\n\n _pull_status[model_name] = {'status': 'pulling', 'progress': 0, 'detail': '', 'error': ''}\n\n def pull_in_background():\n try:\n # Use Ollama streaming pull API to track progress\n url = 'http://localhost:11434/api/pull'\n payload = json.dumps({'name': model_name, 'stream': True}).encode()\n req = _ur.Request(url, data=payload, method='POST',\n headers={'Content-Type': 'application/json'})\n with _ur.urlopen(req, timeout=1800) as resp:\n for raw_line in resp:\n line = raw_line.decode().strip()\n if not line:\n continue\n try:\n obj = json.loads(line)\n except Exception:\n continue\n status_msg = obj.get('status', '')\n total = obj.get('total', 0)\n completed = obj.get('completed', 0)\n pct = int(completed * 100 / total) if total else 0\n detail = f\"{completed/1e9:.1f} GB / {total/1e9:.1f} GB\" if total else status_msg\n _pull_status[model_name] = {\n 'status': 'pulling', 'progress': pct,\n 'detail': detail, 'error': ''\n }\n _pull_status[model_name] = {'status': 'done', 'progress': 100, 'detail': '', 'error': ''}\n except Exception as ex:\n _pull_status[model_name] = {'status': 'error', 'progress': 0,\n 'detail': '', 'error': str(ex)}\n\n thread = threading.Thread(target=pull_in_background, daemon=True)\n thread.start()\n return jsonify({'message': f'Started pulling model: {model_name}'})\n\n\n@bp.route('/api/llm/online/models', methods=['GET'])\ndef get_online_api_models():\n \"\"\"从在线 API 获取可用模型列表。支持 GET 参数: api_base, api_key (可选,优先用已保存的 key)\"\"\"\n from config_manager import get_config_manager\n\n api_base = request.args.get('api_base', '').strip()\n api_key = request.args.get('api_key', '').strip()\n\n # 如果请求没带 api_key,使用已保存的\n if not api_key:\n cfg = get_config_manager()\n api_key = cfg.config.get('llm', {}).get('api_key', '')\n\n if not api_base:\n return jsonify({'error': '请提供 api_base 参数', 'models': []}), 400\n if not api_key:\n return jsonify({'error': '请先填写并保存 API Key,或在参数中提供 api_key', 'models': []}), 400\n\n import urllib.request as _urq\n import urllib.error as _ure\n\n url = api_base.rstrip('/') + '/models'\n try:\n req = _urq.Request(\n url,\n headers={\n 'Authorization': f'Bearer {api_key}',\n 'Content-Type': 'application/json',\n }\n )\n with _urq.urlopen(req, timeout=12) as resp:\n data = json.loads(resp.read().decode('utf-8'))\n\n models = []\n if isinstance(data.get('data'), list):\n models = [m['id'] for m in data['data'] if isinstance(m, dict) and m.get('id')]\n elif isinstance(data.get('models'), list):\n models = [m.get('id') or m.get('name', '') for m in data['models'] if isinstance(m, dict)]\n\n models = sorted([m for m in models if m])\n if not models:\n return jsonify({'error': '接口返回了空模型列表', 'models': []}), 502\n\n return jsonify({'models': models})\n\n except _ure.HTTPError as e:\n msgs = {401: 'API Key 无效或已过期 (401)', 403: '访问被拒绝,请检查权限 (403)',\n 404: '该平台不支持模型列表接口 (404)'}\n return jsonify({'error': msgs.get(e.code, f'HTTP {e.code}: {e.reason}'), 'models': []}), 502\n except (_ure.URLError, OSError) as e:\n return jsonify({'error': f'网络连接失败: {e.reason if hasattr(e, \"reason\") else str(e)}', 'models': []}), 502\n except Exception as e:\n return jsonify({'error': f'获取模型列表失败: {str(e)}', 'models': []}), 502\n\n\n@bp.route('/api/llm/test', methods=['POST'])\ndef test_llm_connection():\n \"\"\"轻量测试 LLM 连接:发送一条最短的 chat 消息,返回详细错误信息。\"\"\"\n from config_manager import get_config_manager\n\n cfg = get_config_manager().get_llm_config()\n provider = cfg.get('provider', 'ollama')\n model = cfg.get('model', '')\n api_base = cfg.get('api_base', 'http://localhost:11434')\n api_key = cfg.get('api_key', '')\n\n if not model:\n return jsonify({'ok': False, 'error': '未配置模型,请先保存配置'})\n\n import urllib.request as _urq\n import urllib.error as _ure\n\n # ── Ollama 分支 ─────────────────────────────────────────────────\n if provider == 'ollama':\n try:\n req = _urq.Request(f'{api_base.rstrip(\"/\")}/api/tags')\n with _urq.urlopen(req, timeout=5) as resp:\n ok = resp.status == 200\n if ok:\n return jsonify({'ok': True, 'message': f'Ollama 连接正常,当前模型: {model}'})\n return jsonify({'ok': False, 'error': 'Ollama 服务未响应,请运行 ollama serve'})\n except Exception as e:\n return jsonify({'ok': False, 'error': f'Ollama 连接失败: {str(e)}'})\n\n # ── 在线 API 分支 ────────────────────────────────────────────────\n if not api_key:\n return jsonify({'ok': False, 'error': '未配置 API Key,请填写后保存'})\n\n payload = json.dumps({\n 'model': model,\n 'messages': [{'role': 'user', 'content': 'hi'}],\n 'max_tokens': 5,\n 'temperature': 0,\n }).encode('utf-8')\n\n try:\n req = _urq.Request(\n f'{api_base.rstrip(\"/\")}/chat/completions',\n data=payload,\n headers={\n 'Content-Type': 'application/json',\n 'Authorization': f'Bearer {api_key}',\n },\n method='POST',\n )\n with _urq.urlopen(req, timeout=30) as resp:\n result = json.loads(resp.read().decode('utf-8'))\n\n if result.get('choices'):\n return jsonify({'ok': True, 'message': f'✓ {model} 响应正常'})\n return jsonify({'ok': False, 'error': '模型返回了空结果,请检查模型名称'})\n\n except _ure.HTTPError as e:\n body = ''\n try: body = e.read().decode('utf-8', errors='ignore')[:200]\n except Exception: pass\n msgs = {\n 401: f'API Key 无效或已过期 (401)',\n 403: f'访问被拒绝,请检查权限 (403)',\n 404: f'模型 \"{model}\" 不存在,请确认模型名称 (404)',\n 429: '请求频率超限,稍后再试 (429)',\n }\n err = msgs.get(e.code, f'HTTP {e.code}')\n if body: err += f' — {body}'\n return jsonify({'ok': False, 'error': err})\n except (_ure.URLError, OSError) as e:\n reason = e.reason if hasattr(e, 'reason') else str(e)\n if 'timed out' in str(reason).lower():\n return jsonify({'ok': False, 'error': '连接超时,请检查网络或 API 地址是否正确'})\n return jsonify({'ok': False, 'error': f'网络连接失败: {reason}'})\n except Exception as e:\n return jsonify({'ok': False, 'error': f'测试失败: {str(e)}'})\n\n\n@bp.route('/api/llm/ollama/pull/status', methods=['GET'])\ndef pull_ollama_status():\n \"\"\"Return current pull progress for a model.\"\"\"\n model_name = request.args.get('model', '')\n if not model_name:\n return jsonify({'error': 'model param required'}), 400\n info = _pull_status.get(model_name, {'status': 'unknown', 'progress': 0, 'detail': '', 'error': ''})\n return jsonify(info)\n"}, "55f2c6c1786312cbce126a819d884e7942ebce74": {"t": 1785763104.3573942, "n": 5016, "h": "2c18e189462468b8", "c": "\"\"\"LLM 配置路由\"\"\"\nfrom flask import Blueprint, request, jsonify\nimport sys\nimport json\nimport threading\nimport time as _time\nimport urllib.request as _ur\nfrom pathlib import Path\nfrom state import _pull_status\n\nbp = Blueprint('llm', __name__)\n\n\n@bp.route('/api/llm/config', methods=['GET'])\ndef llm_config_get():\n \"\"\"Get current LLM configuration (reads directly from config.json)\"\"\"\n from config_manager import LLMConfigManager\n cfg_mgr = LLMConfigManager()\n llm_config = dict(cfg_mgr.config.get('llm', {}))\n\n # Hide API key for security\n if llm_config.get('api_key'):\n k = llm_config['api_key']\n llm_config['api_key_masked'] = '****' + k[-4:] if len(k) > 4 else '****'\n llm_config['api_key'] = '' # don't send raw key to browser\n\n return jsonify({\n 'config': llm_config,\n 'first_run': cfg_mgr.is_first_run(),\n 'ollama_available': cfg_mgr.check_ollama_available()\n })\n\n\n@bp.route('/api/llm/config', methods=['POST'])\ndef update_llm_config():\n \"\"\"Update LLM configuration\"\"\"\n from config_manager import get_config_manager\n data = request.json\n config = get_config_manager()\n\n try:\n if 'provider' in data:\n config.set_llm_provider(data['provider'])\n\n if 'model' in data:\n config.set_llm_model(data['model'])\n\n if 'api_key' in data and data['api_key']:\n config.set_api_key(data['api_key'])\n\n if 'api_base' in data:\n config.set_api_base(data['api_base'])\n\n if 'temperature' in data:\n config.config['llm']['temperature'] = data['temperature']\n config.save_config()\n\n if 'max_tokens' in data:\n config.config['llm']['max_tokens'] = data['max_tokens']\n config.save_config()\n\n # Mark first run complete if this is the first setup\n if config.is_first_run():\n config.mark_first_run_complete()\n\n return jsonify({'message': 'Configuration updated successfully'})\n except Exception as e:\n return jsonify({'error': str(e)}), 400\n\n\n@bp.route('/api/llm/ollama/models', methods=['GET'])\ndef get_ollama_models():\n \"\"\"Get available Ollama models\"\"\"\n from config_manager import get_config_manager\n config = get_config_manager()\n models = config.get_ollama_models()\n recommended = config.get_recommended_models().get('ollama', [])\n\n return jsonify({\n 'installed': models,\n 'recommended': recommended,\n 'ollama_available': config.check_ollama_available()\n })\n\n\n@bp.route('/api/llm/ollama/pull', methods=['POST'])\ndef pull_ollama_model():\n \"\"\"Pull an Ollama model and track progress.\"\"\"\n data = request.json\n model_name = data.get('model')\n\n if not model_name:\n return jsonify({'error': 'Model name required'}), 400\n\n _pull_status[model_name] = {'status': 'pulling', 'progress': 0, 'detail': '', 'error': ''}\n\n def pull_in_background():\n try:\n # Use Ollama streaming pull API to track progress\n url = 'http://localhost:11434/api/pull'\n payload = json.dumps({'name': model_name, 'stream': True}).encode()\n req = _ur.Request(url, data=payload, method='POST',\n headers={'Content-Type': 'application/json'})\n with _ur.urlopen(req, timeout=1800) as resp:\n for raw_line in resp:\n line = raw_line.decode().strip()\n if not line:\n continue\n try:\n obj = json.loads(line)\n except Exception:\n continue\n status_msg = obj.get('status', '')\n total = obj.get('total', 0)\n completed = obj.get('completed', 0)\n pct = int(completed * 100 / total) if total else 0\n detail = f\"{completed/1e9:.1f} GB / {total/1e9:.1f} GB\" if total else status_msg\n _pull_status[model_name] = {\n 'status': 'pulling', 'progress': pct,\n 'detail': detail, 'error': ''\n }\n _pull_status[model_name] = {'status': 'done', 'progress': 100, 'detail': '', 'error': ''}\n except Exception as ex:\n _pull_status[model_name] = {'status': 'error', 'progress': 0,\n 'detail': '', 'error': str(ex)}\n\n thread = threading.Thread(target=pull_in_background, daemon=True)\n thread.start()\n return jsonify({'message': f'Started pulling model: {model_name}'})\n\n\n@bp.route('/api/llm/ollama/pull/status', methods=['GET'])\ndef pull_ollama_status():\n \"\"\"Return current pull progress for a model.\"\"\"\n model_name = request.args.get('model', '')\n if not model_name:\n return jsonify({'error': 'model param required'}), 400\n info = _pull_status.get(model_name, {'status': 'unknown', 'progress': 0, 'detail': '', 'error': ''})\n return jsonify(info)\n"}}, "ONLINE_API_SETUP.md": {"5ef458cab746280eb97d2dfb27bd545c578d5400": {"t": 1785747354.8156664, "n": 5621, "h": "2cd419e3d01ee55c", "c": "# 在线 API 模型配置指南\n\n## 问题诊断\n\n你的大模型接入在线 API 不工作的原因已找到并已修复!\n\n### 原始问题\n- **旧代码**只支持 Ollama 本地模型\n- 当设置 Qwen/DeepSeek 的在线 API 时,系统会自动降级到规则引擎\n- 没有自动检测在线 API 的模型列表功能\n\n### 已修复项目\n✅ 扩展了 `llm_agent.py`,添加 `OpenAICompatibleClient` 支持 \n✅ 修改了 `build_agent_from_config()` 以支持在线 API \n✅ 添加了在线 API 模型列表自动检测功能 \n✅ 更新了 `IntentClassifier` 以正确调用在线 API \n✅ 提供了模型列表获取工具\n\n---\n\n## 配置方法\n\n### 方法 1:使用 Python API(推荐)\n\n```python\nfrom config_manager import LLMConfigManager\n\ncfg = LLMConfigManager()\n\n# 设置 DeepSeek\ncfg.set_llm_provider('deepseek')\ncfg.set_api_base('https://api.deepseek.com/v1')\ncfg.set_api_key('your-deepseek-api-key-here')\n\n# 自动检测可用模型\nmodels = cfg.get_online_api_models()\nif models:\n print(f\"可用模型: {models}\")\n # 选择第一个模型\n cfg.set_llm_model(models[0])\n```\n\n### 方法 2:直接编辑配置文件\n\n配置文件位置:`~/.seismicx/config.json`\n\n```json\n{\n \"llm\": {\n \"provider\": \"deepseek\",\n \"model\": \"deepseek-v4-flash\",\n \"api_base\": \"https://api.deepseek.com/v1\",\n \"api_key\": \"your-api-key-here\",\n \"temperature\": 0.3,\n \"max_tokens\": 2000\n }\n}\n```\n\n---\n\n## 支持的在线 API 服务商\n\n### 🔷 DeepSeek\n- **官网**: https://www.deepseek.com/\n- **API 文档**: https://platform.deepseek.com/api-docs/\n- **API Base**: `https://api.deepseek.com/v1`\n- **推荐模型**:\n - `deepseek-v4-flash` (快速,适合实时应用)\n - `deepseek-v4-pro` (强大的推理)\n\n### 🟢 OpenAI\n- **官网**: https://openai.com/\n- **API 文档**: https://platform.openai.com/docs/\n- **API Base**: `https://api.openai.com/v1`\n- **推荐模型**:\n - `gpt-4o-mini` (经济,性价比高)\n - `gpt-4o` (最强)\n\n### 🔶 通义千问(SiliconFlow 代理)\n- **官网**: https://www.siliconflow.cn/\n- **API 文档**: https://docs.siliconflow.cn/\n- **API Base**: `https://api.siliconflow.cn/v1`\n- **推荐模型**:\n - `Qwen/Qwen2.5-7B-Instruct` (中文优化)\n - `Qwen/Qwen2.5-14B-Instruct` (更强)\n\n### 🌙 Moonshot / Kimi\n- **官网**: https://www.moonshot.cn/\n- **API 文档**: https://platform.moonshot.cn/docs/\n- **API Base**: `https://api.moonshot.cn/v1`\n- **推荐模型**:\n - `moonshot-v1-8k`\n\n### 🌩️ 阿里云 DashScope\n- **官网**: https://dashscope.aliyun.com/\n- **API Base**: `https://dashscope.aliyuncs.com/compatible-mode/v1`\n- **推荐模型**:\n - `qwen-turbo` (快速)\n - `qwen-plus` (均衡)\n\n### 💠 智谱 AI\n- **官网**: https://www.zhipuai.cn/\n- **API Base**: `https://open.bigmodel.cn/api/paas/v4`\n- **推荐模型**:\n - `glm-4-flash` (免费)\n - `glm-4` (强大)\n\n---\n\n## 快速开始\n\n### 1️⃣ 获取 API Key\n\n以 DeepSeek 为例:\n1. 访问 https://platform.deepseek.com/\n2. 创建账户并充值\n3. 在 API Keys 页面创建新的 API Key\n4. 复制 API Key\n\n### 2️⃣ 检测可用模型\n\n运行工具脚本:\n\n```bash\n# 检测当前配置\npython test_online_api_models.py\n\n# 检测特定 API\npython test_online_api_models.py deepseek sk-xxxxxxxxxxxxxxxx\npython test_online_api_models.py qwen sk-xxxxxxxxxxxxxxxx\n```\n\n### 3️⃣ 配置系统\n\n```python\nfrom config_manager import LLMConfigManager\n\ncfg = LLMConfigManager()\ncfg.set_llm_provider('deepseek')\ncfg.set_api_key('sk-your-key-here')\n\n# 自动获取并设置第一个可用模型\nmodels = cfg.get_online_api_models()\nif models:\n cfg.set_llm_model(models[0])\n print(f\"✓ 已配置为: {models[0]}\")\n```\n\n### 4️⃣ 测试连接\n\n```python\nfrom conversational_agent import ConversationalAgent\n\nagent = ConversationalAgent()\nif agent.llm_agent:\n print(\"✓ 在线 API 已连接,支持 AI 驱动的对话\")\n result = agent.process_message(\"你好,我想检测地震数据\")\n print(result['response'])\nelse:\n print(\"✗ 在线 API 连接失败,使用规则引擎模式\")\n```\n\n---\n\n## 常见问题\n\n### Q: 为什么设置完 API 后还是不工作?\n\n**A**: 请检查:\n1. **API Key 是否正确**:在代码中测试 API Key\n2. **网络连接**:确保能访问 API 端点\n3. **模型名称**:确保模型名称与 API 返回的完全一致\n4. **API Base URL**:检查是否包含 `/v1` 后缀(某些 API 需要)\n\n### Q: 如何查看当前是否使用了在线 API?\n\n**A**: \n```python\nfrom conversational_agent import ConversationalAgent\nagent = ConversationalAgent()\nprint(f\"使用 LLM 路径: {agent.llm_agent is not None}\")\nprint(f\"错误信息: {agent.llm_error}\")\n```\n\n### Q: 为什么 API 调用很慢?\n\n**A**: \n- 在线 API 响应时间取决于网络和服务器\n- 建议使用 `temperature=0.3` 以下的低温值以加快响应\n- 某些模型比其他模型更快(如 `-flash` / `-mini` 版本)\n\n### Q: 可以同时使用多个 API 吗?\n\n**A**: 当前配置支持一个活跃 API。如果需要多个,可以:\n1. 在不同的配置文件中切换\n2. 在代码中动态创建多个 `OpenAICompatibleClient` 实例\n\n---\n\n## 深度集成\n\n### 在自定义脚本中使用\n\n```python\nfrom llm_agent import OpenAICompatibleClient, OllamaToolAgent\nfrom conversational_agent import SkillExecutor, ConversationContext\nfrom config_manager import LLMConfigManager\n\n# 获取配置\ncfg = LLMConfigManager()\nllm_cfg = cfg.get_llm_config()\n\n# 创建 OpenAI-compatible 客户端\nclient = OpenAICompatibleClient(\n api_base=llm_cfg['api_base'],\n model=llm_cfg['model'],\n api_key=llm_cfg['api_key'],\n)\n\n# 创建 Agent\nexecutor = SkillExecutor()\ncontext = ConversationContext()\nagent = OllamaToolAgent(\n skill_executor=executor,\n context=context,\n client=client,\n)\n\n# 使用 Agent\nresponse = agent.process_message(\"检测数据目录下的所有地震波形\")\nprint(response['response'])\n```\n\n### 获取在线 API 的模型列表\n\n```python\nfrom llm_agent import OpenAICompatibleClient\n\n# 获取 DeepSeek 的所有模型\nmodels = OpenAICompatibleClient.list_models(\n api_base='https://api.deepseek.com/v1',\n api_key='your-api-key'\n)\nprint(f\"可用模型: {models}\")\n```\n\n---\n\n## 技术细节\n\n### 支持的 API 格式\n系统支持 **OpenAI-compatible** API 格式,包括:\n- `/v1/chat/completions` 端点\n- `/v1/models` 或 `/models` 端点\n- `Bearer {api_key}` 认证方式\n\n### 响应格式兼容\n自动处理的响应格式:\n```json\n{\n \"data\": [\n {\"id\": \"model-name\"},\n ...\n ]\n}\n```\n\n或\n\n```json\n{\n \"models\": [\n {\"name\": \"model-name\"},\n ...\n ]\n}\n```\n\n### 工具调用支持\n在线 API 支持与 Ollama 相同的 tool-calling 格式,自动转换为 OpenAI 格式。\n\n---\n\n## 性能建议\n\n| 场景 | 推荐模型 | API |\n|------|---------|-----|\n| **快速响应** | deepseek-v4-flash / gpt-4o-mini | DeepSeek / OpenAI |\n| **中文优化** | Qwen2.5-7B-Instruct | SiliconFlow |\n| **最高质量** | deepseek-v4-pro / gpt-4o | DeepSeek / OpenAI |\n| **成本最低** | glm-4-flash | 智谱 AI |\n\n---\n\n## 更新日志\n\n### v2025.04.28\n✅ 添加 OpenAI-compatible API 支持 \n✅ 自动模型列表检测 \n✅ 多 provider 支持(DeepSeek、OpenAI、通义千问等) \n✅ 工具调用兼容性 \n\n---\n\n**需要帮助?** 查看 `pnsn/LLM_SETUP_GUIDE.md` 了解更多详情。\n"}, "4e0caf42daab295ef7c847f9fbd2c98b89993812": {"t": 1785763087.5777206, "n": 5621, "h": "2cd419e3d01ee55c", "c": "# 在线 API 模型配置指南\n\n## 问题诊断\n\n你的大模型接入在线 API 不工作的原因已找到并已修复!\n\n### 原始问题\n- **旧代码**只支持 Ollama 本地模型\n- 当设置 Qwen/DeepSeek 的在线 API 时,系统会自动降级到规则引擎\n- 没有自动检测在线 API 的模型列表功能\n\n### 已修复项目\n✅ 扩展了 `llm_agent.py`,添加 `OpenAICompatibleClient` 支持 \n✅ 修改了 `build_agent_from_config()` 以支持在线 API \n✅ 添加了在线 API 模型列表自动检测功能 \n✅ 更新了 `IntentClassifier` 以正确调用在线 API \n✅ 提供了模型列表获取工具\n\n---\n\n## 配置方法\n\n### 方法 1:使用 Python API(推荐)\n\n```python\nfrom config_manager import LLMConfigManager\n\ncfg = LLMConfigManager()\n\n# 设置 DeepSeek\ncfg.set_llm_provider('deepseek')\ncfg.set_api_base('https://api.deepseek.com/v1')\ncfg.set_api_key('your-deepseek-api-key-here')\n\n# 自动检测可用模型\nmodels = cfg.get_online_api_models()\nif models:\n print(f\"可用模型: {models}\")\n # 选择第一个模型\n cfg.set_llm_model(models[0])\n```\n\n### 方法 2:直接编辑配置文件\n\n配置文件位置:`~/.seismicx/config.json`\n\n```json\n{\n \"llm\": {\n \"provider\": \"deepseek\",\n \"model\": \"deepseek-v4-flash\",\n \"api_base\": \"https://api.deepseek.com/v1\",\n \"api_key\": \"your-api-key-here\",\n \"temperature\": 0.3,\n \"max_tokens\": 2000\n }\n}\n```\n\n---\n\n## 支持的在线 API 服务商\n\n### 🔷 DeepSeek\n- **官网**: https://www.deepseek.com/\n- **API 文档**: https://platform.deepseek.com/api-docs/\n- **API Base**: `https://api.deepseek.com/v1`\n- **推荐模型**:\n - `deepseek-v4-flash` (快速,适合实时应用)\n - `deepseek-v4-pro` (强大的推理)\n\n### 🟢 OpenAI\n- **官网**: https://openai.com/\n- **API 文档**: https://platform.openai.com/docs/\n- **API Base**: `https://api.openai.com/v1`\n- **推荐模型**:\n - `gpt-4o-mini` (经济,性价比高)\n - `gpt-4o` (最强)\n\n### 🔶 通义千问(SiliconFlow 代理)\n- **官网**: https://www.siliconflow.cn/\n- **API 文档**: https://docs.siliconflow.cn/\n- **API Base**: `https://api.siliconflow.cn/v1`\n- **推荐模型**:\n - `Qwen/Qwen2.5-7B-Instruct` (中文优化)\n - `Qwen/Qwen2.5-14B-Instruct` (更强)\n\n### 🌙 Moonshot / Kimi\n- **官网**: https://www.moonshot.cn/\n- **API 文档**: https://platform.moonshot.cn/docs/\n- **API Base**: `https://api.moonshot.cn/v1`\n- **推荐模型**:\n - `moonshot-v1-8k`\n\n### 🌩️ 阿里云 DashScope\n- **官网**: https://dashscope.aliyun.com/\n- **API Base**: `https://dashscope.aliyuncs.com/compatible-mode/v1`\n- **推荐模型**:\n - `qwen-turbo` (快速)\n - `qwen-plus` (均衡)\n\n### 💠 智谱 AI\n- **官网**: https://www.zhipuai.cn/\n- **API Base**: `https://open.bigmodel.cn/api/paas/v4`\n- **推荐模型**:\n - `glm-4-flash` (免费)\n - `glm-4` (强大)\n\n---\n\n## 快速开始\n\n### 1️⃣ 获取 API Key\n\n以 DeepSeek 为例:\n1. 访问 https://platform.deepseek.com/\n2. 创建账户并充值\n3. 在 API Keys 页面创建新的 API Key\n4. 复制 API Key\n\n### 2️⃣ 检测可用模型\n\n运行工具脚本:\n\n```bash\n# 检测当前配置\npython test_online_api_models.py\n\n# 检测特定 API\npython test_online_api_models.py deepseek sk-xxxxxxxxxxxxxxxx\npython test_online_api_models.py qwen sk-xxxxxxxxxxxxxxxx\n```\n\n### 3️⃣ 配置系统\n\n```python\nfrom config_manager import LLMConfigManager\n\ncfg = LLMConfigManager()\ncfg.set_llm_provider('deepseek')\ncfg.set_api_key('sk-your-key-here')\n\n# 自动获取并设置第一个可用模型\nmodels = cfg.get_online_api_models()\nif models:\n cfg.set_llm_model(models[0])\n print(f\"✓ 已配置为: {models[0]}\")\n```\n\n### 4️⃣ 测试连接\n\n```python\nfrom conversational_agent import ConversationalAgent\n\nagent = ConversationalAgent()\nif agent.llm_agent:\n print(\"✓ 在线 API 已连接,支持 AI 驱动的对话\")\n result = agent.process_message(\"你好,我想检测地震数据\")\n print(result['response'])\nelse:\n print(\"✗ 在线 API 连接失败,使用规则引擎模式\")\n```\n\n---\n\n## 常见问题\n\n### Q: 为什么设置完 API 后还是不工作?\n\n**A**: 请检查:\n1. **API Key 是否正确**:在代码中测试 API Key\n2. **网络连接**:确保能访问 API 端点\n3. **模型名称**:确保模型名称与 API 返回的完全一致\n4. **API Base URL**:检查是否包含 `/v1` 后缀(某些 API 需要)\n\n### Q: 如何查看当前是否使用了在线 API?\n\n**A**: \n```python\nfrom conversational_agent import ConversationalAgent\nagent = ConversationalAgent()\nprint(f\"使用 LLM 路径: {agent.llm_agent is not None}\")\nprint(f\"错误信息: {agent.llm_error}\")\n```\n\n### Q: 为什么 API 调用很慢?\n\n**A**: \n- 在线 API 响应时间取决于网络和服务器\n- 建议使用 `temperature=0.3` 以下的低温值以加快响应\n- 某些模型比其他模型更快(如 `-flash` / `-mini` 版本)\n\n### Q: 可以同时使用多个 API 吗?\n\n**A**: 当前配置支持一个活跃 API。如果需要多个,可以:\n1. 在不同的配置文件中切换\n2. 在代码中动态创建多个 `OpenAICompatibleClient` 实例\n\n---\n\n## 深度集成\n\n### 在自定义脚本中使用\n\n```python\nfrom llm_agent import OpenAICompatibleClient, OllamaToolAgent\nfrom conversational_agent import SkillExecutor, ConversationContext\nfrom config_manager import LLMConfigManager\n\n# 获取配置\ncfg = LLMConfigManager()\nllm_cfg = cfg.get_llm_config()\n\n# 创建 OpenAI-compatible 客户端\nclient = OpenAICompatibleClient(\n api_base=llm_cfg['api_base'],\n model=llm_cfg['model'],\n api_key=llm_cfg['api_key'],\n)\n\n# 创建 Agent\nexecutor = SkillExecutor()\ncontext = ConversationContext()\nagent = OllamaToolAgent(\n skill_executor=executor,\n context=context,\n client=client,\n)\n\n# 使用 Agent\nresponse = agent.process_message(\"检测数据目录下的所有地震波形\")\nprint(response['response'])\n```\n\n### 获取在线 API 的模型列表\n\n```python\nfrom llm_agent import OpenAICompatibleClient\n\n# 获取 DeepSeek 的所有模型\nmodels = OpenAICompatibleClient.list_models(\n api_base='https://api.deepseek.com/v1',\n api_key='your-api-key'\n)\nprint(f\"可用模型: {models}\")\n```\n\n---\n\n## 技术细节\n\n### 支持的 API 格式\n系统支持 **OpenAI-compatible** API 格式,包括:\n- `/v1/chat/completions` 端点\n- `/v1/models` 或 `/models` 端点\n- `Bearer {api_key}` 认证方式\n\n### 响应格式兼容\n自动处理的响应格式:\n```json\n{\n \"data\": [\n {\"id\": \"model-name\"},\n ...\n ]\n}\n```\n\n或\n\n```json\n{\n \"models\": [\n {\"name\": \"model-name\"},\n ...\n ]\n}\n```\n\n### 工具调用支持\n在线 API 支持与 Ollama 相同的 tool-calling 格式,自动转换为 OpenAI 格式。\n\n---\n\n## 性能建议\n\n| 场景 | 推荐模型 | API |\n|------|---------|-----|\n| **快速响应** | deepseek-v4-flash / gpt-4o-mini | DeepSeek / OpenAI |\n| **中文优化** | Qwen2.5-7B-Instruct | SiliconFlow |\n| **最高质量** | deepseek-v4-pro / gpt-4o | DeepSeek / OpenAI |\n| **成本最低** | glm-4-flash | 智谱 AI |\n\n---\n\n## 更新日志\n\n### v2025.04.28\n✅ 添加 OpenAI-compatible API 支持 \n✅ 自动模型列表检测 \n✅ 多 provider 支持(DeepSeek、OpenAI、通义千问等) \n✅ 工具调用兼容性 \n\n---\n\n**需要帮助?** 查看 `pnsn/LLM_SETUP_GUIDE.md` 了解更多详情。\n"}}}}