Download app.py from IQuestLab/IQuest-Q1: direct link, hf CLI and curl.
- Browser
- Download file 19.9 kB
-
https://huggingface.co/spaces/IQuestLab/IQuest-Q1/resolve/main/app.py
- Command line
-
hf download hf://spaces/IQuestLab/IQuest-Q1/app.py
-
curl -L -o app.py https://huggingface.co/spaces/IQuestLab/IQuest-Q1/resolve/main/app.py
19.9 kB
| """Configurable text-only Gradio chat UI for an OpenAI-compatible API. | |
| Recommended requirements.txt: | |
| gradio==6.28.0 | |
| openai==2.54.0 | |
| Required Space settings: | |
| MODEL_API_BASE or OPENAI_BASE_URL | |
| MODEL_API_KEY or OPENAI_API_KEY (store as a Secret) | |
| Model and runtime settings: | |
| MODEL_ID or MODEL_NAME, SYSTEM_PROMPT, MAX_OUTPUT_TOKENS, TEMPERATURE, | |
| TOP_P, API_TIMEOUT_SECONDS, API_MAX_RETRIES, CONCURRENCY_LIMIT, | |
| QUEUE_MAX_SIZE, STREAM_UPDATE_INTERVAL_SECONDS, SERVER_NAME, SERVER_PORT, | |
| SHARE, ANALYTICS_ENABLED | |
| Brand and UI settings: | |
| APP_TITLE, APP_HEADING_MARKDOWN, MODEL_LABEL, CHAT_PLACEHOLDER, | |
| INPUT_PLACEHOLDER, SEND_BUTTON_TEXT, STOP_BUTTON_TEXT, CLEAR_BUTTON_TEXT, | |
| THINKING_TITLE, STATUS_TITLE, NOTICE_TITLE, STATUS_GENERATING_TEXT, | |
| STOPPED_TEXT, CHATBOT_HEIGHT, CHATBOT_LAYOUT, CHATBOT_BUTTONS, | |
| INPUT_LINES, INPUT_MAX_LINES | |
| All validation errors, upstream errors, and generation notices can also be | |
| overridden with the ERROR_* and NOTICE_* environment variables defined below. | |
| The UI renders Markdown in both reasoning and final answers. Reasoning must be | |
| provided by the API in `reasoning_content` or `reasoning`; this app does not | |
| split <think> tags embedded in the normal `content` field. Reasoning and UI | |
| notices are kept out of the model conversation history. | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| import copy | |
| import logging | |
| import os | |
| import time | |
| import uuid | |
| import gradio as gr | |
| from openai import APIConnectionError, APIStatusError, APITimeoutError, AsyncOpenAI | |
| def env_bool(name: str, default: bool) -> bool: | |
| raw = os.getenv(name) | |
| if raw is None: | |
| return default | |
| normalized = raw.strip().lower() | |
| if normalized in {"1", "true", "yes", "on"}: | |
| return True | |
| if normalized in {"0", "false", "no", "off"}: | |
| return False | |
| raise ValueError(f"{name} must be true or false.") | |
| def env_int(name: str, default: int) -> int: | |
| raw = os.getenv(name) | |
| if raw is None: | |
| return default | |
| try: | |
| return int(raw) | |
| except ValueError as exc: | |
| raise ValueError(f"{name} must be an integer.") from exc | |
| def env_float(name: str, default: float) -> float: | |
| raw = os.getenv(name) | |
| if raw is None: | |
| return default | |
| try: | |
| return float(raw) | |
| except ValueError as exc: | |
| raise ValueError(f"{name} must be a number.") from exc | |
| def first_env(*names: str, default: str = "") -> str: | |
| for name in names: | |
| value = os.getenv(name) | |
| if value is not None and value.strip(): | |
| return value.strip() | |
| return default | |
| # Model API and generation settings. | |
| MODEL_ID = first_env( | |
| "MODEL_ID", "MODEL_NAME", default="IQuest-Q1" | |
| ) | |
| API_BASE = first_env("MODEL_API_BASE", "OPENAI_BASE_URL") | |
| API_KEY = first_env("MODEL_API_KEY", "OPENAI_API_KEY") | |
| SYSTEM_PROMPT = os.getenv( | |
| "SYSTEM_PROMPT", | |
| "You are IQuest-Q1, a helpful AI assistent developed by IQuestLab.", | |
| ) | |
| MAX_OUTPUT_TOKENS = env_int("MAX_OUTPUT_TOKENS", 16384) | |
| TEMPERATURE = env_float("TEMPERATURE", 1.0) | |
| TOP_P = env_float("TOP_P", 0.95) | |
| API_TIMEOUT_SECONDS = env_float("API_TIMEOUT_SECONDS", 180.0) | |
| API_MAX_RETRIES = env_int("API_MAX_RETRIES", 0) | |
| CONCURRENCY_LIMIT = env_int("CONCURRENCY_LIMIT", 10) | |
| QUEUE_MAX_SIZE = env_int("QUEUE_MAX_SIZE", 32) | |
| STREAM_UPDATE_INTERVAL_SECONDS = env_float("STREAM_UPDATE_INTERVAL_SECONDS", 0.05) | |
| # Server settings. | |
| SERVER_NAME = os.getenv("SERVER_NAME", "0.0.0.0") | |
| SERVER_PORT = env_int("SERVER_PORT", 7860) | |
| SHARE = env_bool("SHARE", False) | |
| ANALYTICS_ENABLED = env_bool("ANALYTICS_ENABLED", False) | |
| LOGGER_NAME = os.getenv("LOGGER_NAME", "iquest_chat") | |
| # Brand, layout, and all user-visible UI text. | |
| APP_TITLE = os.getenv("APP_TITLE", "IQuest-Q1") | |
| APP_HEADING_MARKDOWN = os.getenv("APP_HEADING_MARKDOWN", f"# {APP_TITLE}") | |
| MODEL_LABEL = os.getenv("MODEL_LABEL", MODEL_ID) | |
| CHAT_PLACEHOLDER = os.getenv( | |
| "CHAT_PLACEHOLDER", "Enter a question or paste code to start a conversation." | |
| ) | |
| INPUT_PLACEHOLDER = os.getenv( | |
| "INPUT_PLACEHOLDER", "Enter a question or paste code..." | |
| ) | |
| SEND_BUTTON_TEXT = os.getenv("SEND_BUTTON_TEXT", "Send") | |
| STOP_BUTTON_TEXT = os.getenv("STOP_BUTTON_TEXT", "Stop") | |
| CLEAR_BUTTON_TEXT = os.getenv("CLEAR_BUTTON_TEXT", "Clear conversation") | |
| THINKING_TITLE = os.getenv("THINKING_TITLE", "Thinking") | |
| STATUS_TITLE = os.getenv("STATUS_TITLE", "Status") | |
| NOTICE_TITLE = os.getenv("NOTICE_TITLE", "Notice") | |
| STATUS_GENERATING_TEXT = os.getenv("STATUS_GENERATING_TEXT", "Generating...") | |
| STOPPED_TEXT = os.getenv("STOPPED_TEXT", "Generation stopped.") | |
| CHATBOT_HEIGHT = env_int("CHATBOT_HEIGHT", 560) | |
| CHATBOT_LAYOUT = os.getenv("CHATBOT_LAYOUT", "panel") | |
| CHATBOT_BUTTONS = [ | |
| item.strip() | |
| for item in os.getenv("CHATBOT_BUTTONS", "copy").split(",") | |
| if item.strip() | |
| ] | |
| INPUT_LINES = env_int("INPUT_LINES", 2) | |
| INPUT_MAX_LINES = env_int("INPUT_MAX_LINES", 12) | |
| # User-visible validation errors. | |
| ERROR_EMPTY_INPUT = os.getenv("ERROR_EMPTY_INPUT", "Please enter a message.") | |
| ERROR_ALREADY_RUNNING = os.getenv( | |
| "ERROR_ALREADY_RUNNING", | |
| "Stop the current response before sending another message.", | |
| ) | |
| ERROR_MISSING_API_CONFIG = os.getenv( | |
| "ERROR_MISSING_API_CONFIG", | |
| "The model API endpoint or API key is not configured. Please contact the administrator.", | |
| ) | |
| # User-visible upstream and runtime errors. | |
| ERROR_TIMEOUT = os.getenv( | |
| "ERROR_TIMEOUT", "The model response timed out. Please try again." | |
| ) | |
| ERROR_CONNECTION = os.getenv( | |
| "ERROR_CONNECTION", "Unable to connect to the model service. Please try again later." | |
| ) | |
| ERROR_AUTH = os.getenv( | |
| "ERROR_AUTH", | |
| "Model service authentication failed. Please contact the administrator.", | |
| ) | |
| ERROR_RATE_LIMIT = os.getenv( | |
| "ERROR_RATE_LIMIT", "The model service is busy or the quota is exhausted. Please try again later." | |
| ) | |
| ERROR_BAD_REQUEST = os.getenv( | |
| "ERROR_BAD_REQUEST", | |
| "The model service rejected the request. The conversation may be too long or a request parameter may be unsupported.", | |
| ) | |
| ERROR_HTTP = os.getenv( | |
| "ERROR_HTTP", "The model service returned HTTP {status_code}. Please try again later." | |
| ) | |
| ERROR_GENERIC = os.getenv( | |
| "ERROR_GENERIC", "An unexpected error occurred while generating the response. Please try again." | |
| ) | |
| # User-visible generation notices. | |
| NOTICE_LENGTH_LIMIT = os.getenv( | |
| "NOTICE_LENGTH_LIMIT", "The output token limit was reached. The response may be incomplete." | |
| ) | |
| NOTICE_CONTENT_FILTER = os.getenv( | |
| "NOTICE_CONTENT_FILTER", "The model service did not return the complete response." | |
| ) | |
| NOTICE_EMPTY_RESPONSE = os.getenv( | |
| "NOTICE_EMPTY_RESPONSE", | |
| "The model returned no final answer. Please retry or check the output limit and API response format.", | |
| ) | |
| NOTICE_MISSING_FINISH_REASON = os.getenv( | |
| "NOTICE_MISSING_FINISH_REASON", | |
| "No normal completion marker was received. The response may be incomplete.", | |
| ) | |
| # Explicit Markdown and safety behavior for both Thinking and final answers. | |
| RENDER_MARKDOWN = env_bool("RENDER_MARKDOWN", True) | |
| LINE_BREAKS = env_bool("LINE_BREAKS", True) | |
| SANITIZE_HTML = env_bool("SANITIZE_HTML", True) | |
| ALLOW_TAGS = env_bool("ALLOW_TAGS", False) | |
| AUTOSCROLL = env_bool("AUTOSCROLL", True) | |
| logger = logging.getLogger(LOGGER_NAME) | |
| def validate_config() -> None: | |
| if MAX_OUTPUT_TOKENS < 1: | |
| raise ValueError("MAX_OUTPUT_TOKENS must be greater than 0.") | |
| if API_TIMEOUT_SECONDS <= 0: | |
| raise ValueError("API_TIMEOUT_SECONDS must be greater than 0.") | |
| if API_MAX_RETRIES < 0: | |
| raise ValueError("API_MAX_RETRIES must be 0 or greater.") | |
| if not 0 <= TEMPERATURE <= 2: | |
| raise ValueError("TEMPERATURE must be between 0 and 2.") | |
| if not 0 < TOP_P <= 1: | |
| raise ValueError("TOP_P must be greater than 0 and no greater than 1.") | |
| if CONCURRENCY_LIMIT < 1: | |
| raise ValueError("CONCURRENCY_LIMIT must be greater than 0.") | |
| if QUEUE_MAX_SIZE < 1: | |
| raise ValueError("QUEUE_MAX_SIZE must be greater than 0.") | |
| if STREAM_UPDATE_INTERVAL_SECONDS < 0: | |
| raise ValueError("STREAM_UPDATE_INTERVAL_SECONDS must be 0 or greater.") | |
| if SERVER_PORT < 1 or SERVER_PORT > 65535: | |
| raise ValueError("SERVER_PORT must be between 1 and 65535.") | |
| if CHATBOT_HEIGHT < 1: | |
| raise ValueError("CHATBOT_HEIGHT must be greater than 0.") | |
| if CHATBOT_LAYOUT not in {"panel", "bubble"}: | |
| raise ValueError("CHATBOT_LAYOUT must be panel or bubble.") | |
| invalid_buttons = set(CHATBOT_BUTTONS) - {"copy", "copy_all", "share"} | |
| if invalid_buttons: | |
| raise ValueError( | |
| "CHATBOT_BUTTONS only supports copy, copy_all, and share." | |
| ) | |
| if INPUT_LINES < 1 or INPUT_MAX_LINES < INPUT_LINES: | |
| raise ValueError("INPUT_MAX_LINES must be greater than or equal to INPUT_LINES.") | |
| def new_session() -> dict: | |
| # Keep session state deepcopy-safe and isolated between browser sessions. | |
| return {"messages": [], "display": [], "run_id": None} | |
| def ui_updates(state: dict, clear_input: bool = False) -> tuple: | |
| running = state["run_id"] is not None | |
| textbox_update = {"interactive": not running} | |
| if clear_input: | |
| textbox_update["value"] = "" | |
| return ( | |
| copy.deepcopy(state["display"]), | |
| state, | |
| gr.update(**textbox_update), | |
| gr.update(visible=not running), | |
| gr.update(visible=running), | |
| ) | |
| async def submit(message: str, state: dict) -> tuple: | |
| """Validate and append a user message before the generation event starts.""" | |
| if not isinstance(message, str) or not message.strip(): | |
| raise gr.Error(ERROR_EMPTY_INPUT) | |
| if state["run_id"] is not None: | |
| raise gr.Error(ERROR_ALREADY_RUNNING) | |
| if not API_BASE or not API_KEY: | |
| raise gr.Error(ERROR_MISSING_API_CONFIG) | |
| # Preserve whitespace so pasted code keeps its indentation. | |
| user = {"role": "user", "content": message} | |
| state["messages"].append(user.copy()) | |
| state["display"].append(user.copy()) | |
| state["run_id"] = uuid.uuid4().hex | |
| job = { | |
| "id": state["run_id"], | |
| "messages": copy.deepcopy(state["messages"]), | |
| "display": copy.deepcopy(state["display"]), | |
| } | |
| state["display"].append( | |
| { | |
| "role": "assistant", | |
| "content": STATUS_GENERATING_TEXT, | |
| "metadata": {"title": STATUS_TITLE, "status": "pending"}, | |
| } | |
| ) | |
| return (*ui_updates(state, clear_input=True), job) | |
| def publish( | |
| job: dict, | |
| state: dict, | |
| thinking: str, | |
| answer: str, | |
| *, | |
| done: bool = False, | |
| notice: str = "", | |
| ) -> tuple: | |
| """Build separate model and display histories for the current stream state.""" | |
| state["messages"] = copy.deepcopy(job["messages"]) | |
| state["display"] = copy.deepcopy(job["display"]) | |
| if thinking: | |
| state["display"].append( | |
| { | |
| "role": "assistant", | |
| "content": thinking, | |
| "metadata": { | |
| "title": THINKING_TITLE, | |
| "status": "done" if done or answer else "pending", | |
| }, | |
| } | |
| ) | |
| if answer: | |
| reply = {"role": "assistant", "content": answer} | |
| state["messages"].append(reply.copy()) | |
| state["display"].append(reply.copy()) | |
| if notice: | |
| state["display"].append( | |
| { | |
| "role": "assistant", | |
| "content": notice, | |
| "metadata": {"title": NOTICE_TITLE, "status": "done"}, | |
| } | |
| ) | |
| return ui_updates(state) | |
| def error_message(exc: Exception) -> str: | |
| if isinstance(exc, APITimeoutError): | |
| return ERROR_TIMEOUT | |
| if isinstance(exc, APIConnectionError): | |
| return ERROR_CONNECTION | |
| if isinstance(exc, APIStatusError): | |
| if exc.status_code in (401, 403): | |
| return ERROR_AUTH | |
| if exc.status_code == 429: | |
| return ERROR_RATE_LIMIT | |
| if exc.status_code in (400, 413, 422): | |
| return ERROR_BAD_REQUEST | |
| return ERROR_HTTP.replace("{status_code}", str(exc.status_code)) | |
| return ERROR_GENERIC | |
| async def respond(job: dict | None, state: dict): | |
| # Clear or Stop may invalidate a queued job before it starts. | |
| if not job or state["run_id"] != job["id"]: | |
| return | |
| messages = copy.deepcopy(job["messages"]) | |
| if SYSTEM_PROMPT.strip(): | |
| messages.insert(0, {"role": "system", "content": SYSTEM_PROMPT}) | |
| thinking = "" | |
| answer = "" | |
| notice = "" | |
| finish_reason = None | |
| last_update = 0.0 | |
| try: | |
| # A separate client per generation makes cancellation close its connection. | |
| async with AsyncOpenAI( | |
| base_url=API_BASE, | |
| api_key=API_KEY, | |
| timeout=API_TIMEOUT_SECONDS, | |
| max_retries=API_MAX_RETRIES, | |
| ) as client: | |
| async with await client.chat.completions.create( | |
| model=MODEL_ID, | |
| messages=messages, | |
| max_tokens=MAX_OUTPUT_TOKENS, | |
| temperature=TEMPERATURE, | |
| top_p=TOP_P, | |
| stream=True, | |
| ) as stream: | |
| async for chunk in stream: | |
| if state["run_id"] != job["id"]: | |
| return | |
| if not chunk.choices: | |
| # Some providers send a usage-only final chunk. | |
| continue | |
| choice = chunk.choices[0] | |
| if choice.finish_reason: | |
| finish_reason = choice.finish_reason | |
| delta = choice.delta | |
| if delta is None: | |
| continue | |
| thought = getattr(delta, "reasoning_content", None) | |
| if not isinstance(thought, str): | |
| thought = getattr(delta, "reasoning", None) | |
| content = delta.content or getattr(delta, "refusal", None) | |
| changed = False | |
| if isinstance(thought, str) and thought: | |
| thinking += thought | |
| changed = True | |
| if isinstance(content, str) and content: | |
| answer += content | |
| changed = True | |
| now = time.monotonic() | |
| if changed and now - last_update >= STREAM_UPDATE_INTERVAL_SECONDS: | |
| yield publish(job, state, thinking, answer) | |
| last_update = now | |
| if finish_reason == "length": | |
| notice = NOTICE_LENGTH_LIMIT | |
| elif finish_reason == "content_filter": | |
| notice = NOTICE_CONTENT_FILTER | |
| elif not answer.strip(): | |
| notice = NOTICE_EMPTY_RESPONSE | |
| elif finish_reason is None: | |
| notice = NOTICE_MISSING_FINISH_REASON | |
| except asyncio.CancelledError: | |
| # Stop and Clear restore the UI; cancellation closes the HTTP stream. | |
| raise | |
| except Exception as exc: | |
| # Avoid logging API keys, conversation content, or upstream response bodies. | |
| logger.warning( | |
| "generation_failed type=%s http_status=%s", | |
| type(exc).__name__, | |
| getattr(exc, "status_code", None), | |
| ) | |
| notice = error_message(exc) | |
| if state["run_id"] == job["id"]: | |
| state["run_id"] = None | |
| yield publish(job, state, thinking, answer, done=True, notice=notice) | |
| async def stop(state: dict) -> tuple: | |
| if state["run_id"] is not None: | |
| state["run_id"] = None | |
| # Remove the temporary status message when stopping before the first token. | |
| state["display"] = [ | |
| message | |
| for message in state["display"] | |
| if not ( | |
| message.get("content") == STATUS_GENERATING_TEXT | |
| and message.get("metadata", {}).get("title") == STATUS_TITLE | |
| and message.get("metadata", {}).get("status") == "pending" | |
| ) | |
| ] | |
| for message in state["display"]: | |
| if message.get("metadata"): | |
| message["metadata"]["status"] = "done" | |
| state["display"].append( | |
| { | |
| "role": "assistant", | |
| "content": STOPPED_TEXT, | |
| "metadata": {"title": NOTICE_TITLE, "status": "done"}, | |
| } | |
| ) | |
| return ui_updates(state) | |
| async def clear(state: dict) -> tuple: | |
| # Mutate in place so any running task sees that its run ID is invalid. | |
| state.update(new_session()) | |
| return ui_updates(state, clear_input=True) | |
| def build_app() -> gr.Blocks: | |
| validate_config() | |
| with gr.Blocks(title=APP_TITLE, analytics_enabled=ANALYTICS_ENABLED) as demo: | |
| state = gr.State(value=new_session()) | |
| job = gr.State(value=None) | |
| gr.Markdown(APP_HEADING_MARKDOWN) | |
| chatbot = gr.Chatbot( | |
| label=MODEL_LABEL, | |
| height=CHATBOT_HEIGHT, | |
| layout=CHATBOT_LAYOUT, | |
| render_markdown=RENDER_MARKDOWN, | |
| line_breaks=LINE_BREAKS, | |
| sanitize_html=SANITIZE_HTML, | |
| allow_tags=ALLOW_TAGS, | |
| autoscroll=AUTOSCROLL, | |
| buttons=CHATBOT_BUTTONS, | |
| placeholder=CHAT_PLACEHOLDER, | |
| ) | |
| with gr.Row(): | |
| textbox = gr.Textbox( | |
| placeholder=INPUT_PLACEHOLDER, | |
| show_label=False, | |
| lines=INPUT_LINES, | |
| max_lines=INPUT_MAX_LINES, | |
| scale=10, | |
| ) | |
| send_btn = gr.Button(SEND_BUTTON_TEXT, variant="primary", scale=1) | |
| stop_btn = gr.Button( | |
| STOP_BUTTON_TEXT, variant="stop", scale=1, visible=False | |
| ) | |
| clear_btn = gr.Button(CLEAR_BUTTON_TEXT, size="sm") | |
| outputs = [chatbot, state, textbox, send_btn, stop_btn] | |
| submitted = gr.on( | |
| triggers=[send_btn.click, textbox.submit], | |
| fn=submit, | |
| inputs=[textbox, state], | |
| outputs=outputs + [job], | |
| queue=False, | |
| api_visibility="private", | |
| ) | |
| generation = submitted.success( | |
| fn=respond, | |
| inputs=[job, state], | |
| outputs=outputs, | |
| concurrency_limit=CONCURRENCY_LIMIT, | |
| api_visibility="private", | |
| ) | |
| stop_btn.click( | |
| fn=stop, | |
| inputs=[state], | |
| outputs=outputs, | |
| cancels=[generation], | |
| queue=False, | |
| api_visibility="private", | |
| ) | |
| clear_btn.click( | |
| fn=clear, | |
| inputs=[state], | |
| outputs=outputs, | |
| cancels=[generation], | |
| queue=False, | |
| api_visibility="private", | |
| ) | |
| # Keep the Chatbot toolbar's built-in clear action in sync with model history. | |
| chatbot.clear( | |
| fn=clear, | |
| inputs=[state], | |
| outputs=outputs, | |
| cancels=[generation], | |
| queue=False, | |
| api_visibility="private", | |
| ) | |
| return demo | |
| demo = build_app() | |
| demo.queue( | |
| default_concurrency_limit=CONCURRENCY_LIMIT, | |
| max_size=QUEUE_MAX_SIZE, | |
| ) | |
| if __name__ == "__main__": | |
| logging.basicConfig(level=logging.WARNING) | |
| demo.launch( | |
| server_name=SERVER_NAME, | |
| server_port=SERVER_PORT, | |
| share=SHARE, | |
| theme=gr.themes.Soft( | |
| primary_hue="sky", | |
| secondary_hue="blue", | |
| neutral_hue="slate", | |
| radius_size="lg", | |
| font=[ | |
| "system-ui", | |
| "-apple-system", | |
| "BlinkMacSystemFont", | |
| "Segoe UI", | |
| "Roboto", | |
| "Arial", | |
| "sans-serif", | |
| ], | |
| font_mono=[ | |
| "ui-monospace", | |
| "SFMono-Regular", | |
| "Menlo", | |
| "Monaco", | |
| "Consolas", | |
| "monospace", | |
| ], | |
| ), | |
| ssr_mode=False, | |
| ) | |