Spaces:
Sleeping
Sleeping
Download app/ui/setup_screen.py from SkillArbitrage/UpworkAutomation: direct link, hf CLI and curl.
- Browser
- Download file 42.9 kB
-
https://huggingface.co/spaces/SkillArbitrage/UpworkAutomation/resolve/main/app/ui/setup_screen.py
- Command line
-
hf download hf://spaces/SkillArbitrage/UpworkAutomation/app/ui/setup_screen.py
-
curl -L -o setup_screen.py https://huggingface.co/spaces/SkillArbitrage/UpworkAutomation/resolve/main/app/ui/setup_screen.py
42.9 kB
| """Screen 1: Setup — configure the AI service and run the API check. | |
| This is the only onboarding step a user needs. They pick a provider, model, | |
| and (vision) model and paste their API key directly into the **Configure AI | |
| Service** form; clicking *Save Configuration* applies those values to the | |
| session and, when key storage is configured, also remembers them across visits | |
| by saving them to Supabase **encrypted with this browser's invisible tag** | |
| (Supabase only ever holds ciphertext; the key is never written to ``os.environ`` | |
| and never readable by another visitor). They then click *Run API Check* to | |
| confirm the service is reachable, which unlocks the rest of the flow. On a | |
| return visit the saved config is decrypted and restored automatically, so they | |
| skip Setup entirely. See :mod:`app.services.key_store` / `browser_tag`. | |
| The **Interview key** field is deliberately outside that provider picker. The | |
| guided interview runs on the provider configured here by default; a key in | |
| this field moves it onto :data:`app.config.INTERVIEW_PROVIDER` instead, which | |
| keeps the interview's calls off the main API (see | |
| ``llm_client.interview_route``). It is optional, so the API check below never | |
| consults it and a dossier user with no interview key passes Setup exactly as | |
| before. It is saved, restored, and | |
| wiped through the same encrypted per-browser store as the main key, because it | |
| lives in its provider's own ``*_API_KEY`` slot. | |
| Internal data-handling rules (kept in code/README, not surfaced as a | |
| required UI step): | |
| - Original dossier files stay on the user's machine. | |
| - Only selected extracted text from the dossier and screenshot may be | |
| sent to the configured LLM API. | |
| - Raw dossier text, screenshot bytes, generated proposals, and API | |
| keys are never logged. | |
| Secret-safety: both key fields (the provider API key and the separate | |
| interview key) are masked, a saved key is shown only as a | |
| ``sk-****abcd`` mask (never prefilled or echoed), and the short, actionable | |
| error messages shown to every user never include API keys, organization IDs, | |
| raw provider errors, stack traces, or internal task names. The exact API | |
| status string and the configuration detail block only appear inside the | |
| Developer Debug Panel when ``SHOW_DEBUG_PANEL=true``. | |
| """ | |
| from __future__ import annotations | |
| import hashlib | |
| import streamlit as st | |
| from app import config | |
| from app.config import ( | |
| GROQ_VISION_UNSUPPORTED_MESSAGE, | |
| PROVIDER_OPTIONS, | |
| VISION_PROVIDER_OPTIONS, | |
| VISION_SAME_AS_LLM, | |
| apply_session_overrides, | |
| build_env_updates, | |
| get_settings, | |
| reload_settings, | |
| ) | |
| from app.models.provider_models import get_text_models, get_vision_models | |
| from app.services import browser_tag, key_store | |
| from app.services.api_gate import ApiGateError, run_capability_test | |
| from app.ui import theme | |
| # Label for the "type the model name myself" choice appended to every model | |
| # dropdown. Selecting it reveals a free-text box, so a model that isn't in the | |
| # curated suggestion list (e.g. a brand-new release) stays usable — the list is | |
| # guidance, not a hard allow-list (see app.models.provider_models). | |
| _MODEL_CUSTOM_LABEL = "Custom — type a model name…" | |
| # Leading choice in the Vision Model dropdown: leave the model blank so the app | |
| # reuses the LLM model when that model can read images. | |
| _VISION_REUSE_LABEL = "Reuse the LLM model (when it supports images)" | |
| MINIMAL_DATA_NOTICE = ( | |
| "Files are processed locally; selected extracted text may be sent " | |
| "to the configured API." | |
| ) | |
| # Session-state keys cleared after Save / "Refresh API Config" / Clear | |
| # Credentials so a changed configuration is re-checked cleanly without a | |
| # server restart. | |
| # | |
| # ``interview_api_check`` is the interview screen's cached verdict on the | |
| # interview provider: it is probed once per session and never again, so a key | |
| # saved or cleared after that probe would otherwise leave the interview | |
| # reporting a stale answer for the rest of the session. Dropping the key is | |
| # the whole invalidation — nothing re-seeds it, so the next visit re-probes. | |
| _API_STATE_KEYS: tuple[str, ...] = ( | |
| "api_status", | |
| "api_ok", | |
| "interview_api_check", | |
| "interview_route_pref", | |
| "interview_route_note", | |
| ) | |
| # One-shot flag set right after a successful save; consumed on the next run to | |
| # flash a confirmation and clear the typed key out of widget state. | |
| _JUST_SAVED_FLAG = "_config_just_saved" | |
| # One-shot flag set right after Clear Credentials; consumed on the next run to | |
| # flash a confirmation that every saved key was wiped, or an error when the | |
| # copy stored for this browser could not be deleted. | |
| _CREDS_CLEARED_FLAG = "_creds_cleared" | |
| # One-shot flag set when Save Configuration could not store the config for | |
| # this browser; consumed on the next run to say the key is session-only. | |
| _REMEMBER_FAILED_FLAG = "_config_remember_failed" | |
| # Whether this session's keys are really stored for this browser right now: | |
| # set by every save to key storage, cleared by Clear Credentials. Until one of | |
| # those runs, a config restored at the start of the session counts as stored | |
| # (``_config_restored``, set by main). | |
| _REMEMBERED_FLAG = "_config_remembered" | |
| # Browsers whose stored config Clear Credentials could not delete, as salted | |
| # hashes of their tag (never the tag itself). Process-wide on purpose: the | |
| # next visit from that browser is a new session, and it must finish the | |
| # delete instead of restoring the key the user asked to forget. A hash is | |
| # only known to its own browser, so no visitor can touch another's entry. | |
| _PENDING_FORGET: set[str] = set() | |
| _FORGET_SALT = b"upwork-strategist::pending-forget::v1" | |
| # Setup-form widget keys. Kept distinct from the gate-state keys above so the | |
| # form can be prefilled and cleared independently. | |
| _FORM_PROVIDER = "cfg_llm_provider" | |
| _FORM_MODEL = "cfg_llm_model" | |
| _FORM_API_KEY = "cfg_api_key" | |
| _FORM_INTERVIEW_KEY = "cfg_interview_api_key" | |
| _FORM_VISION_PROVIDER = "cfg_vision_provider" | |
| _FORM_VISION_MODEL = "cfg_vision_model" | |
| _FORM_VISION_KEY = "cfg_vision_api_key" | |
| _FORM_ALLOW_FALLBACK = "cfg_allow_provider_fallback" | |
| _FORM_ALLOW_LOCAL = "cfg_allow_local_placeholders" | |
| _FORM_SHOW_DEBUG = "cfg_show_debug_panel" | |
| _FORM_MAX_CHARS = "cfg_max_proposal_context_chars" | |
| _FORM_MAX_EVIDENCE = "cfg_max_proposal_evidence_points" | |
| _FORM_MAX_TOKENS = "cfg_proposal_max_output_tokens" | |
| _FORM_LOG_DAYS = "cfg_log_retention_days" | |
| # Safe, actionable, user-facing messages keyed by gate status. These are | |
| # shown to ALL users (debug or not): they contain no key, no org id, no raw | |
| # provider text, no task name. The debug panel adds the raw status on top. | |
| _SAFE_STATUS_MESSAGE: dict[str, str] = { | |
| ApiGateError.MODEL_NOT_ACCEPTED: "The provider rejected this model. Pick a different model, save the configuration, and check again.", | |
| ApiGateError.ACCESS_DENIED: "Your key is not allowed to use this model. Check your provider permissions or choose another model.", | |
| ApiGateError.NO_API_ADDED: ( | |
| "No API key found. Paste your key in the API Key box above and click " | |
| "Save Configuration, then run this check again." | |
| ), | |
| ApiGateError.MODEL_NOT_CONFIGURED: ( | |
| "No model is set. Pick a model for your provider above and click Save " | |
| "Configuration." | |
| ), | |
| ApiGateError.INVALID_API_KEY: ( | |
| "Your API key was rejected. Common causes: (1) a typo or only part of " | |
| "the key was pasted, (2) the key is for a different provider than the " | |
| "one selected above (e.g. an OpenAI key with Anthropic selected), or " | |
| "(3) the key was revoked. Re-copy the full key, confirm the provider " | |
| "matches, Save Configuration, and try again." | |
| ), | |
| ApiGateError.API_QUOTA_EXCEEDED: ( | |
| "Your API key has no remaining credit (quota/billing exhausted). Add " | |
| "credits or a payment method in your provider's dashboard " | |
| "(e.g. console.anthropic.com or platform.openai.com), then try again." | |
| ), | |
| ApiGateError.API_CONNECTION_FAILED: ( | |
| "Couldn't reach the AI service. Check your internet connection (and any " | |
| "firewall/VPN), then try again." | |
| ), | |
| ApiGateError.VISION_MODEL_NOT_AVAILABLE: ( | |
| "The selected model can't read screenshots. Use OpenAI, Anthropic, or " | |
| "Gemini (not Groq) and keep Vision Provider on 'same_as_llm', then Save " | |
| "Configuration." | |
| ), | |
| } | |
| # Fallback for any unmapped status — still safe and generic. | |
| _GENERIC_SAFE_MESSAGE = ( | |
| "The AI service couldn't be verified. Check your key, provider, and " | |
| "internet connection, then try again." | |
| ) | |
| def _safe_message(status: str) -> str: | |
| """Return a clean, leak-free message for a gate status string.""" | |
| return _SAFE_STATUS_MESSAGE.get(status, _GENERIC_SAFE_MESSAGE) | |
| # --------------------------------------------------------------------------- | |
| # Configure AI Service form | |
| # --------------------------------------------------------------------------- | |
| def _initial_vision_provider(settings) -> str: | |
| """Map saved VISION_PROVIDER to a dropdown choice. | |
| Anything not in the vision dropdown (blank, ``same_as_llm``, or an | |
| unsupported value such as a hand-edited ``groq``) falls back to | |
| ``same_as_llm`` so the form never shows an option that isn't offered. | |
| """ | |
| raw = (getattr(settings, "vision_provider", None) or "").strip().lower() | |
| return raw if raw in ("openai", "anthropic", "gemini") else VISION_SAME_AS_LLM | |
| def _seed_form_defaults(settings) -> None: | |
| """Prefill the form widgets from current settings — never the API key. | |
| Uses ``setdefault`` so a user's in-progress edits survive reruns. Both key | |
| fields are always seeded blank: a saved key is shown only as a mask, never | |
| prefilled or revealed. | |
| """ | |
| ss = st.session_state | |
| provider = settings.llm_provider if settings.llm_provider in PROVIDER_OPTIONS else "openai" | |
| ss.setdefault(_FORM_PROVIDER, provider) | |
| ss.setdefault(_FORM_API_KEY, "") | |
| ss.setdefault(_FORM_INTERVIEW_KEY, "") | |
| ss.setdefault(_FORM_VISION_PROVIDER, _initial_vision_provider(settings)) | |
| # The LLM/Vision model dropdowns seed their own state from the saved model | |
| # whenever they have none (see _render_model_dropdown): their options | |
| # depend on the live provider selection, so they can't be seeded with a | |
| # flat setdefault. | |
| ss.setdefault(_FORM_ALLOW_FALLBACK, bool(settings.allow_provider_fallback)) | |
| ss.setdefault(_FORM_ALLOW_LOCAL, bool(settings.allow_local_placeholders)) | |
| ss.setdefault(_FORM_SHOW_DEBUG, bool(settings.show_debug_panel)) | |
| ss.setdefault(_FORM_MAX_CHARS, int(settings.max_proposal_context_chars)) | |
| ss.setdefault(_FORM_MAX_EVIDENCE, int(settings.max_proposal_evidence_points)) | |
| ss.setdefault(_FORM_MAX_TOKENS, int(settings.proposal_max_output_tokens)) | |
| ss.setdefault(_FORM_LOG_DAYS, int(config.LOG_RETENTION_DAYS)) | |
| def _interview_provider() -> str: | |
| """The provider the guided interview always runs on.""" | |
| raw = str(getattr(config, "INTERVIEW_PROVIDER", "groq") or "").strip().lower() | |
| return raw or "groq" | |
| def _interview_provider_label() -> str: | |
| """The interview provider's name as shown to the user, e.g. ``Groq``.""" | |
| return _interview_provider().title() | |
| def _provider_label(provider: str) -> str: | |
| """A provider's name as shown to the user, e.g. ``OpenAI``.""" | |
| return {"openai": "OpenAI"}.get(provider, (provider or "").title()) | |
| def _interview_key_env() -> str: | |
| """Env name holding the interview key, e.g. ``GROQ_API_KEY``. | |
| The interview runs on its own provider rather than the one picked in the | |
| form, so its key belongs in *that* provider's slot. Reusing the standard | |
| slot is what makes this key persist, restore, and clear through exactly | |
| the same paths as the main one — it is already one of the names | |
| ``_persist_config_to_store`` saves and ``clear_session_api_keys`` blanks. | |
| Falls back to Groq's slot if the interview provider is ever pointed at | |
| something with no key slot of its own. | |
| """ | |
| name = f"{_interview_provider().upper()}_API_KEY" | |
| return name if name in config._PROVIDER_API_KEY_ENV else "GROQ_API_KEY" | |
| def _interview_key_masked(settings) -> str: | |
| """Masked status of the saved interview key (or "") — never the raw key.""" | |
| return config.mask_secret( | |
| getattr(settings, f"{_interview_provider()}_api_key", None) | |
| ) | |
| def _saved_key_status(settings) -> str: | |
| """Masked status of the saved key for the active provider (or ""). | |
| Uses the central, provider-aware mask so Groq/Gemini keys are shown | |
| correctly and the raw key is never exposed. | |
| """ | |
| return settings.active_api_key_masked | |
| def _handle_config_save( | |
| *, | |
| provider: str, | |
| model: str, | |
| api_key: str, | |
| interview_api_key: str, | |
| vision_provider: str, | |
| vision_model: str, | |
| allow_provider_fallback: object, | |
| allow_local_placeholders: object, | |
| show_debug_panel: object, | |
| max_proposal_context_chars: object, | |
| max_proposal_evidence_points: object, | |
| proposal_max_output_tokens: object, | |
| log_retention_days: object, | |
| vision_api_key: str = "", | |
| ) -> None: | |
| """Write the form values to ``.env``, reload, and re-lock the API gate.""" | |
| updates = build_env_updates( | |
| llm_provider=provider, | |
| llm_model=model, | |
| api_key=api_key, | |
| vision_provider=vision_provider, | |
| vision_model=vision_model, | |
| allow_provider_fallback=allow_provider_fallback, | |
| allow_local_placeholders=allow_local_placeholders, | |
| show_debug_panel=show_debug_panel, | |
| max_proposal_context_chars=max_proposal_context_chars, | |
| max_proposal_evidence_points=max_proposal_evidence_points, | |
| proposal_max_output_tokens=proposal_max_output_tokens, | |
| log_retention_days=log_retention_days, | |
| vision_api_key=vision_api_key, | |
| ) | |
| # The interview runs on its own provider, so its key is written to that | |
| # provider's slot no matter which provider is selected above. Blank means | |
| # "keep the key already saved", exactly as the main key field does. When | |
| # the selected provider IS the interview provider the two fields would | |
| # address the same slot, so the interview field is hidden and ignored: | |
| # the main key already serves the interview, and a second key must | |
| # never silently replace it. | |
| interview_key = (interview_api_key or "").strip() | |
| if interview_key and (provider or "").strip().lower() != _interview_provider(): | |
| updates[_interview_key_env()] = interview_key | |
| # ``updates`` holds the raw key — applied to this browser session's memory | |
| # (never on disk or in os.environ). Never logged. | |
| apply_session_overrides(updates) | |
| # Remember it for next time: persist the config to Supabase, ENCRYPTED with | |
| # this browser's invisible tag (Supabase only ever holds ciphertext). Fully | |
| # fail-safe — if storage isn't configured or the call fails, the app simply | |
| # behaves session-only; a failed save is reported, never hidden. | |
| remembered = _persist_config_to_store() | |
| # Clear stale gate state so the next step re-locks until the new config | |
| # passes the API check. | |
| for key in _API_STATE_KEYS: | |
| st.session_state.pop(key, None) | |
| st.session_state["api_status"] = None | |
| st.session_state["api_ok"] = False | |
| st.session_state[_JUST_SAVED_FLAG] = True | |
| st.session_state[_REMEMBER_FAILED_FLAG] = remembered is False | |
| st.rerun() | |
| def _persist_config_to_store() -> bool | None: | |
| """Save the current session config to Supabase, encrypted per-browser. | |
| Returns True once it is stored and False when storing it failed; None | |
| when there is nothing to do because key storage isn't configured or there | |
| is no API key to remember yet. Never raises (see | |
| :mod:`app.services.key_store`). | |
| """ | |
| if not key_store.is_configured(): | |
| return None | |
| overrides = config.export_session_overrides() | |
| # Provider, model and key choices only: operator-only settings such as | |
| # SHOW_DEBUG_PANEL and the proposal limits must keep following the server. | |
| payload = { | |
| k: v | |
| for k, v in overrides.items() | |
| if isinstance(v, str) and k in config.REMEMBERED_CONFIG_KEYS | |
| } | |
| if not any(payload.get(name) for name in config._PROVIDER_API_KEY_ENV): | |
| return None # nothing worth remembering yet | |
| tag = browser_tag.get_or_create_tag() | |
| saved = bool(tag and key_store.save_config(tag, payload)) | |
| if saved: | |
| # The stored copy is now the one the user wants kept, so a delete left | |
| # pending by an earlier Clear Credentials must not remove it. | |
| _PENDING_FORGET.discard(_forget_id(tag)) | |
| st.session_state[_REMEMBERED_FLAG] = saved | |
| return saved | |
| def _forget_id(tag: str) -> str: | |
| """The pending-delete entry for ``tag`` (a salted hash, never the tag).""" | |
| return hashlib.sha256(_FORGET_SALT + tag.encode("utf-8")).hexdigest() | |
| def _forget_saved_config(tag: str) -> bool: | |
| """Delete the config stored for ``tag``; keep the delete pending on failure.""" | |
| if key_store.delete_config(tag): | |
| _PENDING_FORGET.discard(_forget_id(tag)) | |
| return True | |
| _PENDING_FORGET.add(_forget_id(tag)) | |
| return False | |
| def _forget_pending() -> bool: | |
| """True while a failed Clear Credentials delete for this browser is pending.""" | |
| if not (_PENDING_FORGET and key_store.is_configured()): | |
| return False | |
| tag = browser_tag.get_or_create_tag() | |
| return bool(tag) and _forget_id(tag) in _PENDING_FORGET | |
| def finish_pending_forget(tag: str) -> bool: | |
| """Retry a failed Clear Credentials delete for ``tag``, if there is one. | |
| Called before a saved config is restored. Returns True while the delete | |
| is still pending, in which case nothing may be restored. | |
| """ | |
| if _forget_id(tag) not in _PENDING_FORGET: | |
| return False | |
| return not _forget_saved_config(tag) | |
| def _key_remembered() -> bool: | |
| """True when this session's keys are really stored for this browser.""" | |
| ss = st.session_state | |
| return bool( | |
| key_store.is_configured() | |
| and ss.get(_REMEMBERED_FLAG, ss.get("_config_restored")) | |
| and browser_tag.persistence_confirmed() | |
| ) | |
| def _key_storage_note() -> str: | |
| """How a saved key is kept, for the key fields' help text.""" | |
| if key_store.is_configured(): | |
| return "Stored encrypted. Remembering it across visits requires browser cookies; if blocked, open the app directly instead of in an embedded page." | |
| return "Kept for this browser session only." | |
| def _handle_remove_interview_key() -> None: | |
| """Forget only the interview key, then rerun. | |
| The main key and the API check are untouched, so the user stays past | |
| Setup; the interview simply goes back to the main API. Only offered | |
| when the main provider is not the interview's own provider, so this can | |
| never blank the main key. | |
| """ | |
| apply_session_overrides({_interview_key_env(): ""}) | |
| _persist_config_to_store() | |
| for key in ("interview_api_check", "interview_route_pref", "interview_route_note"): | |
| st.session_state.pop(key, None) | |
| st.session_state.pop(_FORM_INTERVIEW_KEY, None) | |
| st.rerun() | |
| def remember_config() -> None: | |
| """Public entry to :func:`_persist_config_to_store` for other screens. | |
| The interview screen takes a Groq key of its own and must remember it | |
| through exactly this path, so a key entered there survives a revisit | |
| the same way one entered here does. | |
| """ | |
| _persist_config_to_store() | |
| def _handle_clear_credentials() -> None: | |
| """Wipe every API key from this session's memory and re-lock the API gate.""" | |
| config.clear_session_api_keys() | |
| # Also forget the encrypted copy stored for this browser, so "Clear | |
| # Credentials" truly removes it everywhere. A delete that fails is | |
| # reported and stays pending, to be retried before that copy could be | |
| # restored on a later visit. | |
| forgotten = True | |
| if key_store.is_configured(): | |
| tag = browser_tag.get_or_create_tag() | |
| if tag: | |
| forgotten = _forget_saved_config(tag) | |
| # Drop any typed key from widget state so nothing is re-rendered, then | |
| # re-lock the gate: with no key saved, the rest of the flow must stay | |
| # locked until a new key is saved and the check passes again. | |
| st.session_state.pop(_FORM_API_KEY, None) | |
| st.session_state.pop(_FORM_INTERVIEW_KEY, None) | |
| st.session_state.pop(_FORM_VISION_KEY, None) | |
| for key in _API_STATE_KEYS: | |
| st.session_state.pop(key, None) | |
| st.session_state["api_status"] = None | |
| st.session_state["api_ok"] = False | |
| st.session_state[_REMEMBERED_FLAG] = False | |
| st.session_state[_CREDS_CLEARED_FLAG] = forgotten | |
| st.rerun() | |
| def _render_model_dropdown( | |
| *, | |
| label: str, | |
| options: list[str], | |
| state_prefix: str, | |
| saved_model: str, | |
| help_text: str, | |
| reuse_label: str | None = None, | |
| ) -> str: | |
| """Render a provider-aware model dropdown and return the chosen model. | |
| ``options`` is the curated model list for the *currently selected* provider | |
| (from :mod:`app.models.provider_models`). A *Custom* choice is always | |
| appended; picking it reveals a free-text box so a model that isn't in the | |
| list is still usable. ``reuse_label`` — passed for the Vision Model only — | |
| adds a leading "leave blank" choice that returns ``""`` so the app reuses | |
| the LLM model. | |
| State is namespaced under ``state_prefix`` (a selectbox key and a | |
| custom-text key). The saved model survives reruns, and a provider switch | |
| can never leave the widget pointing at an option the new provider lacks — | |
| the stored choice is reconciled against the live option list *before* the | |
| widget is built, so Streamlit never sees a stale value. | |
| """ | |
| ss = st.session_state | |
| sel_key = f"{state_prefix}__select" | |
| custom_key = f"{state_prefix}__custom" | |
| choices: list[str] = [] | |
| if reuse_label is not None: | |
| choices.append(reuse_label) | |
| choices.extend(options) | |
| choices.append(_MODEL_CUSTOM_LABEL) | |
| # Seed from the saved model whenever the widget has no state: on the first | |
| # visit, and again after a visit to another screen, because Streamlit | |
| # drops the state of widgets that were not drawn. Match it to a curated | |
| # option, fall back to the (prefilled) Custom box for a hand-entered | |
| # model, else pick a sensible first choice. | |
| if sel_key not in ss: | |
| saved = (saved_model or "").strip() | |
| if saved and saved in options: | |
| ss[sel_key] = saved | |
| elif saved: | |
| ss[sel_key] = _MODEL_CUSTOM_LABEL | |
| ss[custom_key] = saved | |
| else: | |
| ss[sel_key] = choices[0] | |
| # A provider switch can leave the stored choice pointing at a model the new | |
| # provider doesn't offer — reset to the first choice (the new provider's | |
| # default, or the reuse choice for vision) before the widget is built. | |
| if ss.get(sel_key) not in choices: | |
| ss[sel_key] = choices[0] | |
| choice = st.selectbox(label, choices, key=sel_key, help=help_text) | |
| if choice == _MODEL_CUSTOM_LABEL: | |
| return st.text_input( | |
| f"{label} — custom name", | |
| key=custom_key, | |
| placeholder="e.g. gpt-4.1", | |
| help="Enter the exact model id from your provider account.", | |
| ).strip() | |
| if reuse_label is not None and choice == reuse_label: | |
| return "" | |
| return choice | |
| def _render_config_form(settings) -> None: | |
| """The 'Configure AI Service' card: provider/model/key inputs + Save.""" | |
| # One-time post-save housekeeping: clear the typed key from widget state | |
| # (so it is never re-rendered) and remember to flash a confirmation. | |
| just_saved = bool(st.session_state.pop(_JUST_SAVED_FLAG, False)) | |
| if just_saved: | |
| st.session_state.pop(_FORM_API_KEY, None) | |
| st.session_state.pop(_FORM_INTERVIEW_KEY, None) | |
| st.session_state.pop(_FORM_VISION_KEY, None) | |
| remember_failed = bool(st.session_state.pop(_REMEMBER_FAILED_FLAG, False)) | |
| # One-shot result of Clear Credentials: True when every saved key was | |
| # wiped, False when the copy stored for this browser is still there. | |
| creds_cleared = st.session_state.pop(_CREDS_CLEARED_FLAG, None) | |
| # Where a saved key lives, for the captions under the key fields. | |
| kept = "remembered on this device" if _key_remembered() else "in use" | |
| _seed_form_defaults(settings) | |
| with st.container(border=True, key="ups_panel_setup_1"): | |
| theme.section_label("Configure AI Service") | |
| if key_store.is_configured(): | |
| st.write( | |
| "Enter your AI service details and click Save Configuration. " | |
| "Your API key is **stored encrypted**. Remembering it across visits " | |
| "requires browser cookies. If your browser blocks embedded cookies, " | |
| "open the app directly or enter the key again next visit. It is stored encrypted " | |
| "(never in plain text), never shared with other visitors, and you " | |
| "can remove it any time with Clear Credentials." | |
| ) | |
| else: | |
| st.write( | |
| "Enter your AI service details and click Save Configuration. " | |
| "Your API key is **kept for this browser session only**: it is " | |
| "never written to disk or shared with other visitors, so you'll " | |
| "enter it again on your next visit. You can remove it any time " | |
| "with Clear Credentials." | |
| ) | |
| if just_saved: | |
| st.success("Configuration saved. Run API Check to continue.") | |
| if remember_failed: | |
| st.warning( | |
| "Your key couldn't be remembered on this device just now, so " | |
| "it is kept for this session only. Click Save Configuration " | |
| "again to retry." | |
| ) | |
| if creds_cleared: | |
| st.success("Saved credentials cleared. Enter a new API key to continue.") | |
| elif creds_cleared is False: | |
| st.error( | |
| "Your keys were removed from this session, but the copy saved " | |
| "on this device couldn't be deleted just now. Click Clear " | |
| "Credentials again to retry." | |
| ) | |
| masked = _saved_key_status(settings) | |
| if masked: | |
| st.caption(f"Key {kept} for the current provider: {masked}") | |
| # --- Text / LLM settings --------------------------------------- | |
| provider = st.selectbox( | |
| "LLM Provider", | |
| PROVIDER_OPTIONS, | |
| key=_FORM_PROVIDER, | |
| help="Which AI service to use for text reasoning.", | |
| ) | |
| model = _render_model_dropdown( | |
| label="LLM Model", | |
| options=get_text_models(provider), | |
| state_prefix=_FORM_MODEL, | |
| saved_model=settings.active_model or "", | |
| help_text=( | |
| "Models available for the selected provider. Choose " | |
| f"“{_MODEL_CUSTOM_LABEL}” to enter one yourself." | |
| ), | |
| ) | |
| api_key = st.text_input( | |
| "API Key", | |
| key=_FORM_API_KEY, | |
| type="password", | |
| help=( | |
| f"{_key_storage_note()} " | |
| "Leave blank to keep the key you already saved." | |
| ), | |
| ) | |
| # --- Vision settings ------------------------------------------- | |
| vision_provider = st.selectbox( | |
| "Vision Provider", | |
| VISION_PROVIDER_OPTIONS, | |
| key=_FORM_VISION_PROVIDER, | |
| help="Reads job screenshots. 'same_as_llm' reuses the provider above.", | |
| ) | |
| # 'same_as_llm' has no models of its own — show the LLM provider's | |
| # vision models so the dropdown reflects what will actually be used. | |
| resolved_vision_provider = ( | |
| provider if vision_provider == VISION_SAME_AS_LLM else vision_provider | |
| ) | |
| # Another provider's API doesn't know the LLM model, so the reuse | |
| # choice is only offered when screenshots go to the provider above. | |
| separate_vision = resolved_vision_provider != provider | |
| vision_model = _render_model_dropdown( | |
| label="Vision Model", | |
| options=get_vision_models(resolved_vision_provider), | |
| state_prefix=_FORM_VISION_MODEL, | |
| saved_model=getattr(settings, "vision_model", None) or "", | |
| help_text=( | |
| "Reads job screenshots." | |
| if separate_vision | |
| else "Reads job screenshots. Keep the reuse choice to fall back " | |
| "to the LLM model when it supports images." | |
| ), | |
| reuse_label=None if separate_vision else _VISION_REUSE_LABEL, | |
| ) | |
| # The key above belongs to the LLM provider, so a separate vision | |
| # provider needs a key of its own, saved in that provider's slot. | |
| vision_api_key = "" | |
| if separate_vision: | |
| vision_label = _provider_label(resolved_vision_provider) | |
| vision_masked = config.mask_secret( | |
| getattr(settings, f"{resolved_vision_provider}_api_key", None) | |
| ) | |
| if vision_masked: | |
| st.caption(f"{vision_label} key {kept}: {vision_masked}") | |
| vision_api_key = st.text_input( | |
| f"Vision API key ({vision_label})", | |
| key=_FORM_VISION_KEY, | |
| type="password", | |
| help=( | |
| f"Used only to read job screenshots with {vision_label}. " | |
| f"{_key_storage_note()} Leave blank to keep the key you " | |
| "already saved." | |
| ), | |
| ) | |
| if not vision_masked: | |
| st.warning( | |
| f"Job screenshots are read with {vision_label}, which has " | |
| f"no key saved yet. Add your {vision_label} key above and " | |
| "click Save Configuration." | |
| ) | |
| # Groq is text-only: 'same_as_llm' vision with a Groq text provider | |
| # can't read screenshots. Steer the user to a separate vision provider. | |
| if provider == "groq" and vision_provider == VISION_SAME_AS_LLM: | |
| st.warning(GROQ_VISION_UNSUPPORTED_MESSAGE) | |
| # --- Interview key --------------------------------------------- | |
| # The guided interview runs on the API above by default. This key is | |
| # optional: once saved, the interview's calls go to that provider | |
| # instead, off the main API. It is used by the interview and nothing | |
| # else — the API check below and every dossier step ignore it. When | |
| # the main provider is the interview's provider, the key above serves | |
| # both, so no second field is shown. | |
| st.divider() | |
| interview_api_key = "" | |
| if (provider or "").strip().lower() == _interview_provider(): | |
| # The main key is a key for the interview's own provider, so it | |
| # already serves the interview; a second field for the same slot | |
| # would only let one key overwrite the other. | |
| st.caption( | |
| f"The guided interview uses your {_interview_provider_label()} " | |
| "key above. Your answers are always saved exactly as you type " | |
| "them." | |
| ) | |
| else: | |
| interview_masked = _interview_key_masked(settings) | |
| if interview_masked: | |
| col_note, col_remove = st.columns([3, 1], vertical_alignment="center") | |
| col_note.caption(f"Interview key {kept}: {interview_masked}") | |
| if col_remove.button( | |
| "Remove interview key", | |
| key="remove_interview_key_btn", | |
| use_container_width=True, | |
| help="Forget only this key. The interview goes back to your main API.", | |
| ): | |
| _handle_remove_interview_key() | |
| interview_api_key = st.text_input( | |
| f"Interview key ({_interview_provider_label()}) — free, optional", | |
| key=_FORM_INTERVIEW_KEY, | |
| type="password", | |
| help=( | |
| f"Used only by the guided interview. {_key_storage_note()} " | |
| "Leave blank to keep the key you already saved." | |
| ), | |
| ) | |
| st.caption( | |
| "The guided interview is for people who have no dossier folder. " | |
| "It uses the API you set up above to check each answer fits the " | |
| "question and to ask a follow-up when a detail is missing. Add a " | |
| f"free {_interview_provider_label()} key here only if you'd rather " | |
| "the interview ran on that instead, keeping its calls off your " | |
| "main API. Your answers are always saved exactly as you type them." | |
| ) | |
| # --- Advanced options (collapsed) ------------------------------ | |
| # Booleans use toggles (never checkboxes) so the Setup screen stays | |
| # free of any acceptance-style checkbox. | |
| with st.expander("Advanced options", expanded=False): | |
| allow_fallback = st.toggle( | |
| "Allow provider fallback", key=_FORM_ALLOW_FALLBACK | |
| ) | |
| allow_local = st.toggle( | |
| "Allow local placeholders", key=_FORM_ALLOW_LOCAL | |
| ) | |
| # The developer debug panel exposes provider/model/task names, | |
| # prompt sizes, evidence IDs, and sanitized errors. Enabling it | |
| # is a developer/operator action, not an end-user one, so the | |
| # in-UI toggle is only offered when debug is ALREADY enabled via | |
| # the environment (allowing a developer to turn it back off). A | |
| # normal user running with debug off cannot flip it on from the | |
| # UI — they must set SHOW_DEBUG_PANEL=true in .env deliberately. | |
| if settings.show_debug_panel: | |
| show_debug = st.toggle( | |
| "Show developer debug panel", key=_FORM_SHOW_DEBUG | |
| ) | |
| else: | |
| show_debug = False | |
| st.caption( | |
| "Developer debug panel is off. To enable it, set " | |
| "`SHOW_DEBUG_PANEL=true` in your `.env`." | |
| ) | |
| max_chars = st.number_input( | |
| "Max proposal context characters", | |
| min_value=1000, | |
| step=500, | |
| key=_FORM_MAX_CHARS, | |
| ) | |
| max_evidence = st.number_input( | |
| "Max proposal evidence points", | |
| min_value=1, | |
| step=1, | |
| key=_FORM_MAX_EVIDENCE, | |
| ) | |
| max_tokens = st.number_input( | |
| "Proposal max output tokens", | |
| min_value=50, | |
| step=50, | |
| key=_FORM_MAX_TOKENS, | |
| ) | |
| log_days = st.number_input( | |
| "Log retention days", | |
| min_value=1, | |
| step=1, | |
| key=_FORM_LOG_DAYS, | |
| ) | |
| col_save, col_clear = st.columns([1, 1]) | |
| with col_save: | |
| save_clicked = st.button( | |
| "Save Configuration", type="primary", key="save_config_btn" | |
| ) | |
| with col_clear: | |
| # Security control: wipe every stored API key so no credential is | |
| # left on disk. Disabled when there's nothing saved to clear; kept | |
| # on while a failed delete of this browser's stored copy is | |
| # pending, so it can be retried. | |
| clear_clicked = st.button( | |
| "Clear Credentials", | |
| key="clear_credentials_btn", | |
| disabled=not (settings.has_any_api_key or _forget_pending()), | |
| help=( | |
| "Remove all saved API keys from your local .env. " | |
| "You'll need to re-enter a key before running the app." | |
| ), | |
| ) | |
| if save_clicked: | |
| _handle_config_save( | |
| provider=provider, | |
| model=model, | |
| api_key=api_key, | |
| interview_api_key=interview_api_key, | |
| vision_provider=vision_provider, | |
| vision_model=vision_model, | |
| allow_provider_fallback=allow_fallback, | |
| allow_local_placeholders=allow_local, | |
| show_debug_panel=show_debug, | |
| max_proposal_context_chars=max_chars, | |
| max_proposal_evidence_points=max_evidence, | |
| proposal_max_output_tokens=max_tokens, | |
| log_retention_days=log_days, | |
| vision_api_key=vision_api_key, | |
| ) | |
| if clear_clicked: | |
| _handle_clear_credentials() | |
| # --------------------------------------------------------------------------- | |
| # API check card | |
| # --------------------------------------------------------------------------- | |
| def _render_status(*, debug: bool) -> None: | |
| status = st.session_state.get("api_status") | |
| if status is None: | |
| st.info("Run the check below to confirm your AI service is ready.") | |
| return | |
| if status == ApiGateError.API_OK: | |
| st.success("AI service is ready.") | |
| return | |
| st.error(_safe_message(status)) | |
| if debug: | |
| st.caption(f"Status: {status}") | |
| def _render_refresh_button() -> None: | |
| """Reload .env and clear stale API state, then rerun. | |
| Mostly redundant now that Save Configuration reloads automatically, but | |
| kept for operators who prefer to edit ``.env`` by hand (advanced path). | |
| """ | |
| if st.button("Refresh API Config", key="refresh_api_config_btn"): | |
| # Re-read .env from disk and rebuild the cached Settings so edited | |
| # provider/model/key values take effect without a server restart. | |
| reload_settings() | |
| for key in _API_STATE_KEYS: | |
| st.session_state.pop(key, None) | |
| # Reset the gate flags to their unconfigured defaults so the rest of | |
| # the flow re-locks until the check passes against the new config. | |
| st.session_state["api_status"] = None | |
| st.session_state["api_ok"] = False | |
| st.rerun() | |
| def _render_api_check_card(settings, *, debug: bool) -> None: | |
| with st.container(border=True, key="ups_panel_setup_2"): | |
| theme.section_label("AI service") | |
| col_status, col_action = st.columns([2, 1]) | |
| with col_status: | |
| _render_status(debug=debug) | |
| with col_action: | |
| rerun_label = ( | |
| "Re-run API Check" | |
| if st.session_state.get("api_ok") | |
| else "Run API Check" | |
| ) | |
| test_clicked = st.button( | |
| rerun_label, type="primary", key="run_api_check_btn" | |
| ) | |
| _render_refresh_button() | |
| if test_clicked: | |
| # Re-read .env from disk so the just-saved (or hand-edited) config | |
| # is used — get_settings is process-cached and load_dotenv only | |
| # runs once at import. | |
| settings = reload_settings() | |
| with st.spinner("Checking your AI service…"): | |
| # Live text-provider reachability only. The vision model is | |
| # NOT exercised during setup. | |
| result = run_capability_test(settings=settings, live=True) | |
| if result.ok: | |
| st.session_state.api_status = ApiGateError.API_OK | |
| st.session_state.api_ok = True | |
| # Rerun so the top-right status chip + sidebar immediately | |
| # reflect "API Ready" (they were drawn before this handler ran). | |
| st.rerun() | |
| else: | |
| st.session_state.api_status = result.status | |
| st.session_state.api_ok = False | |
| # Rerun on failure too, for the same reason the success branch | |
| # does: the sidebar and status chip were drawn before this | |
| # handler ran, so without it the diagnostics panel explaining | |
| # the failure cannot appear until the user happens to interact | |
| # again. The message is not lost — `_render_status` above | |
| # re-renders it from ``api_status`` on the next run, which is | |
| # why the inline st.error here is no longer needed. | |
| st.rerun() | |
| def _render_debug_config_block(settings) -> None: | |
| """Developer-only configuration details (gated behind the debug panel). | |
| Shows where config came from and what is missing — never any secret | |
| value. The API key is never read into this block. | |
| """ | |
| st.markdown(f"**Detected project root:** `{config.PROJECT_ROOT}`") | |
| st.markdown( | |
| f"**`.env` loaded from project root:** " | |
| f"`{str(config.env_file_loaded()).lower()}`" | |
| ) | |
| st.markdown(f"**`LLM_PROVIDER`:** `{settings.llm_provider}`") | |
| st.markdown(f"**Text model:** `{settings.active_model}`") | |
| st.markdown(f"**Vision provider:** `{settings.active_vision_provider}`") | |
| st.markdown( | |
| f"**Vision model:** `{settings.active_vision_model or 'not set'}`" | |
| ) | |
| missing = settings.missing_config() | |
| if missing: | |
| st.markdown(f"**Missing required values:** `{', '.join(missing)}`") | |
| else: | |
| st.markdown("**Missing required values:** `none`") | |
| st.markdown( | |
| f"**`ALLOW_PROVIDER_FALLBACK`:** " | |
| f"`{str(settings.allow_provider_fallback).lower()}`" | |
| ) | |
| st.markdown( | |
| f"**`ALLOW_LOCAL_PLACEHOLDERS`:** " | |
| f"`{str(settings.allow_local_placeholders).lower()}`" | |
| ) | |
| st.caption( | |
| "Values are loaded from .env. The API key itself is held in " | |
| "memory only and is never shown, logged, or echoed back." | |
| ) | |
| def render() -> None: | |
| settings = get_settings() | |
| debug = bool(getattr(settings, "show_debug_panel", False)) | |
| theme.screen_head( | |
| "setup", | |
| "Connect your AI service", | |
| "You only need <b>one</b>. The app sends a small slice of relevant text — " | |
| "never your files. Your key is stored privately on this computer.", | |
| ) | |
| _render_config_form(settings) | |
| _render_api_check_card(settings, debug=debug) | |
| if st.session_state.get("api_ok"): | |
| with st.container(border=True, key="ups_panel_setup_3"): | |
| theme.section_label("Next step") | |
| st.write("Your AI service is connected. You're ready to add your dossier.") | |
| if st.button( | |
| "Continue to Dossier", | |
| type="primary", | |
| key="continue_to_dossier_btn", | |
| ): | |
| st.session_state.current_step = "dossier" | |
| st.rerun() | |
| else: | |
| st.caption("Run the API check above to unlock the next step.") | |
| st.caption(MINIMAL_DATA_NOTICE) | |
| if debug: | |
| with st.expander("Developer Debug Panel", expanded=False): | |
| _render_debug_config_block(settings) | |