Files

1 line
69 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": "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"}}}}