| 1 | # Copyright (C) 2025 AIDC-AI |
| 2 | # |
| 3 | # Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | # you may not use this file except in compliance with the License. |
| 5 | # You may obtain a copy of the License at |
| 6 | # http://www.apache.org/licenses/LICENSE-2.0 |
| 7 | # Unless required by applicable law or agreed to in writing, software |
| 8 | # distributed under the License is distributed on an "AS IS" BASIS, |
| 9 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 10 | # See the License for the specific language governing permissions and |
| 11 | # limitations under the License. |
| 12 | |
| 13 | """ |
| 14 | System settings component for web UI |
| 15 | """ |
| 16 | |
| 17 | import streamlit as st |
| 18 | |
| 19 | from web.i18n import tr, get_language |
| 20 | from web.utils.streamlit_helpers import safe_rerun |
| 21 | from pixelle_video.config import config_manager |
| 22 | |
| 23 | |
| 24 | def render_advanced_settings(): |
| 25 | """Render system configuration (required) with 2-column layout""" |
| 26 | # Check if system is configured |
| 27 | is_configured = config_manager.validate() |
| 28 | |
| 29 | # Expand if not configured, collapse if configured |
| 30 | with st.expander(tr("settings.title"), expanded=not is_configured): |
| 31 | # 2-column layout: LLM | ComfyUI, followed by direct media API providers. |
| 32 | llm_col, comfyui_col = st.columns(2) |
| 33 | |
| 34 | # ==================================================================== |
| 35 | # Column 1: LLM Settings |
| 36 | # ==================================================================== |
| 37 | with llm_col: |
| 38 | with st.container(border=True): |
| 39 | st.markdown(f"**{tr('settings.llm.title')}**") |
| 40 | |
| 41 | # Quick preset selection |
| 42 | from pixelle_video.llm_presets import get_preset_names, get_preset, find_preset_by_base_url_and_model |
| 43 | |
| 44 | # Custom at the end |
| 45 | preset_names = get_preset_names() + ["Custom"] |
| 46 | |
| 47 | # Get current config |
| 48 | current_llm = config_manager.get_llm_config() |
| 49 | |
| 50 | # Auto-detect which preset matches current config |
| 51 | current_preset = find_preset_by_base_url_and_model( |
| 52 | current_llm["base_url"], |
| 53 | current_llm["model"] |
| 54 | ) |
| 55 | |
| 56 | # Determine default index based on current config |
| 57 | if current_preset: |
| 58 | # Current config matches a preset |
| 59 | default_index = preset_names.index(current_preset) |
| 60 | else: |
| 61 | # Current config doesn't match any preset -> Custom |
| 62 | default_index = len(preset_names) - 1 |
| 63 | |
| 64 | selected_preset = st.selectbox( |
| 65 | tr("settings.llm.quick_select"), |
| 66 | options=preset_names, |
| 67 | index=default_index, |
| 68 | help=tr("settings.llm.quick_select_help"), |
| 69 | key="llm_preset_select" |
| 70 | ) |
| 71 | |
| 72 | # Auto-fill based on selected preset |
| 73 | if selected_preset != "Custom": |
| 74 | # Preset selected |
| 75 | preset_config = get_preset(selected_preset) |
| 76 | |
| 77 | # If user switched to a different preset (not current one), clear API key |
| 78 | # If it's the same as current config, keep API key |
| 79 | if selected_preset == current_preset: |
| 80 | # Same preset as saved config: keep API key |
| 81 | default_api_key = current_llm["api_key"] |
| 82 | else: |
| 83 | # Different preset: use default_api_key if provided (e.g., Ollama), otherwise clear |
| 84 | default_api_key = preset_config.get("default_api_key", "") |
| 85 | |
| 86 | default_base_url = preset_config.get("base_url", "") |
| 87 | default_model = preset_config.get("model", "") |
| 88 | |
| 89 | # Show API key URL if available |
| 90 | if preset_config.get("api_key_url"): |
| 91 | st.markdown(f"🔑 [{tr('settings.llm.get_api_key')}]({preset_config['api_key_url']})") |
| 92 | else: |
| 93 | # Custom: show current saved config (if any) |
| 94 | default_api_key = current_llm["api_key"] |
| 95 | default_base_url = current_llm["base_url"] |
| 96 | default_model = current_llm["model"] |
| 97 | |
| 98 | st.markdown("---") |
| 99 | |
| 100 | # API Key (use unique key to force refresh when switching preset) |
| 101 | llm_api_key = st.text_input( |
| 102 | f"{tr('settings.llm.api_key')} *", |
| 103 | value=default_api_key, |
| 104 | type="password", |
| 105 | help=tr("settings.llm.api_key_help"), |
| 106 | key=f"llm_api_key_input_{selected_preset}" |
| 107 | ) |
| 108 | |
| 109 | # Base URL (use unique key based on preset to force refresh) |
| 110 | llm_base_url = st.text_input( |
| 111 | f"{tr('settings.llm.base_url')} *", |
| 112 | value=default_base_url, |
| 113 | help=tr("settings.llm.base_url_help"), |
| 114 | key=f"llm_base_url_input_{selected_preset}" |
| 115 | ) |
| 116 | |
| 117 | # Model selection with dropdown and load button |
| 118 | # Initialize session state for loaded models |
| 119 | if "llm_loaded_models" not in st.session_state: |
| 120 | st.session_state.llm_loaded_models = [] |
| 121 | |
| 122 | # Build model options: Custom option + loaded models |
| 123 | CUSTOM_MODEL_OPTION = f"✏️ {tr('settings.llm.custom_model')}" |
| 124 | model_options = [CUSTOM_MODEL_OPTION] + st.session_state.llm_loaded_models |
| 125 | |
| 126 | # Determine default selection |
| 127 | if default_model in st.session_state.llm_loaded_models: |
| 128 | default_model_index = model_options.index(default_model) |
| 129 | else: |
| 130 | # Default model not in loaded list, use custom |
| 131 | default_model_index = 0 |
| 132 | |
| 133 | # Model dropdown with load button on the right |
| 134 | model_col, load_col, test_col = st.columns([3, 1, 1]) |
| 135 | |
| 136 | with model_col: |
| 137 | selected_model_option = st.selectbox( |
| 138 | f"{tr('settings.llm.model')} *", |
| 139 | options=model_options, |
| 140 | index=default_model_index, |
| 141 | help=tr("settings.llm.model_help"), |
| 142 | key=f"llm_model_select_{selected_preset}" |
| 143 | ) |
| 144 | |
| 145 | with load_col: |
| 146 | st.markdown("<div style='height: 28px'></div>", unsafe_allow_html=True) |
| 147 | load_clicked = st.button( |
| 148 | f"🔄 {tr('settings.llm.load_models')}", |
| 149 | help=tr("settings.llm.load_models_help"), |
| 150 | key="load_models_btn", |
| 151 | use_container_width=True |
| 152 | ) |
| 153 | |
| 154 | with test_col: |
| 155 | st.markdown("<div style='height: 28px'></div>", unsafe_allow_html=True) |
| 156 | test_clicked = st.button( |
| 157 | f"🔌 {tr('settings.llm.test_connection')}", |
| 158 | help=tr("settings.llm.test_connection_help"), |
| 159 | key="test_llm_connection_btn", |
| 160 | use_container_width=True |
| 161 | ) |
| 162 | |
| 163 | # Handle load models button click |
| 164 | if load_clicked: |
| 165 | if llm_api_key and llm_base_url: |
| 166 | try: |
| 167 | from pixelle_video.utils.llm_util import fetch_available_models |
| 168 | with st.spinner(tr("settings.llm.loading_models")): |
| 169 | models = fetch_available_models(llm_api_key, llm_base_url) |
| 170 | st.session_state.llm_loaded_models = models |
| 171 | st.success(tr("settings.llm.models_loaded").replace("{count}", str(len(models)))) |
| 172 | safe_rerun() |
| 173 | except Exception as e: |
| 174 | st.error(tr("settings.llm.models_load_failed").replace("{error}", str(e))) |
| 175 | else: |
| 176 | st.warning(tr("status.llm_config_incomplete")) |
| 177 | |
| 178 | # Handle test connection button click |
| 179 | if test_clicked: |
| 180 | if llm_api_key and llm_base_url: |
| 181 | try: |
| 182 | from pixelle_video.utils.llm_util import test_llm_connection |
| 183 | with st.spinner(tr("settings.llm.loading_models")): |
| 184 | success, message, model_count = test_llm_connection(llm_api_key, llm_base_url) |
| 185 | if success: |
| 186 | st.success(tr("settings.llm.connection_success").replace("{count}", str(model_count))) |
| 187 | else: |
| 188 | st.error(tr("settings.llm.connection_failed").replace("{error}", message)) |
| 189 | except Exception as e: |
| 190 | st.error(tr("settings.llm.connection_failed").replace("{error}", str(e))) |
| 191 | else: |
| 192 | st.warning(tr("status.llm_config_incomplete")) |
| 193 | |
| 194 | # If custom option selected, show text input for custom model name |
| 195 | if selected_model_option == CUSTOM_MODEL_OPTION: |
| 196 | llm_model = st.text_input( |
| 197 | tr("settings.llm.custom_model_input"), |
| 198 | value=default_model, |
| 199 | help=tr("settings.llm.model_help"), |
| 200 | key=f"llm_custom_model_input_{selected_preset}" |
| 201 | ) |
| 202 | else: |
| 203 | llm_model = selected_model_option |
| 204 | |
| 205 | # ==================================================================== |
| 206 | # Column 2: ComfyUI Settings |
| 207 | # ==================================================================== |
| 208 | with comfyui_col: |
| 209 | with st.container(border=True): |
| 210 | st.markdown(f"**{tr('settings.comfyui.title')}**") |
| 211 | |
| 212 | # Get current configuration |
| 213 | comfyui_config = config_manager.get_comfyui_config() |
| 214 | |
| 215 | # Local/Self-hosted ComfyUI configuration |
| 216 | st.markdown(f"**{tr('settings.comfyui.local_title')}**") |
| 217 | url_col, key_col = st.columns(2) |
| 218 | with url_col: |
| 219 | comfyui_url = st.text_input( |
| 220 | tr("settings.comfyui.comfyui_url"), |
| 221 | value=comfyui_config.get("comfyui_url", "http://127.0.0.1:8188"), |
| 222 | help=tr("settings.comfyui.comfyui_url_help"), |
| 223 | key="comfyui_url_input" |
| 224 | ) |
| 225 | with key_col: |
| 226 | comfyui_api_key = st.text_input( |
| 227 | tr("settings.comfyui.comfyui_api_key"), |
| 228 | value=comfyui_config.get("comfyui_api_key", ""), |
| 229 | type="password", |
| 230 | help=tr("settings.comfyui.comfyui_api_key_help"), |
| 231 | key="comfyui_api_key_input" |
| 232 | ) |
| 233 | |
| 234 | # Test connection button |
| 235 | if st.button(tr("btn.test_connection"), key="test_comfyui", use_container_width=True): |
| 236 | try: |
| 237 | import requests |
| 238 | response = requests.get(f"{comfyui_url}/system_stats", timeout=5) |
| 239 | if response.status_code == 200: |
| 240 | st.success(tr("status.connection_success")) |
| 241 | else: |
| 242 | st.error(tr("status.connection_failed")) |
| 243 | except Exception as e: |
| 244 | st.error(f"{tr('status.connection_failed')}: {str(e)}") |
| 245 | |
| 246 | st.markdown("---") |
| 247 | |
| 248 | # RunningHub cloud configuration |
| 249 | st.markdown(f"**{tr('settings.comfyui.cloud_title')}**") |
| 250 | runninghub_api_key = st.text_input( |
| 251 | tr("settings.comfyui.runninghub_api_key"), |
| 252 | value=comfyui_config.get("runninghub_api_key", ""), |
| 253 | type="password", |
| 254 | help=tr("settings.comfyui.runninghub_api_key_help"), |
| 255 | key="runninghub_api_key_input" |
| 256 | ) |
| 257 | st.caption( |
| 258 | f"{tr('settings.comfyui.runninghub_hint')} " |
| 259 | f"[{tr('settings.comfyui.runninghub_get_api_key')}]" |
| 260 | f"(https://www.runninghub{'.cn' if get_language() == 'zh_CN' else '.ai'}/?inviteCode=bozpdlbj)" |
| 261 | ) |
| 262 | |
| 263 | # RunningHub concurrent limit and instance type (in one row) |
| 264 | limit_col, instance_col = st.columns(2) |
| 265 | with limit_col: |
| 266 | runninghub_concurrent_limit = st.number_input( |
| 267 | tr("settings.comfyui.runninghub_concurrent_limit"), |
| 268 | min_value=1, |
| 269 | max_value=10, |
| 270 | value=comfyui_config.get("runninghub_concurrent_limit", 1), |
| 271 | help=tr("settings.comfyui.runninghub_concurrent_limit_help"), |
| 272 | key="runninghub_concurrent_limit_input" |
| 273 | ) |
| 274 | with instance_col: |
| 275 | # Check if instance type is "plus" (48G VRAM enabled) |
| 276 | current_instance_type = comfyui_config.get("runninghub_instance_type") or "" |
| 277 | is_plus_enabled = current_instance_type == "plus" |
| 278 | # Instance type options with i18n |
| 279 | instance_options = [ |
| 280 | tr("settings.comfyui.runninghub_instance_24g"), |
| 281 | tr("settings.comfyui.runninghub_instance_48g"), |
| 282 | ] |
| 283 | runninghub_instance_type_display = st.selectbox( |
| 284 | tr("settings.comfyui.runninghub_instance_type"), |
| 285 | options=instance_options, |
| 286 | index=1 if is_plus_enabled else 0, |
| 287 | help=tr("settings.comfyui.runninghub_instance_type_help"), |
| 288 | key="runninghub_instance_type_input" |
| 289 | ) |
| 290 | # Convert display value back to actual value |
| 291 | runninghub_48g_enabled = runninghub_instance_type_display == tr("settings.comfyui.runninghub_instance_48g") |
| 292 | |
| 293 | # ==================================================================== |
| 294 | # Direct API media providers |
| 295 | # ==================================================================== |
| 296 | zh = get_language() == "zh_CN" |
| 297 | api_cfg = config_manager.get_api_providers_config() |
| 298 | common_cfg = api_cfg.get("common", {}) |
| 299 | openai_cfg = api_cfg.get("openai", {}) |
| 300 | dashscope_cfg = api_cfg.get("dashscope", {}) |
| 301 | ark_cfg = api_cfg.get("ark", {}) |
| 302 | kling_cfg = api_cfg.get("kling", {}) |
| 303 | default_api_base_urls = { |
| 304 | "openai": "https://api.openai.com/v1", |
| 305 | "dashscope": "https://dashscope.aliyuncs.com/api/v1", |
| 306 | "ark": "https://ark.cn-beijing.volces.com/api/v3", |
| 307 | "kling": "https://api-beijing.klingai.com", |
| 308 | } |
| 309 | |
| 310 | with st.container(border=True): |
| 311 | st.markdown("**🧩 API 媒体模型**" if zh else "**🧩 API Media Models**") |
| 312 | st.caption( |
| 313 | "用于直连图像/视频模型,不影响上方 LLM 与 ComfyUI/RunningHub 配置。" |
| 314 | if zh |
| 315 | else "Used for direct image/video model calls. This does not affect the LLM or ComfyUI/RunningHub settings above." |
| 316 | ) |
| 317 | |
| 318 | common_col, proxy_col = st.columns(2) |
| 319 | with common_col: |
| 320 | api_print_model_input = st.checkbox( |
| 321 | "打印模型请求参数" if zh else "Print model request parameters", |
| 322 | value=bool(common_cfg.get("print_model_input", False)), |
| 323 | help=( |
| 324 | "调试用。开启后会在终端打印发送给图像/视频模型的 prompt、模型名和输入文件路径。" |
| 325 | if zh |
| 326 | else "For debugging. Prints prompts, model names and input file paths sent to image/video models." |
| 327 | ), |
| 328 | key="api_media_print_model_input", |
| 329 | ) |
| 330 | with proxy_col: |
| 331 | api_local_proxy = st.text_input( |
| 332 | "本地代理(可选)" if zh else "Local proxy (optional)", |
| 333 | value=common_cfg.get("local_proxy", ""), |
| 334 | placeholder="http://127.0.0.1:9090", |
| 335 | help=( |
| 336 | "仅部分提供商会使用,例如 OpenAI 图像模型。留空表示不使用代理。" |
| 337 | if zh |
| 338 | else "Only used by some providers, such as OpenAI image models. Leave blank to disable." |
| 339 | ), |
| 340 | key="api_media_local_proxy", |
| 341 | ) |
| 342 | |
| 343 | st.markdown("---") |
| 344 | |
| 345 | provider_col1, provider_col2 = st.columns(2) |
| 346 | with provider_col1: |
| 347 | st.markdown("**OpenAI / GPT Image**") |
| 348 | api_openai_use_proxy = st.checkbox( |
| 349 | "OpenAI 启用代理" if zh else "Use proxy for OpenAI", |
| 350 | value=bool(openai_cfg.get("use_proxy", False)), |
| 351 | key="api_media_openai_use_proxy", |
| 352 | ) |
| 353 | api_openai_key = st.text_input( |
| 354 | "OpenAI API Key", |
| 355 | value=openai_cfg.get("api_key", ""), |
| 356 | type="password", |
| 357 | key="api_media_openai_key", |
| 358 | ) |
| 359 | api_openai_base_url = st.text_input( |
| 360 | "OpenAI Base URL", |
| 361 | value=openai_cfg.get("base_url") or default_api_base_urls["openai"], |
| 362 | placeholder="https://api.openai.com/v1", |
| 363 | key="api_media_openai_base_url", |
| 364 | ) |
| 365 | |
| 366 | st.markdown("**DashScope / Wan / HappyHorse**") |
| 367 | api_dashscope_use_proxy = st.checkbox( |
| 368 | "DashScope 启用代理" if zh else "Use proxy for DashScope", |
| 369 | value=bool(dashscope_cfg.get("use_proxy", False)), |
| 370 | key="api_media_dashscope_use_proxy", |
| 371 | ) |
| 372 | api_dashscope_key = st.text_input( |
| 373 | "DashScope API Key", |
| 374 | value=dashscope_cfg.get("api_key", ""), |
| 375 | type="password", |
| 376 | key="api_media_dashscope_key", |
| 377 | ) |
| 378 | api_dashscope_base_url = st.text_input( |
| 379 | "DashScope Base URL", |
| 380 | value=dashscope_cfg.get("base_url") or default_api_base_urls["dashscope"], |
| 381 | placeholder="https://dashscope.aliyuncs.com/api/v1", |
| 382 | key="api_media_dashscope_base_url", |
| 383 | ) |
| 384 | |
| 385 | with provider_col2: |
| 386 | st.markdown("**Volcengine ARK / Seedream / Seedance**") |
| 387 | api_ark_use_proxy = st.checkbox( |
| 388 | "ARK 启用代理" if zh else "Use proxy for ARK", |
| 389 | value=bool(ark_cfg.get("use_proxy", False)), |
| 390 | key="api_media_ark_use_proxy", |
| 391 | ) |
| 392 | api_ark_key = st.text_input( |
| 393 | "ARK API Key", |
| 394 | value=ark_cfg.get("api_key", ""), |
| 395 | type="password", |
| 396 | key="api_media_ark_key", |
| 397 | ) |
| 398 | api_ark_base_url = st.text_input( |
| 399 | "ARK Base URL", |
| 400 | value=ark_cfg.get("base_url") or default_api_base_urls["ark"], |
| 401 | placeholder="https://ark.cn-beijing.volces.com/api/v3", |
| 402 | key="api_media_ark_base_url", |
| 403 | ) |
| 404 | |
| 405 | st.markdown("**Kling AI / 可灵**") |
| 406 | api_kling_use_proxy = st.checkbox( |
| 407 | "Kling 启用代理" if zh else "Use proxy for Kling", |
| 408 | value=bool(kling_cfg.get("use_proxy", False)), |
| 409 | key="api_media_kling_use_proxy", |
| 410 | ) |
| 411 | api_kling_base_url = st.text_input( |
| 412 | "Kling Base URL", |
| 413 | value=kling_cfg.get("base_url") or default_api_base_urls["kling"], |
| 414 | placeholder="https://api-beijing.klingai.com", |
| 415 | key="api_media_kling_base_url", |
| 416 | ) |
| 417 | api_kling_access_key = st.text_input( |
| 418 | "Kling Access Key", |
| 419 | value=kling_cfg.get("access_key", ""), |
| 420 | type="password", |
| 421 | key="api_media_kling_access_key", |
| 422 | ) |
| 423 | api_kling_secret_key = st.text_input( |
| 424 | "Kling Secret Key", |
| 425 | value=kling_cfg.get("secret_key", ""), |
| 426 | type="password", |
| 427 | key="api_media_kling_secret_key", |
| 428 | ) |
| 429 | |
| 430 | # ==================================================================== |
| 431 | # Action Buttons (full width at bottom) |
| 432 | # ==================================================================== |
| 433 | st.markdown("---") |
| 434 | |
| 435 | col1, col2 = st.columns(2) |
| 436 | with col1: |
| 437 | if st.button(tr("btn.save_config"), use_container_width=True, key="save_config_btn"): |
| 438 | try: |
| 439 | # Validate and save LLM configuration |
| 440 | if not (llm_api_key and llm_base_url and llm_model): |
| 441 | st.error(tr("status.llm_config_incomplete")) |
| 442 | else: |
| 443 | config_manager.set_llm_config(llm_api_key, llm_base_url, llm_model) |
| 444 | |
| 445 | # Save ComfyUI configuration (optional fields, always save what's provided) |
| 446 | # Convert checkbox to instance type: True -> "plus", False -> "" |
| 447 | instance_type = "plus" if runninghub_48g_enabled else "" |
| 448 | config_manager.set_comfyui_config( |
| 449 | comfyui_url=comfyui_url if comfyui_url else None, |
| 450 | comfyui_api_key=comfyui_api_key if comfyui_api_key else None, |
| 451 | runninghub_api_key=runninghub_api_key if runninghub_api_key else None, |
| 452 | runninghub_concurrent_limit=int(runninghub_concurrent_limit), |
| 453 | runninghub_instance_type=instance_type |
| 454 | ) |
| 455 | |
| 456 | # Save direct image/video API provider configuration. |
| 457 | config_manager.set_api_provider_config("common", { |
| 458 | "print_model_input": bool(api_print_model_input), |
| 459 | "local_proxy": api_local_proxy or "", |
| 460 | }) |
| 461 | config_manager.set_api_provider_config("openai", { |
| 462 | "api_key": api_openai_key or "", |
| 463 | "base_url": api_openai_base_url or "", |
| 464 | "use_proxy": bool(api_openai_use_proxy), |
| 465 | }) |
| 466 | config_manager.set_api_provider_config("dashscope", { |
| 467 | "api_key": api_dashscope_key or "", |
| 468 | "base_url": api_dashscope_base_url or "", |
| 469 | "use_proxy": bool(api_dashscope_use_proxy), |
| 470 | }) |
| 471 | config_manager.set_api_provider_config("ark", { |
| 472 | "api_key": api_ark_key or "", |
| 473 | "base_url": api_ark_base_url or "", |
| 474 | "use_proxy": bool(api_ark_use_proxy), |
| 475 | }) |
| 476 | config_manager.set_api_provider_config("kling", { |
| 477 | "base_url": api_kling_base_url or "", |
| 478 | "access_key": api_kling_access_key or "", |
| 479 | "secret_key": api_kling_secret_key or "", |
| 480 | "use_proxy": bool(api_kling_use_proxy), |
| 481 | }) |
| 482 | |
| 483 | # Only save to file if LLM config is valid |
| 484 | if llm_api_key and llm_base_url and llm_model: |
| 485 | config_manager.save() |
| 486 | st.success(tr("status.config_saved")) |
| 487 | safe_rerun() |
| 488 | except Exception as e: |
| 489 | st.error(f"{tr('status.save_failed')}: {str(e)}") |
| 490 | |
| 491 | with col2: |
| 492 | if st.button(tr("btn.reset_config"), use_container_width=True, key="reset_config_btn"): |
| 493 | # Reset to default |
| 494 | from pixelle_video.config.schema import PixelleVideoConfig |
| 495 | config_manager.config = PixelleVideoConfig() |
| 496 | config_manager.save() |
| 497 | st.success(tr("status.config_reset")) |
| 498 | safe_rerun() |
| 499 |