1 line
69 KiB
JSON
1 line
69 KiB
JSON
{"repo": "boltomli/puzzle-solver", "files": {"src/ui/pages/settings.py": {"9d2e05af71dbe2d32b13e8309be2175a24575097": {"t": 1785746906.5045373, "n": 13673, "h": "75b179ce48f11e7b", "c": "\"\"\"设置页面 — Settings tab (Flet version).\n\nAPI configuration: base URL, key, model, system prompt override.\nModel list loading with selection dialog.\nProject management: delete project (danger zone).\n\"\"\"\n\nimport flet as ft\nfrom loguru import logger\n\nfrom src.services.config import load_config, save_config\nfrom src.ui.state import app_state\n\n\ndef build_settings_tab(page: ft.Page, on_project_deleted=None) -> ft.Control:\n \"\"\"Build and return the settings tab control tree.\n\n Provides:\n - API configuration form (base URL, API key, model, system prompt)\n - Save, test connection, and load models buttons\n - Danger zone: delete current project with two-step confirmation\n \"\"\"\n config = load_config()\n\n # --- Provider selection ---\n current_provider = (config.get(\"provider\") or \"openai\").strip().lower()\n\n subtitle_text = ft.Text(\n \"配置用于 AI 推理的 OpenAI 兼容 API\"\n if current_provider != \"anthropic\"\n else \"配置 Anthropic Claude API\",\n color=ft.Colors.GREY,\n size=13,\n )\n\n provider_dropdown = ft.Dropdown(\n label=\"API 提供商\",\n value=current_provider,\n options=[\n ft.dropdown.Option(key=\"openai\", text=\"OpenAI 兼容\"),\n ft.dropdown.Option(key=\"anthropic\", text=\"Anthropic\"),\n ],\n width=220,\n )\n\n # --- API configuration fields ---\n base_url_field = ft.TextField(\n label=\"API Base URL\",\n value=config.get(\"api_base_url\", \"\"),\n hint_text=(\n \"https://api.openai.com/v1\"\n if current_provider != \"anthropic\"\n else \"https://api.anthropic.com(可选,留空使用默认值)\"\n ),\n )\n\n api_key_field = ft.TextField(\n label=\"API Key(部分服务商可留空)\",\n value=config.get(\"api_key\", \"\"),\n hint_text=(\n \"sk-...(Ollama 等本地服务可留空)\" if current_provider != \"anthropic\" else \"sk-ant-...\"\n ),\n password=True,\n can_reveal_password=True,\n )\n\n model_field = ft.TextField(\n label=\"模型名称\",\n value=config.get(\"model\", \"\"),\n hint_text=(\n \"gpt-4o / deepseek-chat / llama3\"\n if current_provider != \"anthropic\"\n else \"claude-3-5-sonnet-latest / claude-3-5-haiku-latest\"\n ),\n )\n\n def on_provider_change(e):\n is_anthropic = (provider_dropdown.value or \"openai\").strip().lower() == \"anthropic\"\n subtitle_text.value = (\n \"配置 Anthropic Claude API\" if is_anthropic else \"配置用于 AI 推理的 OpenAI 兼容 API\"\n )\n base_url_field.hint_text = (\n \"https://api.anthropic.com(可选,留空使用默认值)\"\n if is_anthropic\n else \"https://api.openai.com/v1\"\n )\n api_key_field.hint_text = (\n \"sk-ant-...\" if is_anthropic else \"sk-...(Ollama 等本地服务可留空)\"\n )\n model_field.hint_text = (\n \"claude-3-5-sonnet-latest / claude-3-5-haiku-latest\"\n if is_anthropic\n else \"gpt-4o / deepseek-chat / llama3\"\n )\n page.update()\n\n provider_dropdown.on_change = on_provider_change\n\n system_prompt_field = ft.TextField(\n label=\"自定义系统提示词(可选)\",\n value=config.get(\"system_prompt_override\", \"\"),\n hint_text=\"覆盖默认的系统提示词。留空则使用内置提示词。\",\n multiline=True,\n min_lines=3,\n )\n\n timeout_field = ft.TextField(\n label=\"请求超时(秒,0 表示不超时)\",\n value=str(config.get(\"timeout\", 300)),\n hint_text=\"300\",\n keyboard_type=ft.KeyboardType.NUMBER,\n width=220,\n )\n\n # --- Helper: collect config from fields ---\n def _collect_config() -> dict:\n try:\n timeout_val = int(timeout_field.value.strip() or \"300\")\n if timeout_val < 0:\n timeout_val = 300\n except ValueError:\n timeout_val = 300\n return {\n \"provider\": (provider_dropdown.value or \"openai\").strip().lower(),\n \"api_base_url\": base_url_field.value.strip(),\n \"api_key\": api_key_field.value.strip(),\n \"model\": model_field.value.strip(),\n \"system_prompt_override\": system_prompt_field.value.strip(),\n \"timeout\": timeout_val,\n }\n\n # --- Helper: show snack bar ---\n def _show_snackbar(message: str, color: str | None = None):\n page.snack_bar = ft.SnackBar(\n content=ft.Text(message),\n bgcolor=color,\n )\n page.snack_bar.open = True\n page.update()\n\n # --- Save config handler ---\n def on_save_config(e):\n cfg = _collect_config()\n logger.info(\n \"settings: save_config base_url={!r} model={!r} api_key_set={}\",\n cfg.get(\"api_base_url\"),\n cfg.get(\"model\"),\n bool(cfg.get(\"api_key\")),\n )\n save_config(cfg)\n _show_snackbar(\"配置已保存\", ft.Colors.GREEN)\n\n # --- Test connection handler ---\n async def on_test_connection(e):\n base_url = base_url_field.value.strip()\n model = model_field.value.strip()\n api_key = api_key_field.value.strip()\n provider = (provider_dropdown.value or \"openai\").strip().lower()\n logger.info(\n \"settings: test_connection clicked provider={!r} base_url={!r} model={!r}\",\n provider,\n base_url,\n model,\n )\n if provider == \"anthropic\":\n if not api_key or not model:\n _show_snackbar(\"请先填写 API Key 和模型名称\", ft.Colors.AMBER)\n return\n else:\n if not base_url or not model:\n _show_snackbar(\"请先填写 API Base URL 和模型名称\", ft.Colors.AMBER)\n return\n\n # Save config first so LLMService can read it\n save_config(_collect_config())\n _show_snackbar(\"正在测试连接...\")\n\n try:\n from src.services.llm_service import LLMService\n\n llm = LLMService()\n reply = await llm.test_connection()\n logger.info(\"settings: test_connection success reply={!r}\", reply)\n _show_snackbar(f\"连接成功!模型响应: {reply}\", ft.Colors.GREEN)\n except Exception as exc:\n logger.exception(\"settings: test_connection failed\")\n _show_snackbar(f\"连接失败: {exc}\", ft.Colors.RED)\n\n # --- Load models handler ---\n async def on_load_models(e):\n base_url = base_url_field.value.strip()\n api_key = api_key_field.value.strip()\n provider = (provider_dropdown.value or \"openai\").strip().lower()\n logger.info(\"settings: load_models clicked provider={!r} base_url={!r}\", provider, base_url)\n if provider == \"anthropic\":\n if not api_key:\n _show_snackbar(\"请先填写 API Key\", ft.Colors.AMBER)\n return\n else:\n if not base_url:\n _show_snackbar(\"请先填写 API Base URL\", ft.Colors.AMBER)\n return\n\n # Save config first so LLMService can read it\n save_config(_collect_config())\n _show_snackbar(\"正在加载模型列表...\")\n\n try:\n from src.services.llm_service import LLMService\n\n llm = LLMService()\n models = await llm.list_models()\n logger.info(\"settings: load_models got {} model(s)\", len(models))\n\n if not models:\n _show_snackbar(\"未发现可用模型\", ft.Colors.AMBER)\n return\n\n # Build model selection dialog\n def make_model_select_handler(model_name: str):\n def handler(e):\n model_field.value = model_name\n dlg.open = False\n page.update()\n\n return handler\n\n model_tiles = [\n ft.ListTile(\n title=ft.Text(m),\n on_click=make_model_select_handler(m),\n )\n for m in models\n ]\n\n def close_dialog(e):\n dlg.open = False\n page.update()\n\n dlg = ft.AlertDialog(\n title=ft.Text(\"选择模型\"),\n content=ft.Container(\n content=ft.Column(\n controls=model_tiles,\n scroll=ft.ScrollMode.AUTO,\n ),\n height=400,\n width=360,\n ),\n actions=[ft.TextButton(\"取消\", on_click=close_dialog)],\n )\n page.overlay.append(dlg)\n dlg.open = True\n page.update()\n\n _show_snackbar(f\"已加载 {len(models)} 个模型\", ft.Colors.GREEN)\n\n except Exception as exc:\n logger.exception(\"settings: load_models failed\")\n _show_snackbar(f\"加载模型失败: {exc}\", ft.Colors.RED)\n\n # --- Action buttons ---\n save_button = ft.Button(\n \"保存配置\",\n icon=ft.Icons.SAVE,\n on_click=on_save_config,\n )\n\n test_button = ft.Button(\n \"测试连接\",\n icon=ft.Icons.WIFI,\n on_click=on_test_connection,\n )\n\n load_models_button = ft.Button(\n \"加载模型列表\",\n icon=ft.Icons.REFRESH,\n on_click=on_load_models,\n )\n\n # --- API config card ---\n api_config_card = ft.Card(\n content=ft.Container(\n content=ft.Column(\n controls=[\n ft.Text(\"API 配置\", size=20, weight=ft.FontWeight.BOLD),\n subtitle_text,\n ft.Divider(),\n provider_dropdown,\n base_url_field,\n api_key_field,\n model_field,\n load_models_button,\n system_prompt_field,\n timeout_field,\n ft.Row(\n controls=[save_button, test_button],\n spacing=15,\n ),\n ],\n spacing=12,\n ),\n padding=20,\n width=700,\n ),\n )\n\n # --- Danger zone (only if a project is loaded) ---\n danger_zone_controls: list[ft.Control] = []\n\n if app_state.current_project:\n proj_name = app_state.current_project.name\n proj_id = app_state.current_project.id\n\n def on_delete_project(e):\n \"\"\"Show two-step deletion confirmation dialog.\"\"\"\n confirm_input = ft.TextField(\n label=f\"请输入项目名称「{proj_name}」以确认\",\n hint_text=proj_name,\n )\n error_label = ft.Text(\"\", color=ft.Colors.RED)\n\n def do_delete(e):\n if confirm_input.value.strip() != proj_name:\n error_label.value = \"项目名称不匹配,请重新输入\"\n page.update()\n return\n app_state.delete_project(proj_id)\n dlg.open = False\n if dlg in page.overlay:\n page.overlay.remove(dlg)\n page.update()\n _show_snackbar(f\"项目「{proj_name}」已删除\", ft.Colors.GREEN)\n if callable(on_project_deleted):\n on_project_deleted()\n\n def close_dlg(e):\n dlg.open = False\n page.update()\n\n dlg = ft.AlertDialog(\n title=ft.Text(\"⚠️ 确认删除项目\", color=ft.Colors.RED),\n content=ft.Column(\n controls=[\n ft.Text(f\"您确定要永久删除项目「{proj_name}」吗?\"),\n ft.Text(\n \"此操作将删除所有人物、地点、剧本、事实和推断数据,且无法恢复。\",\n color=ft.Colors.RED,\n size=13,\n ),\n confirm_input,\n error_label,\n ],\n tight=True,\n spacing=10,\n ),\n actions=[\n ft.TextButton(\"取消\", on_click=close_dlg),\n ft.Button(\n \"永久删除\",\n icon=ft.Icons.DELETE_FOREVER,\n style=ft.ButtonStyle(color=ft.Colors.WHITE, bgcolor=ft.Colors.RED),\n on_click=do_delete,\n ),\n ],\n )\n page.overlay.append(dlg)\n dlg.open = True\n page.update()\n\n danger_zone_controls = [\n ft.Divider(height=30),\n ft.Container(\n content=ft.Column(\n controls=[\n ft.Text(\n \"⚠️ 危险操作\",\n size=20,\n weight=ft.FontWeight.BOLD,\n color=ft.Colors.RED,\n ),\n ft.Text(\n \"以下操作不可撤销,请谨慎操作\",\n color=ft.Colors.GREY,\n size=13,\n ),\n ft.Container(height=10),\n ft.Button(\n \"删除此项目\",\n icon=ft.Icons.DELETE_FOREVER,\n style=ft.ButtonStyle(color=ft.Colors.WHITE, bgcolor=ft.Colors.RED),\n on_click=on_delete_project,\n ),\n ],\n spacing=8,\n ),\n border=ft.Border.all(1, ft.Colors.RED),\n border_radius=10,\n padding=20,\n width=700,\n ),\n ]\n\n # --- Assemble the full settings page ---\n return ft.Column(\n controls=[\n ft.Text(\"设置\", size=28, weight=ft.FontWeight.BOLD),\n ft.Container(height=10),\n api_config_card,\n *danger_zone_controls,\n ],\n scroll=ft.ScrollMode.AUTO,\n spacing=10,\n expand=True,\n )\n"}, "bfee04090fed0afff2bdd2587aadd36cbe5dcd52": {"t": 1785768791.0842853, "n": 11241, "h": "6879e7e1b1edc25e", "c": "\"\"\"设置页面 — Settings tab (Flet version).\n\nAPI configuration: base URL, key, model, system prompt override.\nModel list loading with selection dialog.\nProject management: delete project (danger zone).\n\"\"\"\n\nimport flet as ft\nfrom loguru import logger\n\nfrom src.services.config import load_config, save_config\nfrom src.ui.state import app_state\n\n\ndef build_settings_tab(page: ft.Page, on_project_deleted=None) -> ft.Control:\n \"\"\"Build and return the settings tab control tree.\n\n Provides:\n - API configuration form (base URL, API key, model, system prompt)\n - Save, test connection, and load models buttons\n - Danger zone: delete current project with two-step confirmation\n \"\"\"\n config = load_config()\n\n # --- API configuration fields ---\n base_url_field = ft.TextField(\n label=\"API Base URL\",\n value=config.get(\"api_base_url\", \"\"),\n hint_text=\"https://api.openai.com/v1\",\n )\n\n api_key_field = ft.TextField(\n label=\"API Key(部分服务商可留空)\",\n value=config.get(\"api_key\", \"\"),\n hint_text=\"sk-...(Ollama 等本地服务可留空)\",\n password=True,\n can_reveal_password=True,\n )\n\n model_field = ft.TextField(\n label=\"模型名称\",\n value=config.get(\"model\", \"\"),\n hint_text=\"gpt-4o / deepseek-chat / llama3\",\n )\n\n system_prompt_field = ft.TextField(\n label=\"自定义系统提示词(可选)\",\n value=config.get(\"system_prompt_override\", \"\"),\n hint_text=\"覆盖默认的系统提示词。留空则使用内置提示词。\",\n multiline=True,\n min_lines=3,\n )\n\n timeout_field = ft.TextField(\n label=\"请求超时(秒,0 表示不超时)\",\n value=str(config.get(\"timeout\", 300)),\n hint_text=\"300\",\n keyboard_type=ft.KeyboardType.NUMBER,\n width=220,\n )\n\n # --- Helper: collect config from fields ---\n def _collect_config() -> dict:\n try:\n timeout_val = int(timeout_field.value.strip() or \"300\")\n if timeout_val < 0:\n timeout_val = 300\n except ValueError:\n timeout_val = 300\n return {\n \"api_base_url\": base_url_field.value.strip(),\n \"api_key\": api_key_field.value.strip(),\n \"model\": model_field.value.strip(),\n \"system_prompt_override\": system_prompt_field.value.strip(),\n \"timeout\": timeout_val,\n }\n\n # --- Helper: show snack bar ---\n def _show_snackbar(message: str, color: str | None = None):\n page.snack_bar = ft.SnackBar(\n content=ft.Text(message),\n bgcolor=color,\n )\n page.snack_bar.open = True\n page.update()\n\n # --- Save config handler ---\n def on_save_config(e):\n cfg = _collect_config()\n logger.info(\n \"settings: save_config base_url={!r} model={!r} api_key_set={}\",\n cfg.get(\"api_base_url\"),\n cfg.get(\"model\"),\n bool(cfg.get(\"api_key\")),\n )\n save_config(cfg)\n _show_snackbar(\"配置已保存\", ft.Colors.GREEN)\n\n # --- Test connection handler ---\n async def on_test_connection(e):\n base_url = base_url_field.value.strip()\n model = model_field.value.strip()\n logger.info(\"settings: test_connection clicked base_url={!r} model={!r}\", base_url, model)\n if not base_url or not model:\n _show_snackbar(\"请先填写 API Base URL 和模型名称\", ft.Colors.AMBER)\n return\n\n # Save config first so LLMService can read it\n save_config(_collect_config())\n _show_snackbar(\"正在测试连接...\")\n\n try:\n from src.services.llm_service import LLMService\n\n llm = LLMService()\n reply = await llm.test_connection()\n logger.info(\"settings: test_connection success reply={!r}\", reply)\n _show_snackbar(f\"连接成功!模型响应: {reply}\", ft.Colors.GREEN)\n except Exception as exc:\n logger.exception(\"settings: test_connection failed\")\n _show_snackbar(f\"连接失败: {exc}\", ft.Colors.RED)\n\n # --- Load models handler ---\n async def on_load_models(e):\n base_url = base_url_field.value.strip()\n logger.info(\"settings: load_models clicked base_url={!r}\", base_url)\n if not base_url:\n _show_snackbar(\"请先填写 API Base URL\", ft.Colors.AMBER)\n return\n\n # Save config first so LLMService can read it\n save_config(_collect_config())\n _show_snackbar(\"正在加载模型列表...\")\n\n try:\n from src.services.llm_service import LLMService\n\n llm = LLMService()\n models = await llm.list_models()\n logger.info(\"settings: load_models got {} model(s)\", len(models))\n\n if not models:\n _show_snackbar(\"未发现可用模型\", ft.Colors.AMBER)\n return\n\n # Build model selection dialog\n def make_model_select_handler(model_name: str):\n def handler(e):\n model_field.value = model_name\n dlg.open = False\n page.update()\n\n return handler\n\n model_tiles = [\n ft.ListTile(\n title=ft.Text(m),\n on_click=make_model_select_handler(m),\n )\n for m in models\n ]\n\n def close_dialog(e):\n dlg.open = False\n page.update()\n\n dlg = ft.AlertDialog(\n title=ft.Text(\"选择模型\"),\n content=ft.Container(\n content=ft.Column(\n controls=model_tiles,\n scroll=ft.ScrollMode.AUTO,\n ),\n height=400,\n width=360,\n ),\n actions=[ft.TextButton(\"取消\", on_click=close_dialog)],\n )\n page.overlay.append(dlg)\n dlg.open = True\n page.update()\n\n _show_snackbar(f\"已加载 {len(models)} 个模型\", ft.Colors.GREEN)\n\n except Exception as exc:\n logger.exception(\"settings: load_models failed\")\n _show_snackbar(f\"加载模型失败: {exc}\", ft.Colors.RED)\n\n # --- Action buttons ---\n save_button = ft.Button(\n \"保存配置\",\n icon=ft.Icons.SAVE,\n on_click=on_save_config,\n )\n\n test_button = ft.Button(\n \"测试连接\",\n icon=ft.Icons.WIFI,\n on_click=on_test_connection,\n )\n\n load_models_button = ft.Button(\n \"加载模型列表\",\n icon=ft.Icons.REFRESH,\n on_click=on_load_models,\n )\n\n # --- API config card ---\n api_config_card = ft.Card(\n content=ft.Container(\n content=ft.Column(\n controls=[\n ft.Text(\"API 配置\", size=20, weight=ft.FontWeight.BOLD),\n ft.Text(\n \"配置用于 AI 推理的 OpenAI 兼容 API\",\n color=ft.Colors.GREY,\n size=13,\n ),\n ft.Divider(),\n base_url_field,\n api_key_field,\n model_field,\n load_models_button,\n system_prompt_field,\n timeout_field,\n ft.Row(\n controls=[save_button, test_button],\n spacing=15,\n ),\n ],\n spacing=12,\n ),\n padding=20,\n width=700,\n ),\n )\n\n # --- Danger zone (only if a project is loaded) ---\n danger_zone_controls: list[ft.Control] = []\n\n if app_state.current_project:\n proj_name = app_state.current_project.name\n proj_id = app_state.current_project.id\n\n def on_delete_project(e):\n \"\"\"Show two-step deletion confirmation dialog.\"\"\"\n confirm_input = ft.TextField(\n label=f\"请输入项目名称「{proj_name}」以确认\",\n hint_text=proj_name,\n )\n error_label = ft.Text(\"\", color=ft.Colors.RED)\n\n def do_delete(e):\n if confirm_input.value.strip() != proj_name:\n error_label.value = \"项目名称不匹配,请重新输入\"\n page.update()\n return\n app_state.delete_project(proj_id)\n dlg.open = False\n if dlg in page.overlay:\n page.overlay.remove(dlg)\n page.update()\n _show_snackbar(f\"项目「{proj_name}」已删除\", ft.Colors.GREEN)\n if callable(on_project_deleted):\n on_project_deleted()\n\n def close_dlg(e):\n dlg.open = False\n page.update()\n\n dlg = ft.AlertDialog(\n title=ft.Text(\"⚠️ 确认删除项目\", color=ft.Colors.RED),\n content=ft.Column(\n controls=[\n ft.Text(f\"您确定要永久删除项目「{proj_name}」吗?\"),\n ft.Text(\n \"此操作将删除所有人物、地点、剧本、事实和推断数据,且无法恢复。\",\n color=ft.Colors.RED,\n size=13,\n ),\n confirm_input,\n error_label,\n ],\n tight=True,\n spacing=10,\n ),\n actions=[\n ft.TextButton(\"取消\", on_click=close_dlg),\n ft.Button(\n \"永久删除\",\n icon=ft.Icons.DELETE_FOREVER,\n style=ft.ButtonStyle(color=ft.Colors.WHITE, bgcolor=ft.Colors.RED),\n on_click=do_delete,\n ),\n ],\n )\n page.overlay.append(dlg)\n dlg.open = True\n page.update()\n\n danger_zone_controls = [\n ft.Divider(height=30),\n ft.Container(\n content=ft.Column(\n controls=[\n ft.Text(\n \"⚠️ 危险操作\",\n size=20,\n weight=ft.FontWeight.BOLD,\n color=ft.Colors.RED,\n ),\n ft.Text(\n \"以下操作不可撤销,请谨慎操作\",\n color=ft.Colors.GREY,\n size=13,\n ),\n ft.Container(height=10),\n ft.Button(\n \"删除此项目\",\n icon=ft.Icons.DELETE_FOREVER,\n style=ft.ButtonStyle(color=ft.Colors.WHITE, bgcolor=ft.Colors.RED),\n on_click=on_delete_project,\n ),\n ],\n spacing=8,\n ),\n border=ft.Border.all(1, ft.Colors.RED),\n border_radius=10,\n padding=20,\n width=700,\n ),\n ]\n\n # --- Assemble the full settings page ---\n return ft.Column(\n controls=[\n ft.Text(\"设置\", size=28, weight=ft.FontWeight.BOLD),\n ft.Container(height=10),\n api_config_card,\n *danger_zone_controls,\n ],\n scroll=ft.ScrollMode.AUTO,\n spacing=10,\n expand=True,\n )\n"}, "9cce0f19f5483f3b4ab831061d9e1d54379a3e25": {"t": 1785768793.8366506, "n": 11089, "h": "b3f472983e121006", "c": "\"\"\"设置页面 — Settings tab (Flet version).\n\nAPI configuration: base URL, key, model, system prompt override.\nModel list loading with selection dialog.\nProject management: delete project (danger zone).\n\"\"\"\n\nimport flet as ft\nfrom loguru import logger\n\nfrom src.services.config import load_config, save_config\nfrom src.ui.state import app_state\n\n\ndef build_settings_tab(page: ft.Page) -> ft.Control:\n \"\"\"Build and return the settings tab control tree.\n\n Provides:\n - API configuration form (base URL, API key, model, system prompt)\n - Save, test connection, and load models buttons\n - Danger zone: delete current project with two-step confirmation\n \"\"\"\n config = load_config()\n\n # --- API configuration fields ---\n base_url_field = ft.TextField(\n label=\"API Base URL\",\n value=config.get(\"api_base_url\", \"\"),\n hint_text=\"https://api.openai.com/v1\",\n )\n\n api_key_field = ft.TextField(\n label=\"API Key(部分服务商可留空)\",\n value=config.get(\"api_key\", \"\"),\n hint_text=\"sk-...(Ollama 等本地服务可留空)\",\n password=True,\n can_reveal_password=True,\n )\n\n model_field = ft.TextField(\n label=\"模型名称\",\n value=config.get(\"model\", \"\"),\n hint_text=\"gpt-4o / deepseek-chat / llama3\",\n )\n\n system_prompt_field = ft.TextField(\n label=\"自定义系统提示词(可选)\",\n value=config.get(\"system_prompt_override\", \"\"),\n hint_text=\"覆盖默认的系统提示词。留空则使用内置提示词。\",\n multiline=True,\n min_lines=3,\n )\n\n timeout_field = ft.TextField(\n label=\"请求超时(秒,0 表示不超时)\",\n value=str(config.get(\"timeout\", 300)),\n hint_text=\"300\",\n keyboard_type=ft.KeyboardType.NUMBER,\n width=220,\n )\n\n # --- Helper: collect config from fields ---\n def _collect_config() -> dict:\n try:\n timeout_val = int(timeout_field.value.strip() or \"300\")\n if timeout_val < 0:\n timeout_val = 300\n except ValueError:\n timeout_val = 300\n return {\n \"api_base_url\": base_url_field.value.strip(),\n \"api_key\": api_key_field.value.strip(),\n \"model\": model_field.value.strip(),\n \"system_prompt_override\": system_prompt_field.value.strip(),\n \"timeout\": timeout_val,\n }\n\n # --- Helper: show snack bar ---\n def _show_snackbar(message: str, color: str | None = None):\n page.snack_bar = ft.SnackBar(\n content=ft.Text(message),\n bgcolor=color,\n )\n page.snack_bar.open = True\n page.update()\n\n # --- Save config handler ---\n def on_save_config(e):\n cfg = _collect_config()\n logger.info(\n \"settings: save_config base_url={!r} model={!r} api_key_set={}\",\n cfg.get(\"api_base_url\"),\n cfg.get(\"model\"),\n bool(cfg.get(\"api_key\")),\n )\n save_config(cfg)\n _show_snackbar(\"配置已保存\", ft.Colors.GREEN)\n\n # --- Test connection handler ---\n async def on_test_connection(e):\n base_url = base_url_field.value.strip()\n model = model_field.value.strip()\n logger.info(\"settings: test_connection clicked base_url={!r} model={!r}\", base_url, model)\n if not base_url or not model:\n _show_snackbar(\"请先填写 API Base URL 和模型名称\", ft.Colors.AMBER)\n return\n\n # Save config first so LLMService can read it\n save_config(_collect_config())\n _show_snackbar(\"正在测试连接...\")\n\n try:\n from src.services.llm_service import LLMService\n\n llm = LLMService()\n reply = await llm.test_connection()\n logger.info(\"settings: test_connection success reply={!r}\", reply)\n _show_snackbar(f\"连接成功!模型响应: {reply}\", ft.Colors.GREEN)\n except Exception as exc:\n logger.exception(\"settings: test_connection failed\")\n _show_snackbar(f\"连接失败: {exc}\", ft.Colors.RED)\n\n # --- Load models handler ---\n async def on_load_models(e):\n base_url = base_url_field.value.strip()\n logger.info(\"settings: load_models clicked base_url={!r}\", base_url)\n if not base_url:\n _show_snackbar(\"请先填写 API Base URL\", ft.Colors.AMBER)\n return\n\n # Save config first so LLMService can read it\n save_config(_collect_config())\n _show_snackbar(\"正在加载模型列表...\")\n\n try:\n from src.services.llm_service import LLMService\n\n llm = LLMService()\n models = await llm.list_models()\n logger.info(\"settings: load_models got {} model(s)\", len(models))\n\n if not models:\n _show_snackbar(\"未发现可用模型\", ft.Colors.AMBER)\n return\n\n # Build model selection dialog\n def make_model_select_handler(model_name: str):\n def handler(e):\n model_field.value = model_name\n dlg.open = False\n page.update()\n\n return handler\n\n model_tiles = [\n ft.ListTile(\n title=ft.Text(m),\n on_click=make_model_select_handler(m),\n )\n for m in models\n ]\n\n def close_dialog(e):\n dlg.open = False\n page.update()\n\n dlg = ft.AlertDialog(\n title=ft.Text(\"选择模型\"),\n content=ft.Container(\n content=ft.Column(\n controls=model_tiles,\n scroll=ft.ScrollMode.AUTO,\n ),\n height=400,\n width=360,\n ),\n actions=[ft.TextButton(\"取消\", on_click=close_dialog)],\n )\n page.overlay.append(dlg)\n dlg.open = True\n page.update()\n\n _show_snackbar(f\"已加载 {len(models)} 个模型\", ft.Colors.GREEN)\n\n except Exception as exc:\n logger.exception(\"settings: load_models failed\")\n _show_snackbar(f\"加载模型失败: {exc}\", ft.Colors.RED)\n\n # --- Action buttons ---\n save_button = ft.ElevatedButton(\n \"保存配置\",\n icon=ft.Icons.SAVE,\n on_click=on_save_config,\n )\n\n test_button = ft.ElevatedButton(\n \"测试连接\",\n icon=ft.Icons.WIFI,\n on_click=on_test_connection,\n )\n\n load_models_button = ft.ElevatedButton(\n \"加载模型列表\",\n icon=ft.Icons.REFRESH,\n on_click=on_load_models,\n )\n\n # --- API config card ---\n api_config_card = ft.Card(\n content=ft.Container(\n content=ft.Column(\n controls=[\n ft.Text(\"API 配置\", size=20, weight=ft.FontWeight.BOLD),\n ft.Text(\n \"配置用于 AI 推理的 OpenAI 兼容 API\",\n color=ft.Colors.GREY,\n size=13,\n ),\n ft.Divider(),\n base_url_field,\n api_key_field,\n model_field,\n load_models_button,\n system_prompt_field,\n timeout_field,\n ft.Row(\n controls=[save_button, test_button],\n spacing=15,\n ),\n ],\n spacing=12,\n ),\n padding=20,\n width=700,\n ),\n )\n\n # --- Danger zone (only if a project is loaded) ---\n danger_zone_controls: list[ft.Control] = []\n\n if app_state.current_project:\n proj_name = app_state.current_project.name\n proj_id = app_state.current_project.id\n\n def on_delete_project(e):\n \"\"\"Show two-step deletion confirmation dialog.\"\"\"\n confirm_input = ft.TextField(\n label=f\"请输入项目名称「{proj_name}」以确认\",\n hint_text=proj_name,\n )\n error_label = ft.Text(\"\", color=ft.Colors.RED)\n\n def do_delete(e):\n if confirm_input.value.strip() != proj_name:\n error_label.value = \"项目名称不匹配,请重新输入\"\n page.update()\n return\n app_state.delete_project(proj_id)\n dlg.open = False\n page.update()\n _show_snackbar(f\"项目「{proj_name}」已删除\", ft.Colors.GREEN)\n\n def close_dlg(e):\n dlg.open = False\n page.update()\n\n dlg = ft.AlertDialog(\n title=ft.Text(\"⚠️ 确认删除项目\", color=ft.Colors.RED),\n content=ft.Column(\n controls=[\n ft.Text(f\"您确定要永久删除项目「{proj_name}」吗?\"),\n ft.Text(\n \"此操作将删除所有人物、地点、剧本、事实和推断数据,且无法恢复。\",\n color=ft.Colors.RED,\n size=13,\n ),\n confirm_input,\n error_label,\n ],\n tight=True,\n spacing=10,\n ),\n actions=[\n ft.TextButton(\"取消\", on_click=close_dlg),\n ft.ElevatedButton(\n \"永久删除\",\n icon=ft.Icons.DELETE_FOREVER,\n color=ft.Colors.WHITE,\n bgcolor=ft.Colors.RED,\n on_click=do_delete,\n ),\n ],\n )\n page.overlay.append(dlg)\n dlg.open = True\n page.update()\n\n danger_zone_controls = [\n ft.Divider(height=30),\n ft.Container(\n content=ft.Column(\n controls=[\n ft.Text(\n \"⚠️ 危险操作\",\n size=20,\n weight=ft.FontWeight.BOLD,\n color=ft.Colors.RED,\n ),\n ft.Text(\n \"以下操作不可撤销,请谨慎操作\",\n color=ft.Colors.GREY,\n size=13,\n ),\n ft.Container(height=10),\n ft.ElevatedButton(\n \"删除此项目\",\n icon=ft.Icons.DELETE_FOREVER,\n color=ft.Colors.WHITE,\n bgcolor=ft.Colors.RED,\n on_click=on_delete_project,\n ),\n ],\n spacing=8,\n ),\n border=ft.Border.all(1, ft.Colors.RED),\n border_radius=10,\n padding=20,\n width=700,\n ),\n ]\n\n # --- Assemble the full settings page ---\n return ft.Column(\n controls=[\n ft.Text(\"设置\", size=28, weight=ft.FontWeight.BOLD),\n ft.Container(height=10),\n api_config_card,\n *danger_zone_controls,\n ],\n scroll=ft.ScrollMode.AUTO,\n spacing=10,\n expand=True,\n )\n"}, "c945ceebcf8b7cfe1b59df084b476806d9699148": {"t": 1785768795.2355561, "n": 10603, "h": "752eecba38a3ea2c", "c": "\"\"\"设置页面 — Settings tab (Flet version).\n\nAPI configuration: base URL, key, model, system prompt override.\nModel list loading with selection dialog.\nProject management: delete project (danger zone).\n\"\"\"\n\nimport flet as ft\nfrom loguru import logger\n\nfrom src.services.config import load_config, save_config\nfrom src.ui.state import app_state\n\n\ndef build_settings_tab(page: ft.Page) -> ft.Control:\n \"\"\"Build and return the settings tab control tree.\n\n Provides:\n - API configuration form (base URL, API key, model, system prompt)\n - Save, test connection, and load models buttons\n - Danger zone: delete current project with two-step confirmation\n \"\"\"\n config = load_config()\n\n # --- API configuration fields ---\n base_url_field = ft.TextField(\n label=\"API Base URL\",\n value=config.get(\"api_base_url\", \"\"),\n hint_text=\"https://api.openai.com/v1\",\n )\n\n api_key_field = ft.TextField(\n label=\"API Key(部分服务商可留空)\",\n value=config.get(\"api_key\", \"\"),\n hint_text=\"sk-...(Ollama 等本地服务可留空)\",\n password=True,\n can_reveal_password=True,\n )\n\n model_field = ft.TextField(\n label=\"模型名称\",\n value=config.get(\"model\", \"\"),\n hint_text=\"gpt-4o / deepseek-chat / llama3\",\n )\n\n system_prompt_field = ft.TextField(\n label=\"自定义系统提示词(可选)\",\n value=config.get(\"system_prompt_override\", \"\"),\n hint_text=\"覆盖默认的系统提示词。留空则使用内置提示词。\",\n multiline=True,\n min_lines=3,\n )\n\n # --- Helper: collect config from fields ---\n def _collect_config() -> dict:\n return {\n \"api_base_url\": base_url_field.value.strip(),\n \"api_key\": api_key_field.value.strip(),\n \"model\": model_field.value.strip(),\n \"system_prompt_override\": system_prompt_field.value.strip(),\n }\n\n # --- Helper: show snack bar ---\n def _show_snackbar(message: str, color: str | None = None):\n page.snack_bar = ft.SnackBar(\n content=ft.Text(message),\n bgcolor=color,\n )\n page.snack_bar.open = True\n page.update()\n\n # --- Save config handler ---\n def on_save_config(e):\n cfg = _collect_config()\n logger.info(\n \"settings: save_config base_url={!r} model={!r} api_key_set={}\",\n cfg.get(\"api_base_url\"),\n cfg.get(\"model\"),\n bool(cfg.get(\"api_key\")),\n )\n save_config(cfg)\n _show_snackbar(\"配置已保存\", ft.Colors.GREEN)\n\n # --- Test connection handler ---\n async def on_test_connection(e):\n base_url = base_url_field.value.strip()\n model = model_field.value.strip()\n logger.info(\"settings: test_connection clicked base_url={!r} model={!r}\", base_url, model)\n if not base_url or not model:\n _show_snackbar(\"请先填写 API Base URL 和模型名称\", ft.Colors.AMBER)\n return\n\n # Save config first so LLMService can read it\n save_config(_collect_config())\n _show_snackbar(\"正在测试连接...\")\n\n try:\n from src.services.llm_service import LLMService\n\n llm = LLMService()\n reply = await llm.test_connection()\n logger.info(\"settings: test_connection success reply={!r}\", reply)\n _show_snackbar(f\"连接成功!模型响应: {reply}\", ft.Colors.GREEN)\n except Exception as exc:\n logger.exception(\"settings: test_connection failed\")\n _show_snackbar(f\"连接失败: {exc}\", ft.Colors.RED)\n\n # --- Load models handler ---\n async def on_load_models(e):\n base_url = base_url_field.value.strip()\n logger.info(\"settings: load_models clicked base_url={!r}\", base_url)\n if not base_url:\n _show_snackbar(\"请先填写 API Base URL\", ft.Colors.AMBER)\n return\n\n # Save config first so LLMService can read it\n save_config(_collect_config())\n _show_snackbar(\"正在加载模型列表...\")\n\n try:\n from src.services.llm_service import LLMService\n\n llm = LLMService()\n models = await llm.list_models()\n logger.info(\"settings: load_models got {} model(s)\", len(models))\n\n if not models:\n _show_snackbar(\"未发现可用模型\", ft.Colors.AMBER)\n return\n\n # Build model selection dialog\n def make_model_select_handler(model_name: str):\n def handler(e):\n model_field.value = model_name\n dlg.open = False\n page.update()\n\n return handler\n\n model_tiles = [\n ft.ListTile(\n title=ft.Text(m),\n on_click=make_model_select_handler(m),\n )\n for m in models\n ]\n\n def close_dialog(e):\n dlg.open = False\n page.update()\n\n dlg = ft.AlertDialog(\n title=ft.Text(\"选择模型\"),\n content=ft.Container(\n content=ft.Column(\n controls=model_tiles,\n scroll=ft.ScrollMode.AUTO,\n ),\n height=400,\n width=360,\n ),\n actions=[ft.TextButton(\"取消\", on_click=close_dialog)],\n )\n page.overlay.append(dlg)\n dlg.open = True\n page.update()\n\n _show_snackbar(f\"已加载 {len(models)} 个模型\", ft.Colors.GREEN)\n\n except Exception as exc:\n logger.exception(\"settings: load_models failed\")\n _show_snackbar(f\"加载模型失败: {exc}\", ft.Colors.RED)\n\n # --- Action buttons ---\n save_button = ft.ElevatedButton(\n \"保存配置\",\n icon=ft.Icons.SAVE,\n on_click=on_save_config,\n )\n\n test_button = ft.ElevatedButton(\n \"测试连接\",\n icon=ft.Icons.WIFI,\n on_click=on_test_connection,\n )\n\n load_models_button = ft.ElevatedButton(\n \"加载模型列表\",\n icon=ft.Icons.REFRESH,\n on_click=on_load_models,\n )\n\n # --- API config card ---\n api_config_card = ft.Card(\n content=ft.Container(\n content=ft.Column(\n controls=[\n ft.Text(\"API 配置\", size=20, weight=ft.FontWeight.BOLD),\n ft.Text(\n \"配置用于 AI 推理的 OpenAI 兼容 API\",\n color=ft.Colors.GREY,\n size=13,\n ),\n ft.Divider(),\n base_url_field,\n api_key_field,\n model_field,\n load_models_button,\n system_prompt_field,\n ft.Row(\n controls=[save_button, test_button],\n spacing=15,\n ),\n ],\n spacing=12,\n ),\n padding=20,\n width=700,\n ),\n )\n\n # --- Danger zone (only if a project is loaded) ---\n danger_zone_controls: list[ft.Control] = []\n\n if app_state.current_project:\n proj_name = app_state.current_project.name\n proj_id = app_state.current_project.id\n\n def on_delete_project(e):\n \"\"\"Show two-step deletion confirmation dialog.\"\"\"\n confirm_input = ft.TextField(\n label=f\"请输入项目名称「{proj_name}」以确认\",\n hint_text=proj_name,\n )\n error_label = ft.Text(\"\", color=ft.Colors.RED)\n\n def do_delete(e):\n if confirm_input.value.strip() != proj_name:\n error_label.value = \"项目名称不匹配,请重新输入\"\n page.update()\n return\n app_state.delete_project(proj_id)\n dlg.open = False\n page.update()\n _show_snackbar(f\"项目「{proj_name}」已删除\", ft.Colors.GREEN)\n\n def close_dlg(e):\n dlg.open = False\n page.update()\n\n dlg = ft.AlertDialog(\n title=ft.Text(\"⚠️ 确认删除项目\", color=ft.Colors.RED),\n content=ft.Column(\n controls=[\n ft.Text(f\"您确定要永久删除项目「{proj_name}」吗?\"),\n ft.Text(\n \"此操作将删除所有人物、地点、剧本、事实和推断数据,且无法恢复。\",\n color=ft.Colors.RED,\n size=13,\n ),\n confirm_input,\n error_label,\n ],\n tight=True,\n spacing=10,\n ),\n actions=[\n ft.TextButton(\"取消\", on_click=close_dlg),\n ft.ElevatedButton(\n \"永久删除\",\n icon=ft.Icons.DELETE_FOREVER,\n color=ft.Colors.WHITE,\n bgcolor=ft.Colors.RED,\n on_click=do_delete,\n ),\n ],\n )\n page.overlay.append(dlg)\n dlg.open = True\n page.update()\n\n danger_zone_controls = [\n ft.Divider(height=30),\n ft.Container(\n content=ft.Column(\n controls=[\n ft.Text(\n \"⚠️ 危险操作\",\n size=20,\n weight=ft.FontWeight.BOLD,\n color=ft.Colors.RED,\n ),\n ft.Text(\n \"以下操作不可撤销,请谨慎操作\",\n color=ft.Colors.GREY,\n size=13,\n ),\n ft.Container(height=10),\n ft.ElevatedButton(\n \"删除此项目\",\n icon=ft.Icons.DELETE_FOREVER,\n color=ft.Colors.WHITE,\n bgcolor=ft.Colors.RED,\n on_click=on_delete_project,\n ),\n ],\n spacing=8,\n ),\n border=ft.Border.all(1, ft.Colors.RED),\n border_radius=10,\n padding=20,\n width=700,\n ),\n ]\n\n # --- Assemble the full settings page ---\n return ft.Column(\n controls=[\n ft.Text(\"设置\", size=28, weight=ft.FontWeight.BOLD),\n ft.Container(height=10),\n api_config_card,\n *danger_zone_controls,\n ],\n scroll=ft.ScrollMode.AUTO,\n spacing=10,\n expand=True,\n )\n"}, "b790eda62aaf3c94ae317b14def44c6726609e1c": {"t": 1785768796.7584357, "n": 9909, "h": "1ccc47b6c0f36c7e", "c": "\"\"\"设置页面 — Settings tab (Flet version).\n\nAPI configuration: base URL, key, model, system prompt override.\nModel list loading with selection dialog.\nProject management: delete project (danger zone).\n\"\"\"\n\nimport flet as ft\n\nfrom src.services.config import load_config, save_config\nfrom src.ui.state import app_state\n\n\ndef build_settings_tab(page: ft.Page) -> ft.Control:\n \"\"\"Build and return the settings tab control tree.\n\n Provides:\n - API configuration form (base URL, API key, model, system prompt)\n - Save, test connection, and load models buttons\n - Danger zone: delete current project with two-step confirmation\n \"\"\"\n config = load_config()\n\n # --- API configuration fields ---\n base_url_field = ft.TextField(\n label=\"API Base URL\",\n value=config.get(\"api_base_url\", \"\"),\n hint_text=\"https://api.openai.com/v1\",\n )\n\n api_key_field = ft.TextField(\n label=\"API Key(部分服务商可留空)\",\n value=config.get(\"api_key\", \"\"),\n hint_text=\"sk-...(Ollama 等本地服务可留空)\",\n password=True,\n can_reveal_password=True,\n )\n\n model_field = ft.TextField(\n label=\"模型名称\",\n value=config.get(\"model\", \"\"),\n hint_text=\"gpt-4o / deepseek-chat / llama3\",\n )\n\n system_prompt_field = ft.TextField(\n label=\"自定义系统提示词(可选)\",\n value=config.get(\"system_prompt_override\", \"\"),\n hint_text=\"覆盖默认的系统提示词。留空则使用内置提示词。\",\n multiline=True,\n min_lines=3,\n )\n\n # --- Helper: collect config from fields ---\n def _collect_config() -> dict:\n return {\n \"api_base_url\": base_url_field.value.strip(),\n \"api_key\": api_key_field.value.strip(),\n \"model\": model_field.value.strip(),\n \"system_prompt_override\": system_prompt_field.value.strip(),\n }\n\n # --- Helper: show snack bar ---\n def _show_snackbar(message: str, color: str | None = None):\n page.snack_bar = ft.SnackBar(\n content=ft.Text(message),\n bgcolor=color,\n )\n page.snack_bar.open = True\n page.update()\n\n # --- Save config handler ---\n def on_save_config(e):\n save_config(_collect_config())\n _show_snackbar(\"配置已保存\", ft.Colors.GREEN)\n\n # --- Test connection handler ---\n async def on_test_connection(e):\n base_url = base_url_field.value.strip()\n model = model_field.value.strip()\n if not base_url or not model:\n _show_snackbar(\"请先填写 API Base URL 和模型名称\", ft.Colors.AMBER)\n return\n\n # Save config first so LLMService can read it\n save_config(_collect_config())\n _show_snackbar(\"正在测试连接...\")\n\n try:\n from src.services.llm_service import LLMService\n\n llm = LLMService()\n reply = await llm.test_connection()\n _show_snackbar(f\"连接成功!模型响应: {reply}\", ft.Colors.GREEN)\n except Exception as exc:\n _show_snackbar(f\"连接失败: {str(exc)[:200]}\", ft.Colors.RED)\n\n # --- Load models handler ---\n async def on_load_models(e):\n base_url = base_url_field.value.strip()\n if not base_url:\n _show_snackbar(\"请先填写 API Base URL\", ft.Colors.AMBER)\n return\n\n # Save config first so LLMService can read it\n save_config(_collect_config())\n _show_snackbar(\"正在加载模型列表...\")\n\n try:\n from src.services.llm_service import LLMService\n\n llm = LLMService()\n models = await llm.list_models()\n\n if not models:\n _show_snackbar(\"未发现可用模型\", ft.Colors.AMBER)\n return\n\n # Build model selection dialog\n def make_model_select_handler(model_name: str):\n def handler(e):\n model_field.value = model_name\n dlg.open = False\n page.update()\n\n return handler\n\n model_tiles = [\n ft.ListTile(\n title=ft.Text(m),\n on_click=make_model_select_handler(m),\n )\n for m in models\n ]\n\n def close_dialog(e):\n dlg.open = False\n page.update()\n\n dlg = ft.AlertDialog(\n title=ft.Text(\"选择模型\"),\n content=ft.Container(\n content=ft.Column(\n controls=model_tiles,\n scroll=ft.ScrollMode.AUTO,\n ),\n height=400,\n width=360,\n ),\n actions=[ft.TextButton(\"取消\", on_click=close_dialog)],\n )\n page.overlay.append(dlg)\n dlg.open = True\n page.update()\n\n _show_snackbar(f\"已加载 {len(models)} 个模型\", ft.Colors.GREEN)\n\n except Exception as exc:\n _show_snackbar(f\"加载模型失败: {str(exc)[:200]}\", ft.Colors.RED)\n\n # --- Action buttons ---\n save_button = ft.ElevatedButton(\n \"保存配置\",\n icon=ft.Icons.SAVE,\n on_click=on_save_config,\n )\n\n test_button = ft.ElevatedButton(\n \"测试连接\",\n icon=ft.Icons.WIFI,\n on_click=on_test_connection,\n )\n\n load_models_button = ft.ElevatedButton(\n \"加载模型列表\",\n icon=ft.Icons.REFRESH,\n on_click=on_load_models,\n )\n\n # --- API config card ---\n api_config_card = ft.Card(\n content=ft.Container(\n content=ft.Column(\n controls=[\n ft.Text(\"API 配置\", size=20, weight=ft.FontWeight.BOLD),\n ft.Text(\n \"配置用于 AI 推理的 OpenAI 兼容 API\",\n color=ft.Colors.GREY,\n size=13,\n ),\n ft.Divider(),\n base_url_field,\n api_key_field,\n model_field,\n load_models_button,\n system_prompt_field,\n ft.Row(\n controls=[save_button, test_button],\n spacing=15,\n ),\n ],\n spacing=12,\n ),\n padding=20,\n width=700,\n ),\n )\n\n # --- Danger zone (only if a project is loaded) ---\n danger_zone_controls: list[ft.Control] = []\n\n if app_state.current_project:\n proj_name = app_state.current_project.name\n proj_id = app_state.current_project.id\n\n def on_delete_project(e):\n \"\"\"Show two-step deletion confirmation dialog.\"\"\"\n confirm_input = ft.TextField(\n label=f\"请输入项目名称「{proj_name}」以确认\",\n hint_text=proj_name,\n )\n error_label = ft.Text(\"\", color=ft.Colors.RED)\n\n def do_delete(e):\n if confirm_input.value.strip() != proj_name:\n error_label.value = \"项目名称不匹配,请重新输入\"\n page.update()\n return\n app_state.delete_project(proj_id)\n dlg.open = False\n page.update()\n _show_snackbar(f\"项目「{proj_name}」已删除\", ft.Colors.GREEN)\n\n def close_dlg(e):\n dlg.open = False\n page.update()\n\n dlg = ft.AlertDialog(\n title=ft.Text(\"⚠️ 确认删除项目\", color=ft.Colors.RED),\n content=ft.Column(\n controls=[\n ft.Text(f\"您确定要永久删除项目「{proj_name}」吗?\"),\n ft.Text(\n \"此操作将删除所有人物、地点、剧本、事实和推断数据,且无法恢复。\",\n color=ft.Colors.RED,\n size=13,\n ),\n confirm_input,\n error_label,\n ],\n tight=True,\n spacing=10,\n ),\n actions=[\n ft.TextButton(\"取消\", on_click=close_dlg),\n ft.ElevatedButton(\n \"永久删除\",\n icon=ft.Icons.DELETE_FOREVER,\n color=ft.Colors.WHITE,\n bgcolor=ft.Colors.RED,\n on_click=do_delete,\n ),\n ],\n )\n page.overlay.append(dlg)\n dlg.open = True\n page.update()\n\n danger_zone_controls = [\n ft.Divider(height=30),\n ft.Container(\n content=ft.Column(\n controls=[\n ft.Text(\n \"⚠️ 危险操作\",\n size=20,\n weight=ft.FontWeight.BOLD,\n color=ft.Colors.RED,\n ),\n ft.Text(\n \"以下操作不可撤销,请谨慎操作\",\n color=ft.Colors.GREY,\n size=13,\n ),\n ft.Container(height=10),\n ft.ElevatedButton(\n \"删除此项目\",\n icon=ft.Icons.DELETE_FOREVER,\n color=ft.Colors.WHITE,\n bgcolor=ft.Colors.RED,\n on_click=on_delete_project,\n ),\n ],\n spacing=8,\n ),\n border=ft.Border.all(1, ft.Colors.RED),\n border_radius=10,\n padding=20,\n width=700,\n ),\n ]\n\n # --- Assemble the full settings page ---\n return ft.Column(\n controls=[\n ft.Text(\"设置\", size=28, weight=ft.FontWeight.BOLD),\n ft.Container(height=10),\n api_config_card,\n *danger_zone_controls,\n ],\n scroll=ft.ScrollMode.AUTO,\n spacing=10,\n expand=True,\n )\n"}, "88c9c0095cf3eef746fd5a77286e0bfdb6d5bb66": {"t": 1785768798.0643165, "n": 7128, "h": "1eab012e1c09fb33", "c": "\"\"\"设置页面 — Settings tab.\n\nAPI configuration: base URL, key, model, system prompt override.\nProject management: delete project.\n\"\"\"\n\nfrom nicegui import ui\n\nfrom src.services.config import load_config, save_config\nfrom src.ui.state import app_state\n\n\ndef settings_tab_content():\n \"\"\"Render the settings tab content.\"\"\"\n config = load_config()\n\n with ui.column().classes(\"w-full q-pa-md gap-4\"):\n ui.label(\"设置\").classes(\"text-h5\")\n\n with ui.card().classes(\"w-full max-w-2xl\"):\n with ui.card_section():\n ui.label(\"API 配置\").classes(\"text-h6\")\n ui.label(\"配置用于 AI 推理的 OpenAI 兼容 API\").classes(\n \"text-body2 text-grey\"\n )\n\n with ui.card_section():\n base_url_input = ui.input(\n label=\"API Base URL\",\n value=config.get(\"api_base_url\", \"\"),\n placeholder=\"https://api.openai.com/v1\",\n ).classes(\"w-full\")\n\n api_key_input = ui.input(\n label=\"API Key\",\n value=config.get(\"api_key\", \"\"),\n placeholder=\"sk-...\",\n password=True,\n password_toggle_button=True,\n ).classes(\"w-full\")\n\n model_input = ui.input(\n label=\"模型名称\",\n value=config.get(\"model\", \"\"),\n placeholder=\"gpt-4\",\n ).classes(\"w-full\")\n\n prompt_input = ui.textarea(\n label=\"自定义系统提示词(可选)\",\n value=config.get(\"system_prompt_override\", \"\"),\n placeholder=\"覆盖默认的系统提示词。留空则使用内置提示词。\",\n ).classes(\"w-full\").props(\"rows=5\")\n\n with ui.card_section():\n with ui.row().classes(\"gap-4\"):\n def do_save():\n new_config = {\n \"api_base_url\": base_url_input.value.strip(),\n \"api_key\": api_key_input.value.strip(),\n \"model\": model_input.value.strip(),\n \"system_prompt_override\": prompt_input.value.strip(),\n }\n save_config(new_config)\n ui.notify(\"配置已保存\", type=\"positive\")\n\n ui.button(\"保存配置\", on_click=do_save, icon=\"save\").props(\n \"color=primary\"\n )\n\n async def do_test():\n base_url = base_url_input.value.strip()\n api_key = api_key_input.value.strip()\n model = model_input.value.strip()\n\n if not base_url or not api_key or not model:\n ui.notify(\n \"请先填写 API Base URL、API Key 和模型名称\",\n type=\"warning\",\n )\n return\n\n # Save config first so LLMService can read it\n temp_config = {\n \"api_base_url\": base_url,\n \"api_key\": api_key,\n \"model\": model,\n \"system_prompt_override\": prompt_input.value.strip(),\n }\n save_config(temp_config)\n\n spinner = ui.spinner(\"dots\", size=\"lg\", color=\"primary\")\n ui.notify(\"正在测试连接...\", type=\"info\")\n try:\n from src.services.llm_service import LLMService\n\n llm = LLMService()\n reply = await llm.test_connection()\n ui.notify(\n f\"连接成功!模型响应: {reply}\",\n type=\"positive\",\n )\n except Exception as e:\n ui.notify(\n f\"连接失败: {str(e)[:200]}\",\n type=\"negative\",\n timeout=10000,\n )\n finally:\n spinner.delete()\n\n ui.button(\"测试连接\", on_click=do_test, icon=\"wifi\").props(\n \"outline\"\n )\n\n # --- Project Danger Zone ---\n if app_state.current_project:\n ui.separator().classes(\"q-my-lg\")\n with ui.card().classes(\"w-full max-w-2xl\").style(\n \"border: 1px solid #ff5252;\"\n ):\n with ui.card_section():\n ui.label(\"⚠️ 危险操作\").classes(\"text-h6 text-negative\")\n ui.label(\"以下操作不可撤销,请谨慎操作\").classes(\n \"text-body2 text-grey\"\n )\n\n with ui.card_section():\n proj_name = app_state.current_project.name\n\n def confirm_delete_project():\n \"\"\"Show a strong confirmation dialog for project deletion.\"\"\"\n with ui.dialog() as dlg, ui.card().classes(\"w-96\"):\n ui.label(\"⚠️ 确认删除项目\").classes(\"text-h6 text-negative q-mb-md\")\n ui.label(\n f\"您确定要永久删除项目「{proj_name}」吗?\"\n ).classes(\"text-body1 q-mb-sm\")\n ui.label(\n \"此操作将删除所有人物、地点、剧本、事实和推断数据,且无法恢复。\"\n ).classes(\"text-body2 text-negative q-mb-md\")\n\n confirm_input = ui.input(\n label=f'请输入项目名称「{proj_name}」以确认',\n placeholder=proj_name,\n ).classes(\"w-full\")\n\n error_label = ui.label(\"\").classes(\"text-negative\")\n\n def do_delete():\n if confirm_input.value.strip() != proj_name:\n error_label.text = \"项目名称不匹配,请重新输入\"\n return\n proj_id = app_state.current_project.id\n app_state.delete_project(proj_id)\n ui.notify(f\"项目「{proj_name}」已删除\", type=\"positive\")\n dlg.close()\n\n with ui.row().classes(\"w-full justify-end q-mt-md\"):\n ui.button(\"取消\", on_click=dlg.close).props(\"flat\")\n ui.button(\n \"永久删除\",\n on_click=do_delete,\n icon=\"delete_forever\",\n ).props(\"color=negative\")\n\n dlg.open()\n\n ui.button(\n \"删除此项目\",\n on_click=confirm_delete_project,\n icon=\"delete_forever\",\n ).props(\"color=negative outline\")\n"}}}} |