diff --git "a/digest.txt" "b/digest.txt" new file mode 100644--- /dev/null +++ "b/digest.txt" @@ -0,0 +1,11945 @@ +Directory structure: +└── pips/ + ├── README.md + ├── LICENSE + ├── MANIFEST.in + ├── __init__.py + ├── __main__.py + ├── core.py + ├── model_registry.py + ├── models.py + ├── pyproject.toml + ├── requirements.txt + ├── utils.py + ├── web_app.py + ├── prompts/ + ├── static/ + │ ├── README.md + │ ├── css/ + │ │ ├── base.css + │ │ ├── main.css + │ │ ├── tokens.css + │ │ └── components/ + │ │ ├── buttons.css + │ │ ├── chat.css + │ │ ├── feedback.css + │ │ ├── forms.css + │ │ ├── modal.css + │ │ ├── panels.css + │ │ ├── responsive.css + │ │ ├── sessions.css + │ │ └── utilities.css + │ └── js/ + │ ├── main.js + │ ├── core/ + │ │ ├── logger.js + │ │ ├── state.js + │ │ └── storage.js + │ ├── handlers/ + │ │ └── socket-handlers.js + │ ├── network/ + │ │ └── socket.js + │ └── ui/ + │ ├── dom-manager.js + │ ├── image-handler.js + │ ├── interactive-feedback.js + │ ├── message-manager.js + │ ├── session-manager.js + │ └── settings-manager.js + ├── templates/ + │ └── index_modular.html + ├── tests/ + └── uploads/ + +================================================ +File: README.md +================================================ +# PIPS: Python Iterative Problem Solving + +**PIPS** (Python Iterative Problem Solving) is a powerful library for iterative code generation and refinement using Large Language Models (LLMs). It provides both programmatic APIs and a web interface for solving complex problems through iterative reasoning and code execution. + +## Features + +- 🤖 **Multi-LLM Support**: Works with OpenAI GPT, Anthropic Claude, and Google GenAI models +- 🔄 **Iterative Problem Solving**: Automatically refines solutions through multiple iterations +- 🧠 **Two Solving Modes**: Chain-of-thought reasoning and code-based problem solving +- 🌐 **Web Interface**: Beautiful Flask-SocketIO web UI for interactive problem solving +- 📊 **Image Support**: Process problems with both text and image inputs +- ⚡ **Streaming Support**: Real-time token streaming for responsive user experience +- 🛡️ **Safe Code Execution**: Sandboxed code execution with timeouts and error handling + +## Installation + +### From PyPI (when available) +```bash +pip install pips-solver +``` + +### From Source +```bash +git clone +cd pips +pip install -e . +``` + +### With Optional Dependencies +```bash +# For web interface +pip install pips-solver[web] + +# For development +pip install pips-solver[dev] + +# All optional dependencies +pip install pips-solver[all] +``` + +## Quick Start + +### 1. Command Line Interface + +Start the web interface: +```bash +pips +# or +python -m pips + +# Custom host and port +pips --host 127.0.0.1 --port 5000 --debug +``` + +### 2. Programmatic Usage + +```python +from pips import PIPSSolver, get_model +from pips.utils import RawInput + +# Initialize a model +model = get_model("gpt-4o", api_key="your-openai-api-key") + +# Create solver +solver = PIPSSolver( + model=model, + max_iterations=8, + temperature=0.0 +) + +# Solve a problem +problem = RawInput( + text_input="What is the sum of the first 10 prime numbers?", + image_input=None +) + +# Chain of thought solving +answer, logs = solver.solve_chain_of_thought(problem) +print(f"Answer: {answer}") + +# Code-based solving +answer, logs = solver.solve_with_code(problem) +print(f"Answer: {answer}") +``` + +### 3. Streaming Usage + +```python +def on_token(token, iteration, model_name): + print(f"Token: {token}", end="", flush=True) + +def on_step(step, message, **kwargs): + print(f"Step {step}: {message}") + +callbacks = { + "on_llm_streaming_token": on_token, + "on_step_update": on_step +} + +# Solve with streaming +answer, logs = solver.solve_with_code( + problem, + stream=True, + callbacks=callbacks +) +``` + +## Supported Models + +### OpenAI Models +- GPT-4o, GPT-4o-mini +- GPT-4, GPT-4-turbo +- GPT-3.5-turbo +- O1-preview, O1-mini +- O3-mini (when available) + +### Anthropic Models +- Claude-3.5-sonnet +- Claude-3-opus, Claude-3-sonnet, Claude-3-haiku +- Claude-2.1, Claude-2.0 + +### Google Models +- Gemini-2.0-flash-exp +- Gemini-1.5-pro, Gemini-1.5-flash +- Gemini-1.0-pro + +## API Reference + +### PIPSSolver + +The main solver class for iterative problem solving. + +```python +PIPSSolver( + model: LLMModel, + max_iterations: int = 8, + temperature: float = 0.0, + max_tokens: int = 4096, + top_p: float = 1.0 +) +``` + +#### Methods + +- `solve_chain_of_thought(sample, stream=False, callbacks=None)`: Solve using chain-of-thought reasoning +- `solve_with_code(sample, stream=False, callbacks=None)`: Solve using iterative code generation + +### Model Factory + +```python +from pips import get_model + +# Get a model instance +model = get_model(model_name, api_key=None) +``` + +### Utilities + +```python +from pips.utils import RawInput, img2base64, base642img + +# Create input with text and optional image +input_data = RawInput( + text_input="Your question here", + image_input=PIL.Image.open("image.jpg") # Optional +) +``` + +## Configuration + +### Environment Variables + +Set your API keys as environment variables: + +```bash +export OPENAI_API_KEY="your-openai-key" +export ANTHROPIC_API_KEY="your-anthropic-key" +export GOOGLE_API_KEY="your-google-key" +``` + +### Web Interface Settings + +The web interface allows you to configure: +- Model selection +- API keys +- Solving mode (chain-of-thought vs code) +- Temperature, max tokens, iterations +- Code execution timeout + +## Examples + +### Mathematical Problem +```python +problem = RawInput( + text_input="Find the derivative of f(x) = x^3 + 2x^2 - 5x + 1", + image_input=None +) +answer, logs = solver.solve_with_code(problem) +``` + +### Image-Based Problem +```python +from PIL import Image + +image = Image.open("chart.png") +problem = RawInput( + text_input="What is the trend shown in this chart?", + image_input=image +) +answer, logs = solver.solve_chain_of_thought(problem) +``` + +### Multi-Step Reasoning +```python +problem = RawInput( + text_input=""" + A company has 3 departments with 10, 15, and 20 employees respectively. + If they want to form a committee with 2 people from each department, + how many different committees are possible? + """, + image_input=None +) +answer, logs = solver.solve_with_code(problem) +``` + +## Web Interface + +The web interface provides: +- **Problem Input**: Text area with optional image upload +- **Model Selection**: Choose from available LLM providers +- **Settings Panel**: Configure solving parameters +- **Real-time Streaming**: Watch the AI solve problems step-by-step +- **Chat History**: Review previous solutions +- **Export Options**: Download chat logs and solutions + +## Development + +### Setup Development Environment +```bash +git clone +cd pips +pip install -e .[dev] +``` + +### Running Tests +```bash +pytest +pytest --cov=pips # With coverage +``` + +### Code Formatting +```bash +black pips/ +isort pips/ +flake8 pips/ +mypy pips/ +``` + +## Contributing + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. + +## Acknowledgments + +- OpenAI for GPT models +- Anthropic for Claude models +- Google for GenAI models +- Flask and SocketIO communities + + + +================================================ +File: LICENSE +================================================ +MIT License + +Copyright (c) 2024 PIPS Development Team + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +================================================ +File: MANIFEST.in +================================================ +include README.md +include LICENSE +include requirements.txt +recursive-include pips/templates * +global-exclude *.pyc +global-exclude __pycache__ +global-exclude .DS_Store + + +================================================ +File: __init__.py +================================================ +""" +PIPS: Python Iterative Problem Solving + +A library for iterative code generation and refinement using LLMs. +""" + +__version__ = "1.0.0" + +from .core import PIPSSolver, PIPSMode +from .models import get_model +from .model_registry import register_model + +try: + from .web_app import run_app + __all__ = ["PIPSSolver", "PIPSMode", "get_model", "register_model", "run_app"] +except ImportError: + __all__ = ["PIPSSolver", "PIPSMode", "get_model", "register_model"] + + +================================================ +File: __main__.py +================================================ +#!/usr/bin/env python3 +""" +PIPS entry-point. + +Usage: + python -m pips # starts on 0.0.0.0:8080 + python -m pips --port 5000 # custom port + python -m pips --host 127.0.0.1 --debug +""" + +import argparse +import sys + +# Import the runner we exposed in the simplified web_app.py +from .web_app import run_app + + + + + +def main() -> None: + parser = argparse.ArgumentParser( + prog="pips", + description="PIPS – Python Iterative Problem Solving web interface", + ) + + parser.add_argument( + "-p", "--port", + type=int, + default=8080, + help="HTTP port to listen on (default 8080)", + ) + parser.add_argument( + "--host", + type=str, + default="0.0.0.0", + help="Bind address (default 0.0.0.0)", + ) + parser.add_argument( + "--debug", + action="store_true", + help="Enable Flask/SockeIO debug mode", + ) + + args = parser.parse_args() + + print(f"▶️ PIPS web UI: http://{args.host}:{args.port} (debug={args.debug})") + + try: + run_app(host=args.host, port=args.port, debug=args.debug) + except KeyboardInterrupt: + print("\n👋 Shutting down PIPS—good-bye!") + sys.exit(0) + except Exception as exc: # pragma: no cover + print(f"❌ Fatal error starting PIPS: {exc}") + sys.exit(1) + + +if __name__ == "__main__": + main() + + + +================================================ +File: core.py +================================================ +import re, json +from enum import Enum +from typing import Any, Dict, List, Tuple, Optional, Callable +from .utils import RawInput, img2base64, python_eval +from .models import LLMModel, SamplingParams + + +# --------------------------------------------------------------------- +# PIPSMode enum for agent vs interactive modes +# --------------------------------------------------------------------- +class PIPSMode(Enum): + AGENT = "AGENT" + INTERACTIVE = "INTERACTIVE" + + +# --------------------------------------------------------------------- +# Helper-type aliases +TokenCb = Callable[[str, int, str], None] +CbMap = Dict[str, Callable[..., Any]] +# --------------------------------------------------------------------- + + +class PIPSSolver: + """Python Iterative Problem Solving (PIPS) solver — unified streaming & non-streaming.""" + + def __init__( + self, + model: LLMModel, + *, + max_iterations: int = 8, + temperature: float = 0.0, + max_tokens: int = 4096, + top_p: float = 1.0, + interactive: bool = False, + critic_model: Optional[LLMModel] = None, + ): + """ + Args: + model: An object that implements .chat(...) and, optionally, .stream_chat(...). + max_iterations: Maximum refinement loops for the code-generation mode. + temperature: Sampling temperature passed to the LLM. + max_tokens: Max tokens for each LLM response. + top_p: Nucleus-sampling parameter. + interactive: Whether to use interactive mode (wait for user feedback). + critic_model: Optional separate model for criticism (defaults to main model). + """ + self.model = model + self.critic_model = critic_model or model + self.max_iterations = max_iterations + self.temperature = temperature + self.max_tokens = max_tokens + self.top_p = top_p + self.interactive = interactive + + # Interactive mode state + self._checkpoint = None + self._current_conversation = None + + # System prompt identical to the original implementation + self.system_prompt = """You will be given a question and you must answer it by extracting relevant symbols in JSON format and then writing a Python program to calculate the final answer. + +You MUST always plan extensively before outputting any symbols or code. + +You MUST iterate and keep going until the problem is solved. + +# Workflow + +## Problem Solving Steps +1. First extract relevant information from the input as JSON. Try to represent the relevant information in as much of a structured format as possible to help with further reasoning/processing. +2. Using the information extracted, determine a reasonable approach to solving the problem using code, such that executing the code will return the final answer. +3. Write a Python program to calculate and return the final answer. Use comments to explain the structure of the code and do not use a main() function. +The JSON must be enclosed in a markdown code block and the Python function must be in a separate markdown code block and be called `solve` and accept a single input called `symbols` representing the JSON information extracted. Do not include any `if __name__ == "__main__"` statement and you can assume the JSON will be loaded into the variable called `symbols` by the user. +The Python code should not just return the answer or perform all reasoning in comments and instead leverage the code itself to perform the reasoning. +Be careful that the code returns the answer as expected by the question, for instance, if the question is multiple choice, the code must return the choice as described in the question. +Be sure to always output a JSON code block and a Python code block. +Make sure to follow these formatting requirements exactly. +""" + + + # ========= INTERNAL HELPERS ===================================== + + def _chat( + self, + conversation: List[Dict[str, Any]], + sampling_params: SamplingParams, + stream: bool, + iteration: int, + callbacks: Optional[CbMap] = None, + ) -> str: + """ + Wrapper around model.chat / model.stream_chat that: + • chooses the right API based on `stream` + • fires streaming callbacks if supplied + • returns the full assistant text + """ + callbacks = callbacks or {} + + # Dummy lambdas so we can call without branch checks later + on_start = callbacks.get("on_llm_streaming_start", lambda *a, **k: None) + on_token = callbacks.get("on_llm_streaming_token", lambda *a, **k: None) + on_end = callbacks.get("on_llm_streaming_end", lambda *a, **k: None) + interrupted = callbacks.get("check_interrupted", lambda: False) + + model_name = self.model.__class__.__name__ + + if not stream: + # plain synchronous call + resp = self.model.chat(conversation, sampling_params=sampling_params, use_tqdm=False) + return resp[0].outputs[0].text + + # ---- streaming path ---- + on_start(iteration, model_name) + + def _emit(tok: str): + if not interrupted(): + on_token(tok, iteration, model_name) + + if hasattr(self.model, "stream_chat"): + resp = self.model.stream_chat( + conversation, + sampling_params=sampling_params, + emit_callback=_emit, + interrupted_callback=interrupted, + ) + else: # fallback + resp = self.model.chat(conversation, sampling_params=sampling_params, use_tqdm=False) + + on_end(iteration, model_name) + return resp[0].outputs[0].text + + # --------------------------------------------------------------- + + def _extract_components(self, output: str) -> Tuple[Any, str, str]: + """(unchanged helper) extract JSON, code, and reasoning.""" + json_obj, code_str, reasoning = "", "", "" + try: + if m := re.findall(r"```json(.*?)```", output, re.DOTALL): + json_obj = json.loads(m[-1]) + except Exception: + pass + try: + j_end = output.index("```", output.index("```json") + 7) + 3 + p_start = output.index("```python", j_end) + reasoning = output[j_end:p_start].strip() + except Exception: + pass + try: + if m := re.findall(r"```python(.*?)```", output, re.DOTALL): + code_str = m[-1] + except Exception: + pass + return json_obj, code_str, reasoning + + # ========= PUBLIC SOLVERS ====================================== + + def solve_chain_of_thought( + self, + sample: RawInput, + *, + stream: bool = False, + callbacks: Optional[CbMap] = None, + additional_rules: str = "", + ) -> Tuple[str, Dict[str, Any]]: + """ + One implementation covers both streaming & non-streaming. + If `stream=True`, supply the standard streaming callbacks. + """ + callbacks = callbacks or {} + step = callbacks.get("on_step_update", lambda *a, **k: None) + logs: Dict[str, Any] = {} + + # Build prompt with additional rules if provided + system_content = "" + if additional_rules.strip(): + system_content = f"Additional Requirements:\n{additional_rules.strip()}\n\nMake sure to follow these additional requirements when answering." + print(f"[DEBUG] Added custom rules to chain of thought prompt: {repr(additional_rules)}") + + if sample.image_input is not None: + img_b64 = img2base64(sample.image_input) + user_content = [ + {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img_b64}"}}, + {"type": "text", "text": f"Question: {sample.text_input}"}, + {"type": "text", "text": "Answer step-by-step and finish with 'FINAL ANSWER:'"}, + ] + else: + user_content = f"Question: {sample.text_input}\nAnswer step-by-step and finish with 'FINAL ANSWER:'." + + prompt = [] + if system_content: + prompt.append({"role": "system", "content": system_content}) + prompt.append({"role": "user", "content": user_content}) + params = SamplingParams(self.temperature, self.max_tokens, self.top_p) + + # Create prompt details for chain of thought + cot_prompt_details = { + "description": "Chain of thought reasoning", + "conversation": prompt + } + + step("reasoning", "Thinking step-by-step...", prompt_details=cot_prompt_details) + + # Call LLM through unified wrapper + output = self._chat(prompt, params, stream, iteration=0, callbacks=callbacks) + logs["output"] = output + + # Parse FINAL ANSWER (same logic) + ans = "" + try: + ans = re.findall(r"FINAL ANSWER:(.*)", output, re.DOTALL)[-1].strip() + except Exception: + pass + + # Check if we were interrupted during processing + interrupted = callbacks.get("check_interrupted", lambda: False) + if interrupted(): + step("interrupted", "PIPS was interrupted by the user.", prompt_details=None) + else: + step("finished", "Chain of thought completed!", prompt_details=None) + + final = f"FINAL ANSWER: {ans}" if ans else output + logs["final_answer"] = ans + return final, logs + + # --------------------------------------------------------------- + + def solve_with_code( + self, + sample: RawInput, + *, + stream: bool = False, + callbacks: Optional[CbMap] = None, + additional_rules: str = "", + ) -> Tuple[str, Dict[str, Any]]: + """ + Iterative code-generation solver (streaming or not). + `callbacks` is optional; provide it only when you care about + fine-grained streaming events. + Args: + sample: The raw input containing text and/or image. + stream: Whether to stream tokens from the underlying LLM. + callbacks: Optional callback map for streaming & execution events. + additional_rules: Extra natural-language rules that will be forwarded to the internal code critic for more specialized checking. + """ + callbacks = callbacks or {} + interrupted = callbacks.get("check_interrupted", lambda: False) + step = callbacks.get("on_step_update", lambda *a, **k: None) + + logs = {"all_outputs": [], "all_symbols": [], "all_programs": [], "all_reasoning": []} + + # Abort early? + if interrupted(): + return "", logs + + # ---- Build initial prompt with custom rules ---- + # Create system prompt with additional rules if provided + system_content = self.system_prompt + if additional_rules.strip(): + system_content += f"\n\nAdditional Requirements: \n{additional_rules.strip()}\n\n Make sure to follow these additional requirements when generating your solution." + print(f"[DEBUG] Added custom rules to initial code generation prompt: {repr(additional_rules)}") + + if sample.image_input is not None: + img_b64 = img2base64(sample.image_input) + content = [ + {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img_b64}"}}, + {"type": "text", "text": sample.text_input}, + ] + else: + content = sample.text_input + + conv = [ + {"role": "system", "content": system_content}, + {"role": "user", "content": content}, + ] + params = SamplingParams(self.temperature, self.max_tokens, self.top_p) + + # Create prompt details for initial generation + initial_prompt_details = { + "description": "Initial solution generation", + "conversation": conv + } + + step("initial_generation", "Generating first solution…", prompt_details=initial_prompt_details) + raw = self._chat(conv, params, stream, iteration=0, callbacks=callbacks) + logs["all_outputs"].append(raw) + conv.append({"role": "assistant", "content": raw}) + + # Extract JSON / code / reasoning + symbols, code, reasoning = self._extract_components(raw) + logs["all_symbols"].append(symbols) + logs["all_programs"].append(code) + logs["all_reasoning"].append(reasoning) + + # -------- execute & refine up to max_iterations -------- + exec_out, stdout, err = self._run_code(symbols, code, 0, callbacks, logs) + for i in range(1, self.max_iterations + 1): + if interrupted(): + break + + # --- evaluate code quality with prompt details --- + feedback = self._critic( + question=sample.text_input, + code=code, + symbols=symbols, + out=exec_out, + stdout=stdout, + err=err, + params=params, + additional_rules=additional_rules, + stream=stream, + iteration=i, + callbacks=callbacks, + ) + # Note: feedback is now displayed via streaming, no need for legacy callback + + # Interactive mode: wait for user feedback if enabled + if self.interactive: + print(f"[DEBUG] Interactive mode triggered at iteration {i}") + # Emit waiting for user feedback event + on_waiting_for_user = callbacks.get("on_waiting_for_user", lambda *a, **k: None) + on_waiting_for_user(i, feedback, code, symbols) + print(f"[DEBUG] Emitted awaiting_user_feedback event") + + # Store checkpoint for later continuation + self._checkpoint = { + "sample": sample, + "logs": logs, + "conv": conv, + "symbols": symbols, + "code": code, + "exec_out": exec_out, + "stdout": stdout, + "err": err, + "feedback": feedback, + "iteration": i, + "params": params, + "additional_rules": additional_rules, + "stream": stream, + "callbacks": callbacks + } + + # Return control to web_app - it will call continue_from_checkpoint + return "", logs + + # ask model to improve + fix_prompt = self._fix_prompt(sample.text_input, code, symbols, exec_out, stdout, err, feedback) + conv.append({"role": "user", "content": fix_prompt}) + + # Create prompt details for refinement + refinement_prompt_details = { + "description": f"Solution refinement (iteration {i})", + "conversation": conv + } + + step("refinement", f"Refining solution (iteration {i})...", iteration=i, prompt_details=refinement_prompt_details) + raw = self._chat(conv, params, stream, iteration=i, callbacks=callbacks) + logs["all_outputs"].append(raw) + conv.append({"role": "assistant", "content": raw}) + + if "FINISHED" in raw: + break + + # update code / symbols + symbols, code, reasoning = self._extract_components(raw) + if symbols: logs["all_symbols"].append(symbols) + if code: logs["all_programs"].append(code) + if reasoning: logs["all_reasoning"].append(reasoning) + + exec_out, stdout, err = self._run_code(symbols, code, i, callbacks, logs) + + # Check if we were interrupted during processing + if interrupted(): + step("interrupted", "PIPS was interrupted by the user.", prompt_details=None) + else: + step("finished", "Solution completed successfully!", prompt_details=None) + + final = f"FINAL ANSWER: {exec_out}" + return final, logs + + # ========= INTERACTIVE MODE HELPERS ============================ + + def continue_from_checkpoint(self, user_feedback: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]: + """ + Continue solving from a saved checkpoint with user feedback. + + Args: + user_feedback: Dictionary containing user feedback with keys: + - accept_critic: bool - whether to accept critic's feedback + - extra_comments: str - additional user comments + - quoted_ranges: list - specific code snippets user highlighted + - terminate: bool - whether user wants to terminate + + Returns: + Final answer and logs + """ + if not self._checkpoint: + raise ValueError("No checkpoint available - cannot continue interactive mode") + + checkpoint = self._checkpoint + user_feedback = user_feedback or {} + + # Check if user wants to terminate + if user_feedback.get("terminate", False): + final = f"FINAL ANSWER: {checkpoint['exec_out']}" + return final, checkpoint["logs"] + + # Merge critic feedback with user feedback + merged_feedback = self.merge_user_feedback( + checkpoint["feedback"], + user_feedback.get("accept_critic", True), + user_feedback.get("quoted_ranges", []) + ) + + # Check if user provided any feedback + has_user_feedback = bool(user_feedback.get("quoted_ranges", [])) + + # Continue the solving process + fix_prompt = self._fix_prompt( + checkpoint["sample"].text_input, + checkpoint["code"], + checkpoint["symbols"], + checkpoint["exec_out"], + checkpoint["stdout"], + checkpoint["err"], + merged_feedback, + has_user_feedback + ) + + checkpoint["conv"].append({"role": "user", "content": fix_prompt}) + + # Create prompt details for refinement + refinement_prompt_details = { + "description": f"Solution refinement (iteration {checkpoint['iteration']})", + "conversation": checkpoint["conv"] + } + + step = checkpoint["callbacks"].get("on_step_update", lambda *a, **k: None) + step("refinement", f"Refining solution (iteration {checkpoint['iteration']})...", + iteration=checkpoint['iteration'], prompt_details=refinement_prompt_details) + + raw = self._chat(checkpoint["conv"], checkpoint["params"], checkpoint["stream"], + iteration=checkpoint['iteration'], callbacks=checkpoint["callbacks"]) + + checkpoint["logs"]["all_outputs"].append(raw) + checkpoint["conv"].append({"role": "assistant", "content": raw}) + + if "FINISHED" in raw: + final = f"FINAL ANSWER: {checkpoint['exec_out']}" + return final, checkpoint["logs"] + + # Update code/symbols and continue + symbols, code, reasoning = self._extract_components(raw) + if symbols: checkpoint["logs"]["all_symbols"].append(symbols) + if code: checkpoint["logs"]["all_programs"].append(code) + if reasoning: checkpoint["logs"]["all_reasoning"].append(reasoning) + + exec_out, stdout, err = self._run_code(symbols, code, checkpoint['iteration'], + checkpoint["callbacks"], checkpoint["logs"]) + + # Temporarily disable interactive mode and continue with remaining iterations + original_interactive = self.interactive + self.interactive = False + + # Continue solving from next iteration + remaining_iterations = self.max_iterations - checkpoint['iteration'] + if remaining_iterations > 0: + # Create a new sample with current state + sample = checkpoint["sample"] + + # Continue refinement loop + for i in range(checkpoint['iteration'] + 1, self.max_iterations + 1): + interrupted = checkpoint["callbacks"].get("check_interrupted", lambda: False) + if interrupted(): + break + + feedback = self._critic( + question=sample.text_input, + code=code, + symbols=symbols, + out=exec_out, + stdout=stdout, + err=err, + params=checkpoint["params"], + additional_rules=checkpoint["additional_rules"], + stream=checkpoint["stream"], + iteration=i, + callbacks=checkpoint["callbacks"], + ) + + fix_prompt = self._fix_prompt(sample.text_input, code, symbols, exec_out, stdout, err, feedback) + checkpoint["conv"].append({"role": "user", "content": fix_prompt}) + + refinement_prompt_details = { + "description": f"Solution refinement (iteration {i})", + "conversation": checkpoint["conv"] + } + + step("refinement", f"Refining solution (iteration {i})...", + iteration=i, prompt_details=refinement_prompt_details) + + raw = self._chat(checkpoint["conv"], checkpoint["params"], checkpoint["stream"], + iteration=i, callbacks=checkpoint["callbacks"]) + + checkpoint["logs"]["all_outputs"].append(raw) + checkpoint["conv"].append({"role": "assistant", "content": raw}) + + if "FINISHED" in raw: + break + + symbols, code, reasoning = self._extract_components(raw) + if symbols: checkpoint["logs"]["all_symbols"].append(symbols) + if code: checkpoint["logs"]["all_programs"].append(code) + if reasoning: checkpoint["logs"]["all_reasoning"].append(reasoning) + + exec_out, stdout, err = self._run_code(symbols, code, i, checkpoint["callbacks"], checkpoint["logs"]) + + # Restore interactive mode + self.interactive = original_interactive + + # Clear checkpoint + self._checkpoint = None + + final = f"FINAL ANSWER: {exec_out}" + return final, checkpoint["logs"] + + def merge_user_feedback(self, critic_feedback: str, accept_critic: bool, + quoted_ranges: List[Dict]) -> str: + """ + Merge critic feedback with user feedback. + + Args: + critic_feedback: Original feedback from the critic + accept_critic: Whether to include critic's feedback + quoted_ranges: List of user feedback items (general comments, code feedback, symbol feedback) + + Returns: + Merged feedback string + """ + feedback_parts = [] + + if accept_critic and critic_feedback: + feedback_parts.append("AI Critic's feedback:") + feedback_parts.append(critic_feedback) + + if quoted_ranges: + # Separate general comments from specific code/symbol feedback + general_comments = [] + specific_feedback = [] + + for item in quoted_ranges: + if not item.get("comment"): + continue + + if item.get("type") == "general" or not item.get("text"): + general_comments.append(item["comment"]) + else: + specific_feedback.append(item) + + # Add general user comments + if general_comments: + feedback_parts.append("User feedback:") + feedback_parts.extend(general_comments) + + # Add specific code/symbol feedback + if specific_feedback: + feedback_parts.append("Specific code feedback:") + for item in specific_feedback: + feedback_parts.append(f"Regarding: {item['text']}") + feedback_parts.append(f"Comment: {item['comment']}") + + return "\n\n".join(feedback_parts) if feedback_parts else "No specific issues identified." + + # ========= SMALL UTILITY HELPERS (private) ===================== + + def _run_code( + self, + symbols: Any, + code: str, + iteration: int, + callbacks: CbMap, + logs: Dict[str, Any], + ) -> Tuple[str, str, str]: + """Execute candidate code, emit callbacks, store logs, return (out, stdout, err).""" + on_exec_start = callbacks.get("on_code_execution_start", lambda *a, **k: None) + on_exec_end = callbacks.get("on_code_execution_end", lambda *a, **k: None) + on_exec = callbacks.get("on_code_execution", lambda *a, **k: None) + max_time = callbacks.get("get_max_execution_time", lambda: 10)() + + on_exec_start(iteration) + try: + out, std, err = python_eval( + f"{code}\nsymbols = {json.dumps(symbols)}\nanswer = solve(symbols)", + max_execution_time=max_time, + ) + except Exception as e: + out, std, err = "None", "", str(e) + + on_exec_end(iteration) + on_exec(iteration, str(out), std, err) + logs.setdefault("execution_results", []).append({"output": out, "stdout": std, "error": err}) + return str(out), std, err + + # --------------------------------------------------------------- + + def _critic( + self, + question: str, + code: str, + symbols: Any, + out: str, + stdout: str, + err: str, + params: SamplingParams, + additional_rules: str = "", + stream: bool = False, + iteration: int = 1, + callbacks: Optional[CbMap] = None, + ) -> str: + """Ask the model to critique the code once per iteration.""" + system_content = f"""You will be given a question and a code solution and you must judge the quality of the code for solving the problem. + +Look for any of the following issues in the code: +- The code should be input dependent, meaning it should use the input symbols to compute the answer. It is OK for the code to be specialized to the input (i.e. the reasoning itself may be hardcoded, like a decision tree where the branches are hardcoded). +- The code should not return None unless "None" is the correct answer. +- The code should return the answer, not just print it. If the question asks for a multiple choice answer, the code should return the choice as described in the question. +- There should not be any example usage of the code. +- If there is a simpler way to solve the problem, please describe it. +- If there are any clear bugs in the code which impact the correctness of the answer, please describe them. +- If there are any issues with the extracted symbols, please describe them as well, but separate these issues from the issues with the code. +- If it is possible to sanity check the output of the code, please do so and describe if there are any obvious issues with the output and how the code could be fixed to avoid these issues. + +{"Additional issues and specifications to looks for: " if additional_rules else ""} +{additional_rules} + +After analyzing the code in depth, output a concrete and concise summary of the issues that are present, do not include any code examples. Please order the issues by impact on answer correctness.""" + + user_content = f"""Question: {question} + +The following are extracted symbols from the question in JSON format followed by a Python program which takes the JSON as an argument called `symbols` and computes the answer. +```json +{json.dumps(symbols, indent=2)} +``` + +```python +{code} +``` + +Code execution result: +``` +Return value: {out} +Standard output: {stdout} +Exceptions: {err} +``` + +Output a concrete and concise summary of only the issues that are present, do not include any code examples. +""" + + prompt = [ + {"role": "system", "content": system_content}, + {"role": "user", "content": user_content}, + ] + + # Create prompt details for the critic + critic_prompt_details = { + "description": f"Code quality analysis and critique (iteration {iteration})", + "conversation": prompt + } + + # Emit step update with critic prompt details + callbacks = callbacks or {} + step = callbacks.get("on_step_update", lambda *a, **k: None) + step("code_checking", f"Running code critic (iteration {iteration})...", iteration=iteration, prompt_details=critic_prompt_details) + + if not stream: + # Non-streaming path (backward compatibility) + return self.critic_model.chat(prompt, sampling_params=params, use_tqdm=False)[0].outputs[0].text + + # Streaming path for code reviewer + + # Create specialized callbacks for code reviewer streaming + def _make_reviewer_callbacks(): + on_start = callbacks.get("on_code_check_streaming_start", lambda *a, **k: None) + on_token = callbacks.get("on_code_check_streaming_token", lambda *a, **k: None) + on_end = callbacks.get("on_code_check_streaming_end", lambda *a, **k: None) + interrupted = callbacks.get("check_interrupted", lambda: False) + + def _emit(tok: str): + if not interrupted(): + on_token(tok, iteration, "AI Code Reviewer") + + return on_start, on_token, on_end, _emit + + on_start, on_token, on_end, _emit = _make_reviewer_callbacks() + + # Start streaming + model_name = "AI Code Reviewer" + on_start(iteration, model_name) + + # Call streaming method + if hasattr(self.critic_model, "stream_chat"): + resp = self.critic_model.stream_chat( + prompt, + sampling_params=params, + emit_callback=_emit, + ) + else: + # Fallback to regular chat with simulated streaming + resp = self.critic_model.chat(prompt, sampling_params=params, use_tqdm=False) + + on_end(iteration, model_name) + return resp[0].outputs[0].text + + # --------------------------------------------------------------- + + def _fix_prompt( + self, question, code, symbols, out, stdout, err, feedback, has_user_feedback=False + ) -> str: + """Return the prompt that asks the LLM to fix problems.""" + base_prompt = f"""Please fix the issues with the code and symbols or output "FINISHED". +The following is the result of evaluating the above code with the extracted symbols. +``` +Return value: {out} +Standard output: {stdout} +Exceptions: {err} +``` + +The following is the summary of issues found with the code or the extracted symbols by another model: +``` +{feedback} +``` +""" + + if has_user_feedback: + emphasis = """ +IMPORTANT: The feedback above includes specific user input that you MUST prioritize and address. Pay special attention to any user comments and requirements, as they represent critical guidance from the human user that should take precedence in your solution. +""" + base_prompt += emphasis + + base_prompt += """ +If there are any issues which impact the correctness of the answer, please output code which does not have the issues. Before outputting any code, plan how the code will solve the problem and avoid the issues. +If stuck, try outputting different code to solve the problem in a different way. +You may also revise the extracted symbols. To do this, output the revised symbols in a JSON code block. Only include information in the JSON which is present in the original input to keep the code grounded in the specific problem. Some examples of symbol revisions are changing the names of certain symbols, providing further granularity, and adding information which was originally missed. +If everything is correct, output the word "FINISHED" and nothing else. +""" + return base_prompt + + + +================================================ +File: model_registry.py +================================================ +""" +Model registry for PIPS - centralized model management. + +This module provides a pluggable model registry that makes it easy to add +new models from different providers without modifying the core codebase. +""" + +from typing import Dict, Any, Optional + +# Internal registry storage +_registry: Dict[str, Dict[str, Any]] = {} + +def register_model(name: str, provider: str, display: str = "", **config): + """ + Register a new model in the registry. + + Args: + name: Unique model identifier + provider: Provider name (openai, google, anthropic) + display: Human-readable display name + **config: Additional configuration parameters + """ + _registry[name] = { + "provider": provider, + "display": display or name, + **config + } + +def list_models() -> Dict[str, Dict[str, Any]]: + """ + Get all registered models. + + Returns: + Dictionary mapping model names to their configuration + """ + return _registry.copy() + +def get_model_config(name: str) -> Optional[Dict[str, Any]]: + """ + Get configuration for a specific model. + + Args: + name: Model identifier + + Returns: + Model configuration or None if not found + """ + return _registry.get(name) + +def get_available_models() -> Dict[str, str]: + """ + Get available models in the format expected by the UI. + + Returns: + Dictionary mapping model IDs to display names + """ + return {name: config["display"] for name, config in _registry.items()} + +# Initialize with default models +def _initialize_default_models(): + """Initialize the registry with default models.""" + + # OpenAI Models + register_model("gpt-4.1-2025-04-14", "openai", "OpenAI GPT-4.1") + register_model("gpt-4o-2024-08-06", "openai", "OpenAI GPT-4o") + register_model("gpt-4.1-mini-2025-04-14", "openai", "OpenAI GPT-4.1 Mini") + register_model("gpt-4o-mini", "openai", "OpenAI GPT-4o Mini") + register_model("o4-mini-2025-04-16", "openai", "OpenAI o4 Mini") + register_model("o3-2025-04-16", "openai", "OpenAI o3") + + # Google Models + register_model("gemini-2.0-flash", "google", "Google Gemini 2.0 Flash") + register_model("gemini-2.0-flash-codeinterpreter", "google", "Google Gemini 2.0 Flash (Code Interpreter)") + + # Anthropic Models + register_model("claude-sonnet-4-20250514", "anthropic", "Anthropic Claude 4 Sonnet") + register_model("claude-opus-4-20250514", "anthropic", "Anthropic Claude 4 Opus") + register_model("claude-3-5-haiku-latest", "anthropic", "Anthropic Claude 3.5 Haiku") + +# Initialize default models when module is imported +_initialize_default_models() + + +================================================ +File: models.py +================================================ +""" +LLM model interfaces for PIPS. + +This module provides a unified interface for various LLM providers including +OpenAI, Google Gemini, and Anthropic Claude models. +""" + +import os +import time +import json +import re +from openai import OpenAI +from typing import List, Dict, Any, Optional + +try: + import anthropic +except ImportError: + anthropic = None + +try: + from google import genai + from google.genai import types +except ImportError: + genai = None + types = None + +from .utils import RawInput, img2base64, base642img + + +class SamplingParams: + """ + Sampling parameters for LLM generation. + + Args: + temperature (float): Sampling temperature (0.0 to 2.0) + max_tokens (int): Maximum number of tokens to generate + top_p (float): Nucleus sampling parameter + n (int): Number of completions to generate + stop (list): List of stop sequences + """ + def __init__(self, temperature=0.0, max_tokens=4096, top_p=0.9, n=1, stop=None): + self.temperature = temperature + self.max_tokens = max_tokens + self.top_p = top_p + self.n = n + self.stop = stop + + +class LLMModel: + """ + Base class for LLM models. + + Provides a common interface for all LLM providers with lazy initialization + and both regular and streaming chat capabilities. + """ + + def __init__(self, model_name: str): + self.model_name = model_name + self._client = None + self._initialized = False + + def _ensure_initialized(self): + """Ensure the model client is initialized before use.""" + if not self._initialized: + self._initialize_client() + self._initialized = True + + def _initialize_client(self): + """Initialize the client - to be implemented by subclasses.""" + raise NotImplementedError + + def chat(self, prompt: List[Dict], sampling_params: SamplingParams, use_tqdm=False): + """ + Generate response using the model. + + Args: + prompt: List of message dictionaries in OpenAI format + sampling_params: Sampling configuration + use_tqdm: Whether to show progress bar (unused in base implementation) + + Returns: + List containing Outputs object with generated text + """ + self._ensure_initialized() + return self._chat_impl(prompt, sampling_params, use_tqdm) + + def _chat_impl(self, prompt: List[Dict], sampling_params: SamplingParams, use_tqdm=False): + """Actual chat implementation - to be implemented by subclasses.""" + raise NotImplementedError + + def stream_chat(self, prompt: List[Dict], sampling_params: SamplingParams, emit_callback=None, interrupted_callback=None): + """ + Stream response using the model with callback for each token. + + Default implementation falls back to regular chat with simulated streaming. + + Args: + prompt: List of message dictionaries in OpenAI format + sampling_params: Sampling configuration + emit_callback: Function to call for each generated token + interrupted_callback: Function to check if streaming should be interrupted + + Returns: + List containing Outputs object with generated text + """ + # Get the full response + result = self.chat(prompt, sampling_params, use_tqdm=False) + full_response = result[0].outputs[0].text + + # Simulate streaming by emitting tokens immediately + if emit_callback and full_response: + # Split response into reasonable chunks (words/punctuation) + words = re.findall(r'\S+|\s+', full_response) + for word in words: + # Check for interruption before emitting each word + if interrupted_callback and interrupted_callback(): + break + if emit_callback: + emit_callback(word) + + return result + + +class OpenAIModel(LLMModel): + """ + OpenAI GPT model interface. + + Supports GPT-4, GPT-4o, o3, and o4 model families with proper handling + of different model requirements (reasoning effort for o3/o4 models). + """ + + def __init__(self, model_name: str, api_key: Optional[str] = None): + super().__init__(model_name) + self.api_key = api_key or os.getenv("OPENAI_API_KEY") + if not self.api_key: + raise ValueError("OpenAI API key not provided and OPENAI_API_KEY environment variable not set") + + def _initialize_client(self): + """Initialize OpenAI client with appropriate settings.""" + self._client = OpenAI( + api_key=self.api_key, + timeout=900000000, + max_retries=3, + ) + + def _create_completion_with_retry(self, model, messages, max_attempts=5, delay_seconds=2, **kwargs): + """ + Call chat.completions.create with retry logic. + + Args: + model: Model name to use + messages: List of message dictionaries + max_attempts: Maximum number of retry attempts + delay_seconds: Delay between retries + **kwargs: Additional arguments for the API call + + Returns: + OpenAI ChatCompletion response + + Raises: + Exception: If all retry attempts fail + """ + if not self._client: + raise RuntimeError("Client not initialized") + + last_exception = None + for attempt in range(max_attempts): + try: + response = self._client.chat.completions.create( + model=model, + messages=messages, + **kwargs + ) + return response + except Exception as e: + last_exception = e + if attempt < max_attempts - 1: + time.sleep(delay_seconds) + else: + raise last_exception + + if last_exception: + raise last_exception + return None + + def _chat_impl(self, prompt: List[Dict], sampling_params: SamplingParams, use_tqdm=False): + """Implementation of chat for OpenAI models.""" + extra_args = {} + + # Configure parameters based on model type + if "o3" in self.model_name or "o4" in self.model_name: + # Reasoning models have special parameters + extra_args["reasoning_effort"] = "medium" + extra_args["max_completion_tokens"] = 20000 + extra_args["n"] = sampling_params.n + else: + # Standard models + extra_args["max_completion_tokens"] = sampling_params.max_tokens + extra_args["n"] = sampling_params.n + extra_args["temperature"] = sampling_params.temperature + extra_args["top_p"] = sampling_params.top_p + + response = self._create_completion_with_retry( + model=self.model_name, + messages=prompt, + **extra_args + ) + + # Create response wrapper classes + class Outputs: + def __init__(self, outputs): + self.outputs = outputs + + class Text: + def __init__(self, text): + self.text = text + + if hasattr(response, 'usage') and response.usage.completion_tokens > 0: + return [Outputs([Text(response.choices[i].message.content) for i in range(sampling_params.n)])] + else: + return [Outputs([Text("") for i in range(sampling_params.n)])] + + def stream_chat(self, prompt: List[Dict], sampling_params: SamplingParams, emit_callback=None, interrupted_callback=None): + """Stream response using OpenAI's streaming API.""" + self._ensure_initialized() + return self._stream_chat_impl(prompt, sampling_params, emit_callback, interrupted_callback) + + def _stream_chat_impl(self, prompt: List[Dict], sampling_params: SamplingParams, emit_callback=None, interrupted_callback=None): + """Implementation of streaming chat for OpenAI models.""" + if not self._client: + raise RuntimeError("Client not initialized") + + extra_args = {} + + # Configure parameters based on model type + if "o3" in self.model_name or "o4" in self.model_name: + extra_args["reasoning_effort"] = "medium" + extra_args["max_completion_tokens"] = 20000 + else: + extra_args["max_completion_tokens"] = sampling_params.max_tokens + extra_args["temperature"] = sampling_params.temperature + extra_args["top_p"] = sampling_params.top_p + + try: + stream = self._client.chat.completions.create( + model=self.model_name, + messages=prompt, + stream=True, + **extra_args + ) + + full_response = "" + for chunk in stream: + # Check for interruption before processing each chunk + if interrupted_callback and interrupted_callback(): + # Stop streaming immediately if interrupted + break + + if chunk.choices[0].delta.content is not None: + token = chunk.choices[0].delta.content + full_response += token + if emit_callback: + emit_callback(token) + + # Return in the same format as the non-streaming version + class Outputs: + def __init__(self, outputs): + self.outputs = outputs + + class Text: + def __init__(self, text): + self.text = text + + return [Outputs([Text(full_response)])] + + except Exception as e: + raise e + + +class GoogleModel(LLMModel): + """ + Google Gemini model interface. + + Supports both standard Gemini models and code interpreter variants + through different API endpoints. + """ + + def __init__(self, model_name: str, api_key: Optional[str] = None): + super().__init__(model_name) + self.api_key = api_key or os.getenv("GOOGLE_API_KEY") + if not self.api_key: + raise ValueError("Google API key not provided and GOOGLE_API_KEY environment variable not set") + + # Determine which provider to use based on model name + if "codeinterpreter" in model_name: + self.provider = "google-genai" + else: + self.provider = "google" + + def _initialize_client(self): + """Initialize Google client based on provider type.""" + if self.provider == "google-genai": + if not genai: + raise ImportError("google-genai library not installed. Install with: pip install google-genai") + self._client = genai.Client(api_key=self.api_key, http_options=types.HttpOptions(timeout=60*1000)) + else: + # Use OpenAI-compatible API endpoint + self._client = OpenAI( + api_key=self.api_key, + base_url="https://generativelanguage.googleapis.com/v1beta/openai/", + timeout=900000000, + max_retries=3, + ) + + def _chat_impl(self, prompt: List[Dict], sampling_params: SamplingParams, use_tqdm=False): + """Implementation of chat for Google models.""" + if self.provider == "google-genai": + return self._chat_genai(prompt, sampling_params) + else: + return self._chat_openai_compatible(prompt, sampling_params) + + def _chat_genai(self, prompt: List[Dict], sampling_params: SamplingParams): + """Chat implementation using Google GenAI library.""" + # Convert OpenAI format to Google GenAI format + genai_contents = [] + for message in prompt: + role = message["role"] + content = message["content"] + + if isinstance(content, str): + genai_contents.append( + types.Content( + role=role, + parts=[types.Part(text=content)] + ) + ) + elif isinstance(content, list): + parts = [] + for item in content: + if item["type"] == "text": + parts.append(types.Part(text=item["text"])) + elif item["type"] == "image_url": + img_url = item["image_url"]["url"] + if img_url.startswith("data:image"): + # Handle base64 encoded images + base64_data = img_url.split(",")[1] + parts.append( + types.Part( + inline_data=types.Blob( + mime_type="image/jpeg", + data=base64_data + ) + ) + ) + else: + # Handle image URLs + parts.append( + types.Part( + file_data=types.FileData( + file_uri=img_url, + mime_type="image/jpeg" + ) + ) + ) + if parts: + genai_contents.append( + types.Content( + role=role, + parts=parts + ) + ) + + response = self._client.models.generate_content( + model=self.model_name.replace("-codeinterpreter", ""), + contents=genai_contents, + config=types.GenerateContentConfig( + tools=[types.Tool( + code_execution=types.ToolCodeExecution + )], + temperature=sampling_params.temperature, + max_output_tokens=sampling_params.max_tokens, + ) + ) + + # Process response including code execution results + response_text = "" + code_execution_results = [] + + if response.candidates is not None: + for candidate in response.candidates: + if candidate.content is not None: + for part in candidate.content.parts: + if part.text is not None: + response_text += part.text + + if part.executable_code is not None: + executable_code = part.executable_code + if executable_code.code is not None: + code_execution_results.append({ + 'code': executable_code.code, + }) + + if part.code_execution_result is not None: + code_result = part.code_execution_result + if code_result.output is not None: + code_execution_results.append({ + 'output': code_result.output, + }) + + # Format final response with code execution results + final_response = "" + if code_execution_results: + for result in code_execution_results: + if "code" in result: + final_response += f"Code:\n{result['code']}\n" + if "output" in result: + final_response += f"Output:\n{result['output']}\n" + final_response += response_text + + class Outputs: + def __init__(self, outputs): + self.outputs = outputs + + class Text: + def __init__(self, text): + self.text = text + + return [Outputs([Text(final_response)])] + + def _chat_openai_compatible(self, prompt: List[Dict], sampling_params: SamplingParams): + """Chat implementation using OpenAI-compatible API.""" + response = self._client.chat.completions.create( + model=self.model_name, + messages=prompt, + max_completion_tokens=sampling_params.max_tokens, + n=sampling_params.n, + temperature=sampling_params.temperature, + top_p=sampling_params.top_p, + ) + + class Outputs: + def __init__(self, outputs): + self.outputs = outputs + + class Text: + def __init__(self, text): + self.text = text + + if response.usage.completion_tokens > 0: + return [Outputs([Text(response.choices[i].message.content) for i in range(sampling_params.n)])] + else: + return [Outputs([Text("") for i in range(sampling_params.n)])] + + def stream_chat(self, prompt: List[Dict], sampling_params: SamplingParams, emit_callback=None, interrupted_callback=None): + """Stream response using Google models.""" + self._ensure_initialized() + return self._stream_chat_impl(prompt, sampling_params, emit_callback, interrupted_callback) + + def _stream_chat_impl(self, prompt: List[Dict], sampling_params: SamplingParams, emit_callback=None, interrupted_callback=None): + """Implementation of streaming chat for Google models.""" + if self.provider == "google-genai": + return self._stream_chat_genai(prompt, sampling_params, emit_callback, interrupted_callback) + else: + return self._stream_chat_openai_compatible(prompt, sampling_params, emit_callback, interrupted_callback) + + def _stream_chat_genai(self, prompt: List[Dict], sampling_params: SamplingParams, emit_callback=None, interrupted_callback=None): + """Stream chat using Google GenAI - simulates streaming as API doesn't support it.""" + # Google GenAI doesn't support streaming yet, so we'll get the full response and simulate streaming + result = self._chat_genai(prompt, sampling_params) + full_response = result[0].outputs[0].text + + # Simulate streaming by emitting tokens immediately + if emit_callback and full_response: + # Split response into reasonable chunks (words/punctuation) + words = re.findall(r'\S+|\s+', full_response) + for word in words: + # Check for interruption before emitting each word + if interrupted_callback and interrupted_callback(): + break + if emit_callback: + emit_callback(word) + + return result + + def _stream_chat_openai_compatible(self, prompt: List[Dict], sampling_params: SamplingParams, emit_callback=None, interrupted_callback=None): + """Stream chat using OpenAI-compatible Google API.""" + if not self._client: + raise RuntimeError("Client not initialized") + + try: + stream = self._client.chat.completions.create( + model=self.model_name, + messages=prompt, + max_completion_tokens=sampling_params.max_tokens, + temperature=sampling_params.temperature, + top_p=sampling_params.top_p, + stream=True + ) + + full_response = "" + for chunk in stream: + # Check for interruption before processing each chunk + if interrupted_callback and interrupted_callback(): + break + + if chunk.choices[0].delta.content is not None: + token = chunk.choices[0].delta.content + full_response += token + if emit_callback: + emit_callback(token) + + # Return in the same format as the non-streaming version + class Outputs: + def __init__(self, outputs): + self.outputs = outputs + + class Text: + def __init__(self, text): + self.text = text + + return [Outputs([Text(full_response)])] + + except Exception as e: + raise e + + +class AnthropicModel(LLMModel): + """ + Anthropic Claude model interface. + + Supports Claude models with proper message format conversion + and streaming capabilities. + """ + + def __init__(self, model_name: str, api_key: Optional[str] = None): + super().__init__(model_name) + self.api_key = api_key or os.getenv("ANTHROPIC_API_KEY") + if not self.api_key: + raise ValueError("Anthropic API key not provided and ANTHROPIC_API_KEY environment variable not set") + + if not anthropic: + raise ImportError("anthropic library not installed. Install with: pip install anthropic") + + def _initialize_client(self): + """Initialize Anthropic client.""" + self._client = anthropic.Anthropic(api_key=self.api_key) + + def _convert_messages(self, prompt: List[Dict]) -> tuple: + """ + Convert OpenAI format messages to Anthropic format. + + Args: + prompt: List of message dictionaries in OpenAI format + + Returns: + Tuple of (system_message, messages) where messages are in Anthropic format + """ + system_message = "" + anthropic_messages = [] + + for message in prompt: + role = message["role"] + content = message["content"] + + if role == "system": + system_message = content if isinstance(content, str) else content[0]["text"] + else: + # Convert role names + if role == "assistant": + anthropic_role = "assistant" + else: + anthropic_role = "user" + + # Handle content format + if isinstance(content, str): + anthropic_content = content + elif isinstance(content, list): + # Handle multimodal content + anthropic_content = [] + for item in content: + if item["type"] == "text": + anthropic_content.append({ + "type": "text", + "text": item["text"] + }) + elif item["type"] == "image_url": + img_url = item["image_url"]["url"] + if img_url.startswith("data:image"): + # Extract base64 data and media type + header, base64_data = img_url.split(",", 1) + media_type = header.split(";")[0].split(":")[1] + anthropic_content.append({ + "type": "image", + "source": { + "type": "base64", + "media_type": media_type, + "data": base64_data + } + }) + else: + anthropic_content = str(content) + + anthropic_messages.append({ + "role": anthropic_role, + "content": anthropic_content + }) + + return system_message, anthropic_messages + + def _chat_impl(self, prompt: List[Dict], sampling_params: SamplingParams, use_tqdm=False): + """Implementation of chat for Anthropic models.""" + system_message, anthropic_messages = self._convert_messages(prompt) + + # Prepare API call arguments + kwargs = { + "model": self.model_name, + "messages": anthropic_messages, + "max_tokens": sampling_params.max_tokens, + "temperature": sampling_params.temperature, + "top_p": sampling_params.top_p, + } + + if system_message: + kwargs["system"] = system_message + + if sampling_params.stop: + kwargs["stop_sequences"] = sampling_params.stop + + response = self._client.messages.create(**kwargs) + + # Extract text from response + response_text = "" + for content_block in response.content: + if content_block.type == "text": + response_text += content_block.text + + # Create response wrapper classes + class Outputs: + def __init__(self, outputs): + self.outputs = outputs + + class Text: + def __init__(self, text): + self.text = text + + return [Outputs([Text(response_text)])] + + def stream_chat(self, prompt: List[Dict], sampling_params: SamplingParams, emit_callback=None, interrupted_callback=None): + """Stream response using Anthropic's streaming API.""" + self._ensure_initialized() + return self._stream_chat_impl(prompt, sampling_params, emit_callback, interrupted_callback) + + def _stream_chat_impl(self, prompt: List[Dict], sampling_params: SamplingParams, emit_callback=None, interrupted_callback=None): + """Implementation of streaming chat for Anthropic models.""" + if not self._client: + raise RuntimeError("Client not initialized") + + system_message, anthropic_messages = self._convert_messages(prompt) + + # Prepare API call arguments + kwargs = { + "model": self.model_name, + "messages": anthropic_messages, + "max_tokens": sampling_params.max_tokens, + "temperature": sampling_params.temperature, + "top_p": sampling_params.top_p, + "stream": True, + } + + if system_message: + kwargs["system"] = system_message + + if sampling_params.stop: + kwargs["stop_sequences"] = sampling_params.stop + + try: + full_response = "" + + with self._client.messages.stream(**kwargs) as stream: + for text in stream.text_stream: + # Check for interruption before processing each text chunk + if interrupted_callback and interrupted_callback(): + break + + full_response += text + if emit_callback: + emit_callback(text) + + # Return in the same format as the non-streaming version + class Outputs: + def __init__(self, outputs): + self.outputs = outputs + + class Text: + def __init__(self, text): + self.text = text + + return [Outputs([Text(full_response)])] + + except Exception as e: + raise e + + +def get_model(model_name: str, api_key: Optional[str] = None) -> LLMModel: + """ + Factory function to get the appropriate model instance. + + Args: + model_name: Name of the model to instantiate + api_key: Optional API key (will use environment variable if not provided) + + Returns: + LLMModel instance for the specified model + + Raises: + ValueError: If the model is not supported + """ + model_name_lower = model_name.lower() + + if any(model_name_lower.startswith(model) for model in ["gpt", "o3", "o4"]): + return OpenAIModel(model_name, api_key) + elif "gemini" in model_name_lower: + return GoogleModel(model_name, api_key) + elif "claude" in model_name_lower: + return AnthropicModel(model_name, api_key) + else: + raise ValueError(f"Unsupported model: {model_name}") + + +# Import models from the registry +from .model_registry import get_available_models + +# Available models - now pulled from the registry +AVAILABLE_MODELS = get_available_models() + + +================================================ +File: pyproject.toml +================================================ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "pips-solver" +version = "1.0.0" +description = "Python Iterative Problem Solving (PIPS) - A library for iterative code generation and refinement using LLMs" +readme = "README.md" +license = {text = "MIT"} +authors = [ + {name = "PIPS Development Team", email = "contact@example.com"} +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Topic :: Software Development :: Libraries :: Python Modules", +] +keywords = ["llm", "code-generation", "ai", "problem-solving", "iterative"] +requires-python = ">=3.8" +dependencies = [ + "openai>=1.0.0", + "anthropic>=0.7.0", + "google-genai>=0.2.0", + "flask>=2.0.0", + "flask-socketio>=5.0.0", + "pillow>=8.0.0", + "timeout-decorator>=0.5.0", + "python-socketio[client]>=5.0.0", +] + +[project.optional-dependencies] +web = [ + "flask>=2.0.0", + "flask-socketio>=5.0.0", + "python-socketio[client]>=5.0.0", +] +dev = [ + "pytest>=6.0.0", + "pytest-cov>=2.0.0", + "black>=22.0.0", + "flake8>=4.0.0", + "mypy>=0.950", + "isort>=5.0.0", +] +all = [ + "pips-solver[web,dev]" +] + +[project.scripts] +pips = "pips.__main__:main" + +[project.urls] +Homepage = "https://github.com/example/pips" +Repository = "https://github.com/example/pips" +Issues = "https://github.com/example/pips/issues" +Documentation = "https://github.com/example/pips#readme" + +[tool.hatch.build.targets.wheel] +packages = ["pips"] + +[tool.hatch.build.targets.sdist] +include = [ + "/pips", + "/README.md", + "/LICENSE", +] + +[tool.black] +line-length = 88 +target-version = ['py38'] + +[tool.isort] +profile = "black" +multi_line_output = 3 + +[tool.mypy] +python_version = "3.8" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = false + + + +================================================ +File: requirements.txt +================================================ +openai>=1.0.0 +anthropic>=0.7.0 +google-genai>=0.2.0 +flask>=2.0.0 +flask-socketio>=5.0.0 +pillow>=8.0.0 +timeout-decorator>=0.5.0 +python-socketio[client]>=5.0.0 + + + +================================================ +File: utils.py +================================================ +""" +Utility functions and data structures for PIPS. +""" + +from dataclasses import dataclass +from typing import Any, Optional +from io import BytesIO +import base64 +import contextlib +import multiprocessing +import timeout_decorator +from io import StringIO +from contextlib import redirect_stdout +from PIL import Image + + +@dataclass +class RawInput: + """Dataclass to store raw input for a function.""" + image_input: Optional[Image.Image] + text_input: Optional[str] + + +def img2base64(img): + """Convert PIL Image to base64 string.""" + buffer = BytesIO() + if img.mode != "RGB": + img = img.convert("RGB") + + # if width or height < 28, resize it keeping aspect ratio + if img.width < 28 or img.height < 28: + # make smallest dimension 28 + new_width = 28 + new_height = 28 + if img.width < img.height: + new_height = int((28 / img.width) * img.height) + else: + new_width = int((28 / img.height) * img.width) + img = img.resize((new_width, new_height)) + + img.save(buffer, format="JPEG") + return base64.b64encode(buffer.getvalue()).decode() + + +def base642img(base64_str): + """Convert base64 string to PIL Image.""" + imgdata = base64.b64decode(base64_str) + return Image.open(BytesIO(imgdata)) + + +@timeout_decorator.timeout(0.5) +def my_exec(code, locs): + exec(code, locs, locs) + + +def run_with_timeout(code, timeout, code_context=None): + """Execute code with timeout and capture output.""" + def target(queue): + locs = {} # Standard dictionary for local variables + locs["__name__"] = "__main__" + try: + if code_context: + exec(code_context, locs, locs) + except Exception as e: + pass + + try: + # store stdout in a variable + f = StringIO() + with redirect_stdout(f): + exec(code, locs, locs) # Execute the code with locs as locals + if "answer" in locs: + queue.put((locs.get("answer", None), f.getvalue())) # Retrieve the value of "answer" + else: + queue.put((None, f.getvalue())) # Retrieve the output + except Exception as e: + queue.put((f"Error: {e}", f.getvalue())) + + queue = multiprocessing.Queue() # Queue for communication + process = multiprocessing.Process(target=target, args=(queue,)) + process.start() + process.join(timeout) + + if process.is_alive(): + process.terminate() + process.join() + return None, "", "Error: Code execution timed out" + + # Retrieve result from the queue + if not queue.empty(): + result = queue.get() + answer, stdout = result[0], result[1] + # Check if the answer indicates an error + if isinstance(answer, str) and answer.startswith("Error:"): + return None, stdout, answer # Return error as the third element + else: + return answer, stdout, None # No error + return None, "", None + + +def python_eval(code: str, code_context: str = None, max_execution_time: int = 5): + """Evaluate Python code and return the result.""" + try: + if "if __name__ == '__main__'" in code: + code = code.replace( + "if __name__ == '__main__':\n main()", + " return answer\nif __name__ == '__main__':\n answer = main()", + ) + code = code.replace( + 'if __name__ == "__main__":\n main()', + " return answer\nif __name__ == '__main__':\n answer = main()", + ) + code = "answer = None\n" + code + if "main():" in code: + code += "\nmain()" + + return run_with_timeout(code, max_execution_time, code_context) + except Exception as e: + print("Exception:", e) + return "None", "", str(e) + + +def eval_extracted_code(code): + """Evaluate extracted code and return the answer.""" + try: + locs = {'__name__': '__main__'} + with contextlib.redirect_stdout(None): + exec(code, locs, locs) + return locs["answer"] + except Exception as e: + return "None" + + +================================================ +File: web_app.py +================================================ +""" +Flask-SocketIO server for the PIPS front-end. + +Matches the JS events used in index.html: + • session_connected + • settings_updated + • solving_started / step_update / llm_streaming_* / code_execution_* / code_check + • solving_complete / solving_error / solving_interrupted + • heartbeat_response + • download_chat_log +""" + +from __future__ import annotations + +import os, json, time, threading +from datetime import datetime +from typing import Any, Dict + +from flask import Flask, render_template, request, jsonify +from flask_socketio import SocketIO, emit + +# ─── project modules ──────────────────────────────────────────────────────────── +from .models import get_model, AVAILABLE_MODELS +from .core import PIPSSolver, PIPSMode +from .utils import RawInput, base642img +from .model_registry import register_model +# ──────────────────────────────────────────────────────────────────────────────── + +# --------------------------------------------------------------------- +# basic app setup +# --------------------------------------------------------------------- +app = Flask(__name__, template_folder="templates") +app.config["SECRET_KEY"] = "change-me" # ← customise for prod +socketio = SocketIO(app, cors_allowed_origins="*") + +# --------------------------------------------------------------------- +# server-side session state +# --------------------------------------------------------------------- +DEFAULT_SETTINGS = dict( + model = next(iter(AVAILABLE_MODELS)), # first model id + openai_api_key = "", + google_api_key = "", + anthropic_api_key = "", + use_code = True, + max_iterations = 8, + temperature = 0.0, + max_tokens = 4096, + max_execution_time = 10, + # New interactive mode settings + pips_mode = "AGENT", # or "INTERACTIVE" + generator_model = next(iter(AVAILABLE_MODELS)), # can be different from critic + critic_model = next(iter(AVAILABLE_MODELS)), # can be different from generator + custom_rules = "", # textarea value + prompt_overrides = {}, # persisted user edits keyed by prompt-id +) + +sessions: Dict[str, Dict[str, Any]] = {} # sid → data +active_tasks: Dict[str, Dict[str, Any]] = {} # sid → {"event":threading.Event, "task":Thread} + + +# ========== helpers ============================================================== + +def _safe(obj): + """JSON-serialise anything (fractions etc. become strings).""" + if obj is None or isinstance(obj, (str, int, float, bool)): + return obj + if isinstance(obj, list): + return [_safe(x) for x in obj] + if isinstance(obj, dict): + return {k: _safe(v) for k, v in obj.items()} + return str(obj) + + +def make_callbacks(sid: str, model_name: str, stop_evt: threading.Event, max_exec: int): + """Build the callbacks dict required by PIPSSolver (stream=True).""" + + def _emit(event: str, payload: dict): + # Force immediate emission without buffering + if event == "llm_streaming_token": + print(f"[DEBUG] Emitting token for session {sid}: '{payload.get('token', '')[:20]}...'") + elif event == "code_check_streaming_token": + print(f"[DEBUG] Emitting code reviewer token for session {sid}: '{payload.get('token', '')[:20]}...'") + else: + print(f"[DEBUG] Emitting {event} for session {sid}") + socketio.emit(event, payload, room=sid) + # Force flush the socket + socketio.sleep(0) # This forces Flask-SocketIO to flush immediately + + cb = dict( + # progress + on_step_update=lambda step, msg, iteration=None, prompt_details=None, **_: _emit( + "step_update", dict(step=step, message=msg, iteration=iteration, prompt_details=prompt_details) + ), + + # streaming + on_llm_streaming_start=lambda it, m: _emit( + "llm_streaming_start", dict(iteration=it, model_name=m) + ), + on_llm_streaming_token=lambda tok, it, m: _emit( + "llm_streaming_token", dict(token=tok, iteration=it, model_name=m) + ), + on_llm_streaming_end=lambda it, m: _emit( + "llm_streaming_end", dict(iteration=it, model_name=m) + ), + + # code reviewer streaming + on_code_check_streaming_start=lambda it, m: _emit( + "code_check_streaming_start", dict(iteration=it, model_name=m) + ), + on_code_check_streaming_token=lambda tok, it, m: _emit( + "code_check_streaming_token", dict(token=tok, iteration=it, model_name=m) + ), + on_code_check_streaming_end=lambda it, m: _emit( + "code_check_streaming_end", dict(iteration=it, model_name=m) + ), + + # code execution lifecycle + on_code_execution_start=lambda it: _emit( + "code_execution_start", dict(iteration=it) + ), + on_code_execution_end=lambda it: _emit( + "code_execution_end", dict(iteration=it) + ), + on_code_execution=lambda it, out, stdout, err: _emit( + "code_execution", + dict(iteration=it, output=str(out), stdout=stdout, error=err), + ), + + # Legacy on_code_check callback removed - now using streaming only + + on_error=lambda msg: _emit("solving_error", dict(error=msg)), + + # interruption / limits + check_interrupted=stop_evt.is_set, + get_max_execution_time=lambda: max_exec, + + # interactive mode callback + on_waiting_for_user=lambda iteration, critic_text, code, symbols: _emit( + "awaiting_user_feedback", + dict(iteration=iteration, critic_text=critic_text, code=code, symbols=_safe(symbols)) + ), + ) + return cb + + +# ========== routes ================================================================= + +@app.route("/") +def index(): + return render_template( + "index_modular.html", + available_models=AVAILABLE_MODELS, + default_settings=DEFAULT_SETTINGS, + ) + + +# ========== socket events =========================================================== + +@socketio.on("connect") +def on_connect(): + sid = request.sid + sessions[sid] = dict(settings=DEFAULT_SETTINGS.copy(), chat=[]) + emit("session_connected", {"session_id": sid}) + print(f"[CONNECT] {sid}") + + +@socketio.on("disconnect") +def on_disconnect(): + sid = request.sid + if sid in active_tasks: + active_tasks[sid]["event"].set() + active_tasks.pop(sid, None) + sessions.pop(sid, None) + print(f"[DISCONNECT] {sid}") + + +@socketio.on("update_settings") +def on_update_settings(data): + sid = request.sid + if sid not in sessions: + emit("settings_updated", {"status": "error", "message": "No session"}) + return + + sessions[sid]["settings"].update(data) + emit("settings_updated", {"status": "success", "settings": sessions[sid]["settings"]}) + + +@socketio.on("solve_problem") +def on_solve_problem(data): + sid = request.sid + if sid not in sessions: + emit("solving_error", {"error": "Session vanished"}) + return + + text = (data.get("text") or "").strip() + if not text: + emit("solving_error", {"error": "Problem text is empty"}) + return + + img_b64 = data.get("image") + img = None + if img_b64 and img_b64.startswith("data:image"): + try: + img = base642img(img_b64.split(",", 1)[1]) + except Exception as e: + emit("solving_error", {"error": f"Bad image: {e}"}) + return + + settings = sessions[sid]["settings"] + generator_model_id = settings.get("generator_model", settings["model"]) + critic_model_id = settings.get("critic_model", settings["model"]) + pips_mode = settings.get("pips_mode", "AGENT") + # Handle both new format (global_rules + session_rules) and legacy format (custom_rules) + global_rules = settings.get("global_rules", "") + session_rules = settings.get("session_rules", "") + legacy_custom_rules = settings.get("custom_rules", "") + + # Combine rules for the critic + combined_rules = [] + if global_rules: + combined_rules.append(f"Global Rules:\n{global_rules}") + if session_rules: + combined_rules.append(f"Session Rules:\n{session_rules}") + if legacy_custom_rules and not global_rules and not session_rules: + # Backward compatibility + combined_rules.append(legacy_custom_rules) + + custom_rules = "\n\n".join(combined_rules) + + print(f"[DEBUG] Custom rules processing for session {sid}:") + print(f" Global rules: {repr(global_rules)}") + print(f" Session rules: {repr(session_rules)}") + print(f" Legacy rules: {repr(legacy_custom_rules)}") + print(f" Combined rules: {repr(custom_rules)}") + + # Helper function to get API key for a model + def get_api_key_for_model(model_id): + if any(model_id.startswith(model) for model in ["gpt", "o3", "o4"]): + return settings.get("openai_api_key") + elif "gemini" in model_id: + return settings.get("google_api_key") + elif "claude" in model_id: + return settings.get("anthropic_api_key") + return None + + # Validate API keys for both models + generator_api_key = get_api_key_for_model(generator_model_id) + critic_api_key = get_api_key_for_model(critic_model_id) + + if not generator_api_key: + emit("solving_error", {"error": f"API key missing for generator model: {generator_model_id}"}) + return + + if not critic_api_key: + emit("solving_error", {"error": f"API key missing for critic model: {critic_model_id}"}) + return + + stop_evt = threading.Event() + + def task(): + try: + print(f"[DEBUG] Starting solving task for session {sid}") + + # Create models + generator_model = get_model(generator_model_id, generator_api_key) + critic_model = get_model(critic_model_id, critic_api_key) if critic_model_id != generator_model_id else generator_model + + # Create solver with interactive mode support + is_interactive = (pips_mode == "INTERACTIVE") + print(f"[DEBUG] PIPS mode: {pips_mode}, is_interactive: {is_interactive}") + solver = PIPSSolver( + generator_model, + max_iterations=settings["max_iterations"], + temperature=settings["temperature"], + max_tokens=settings["max_tokens"], + interactive=is_interactive, + critic_model=critic_model, + ) + + sample = RawInput(text_input=text, image_input=img) + cbs = make_callbacks( + sid, generator_model.__class__.__name__, stop_evt, settings["max_execution_time"] + ) + + print(f"[DEBUG] Emitting solving_started for session {sid}") + socketio.emit("solving_started", {}, room=sid) + socketio.sleep(0) # Force flush + + if settings["use_code"]: + print(f"[DEBUG] Starting solve_with_code with streaming for session {sid}") + answer, logs = solver.solve_with_code( + sample, + stream=True, + callbacks=cbs, + additional_rules=custom_rules + ) + + # If interactive mode returned early (waiting for user), store solver in session + if is_interactive and not answer and solver._checkpoint: + sessions[sid]["solver"] = solver + print(f"[DEBUG] Interactive mode - waiting for user feedback for session {sid}") + return + + else: + print(f"[DEBUG] Starting solve_chain_of_thought with streaming for session {sid}") + answer, logs = solver.solve_chain_of_thought(sample, stream=True, callbacks=cbs, additional_rules=custom_rules) + + if stop_evt.is_set(): + print(f"[DEBUG] Task was interrupted for session {sid}") + socketio.emit("solving_interrupted", {"message": "Interrupted"}, room=sid) + return + + print(f"[DEBUG] Solving completed, emitting final answer for session {sid}") + + # Extract final artifacts for display + latest_symbols = logs.get("all_symbols", [])[-1] if logs.get("all_symbols") else {} + latest_code = logs.get("all_programs", [])[-1] if logs.get("all_programs") else "" + + # Emit final artifacts + socketio.emit("final_artifacts", { + "symbols": _safe(latest_symbols), + "code": latest_code + }, room=sid) + + socketio.emit( + "solving_complete", + { + "final_answer": answer, + "logs": _safe(logs), + "method": "iterative_code" if settings["use_code"] else "chain_of_thought", + }, + room=sid, + ) + + except Exception as exc: + print(f"[DEBUG] Exception in solving task for session {sid}: {exc}") + socketio.emit("solving_error", {"error": str(exc)}, room=sid) + finally: + print(f"[DEBUG] Cleaning up task for session {sid}") + active_tasks.pop(sid, None) + + active_tasks[sid] = dict(event=stop_evt, task=socketio.start_background_task(task)) + + +@socketio.on("interrupt_solving") +def on_interrupt(data=None): + sid = request.sid + if sid in active_tasks: + active_tasks[sid]["event"].set() + emit("solving_interrupted", {"message": "Stopped."}) + else: + emit("solving_interrupted", {"message": "No active task."}) + + +@socketio.on("provide_feedback") +def on_provide_feedback(data): + """Handle user feedback in interactive mode.""" + sid = request.sid + if sid not in sessions: + emit("solving_error", {"error": "Session vanished"}) + return + + solver = sessions[sid].get("solver") + if not solver or not solver._checkpoint: + emit("solving_error", {"error": "No interactive session waiting for feedback"}) + return + + # Extract user feedback + user_feedback = { + "accept_critic": data.get("accept_critic", True), + "extra_comments": data.get("extra_comments", ""), + "quoted_ranges": data.get("quoted_ranges", []), + "terminate": data.get("terminate", False) + } + + def continue_task(): + try: + print(f"[DEBUG] Continuing interactive task with user feedback for session {sid}") + + # Continue from checkpoint with user feedback + answer, logs = solver.continue_from_checkpoint(user_feedback) + + # Extract final artifacts + latest_symbols = logs.get("all_symbols", [])[-1] if logs.get("all_symbols") else {} + latest_code = logs.get("all_programs", [])[-1] if logs.get("all_programs") else "" + + # Emit final artifacts + socketio.emit("final_artifacts", { + "symbols": _safe(latest_symbols), + "code": latest_code + }, room=sid) + + # Emit completion + socketio.emit("solving_complete", { + "final_answer": answer, + "logs": _safe(logs), + "method": "iterative_code_interactive", + }, room=sid) + + except Exception as exc: + print(f"[DEBUG] Exception in interactive continuation for session {sid}: {exc}") + socketio.emit("solving_error", {"error": str(exc)}, room=sid) + finally: + # Clean up + sessions[sid].pop("solver", None) + active_tasks.pop(sid, None) + + # Start continuation task + active_tasks[sid] = dict(event=threading.Event(), task=socketio.start_background_task(continue_task)) + + +@socketio.on("terminate_session") +def on_terminate_session(data=None): + """Handle user termination of interactive session.""" + sid = request.sid + if sid not in sessions: + emit("solving_error", {"error": "Session vanished"}) + return + + solver = sessions[sid].get("solver") + if not solver or not solver._checkpoint: + emit("solving_error", {"error": "No interactive session to terminate"}) + return + + # Terminate with current state + user_feedback = {"terminate": True} + + def terminate_task(): + try: + print(f"[DEBUG] Terminating interactive task for session {sid}") + + # Get final answer from checkpoint + answer, logs = solver.continue_from_checkpoint(user_feedback) + + # Extract final artifacts + latest_symbols = logs.get("all_symbols", [])[-1] if logs.get("all_symbols") else {} + latest_code = logs.get("all_programs", [])[-1] if logs.get("all_programs") else "" + + # Emit final artifacts + socketio.emit("final_artifacts", { + "symbols": _safe(latest_symbols), + "code": latest_code + }, room=sid) + + # Emit completion + socketio.emit("solving_complete", { + "final_answer": answer, + "logs": _safe(logs), + "method": "iterative_code_interactive_terminated", + }, room=sid) + + except Exception as exc: + print(f"[DEBUG] Exception in interactive termination for session {sid}: {exc}") + socketio.emit("solving_error", {"error": str(exc)}, room=sid) + finally: + # Clean up + sessions[sid].pop("solver", None) + active_tasks.pop(sid, None) + + # Start termination task + active_tasks[sid] = dict(event=threading.Event(), task=socketio.start_background_task(terminate_task)) + + +@socketio.on("switch_mode") +def on_switch_mode(data): + """Handle switching between AGENT and INTERACTIVE modes.""" + sid = request.sid + if sid not in sessions: + emit("solving_error", {"error": "Session vanished"}) + return + + new_mode = data.get("mode", "AGENT") + if new_mode not in ["AGENT", "INTERACTIVE"]: + emit("solving_error", {"error": "Invalid mode"}) + return + + # Update session settings + sessions[sid]["settings"]["pips_mode"] = new_mode + + emit("mode_switched", {"mode": new_mode}) + + +@socketio.on("heartbeat") +def on_heartbeat(data): + emit("heartbeat_response", {"timestamp": data.get("timestamp"), "server_time": time.time()}) + + +@socketio.on("download_chat_log") +def on_download_chat_log(): + sid = request.sid + sess = sessions.get(sid) + if not sess: + emit("error", {"message": "Session missing"}) + return + + payload = dict( + session_id=sid, + timestamp=datetime.utcnow().isoformat(), + settings=_safe(sess["settings"]), + chat_history=_safe(sess["chat"]), + ) + emit( + "chat_log_ready", + { + "filename": f"pips_chat_{sid[:8]}.json", + "content": json.dumps(payload, indent=2), + }, + ) + + +# ========== public runner ========================================================== + +def run_app(host: str = "0.0.0.0", port: int = 8080, debug: bool = False): + os.makedirs("uploads", exist_ok=True) # if you later add upload support + socketio.run(app, host=host, port=port, debug=debug) + + +# --------------------------------------------------------------------- +if __name__ == "__main__": # script usage: python pips/web_app.py --port 5000 + import argparse + ap = argparse.ArgumentParser() + ap.add_argument("--host", default="0.0.0.0") + ap.add_argument("--port", type=int, default=8080) + ap.add_argument("--debug", action="store_true") + args = ap.parse_args() + run_app(args.host, args.port, args.debug) + + + + +================================================ +File: static/README.md +================================================ +# PIPS Frontend Modularization + +This directory contains the refactored, modular version of the PIPS (Per-Instance Program Synthesis) frontend application. + +## 📁 Structure Overview + +``` +pips/static/ +├── css/ +│ ├── main.css # Main CSS entry point (imports all modules) +│ ├── tokens.css # Design tokens (colors, spacing, etc.) +│ ├── base.css # Global resets and typography +│ └── components/ +│ ├── panels.css # Left/right panel layouts +│ ├── forms.css # Form elements and inputs +│ ├── buttons.css # Button components +│ ├── chat.css # Chat area and message styles +│ ├── sessions.css # Session management UI +│ ├── modal.css # Modal dialogs +│ ├── utilities.css # Utility classes and animations +│ └── responsive.css # Media queries for mobile +├── js/ +│ ├── main.js # Application bootstrap +│ ├── core/ +│ │ ├── logger.js # Debug logging utility +│ │ ├── state.js # Application state management +│ │ └── storage.js # localStorage utilities +│ └── network/ +│ └── socket.js # Socket.IO connection management +└── README.md # This file +``` + +## 🔄 Migration from Monolithic + +### Before (index.html) +- **~4000 lines** in single file +- Inline ` + + +

PIPS Chat Export

+
${chatContent}
+ + + `], { type: 'text/html' }); + + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `pips_chat_${new Date().toISOString().split('T')[0]}.html`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + + // SESSION MANAGEMENT METHODS + getCurrentChatHistory() { + const chatArea = domManager.getElement('chatArea'); + if (!chatArea) { + Logger.warn('MessageManager', 'Chat area not found'); + return []; + } + + const messages = chatArea.querySelectorAll('.chat-message'); + const history = []; + + messages.forEach(message => { + const senderElement = message.querySelector('.message-sender'); + const contentElement = message.querySelector('.message-content'); + const iterationElement = message.querySelector('.iteration-badge'); + + if (!senderElement || !contentElement) { + Logger.debug('MessageManager', 'Skipping malformed message'); + return; // Skip malformed messages + } + + const sender = senderElement.textContent || 'Unknown'; + let content = ''; + + // Get content - extract only the main content, excluding expandable elements + let contentToSave = ''; + const contentChildren = Array.from(contentElement.children); + + // Look for the main content, excluding expand toggles and expandable content + contentChildren.forEach(child => { + if (!child.classList.contains('expand-toggle') && + !child.classList.contains('expandable-content')) { + contentToSave += child.outerHTML; + } + }); + + // If no child elements found, get direct text content + if (!contentToSave) { + // Get text nodes directly, excluding expand button text + const clonedContent = contentElement.cloneNode(true); + const expandToggle = clonedContent.querySelector('.expand-toggle'); + const expandableContent = clonedContent.querySelector('.expandable-content'); + if (expandToggle) expandToggle.remove(); + if (expandableContent) expandableContent.remove(); + contentToSave = clonedContent.innerHTML.trim() || clonedContent.textContent.trim(); + } + + content = contentToSave; + + const iteration = iterationElement ? iterationElement.textContent : null; + + // Skip the welcome message + if (sender === 'PIPS System' && content.includes('Welcome to PIPS')) { + return; + } + + // Skip empty messages but be more specific about what to filter + if (!content || content === '') { + Logger.debug('MessageManager', 'Skipping empty message'); + return; + } + + // Skip only currently active streaming indicators (not completed messages that might have streaming classes) + if (message.classList.contains('ai-thinking') || + message.classList.contains('streaming-message') || + content.includes('AI is thinking...') || + content.includes('Executing code...')) { + Logger.debug('MessageManager', 'Skipping active streaming indicator'); + return; + } + + // Check if this message has prompt details + const expandableContent = message.querySelector('.expandable-content'); + let promptDetails = null; + + if (expandableContent) { + // Extract prompt details from the DOM + const promptDescription = expandableContent.querySelector('.prompt-description'); + const promptMessages = expandableContent.querySelectorAll('.prompt-message'); + + if (promptMessages.length > 0) { + promptDetails = { + description: promptDescription ? promptDescription.textContent : '', + conversation: Array.from(promptMessages).map(promptMsg => ({ + role: promptMsg.querySelector('.prompt-role').textContent.toLowerCase(), + content: promptMsg.querySelector('.prompt-content').textContent + })) + }; + } + } + + history.push({ + sender, + content, + iteration, + promptDetails, + timestamp: new Date().toISOString() + }); + }); + + Logger.debug('MessageManager', `Extracted ${history.length} messages from chat`); + return history; + } + + loadChatHistory(history) { + const chatArea = domManager.getElement('chatArea'); + + // Find and preserve the welcome message first + let welcomeMessage = null; + const existingMessages = chatArea.querySelectorAll('.chat-message'); + existingMessages.forEach(msg => { + const sender = msg.querySelector('.message-sender'); + const content = msg.querySelector('.message-content'); + if (sender && content && + sender.textContent === 'PIPS System' && + content.textContent.includes('Welcome to PIPS')) { + welcomeMessage = msg.cloneNode(true); + } + }); + + // Clear existing messages + chatArea.innerHTML = ''; + + // Restore welcome message if it existed + if (welcomeMessage) { + chatArea.appendChild(welcomeMessage); + } + + // Load messages from history + if (history && history.length > 0) { + Logger.debug('MessageManager', `Loading ${history.length} messages from history`); + + history.forEach((msg, index) => { + if (!msg || !msg.sender || !msg.content) { + Logger.warn('MessageManager', `Skipping invalid message at index ${index}:`, msg); + return; + } + + const messageDiv = document.createElement('div'); + messageDiv.className = 'chat-message'; + + const avatarClass = msg.sender === 'PIPS' || msg.sender === 'PIPS System' ? 'avatar-pips' : + msg.sender === 'AI Code Reviewer' ? 'avatar-reviewer' : + msg.sender.includes('AI') ? 'avatar-llm' : 'avatar-system'; + const avatarLetter = msg.sender === 'PIPS' || msg.sender === 'PIPS System' ? 'P' : + msg.sender === 'AI Code Reviewer' ? 'QA' : + msg.sender.includes('AI') ? 'AI' : 'S'; + + const iterationBadge = msg.iteration ? + `${this.escapeHtml(msg.iteration)}` : ''; + + // Handle expandable content for loaded messages + const expandToggle = msg.promptDetails ? ` + + ` : ''; + + const expandableContent = msg.promptDetails ? ` +
+
+ ${msg.promptDetails.description ? `
${this.escapeHtml(msg.promptDetails.description)}
` : ''} +
+ ${msg.promptDetails.conversation.map(promptMsg => ` +
+
${promptMsg.role}
+
${this.escapeHtml(promptMsg.content)}
+
+ `).join('')} +
+
+
+ ` : ''; + + if (msg.promptDetails) { + messageDiv.classList.add('expandable-message'); + } + + messageDiv.innerHTML = ` +
+
${avatarLetter}
+ ${this.escapeHtml(msg.sender)} + ${iterationBadge} +
+
+ ${msg.content} + ${expandToggle} + ${expandableContent} +
+ `; + + chatArea.appendChild(messageDiv); + }); + + // Replace feather icons for any expandable messages + if (typeof feather !== 'undefined') { + feather.replace(chatArea); + } + } else { + Logger.debug('MessageManager', 'No chat history to load'); + } + + // Re-highlight code blocks + if (typeof Prism !== 'undefined') { + Prism.highlightAll(); + } + + this.smartScrollToBottom(); + } + + clearChatAndRestoreWelcome() { + const chatArea = domManager.getElement('chatArea'); + chatArea.innerHTML = ''; + + // Add fresh welcome message + const welcomeDiv = document.createElement('div'); + welcomeDiv.className = 'chat-message'; + welcomeDiv.innerHTML = ` +
+
P
+ PIPS System +
+
+ Welcome to PIPS! Enter a problem in the left panel and click "Solve Problem" to get started. + Don't forget to configure your model settings first. +
+ `; + chatArea.appendChild(welcomeDiv); + } + + // CLEANUP METHODS - for handling session interruptions and failures + cleanupAllActiveIndicators() { + Logger.debug('MessageManager', 'Cleaning up all active indicators'); + + // Remove all AI thinking indicators + const thinkingElements = domManager.getElement('chatArea').querySelectorAll('.ai-thinking'); + thinkingElements.forEach(el => el.remove()); + + // Remove all execution spinners + const executionSpinners = domManager.getElement('chatArea').querySelectorAll('.execution-spinner'); + executionSpinners.forEach(el => el.remove()); + + // Finalize all streaming messages + const streamingMessages = domManager.getElement('chatArea').querySelectorAll('.streaming-message'); + streamingMessages.forEach(streamingMessage => { + // Remove typing indicator + const typingIndicator = streamingMessage.querySelector('.typing-indicator'); + if (typingIndicator) { + typingIndicator.remove(); + } + + // Remove streaming attributes + streamingMessage.classList.remove('streaming-message'); + streamingMessage.removeAttribute('data-streaming-iteration'); + streamingMessage.removeAttribute('data-streaming-id'); + }); + + // Re-highlight code blocks after cleanup + if (typeof Prism !== 'undefined') { + Prism.highlightAll(); + } + + Logger.debug('MessageManager', 'All active indicators cleaned up'); + } + + // For incremental saving during solving - save messages as they come in + saveMessageIncremental(sender, content, iteration = null, promptDetails = null) { + // This is called after each message is added to save it incrementally + // Import sessionManager to avoid circular dependency + import('./session-manager.js').then(({ sessionManager }) => { + if (window.appState && window.appState.currentSessionData) { + // Update chat history with current messages + window.appState.currentSessionData.chatHistory = this.getCurrentChatHistory(); + window.appState.currentSessionData.lastUsed = new Date().toISOString(); + + // Save to storage incrementally + sessionManager.saveCurrentSessionToStorage(); + + Logger.debug('MessageManager', `Incrementally saved message from ${sender} to session`); + } + }).catch(err => { + Logger.warn('MessageManager', 'Could not save message incrementally:', err); + }); + } +} + +// Create singleton instance +export const messageManager = new MessageManager(); + + +================================================ +File: static/js/ui/session-manager.js +================================================ +/** + * Session Manager - Handles session UI and management functionality + */ +import { Logger } from '../core/logger.js'; +import { appState } from '../core/state.js'; +import { storageManager } from '../core/storage.js'; +import { domManager } from './dom-manager.js'; +import { messageManager } from './message-manager.js'; +import { imageHandler } from './image-handler.js'; + +export class SessionManager { + constructor() { + this.isInitialized = false; + this.periodicSaveInterval = null; + } + + initialize() { + if (this.isInitialized) return; + + // Clean up ghost sessions on startup + this.cleanupGhostSessions(); + + this.setupEventListeners(); + this.refreshSessionsList(); + this.isInitialized = true; + + Logger.debug('Session', 'Session manager initialized'); + } + + setupEventListeners() { + // Session management listeners + domManager.getElement('newSessionBtn')?.addEventListener('click', () => this.startNewSession()); + domManager.getElement('sessionsToggle')?.addEventListener('click', () => this.toggleSessions()); + domManager.getElement('clearSessionsBtn')?.addEventListener('click', () => this.clearAllSessionsEnhanced()); + domManager.getElement('exportSessionsBtn')?.addEventListener('click', () => this.exportSessions()); + + // Session header click + document.querySelector('.sessions-header')?.addEventListener('click', () => { + document.getElementById('sessionsToggle')?.click(); + }); + + Logger.debug('Session', 'Event listeners set up'); + } + + startNewSession() { + Logger.debug('Session', 'Start New Session button clicked'); + this.resetToNewSessionState(); + domManager.updateStatus('Ready to start a new session', 'success'); + } + + resetToNewSessionState() { + console.log('[DEBUG] Resetting to new session state'); + + // Save current session before resetting if we have one + if (appState.currentSessionData) { + console.log('[DEBUG] Saving current session before reset'); + appState.currentSessionData.chatHistory = messageManager.getCurrentChatHistory(); + // Update the current state + appState.currentSessionData.problemText = domManager.getElement('questionInput')?.value.trim() || ''; + const imageElement = domManager.getElement('imagePreview'); + appState.currentSessionData.image = imageElement?.style.display !== 'none' ? imageElement.src : null; + appState.currentSessionData.title = this.generateSessionTitle(appState.currentSessionData.problemText); + this.saveCurrentSessionToStorage(); + } + + // Reset session management state + appState.selectedSessionId = null; + appState.currentSessionData = null; + + // Clear visual selection + document.querySelectorAll('.session-item').forEach(item => { + item.classList.remove('selected'); + }); + + // Clear inputs and make them editable + this.clearAndEnableInputs(); + + // Clear chat and restore welcome message properly + messageManager.clearChatAndRestoreWelcome(); + + // Clear any existing feedback panels from previous sessions + if (window.interactiveFeedback) { + window.interactiveFeedback.removeFeedbackPanel(); + window.interactiveFeedback.removeRestoreButton(); + } + + // Clear any final solution artifacts panels + document.querySelectorAll('.final-artifacts-compact').forEach(panel => { + panel.remove(); + }); + + // Clear per-session custom rules + import('./settings-manager.js').then(({ settingsManager }) => { + settingsManager.clearPerSessionRules(); + }); + + this.updateCurrentSessionDisplay(); + console.log('[DEBUG] Reset to new session state completed'); + } + + clearAndEnableInputs() { + // Clear inputs + domManager.clearInputs(); + + // Enable and reset input field to editable state + const questionInputElement = domManager.getElement('questionInput'); + const solveBtnElement = domManager.getElement('solveBtn'); + + if (questionInputElement) { + questionInputElement.disabled = false; + questionInputElement.style.backgroundColor = ''; + questionInputElement.style.cursor = ''; + questionInputElement.title = ''; + questionInputElement.placeholder = "Enter your problem here... (e.g., 'What is the square root of 144?', 'Solve this math puzzle', etc.)"; + } + + if (solveBtnElement && !appState.isSolving) { + solveBtnElement.style.display = 'inline-flex'; + solveBtnElement.disabled = false; + solveBtnElement.title = ''; + } + + // Remove any read-only messages + this.removeReadOnlyMessage(); + + // Replace feather icons + if (typeof feather !== 'undefined') { + feather.replace(); + } + } + + setInputsReadOnly(reason = 'This session has been used and is now read-only') { + const questionInputElement = domManager.getElement('questionInput'); + const solveBtnElement = domManager.getElement('solveBtn'); + + if (questionInputElement) { + questionInputElement.disabled = true; + questionInputElement.style.backgroundColor = 'var(--gray-100)'; + questionInputElement.style.cursor = 'not-allowed'; + questionInputElement.title = reason; + questionInputElement.placeholder = 'This session is read-only. Start a new session to solve another problem.'; + } + + if (solveBtnElement) { + solveBtnElement.style.display = 'none'; + solveBtnElement.disabled = true; + } + + // Add read-only message + this.showReadOnlyMessage(); + } + + showReadOnlyMessage() { + // Remove any existing message first + this.removeReadOnlyMessage(); + + const messageEl = document.createElement('div'); + messageEl.className = 'session-readonly-message'; + messageEl.style.cssText = ` + background: var(--warning-50); + border: 1px solid var(--warning-200); + border-radius: 8px; + padding: 12px; + margin-top: 8px; + font-size: 13px; + color: var(--warning-700); + text-align: center; + `; + messageEl.innerHTML = ` + + This session is read-only. Click "Start New Session" to solve a new problem. + `; + + // Add message after button group + const buttonGroup = document.querySelector('.button-group'); + if (buttonGroup) { + buttonGroup.insertAdjacentElement('afterend', messageEl); + + if (typeof feather !== 'undefined') { + feather.replace(messageEl); + } + } + } + + removeReadOnlyMessage() { + const message = document.querySelector('.session-readonly-message'); + if (message) { + message.remove(); + } + } + + isSessionUsed(session) { + // A session is considered "used" (read-only) only if it has been + // finished or explicitly interrupted. This mirrors the logic that + // lives in the inline implementation inside index.html. Active or + // in-progress ("solving") sessions remain editable even if they have + // chat history. + const readOnlyStatuses = ['completed', 'interrupted']; + return readOnlyStatuses.includes(session?.status); + } + + toggleSessions() { + appState.sessionsExpanded = !appState.sessionsExpanded; + + const sessionsContainer = domManager.getElement('sessionsContainer'); + const sessionsToggle = domManager.getElement('sessionsToggle'); + + if (appState.sessionsExpanded) { + sessionsContainer?.classList.add('expanded'); + sessionsToggle?.classList.add('expanded'); + } else { + sessionsContainer?.classList.remove('expanded'); + sessionsToggle?.classList.remove('expanded'); + } + + Logger.debug('Session', `Sessions panel ${appState.sessionsExpanded ? 'expanded' : 'collapsed'}`); + } + + clearAllSessions() { + if (confirm('Are you sure you want to clear all session history? This cannot be undone.')) { + try { + storageManager.clearAllSessions(); + this.refreshSessionsList(); + domManager.updateStatus('All sessions cleared', 'success'); + Logger.debug('Session', 'All sessions cleared by user'); + } catch (error) { + Logger.error('Session', 'Error clearing sessions:', error); + domManager.updateStatus('Error clearing sessions', 'error'); + } + } + } + + exportSessions() { + try { + storageManager.exportSessions(); + domManager.updateStatus('Sessions exported successfully', 'success'); + Logger.debug('Session', 'Sessions exported by user'); + } catch (error) { + Logger.error('Session', 'Error exporting sessions:', error); + domManager.updateStatus('Error exporting sessions', 'error'); + } + } + + // Session data management + saveCurrentSessionToStorage() { + if (!appState.currentSessionData) { + console.log('[DEBUG] No current session data to save'); + return; + } + + // Get current state from UI + const problemText = domManager.getElement('questionInput')?.value.trim() || ''; + const imageElement = domManager.getElement('imagePreview'); + const image = imageElement?.style.display !== 'none' ? imageElement.src : null; + + // Update session data + appState.currentSessionData.problemText = problemText; + appState.currentSessionData.image = image; + appState.currentSessionData.title = this.generateSessionTitle(problemText); + + // Always update lastUsed when saving + appState.currentSessionData.lastUsed = new Date().toISOString(); + + // Get current chat history (this is critical for persistence) + const chatHistory = messageManager.getCurrentChatHistory(); + appState.currentSessionData.chatHistory = chatHistory; + + console.log(`[DEBUG] Saving session ${appState.currentSessionData.id}:`); + console.log(`[DEBUG] - Title: ${appState.currentSessionData.title}`); + console.log(`[DEBUG] - Problem text length: ${problemText.length}`); + console.log(`[DEBUG] - Chat history messages: ${chatHistory.length}`); + if (chatHistory.length > 0) { + console.log(`[DEBUG] - Sample message: ${chatHistory[0].sender} - ${chatHistory[0].content.substring(0, 50)}...`); + } + + // Save to storage + storageManager.saveSession(appState.currentSessionData.id, appState.currentSessionData); + + console.log(`[DEBUG] Successfully saved session: ${appState.currentSessionData.id} with ${appState.currentSessionData.chatHistory.length} messages`); + } + + generateSessionTitle(problemText) { + if (!problemText || problemText.trim() === '') { + return 'Untitled Session'; + } + + // Take first meaningful part of the problem text + const cleaned = problemText.trim().replace(/\s+/g, ' '); + const maxLength = 50; + + if (cleaned.length <= maxLength) { + return cleaned; + } + + // Try to break at word boundaries + const truncated = cleaned.substring(0, maxLength); + const lastSpace = truncated.lastIndexOf(' '); + + if (lastSpace > maxLength * 0.6) { + return truncated.substring(0, lastSpace) + '...'; + } + + return truncated + '...'; + } + + createNewSession(problemText, image = null) { + const sessionId = this.generateSessionId(); + const now = new Date().toISOString(); + + // Validate that we have meaningful content before creating a session + const hasContent = problemText && problemText.trim().length > 0; + const title = hasContent ? this.generateSessionTitle(problemText) : 'Untitled Session'; + + const newSession = { + id: sessionId, + title: title, + problemText: problemText || '', + image: image, + createdAt: now, + lastUsed: now, + status: 'active', + chatHistory: [] + }; + + console.log(`[DEBUG] Created new session: ${sessionId}, title: "${title}", hasContent: ${hasContent}`); + return newSession; + } + + generateSessionId() { + return 'session_' + Math.random().toString(36).substr(2, 16) + '_' + Date.now(); + } + + switchToSession(sessionId) { + console.log(`[DEBUG] Switching to session: ${sessionId}`); + + // Critical: Handle edge case - prevent switching while solving + if (appState.isSolving) { + domManager.updateStatus('Cannot switch sessions while solving. Please stop the current task first.', 'warning'); + return; + } + + // Prevent multiple simultaneous switches + if (window.sessionSwitchInProgress) { + console.log('[DEBUG] Session switch already in progress, ignoring'); + return; + } + window.sessionSwitchInProgress = true; + + try { + // Save current session state if we have one + if (appState.currentSessionData) { + console.log('[DEBUG] Saving current session state before switching'); + appState.currentSessionData.chatHistory = messageManager.getCurrentChatHistory(); + // Update the current state + appState.currentSessionData.problemText = domManager.getElement('questionInput')?.value.trim() || ''; + const imageElement = domManager.getElement('imagePreview'); + appState.currentSessionData.image = imageElement?.style.display !== 'none' ? imageElement.src : null; + appState.currentSessionData.title = this.generateSessionTitle(appState.currentSessionData.problemText); + this.saveCurrentSessionToStorage(); + } + + // Load the selected session - use the same logic as refreshSessionsList for consistency + let sessions = storageManager.loadSessions(); + console.log(`[DEBUG] Loaded sessions from storage:`, Object.keys(sessions)); + + // Create the same combined sessions that the UI uses + const allSessions = { ...sessions }; + if (appState.currentSessionData && appState.currentSessionData.id) { + allSessions[appState.currentSessionData.id] = appState.currentSessionData; + console.log(`[DEBUG] Added current session to combined sessions: ${appState.currentSessionData.id}`); + } + + console.log(`[DEBUG] All available sessions:`, Object.keys(allSessions)); + + // Debug: Show details about each available session + Object.entries(allSessions).forEach(([id, sess]) => { + console.log(`[DEBUG] Session ${id}: title="${sess.title}", status="${sess.status}"`); + }); + + let session = allSessions[sessionId]; + + if (!session) { + console.error(`[DEBUG] Session not found: ${sessionId}`); + console.error(`[DEBUG] Available sessions:`, Object.keys(allSessions)); + console.error(`[DEBUG] Current session in state:`, appState.currentSessionData?.id); + domManager.updateStatus('Session not found', 'error'); + return; + } + + console.log(`[DEBUG] Found session: ${sessionId}, status: ${session.status}, title: ${session.title}`); + + console.log(`[DEBUG] Loading session: ${sessionId} with ${session.chatHistory ? session.chatHistory.length : 0} messages`); + + // Update state WITHOUT updating lastUsed to prevent reorganization on view + appState.selectedSessionId = sessionId; + appState.currentSessionData = { ...session }; + + // Clear ALL selections first, then set the correct one + document.querySelectorAll('.session-item').forEach(item => { + item.classList.remove('selected'); + }); + + // Set selection on the clicked session + const targetElement = document.querySelector(`[data-session-id="${sessionId}"]`); + if (targetElement) { + console.log(`[DEBUG] Setting selected class on session: ${sessionId}`); + targetElement.classList.add('selected'); + } else { + console.error(`[DEBUG] Target element not found for session: ${sessionId}`); + // Try again after a brief delay in case DOM is updating + setTimeout(() => { + const retryElement = document.querySelector(`[data-session-id="${sessionId}"]`); + if (retryElement) { + retryElement.classList.add('selected'); + console.log(`[DEBUG] Successfully set selected class on retry`); + } + }, 50); + } + + // Load session data into UI + const questionInput = domManager.getElement('questionInput'); + if (questionInput) { + questionInput.value = session.problemText || ''; + } + + // Check if session is used/read-only + const isUsedSession = this.isSessionUsed(session); + + if (isUsedSession) { + // Make session read-only + this.setInputsReadOnly(`This session is ${session.status || 'used'}. Start a new session to solve another problem.`); + domManager.updateStatus(`Viewing ${session.status || 'used'} session (read-only)`, 'info'); + console.log(`[DEBUG] Session ${sessionId} is read-only (status: ${session.status})`); + } else { + // Enable editing for fresh sessions + this.clearAndEnableInputs(); + console.log(`[DEBUG] Session ${sessionId} is editable (status: ${session.status})`); + } + + // Load image if present + imageHandler.loadSessionImage(session.image); + + // Load chat history + messageManager.loadChatHistory(session.chatHistory || []); + + domManager.updateStatus(`Switched to session: ${session.title}`, 'success'); + + } catch (error) { + console.error('[DEBUG] Error in switchToSession:', error); + domManager.updateStatus('Error switching to session', 'error'); + } finally { + // Always clear the switch lock + setTimeout(() => { + window.sessionSwitchInProgress = false; + }, 100); + } + } + + deleteSession(sessionId, event) { + if (event) { + event.stopPropagation(); + } + + console.log(`[DEBUG] Attempting to delete session: ${sessionId}`); + + if (confirm('Are you sure you want to delete this session?')) { + try { + // Load sessions from storage + const sessions = storageManager.loadSessions(); + console.log(`[DEBUG] Loaded ${Object.keys(sessions).length} sessions from storage`); + + // Delete from storage + const sessionExistsInStorage = sessions.hasOwnProperty(sessionId); + if (sessionExistsInStorage) { + delete sessions[sessionId]; + storageManager.saveSessions(sessions); + console.log(`[DEBUG] Deleted session ${sessionId} from storage`); + } else { + console.log(`[DEBUG] Session ${sessionId} not found in storage`); + } + + // If this is the current session in memory, clear it + if (appState.currentSessionData && appState.currentSessionData.id === sessionId) { + console.log(`[DEBUG] Deleting current session from memory: ${sessionId}`); + appState.currentSessionData = null; + appState.selectedSessionId = null; + + // Clear inputs and UI + domManager.clearInputs(); + imageHandler.clearImage(); + messageManager.clearChatAndRestoreWelcome(); + this.clearAndEnableInputs(); + + // Clear any final solution artifacts panels + document.querySelectorAll('.final-artifacts-compact').forEach(panel => { + panel.remove(); + }); + } + + // If this was the selected session, clear selection + if (appState.selectedSessionId === sessionId) { + console.log(`[DEBUG] Clearing selected session: ${sessionId}`); + appState.selectedSessionId = null; + } + + // Force remove the DOM element immediately to provide instant feedback + const sessionElement = document.querySelector(`[data-session-id="${sessionId}"]`); + if (sessionElement) { + sessionElement.remove(); + console.log(`[DEBUG] Removed DOM element for session: ${sessionId}`); + } + + // Refresh the sessions list + this.refreshSessionsList(); + + domManager.updateStatus('Session deleted successfully', 'success'); + console.log(`[DEBUG] Session deletion completed: ${sessionId}`); + + } catch (error) { + console.error(`[DEBUG] Error deleting session ${sessionId}:`, error); + domManager.updateStatus('Error deleting session', 'error'); + } + } + } + + refreshSessionsList() { + console.log('[DEBUG] Updating sessions list'); + + const sessionsList = domManager.getElement('sessionsList'); + + if (!sessionsList) { + console.error('[DEBUG] Sessions list element not found'); + return; + } + + try { + // Ensure current session is saved to storage before refreshing list + if (appState.currentSessionData && appState.currentSessionData.id) { + console.log('[DEBUG] Ensuring current session is saved before refresh'); + this.saveCurrentSessionToStorage(); + } + + const storedSessions = storageManager.loadSessions(); + console.log(`[DEBUG] Loaded ${Object.keys(storedSessions).length} sessions from storage`); + + // Automatically clean up ghost sessions from storage + this.cleanupGhostSessionsFromStorage(storedSessions); + + // Combine stored sessions with current session if it exists + const allSessions = { ...storedSessions }; + if (appState.currentSessionData && appState.currentSessionData.id) { + // Always include current session in the list, overriding stored version + allSessions[appState.currentSessionData.id] = appState.currentSessionData; + console.log(`[DEBUG] Including current session in list: ${appState.currentSessionData.id}`); + } + + // Convert sessions object to array and sort by creation time (newest first) + const sessionsArray = Object.values(allSessions).filter(session => { + // Filter out invalid sessions and ghost sessions + if (!session || !session.id) { + console.log('[DEBUG] Filtering out session without ID:', session); + return false; + } + + // Filter out ghost sessions (much more aggressive filtering) + const isGhostSession = ( + (!session.title || session.title === 'Untitled Session' || session.title.trim() === '') && + (!session.chatHistory || session.chatHistory.length === 0) && + (!session.problemText || session.problemText.trim() === '') && + (!session.image || session.image === null) + ); + + // Also filter out sessions with "solving" status but no actual content and are old + const isStuckSolvingSession = ( + session.status === 'solving' && + (!session.chatHistory || session.chatHistory.length === 0) && + (!session.problemText || session.problemText.trim() === '') && + Date.now() - new Date(session.createdAt || 0).getTime() > 60000 // 1 minute old + ); + + if (isGhostSession) { + console.log('[DEBUG] Filtering out ghost session:', session.id, session.title); + return false; + } + + if (isStuckSolvingSession) { + console.log('[DEBUG] Filtering out stuck solving session:', session.id, session.title); + return false; + } + + return true; + }).sort((a, b) => { + // Primary sort: creation time (newest first) + const createdA = new Date(a.createdAt || 0); + const createdB = new Date(b.createdAt || 0); + + if (createdB - createdA !== 0) { + return createdB - createdA; + } + + // Secondary sort (tie-breaker): lastUsed (newest first) + const usedA = new Date(a.lastUsed || 0); + const usedB = new Date(b.lastUsed || 0); + return usedB - usedA; + }); + + console.log(`[DEBUG] Filtered and sorted ${sessionsArray.length} sessions`); + + // Track which session elements need to be created + const sessionElementsToAdd = []; + + // Update existing elements and identify new ones + sessionsArray.forEach(session => { + const existingElement = sessionsList.querySelector(`[data-session-id="${session.id}"]`); + + if (existingElement) { + // Update existing element in place + this.updateSessionElement(existingElement, session); + } else { + // Create new element + const sessionElement = this.createSessionElement(session); + if (sessionElement) { + sessionElementsToAdd.push(sessionElement); + } + } + }); + + // Add new elements in sorted order + sessionElementsToAdd.forEach(element => { + sessionsList.appendChild(element); + }); + + // Reorder elements according to sort order + const orderedElements = []; + sessionsArray.forEach(session => { + const element = sessionsList.querySelector(`[data-session-id="${session.id}"]`); + if (element) { + orderedElements.push(element); + } + }); + + // Remove orphaned DOM elements (sessions that no longer exist in data) + const existingElements = sessionsList.querySelectorAll('.session-item'); + const validSessionIds = new Set(sessionsArray.map(s => s.id)); + + existingElements.forEach(element => { + const elementSessionId = element.getAttribute('data-session-id'); + if (!validSessionIds.has(elementSessionId)) { + console.log(`[DEBUG] Removing orphaned session element: ${elementSessionId}`); + element.remove(); + } + }); + + // Reorder DOM elements + orderedElements.forEach(element => { + sessionsList.appendChild(element); + }); + + // Update selection after reordering + if (appState.selectedSessionId && appState.currentSessionData) { + // Clear all selections first + document.querySelectorAll('.session-item').forEach(item => { + item.classList.remove('selected'); + }); + + // Set selection on the currently selected session + const selectedElement = sessionsList.querySelector(`[data-session-id="${appState.selectedSessionId}"]`); + if (selectedElement) { + selectedElement.classList.add('selected'); + console.log(`[DEBUG] Set selection on session: ${appState.selectedSessionId}`); + } + } + + // Update session count in header + const totalSessions = sessionsArray.length; + console.log(`[DEBUG] Total sessions for header: ${totalSessions}`); + this.updateSessionsHeader(totalSessions); + + // Replace feather icons for newly added session elements only + try { + sessionElementsToAdd.forEach(element => { + if (typeof feather !== 'undefined') { + feather.replace(element); + } + }); + } catch (e) { + console.warn('[DEBUG] Could not replace feather icons in new session elements:', e); + } + + // Final cleanup: ensure no stuck spinner sessions remain in the UI + this.removeStuckSpinnerElements(); + + } catch (error) { + console.error('[DEBUG] Error in refreshSessionsList:', error); + } + } + + // Remove any UI elements that still have spinners but shouldn't + removeStuckSpinnerElements() { + const sessionsList = domManager.getElement('sessionsList'); + if (!sessionsList) return; + + const sessionElements = sessionsList.querySelectorAll('.session-item'); + sessionElements.forEach(element => { + const sessionId = element.getAttribute('data-session-id'); + const icon = element.querySelector('[data-feather="loader"]'); + + // If element has a spinner icon but no corresponding valid session data, remove it + if (icon && sessionId) { + const sessions = storageManager.loadSessions(); + const allSessions = { ...sessions }; + if (appState.currentSessionData && appState.currentSessionData.id) { + allSessions[appState.currentSessionData.id] = appState.currentSessionData; + } + + const session = allSessions[sessionId]; + if (!session || + (!session.problemText && !session.chatHistory?.length && session.status !== 'solving')) { + console.log('[DEBUG] Removing stuck spinner element:', sessionId); + element.remove(); + } + } + }); + } + + updateSessionElement(element, session) { + if (!element || !session) return; + + // Update status-based styling + element.className = 'session-item'; // Reset classes + if (session.status === 'completed') { + element.classList.add('completed-session'); + } else if (session.status === 'interrupted') { + element.classList.add('interrupted-session'); + } else if (session.status === 'solving') { + element.classList.add('solving-session'); + } + + // Determine icon based on status + let iconName = 'file-text'; + if (session.status === 'completed') iconName = 'check-circle'; + else if (session.status === 'interrupted') iconName = 'x-circle'; + else if (session.status === 'solving') iconName = 'loader'; + + // Handle date safely + let timeAgo = 'Unknown time'; + try { + const displayDate = new Date(session.lastUsed || session.createdAt); + timeAgo = this.getTimeAgo(displayDate); + } catch (e) { + console.warn('[DEBUG] Invalid date for session:', session.id, session.lastUsed, session.createdAt); + } + + // Handle message count safely + const messageCount = session.chatHistory ? session.chatHistory.length : 0; + const messageText = messageCount === 1 ? 'message' : 'messages'; + + // Handle title safely + const title = session.title || 'Untitled Session'; + const safeTitle = this.escapeHtml(title); + + // Update icon - force complete refresh for reliability + const iconContainer = element.querySelector('.session-icon'); + if (iconContainer) { + const currentIcon = iconContainer.querySelector('i, svg'); + const currentIconName = currentIcon ? currentIcon.getAttribute('data-feather') : 'unknown'; + console.log(`[DEBUG] Updating session ${session.id} icon from ${currentIconName} to ${iconName} (status: ${session.status})`); + + // Always force refresh the icon to ensure proper updating + iconContainer.innerHTML = ``; + console.log(`[DEBUG] Force replaced icon container for session ${session.id}`); + } + + // Update title and meta + const titleElement = element.querySelector('.session-title'); + const metaElement = element.querySelector('.session-meta'); + if (titleElement) titleElement.textContent = title; + if (metaElement) metaElement.textContent = `${timeAgo} • ${messageCount} ${messageText}`; + + // Update status class + const statusElement = element.querySelector('.session-status'); + if (statusElement) { + statusElement.className = `session-status ${session.status || 'active'}`; + } + + // Replace feather icons for this element only with a small delay to ensure DOM update + setTimeout(() => { + try { + if (typeof feather !== 'undefined') { + feather.replace(element); + } + console.log(`[DEBUG] Feather icons replaced for session ${session.id} with status ${session.status} -> ${iconName}`); + } catch (e) { + console.warn('[DEBUG] Could not replace feather icons in updated element:', e); + } + }, 10); + } + + createSessionElement(session) { + if (!session || !session.id) { + console.error('[DEBUG] Invalid session data:', session); + return null; + } + + const sessionItem = document.createElement('div'); + sessionItem.className = 'session-item'; + sessionItem.setAttribute('data-session-id', session.id); + + // Add status-based styling + if (session.status === 'completed') { + sessionItem.classList.add('completed-session'); + } else if (session.status === 'interrupted') { + sessionItem.classList.add('interrupted-session'); + } else if (session.status === 'solving') { + sessionItem.classList.add('solving-session'); + } + + // Determine icon based on status + let iconName = 'file-text'; + if (session.status === 'completed') iconName = 'check-circle'; + else if (session.status === 'interrupted') iconName = 'x-circle'; + else if (session.status === 'solving') iconName = 'loader'; + + // Handle date safely + let timeAgo = 'Unknown time'; + try { + const displayDate = new Date(session.lastUsed || session.createdAt); + timeAgo = this.getTimeAgo(displayDate); + } catch (e) { + console.warn('[DEBUG] Invalid date for session:', session.id, session.lastUsed, session.createdAt); + } + + // Handle message count safely + const messageCount = session.chatHistory ? session.chatHistory.length : 0; + const messageText = messageCount === 1 ? 'message' : 'messages'; + + // Handle title safely + const title = session.title || 'Untitled Session'; + const safeTitle = this.escapeHtml(title); + + sessionItem.innerHTML = ` +
+ +
+
+
${safeTitle}
+
${timeAgo} • ${messageCount} ${messageText}
+
+
+ +
+ + `; + + // Add click handler for session switching + sessionItem.addEventListener('click', (e) => { + try { + console.log(`[DEBUG] Session item clicked: ${session.id}`, session.title); + + if (!e.target.closest('.session-delete')) { + // Prevent multiple rapid clicks + if (sessionItem.dataset.switching === 'true') { + console.log('[DEBUG] Session switch already in progress, ignoring click'); + return; + } + + sessionItem.dataset.switching = 'true'; + + setTimeout(() => { + this.switchToSession(session.id); + sessionItem.dataset.switching = 'false'; + }, 10); + } else { + console.log(`[DEBUG] Delete button clicked, not switching session`); + } + } catch (error) { + console.error('[DEBUG] Error in session click handler:', error); + sessionItem.dataset.switching = 'false'; + } + }); + + // Add click handler for delete button + const deleteButton = sessionItem.querySelector('.session-delete'); + deleteButton?.addEventListener('click', (e) => { + this.deleteSession(session.id, e); + }); + + return sessionItem; + } + + getTimeAgo(date) { + if (!date) return 'Unknown time'; + + let dateObj; + try { + dateObj = new Date(date); + if (isNaN(dateObj.getTime())) { + return 'Invalid date'; + } + } catch (e) { + return 'Invalid date'; + } + + const now = new Date(); + const diffMs = now - dateObj; + + // Handle future dates + if (diffMs < 0) { + return 'Just now'; + } + + const diffSecs = Math.floor(diffMs / 1000); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + const diffWeeks = Math.floor(diffMs / (86400000 * 7)); + const diffMonths = Math.floor(diffMs / (86400000 * 30)); + + if (diffSecs < 30) return 'Just now'; + if (diffSecs < 60) return `${diffSecs}s ago`; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + if (diffWeeks < 4) return `${diffWeeks}w ago`; + if (diffMonths < 12) return `${diffMonths}mo ago`; + + // For very old dates, show the actual date + return dateObj.toLocaleDateString(); + } + + updateSessionsHeader(totalSessions) { + const header = document.querySelector('.sessions-header .form-label'); + if (!header) return; + + const baseText = 'Session History'; + const sessionCount = Math.max(0, totalSessions || 0); + + if (sessionCount === 0) { + header.innerHTML = ` + + ${baseText} + `; + } else if (sessionCount === 1) { + header.innerHTML = ` + + ${baseText} (1 session) + `; + } else { + header.innerHTML = ` + + ${baseText} (${sessionCount} sessions) + `; + } + + // Ensure feather icons are replaced + try { + // Only replace icons in the header area + const headerElement = document.querySelector('.sessions-header'); + if (headerElement && typeof feather !== 'undefined') { + feather.replace(headerElement); + } + } catch (e) { + console.warn('[DEBUG] Could not replace feather icons:', e); + } + } + + updateCurrentSessionDisplay() { + // Since we've removed the separate currentSession element, + // the session display is now handled by refreshSessionsList() + // We can trigger a refresh of the sessions list to ensure the current session appears correctly + this.refreshSessionsList(); + } + + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + // Handle session-related socket events + handleSessionConnected(data) { + console.log('[DEBUG] SessionManager.handleSessionConnected called with data:', JSON.stringify(data)); + Logger.debug('Session', 'Session connected:', data); + appState.currentSessionId = data.session_id; + console.log('[DEBUG] Set appState.currentSessionId to:', data.session_id); + + const sessionInfoText = `Session: ${data.session_id.substring(0, 8)}`; + console.log('[DEBUG] About to update session info to:', sessionInfoText); + domManager.updateSessionInfo(sessionInfoText); + + console.log('[DEBUG] About to update status to: Connected - Ready to solve problems'); + domManager.updateStatus('Connected - Ready to solve problems', 'success'); + } + + // Session creation for solving + handleSolveProblem(problemText, imageData) { + // Auto-create session if none exists or we're viewing a stored session + if (!appState.currentSessionData || appState.selectedSessionId !== null) { + // Create new session + console.log(`[DEBUG] Creating new session (previous status: ${appState.currentSessionData?.status || 'none'})`); + appState.currentSessionData = this.createNewSession(problemText, imageData); + appState.selectedSessionId = null; // Set to null for current/new session + + // Clear visual selection from stored sessions and update display + document.querySelectorAll('.session-item').forEach(item => { + item.classList.remove('selected'); + }); + + // Immediately save new session to storage + this.saveCurrentSessionToStorage(); + console.log(`[DEBUG] New session created and saved with ID: ${appState.currentSessionData.id}`); + // Ensure UI immediately reflects the newly-created session + this.refreshSessionsList(); + } else { + // Update existing session + appState.currentSessionData.problemText = problemText; + appState.currentSessionData.image = imageData; + appState.currentSessionData.title = this.generateSessionTitle(problemText); + // Save updated session + this.saveCurrentSessionToStorage(); + console.log(`[DEBUG] Updated and saved existing session: ${appState.currentSessionData.id}`); + // Update sessions list to reflect any changes to the current session + this.refreshSessionsList(); + } + + return appState.currentSessionData.id; + } + + // Handle solving state changes + handleSolvingStarted() { + // Save current session data including chat history BEFORE starting to solve + if (appState.currentSessionData) { + appState.currentSessionData.chatHistory = messageManager.getCurrentChatHistory(); + appState.currentSessionData.status = 'solving'; + // Update lastUsed when solving starts + appState.currentSessionData.lastUsed = new Date().toISOString(); + this.saveCurrentSessionToStorage(); + this.updateCurrentSessionDisplay(); + this.refreshSessionsList(); + + // Make inputs read-only once solving starts + this.setInputsReadOnly('Cannot modify problem while solving is in progress'); + + // Add visual indicator to the current session in the unified list + if (appState.selectedSessionId && appState.currentSessionData.id === appState.selectedSessionId) { + const sessionElement = document.querySelector(`[data-session-id="${appState.selectedSessionId}"]`); + if (sessionElement) { + sessionElement.classList.add('active-solving'); + } + } + + // Start periodic saving during solving + this.startPeriodicSaving(); + } + } + + handleSolvingComplete() { + console.log('[DEBUG] Handling solving completed - cleaning up UI and saving session'); + + // Stop periodic saving + this.stopPeriodicSaving(); + + // Clean up any remaining UI indicators + messageManager.cleanupAllActiveIndicators(); + + // Update session status + if (appState.currentSessionData) { + appState.currentSessionData.status = 'completed'; + appState.currentSessionData.chatHistory = messageManager.getCurrentChatHistory(); + appState.currentSessionData.lastUsed = new Date().toISOString(); + this.saveCurrentSessionToStorage(); + this.refreshSessionsList(); + + // Keep inputs read-only for completed sessions + this.setInputsReadOnly('This session is completed. Start a new session to solve another problem.'); + + console.log(`[DEBUG] Session ${appState.currentSessionData.id} marked as completed and saved with ${appState.currentSessionData.chatHistory.length} messages`); + } + } + + handleSolvingInterrupted() { + console.log('[DEBUG] Handling solving interrupted - cleaning up UI and saving session'); + + // Stop periodic saving + this.stopPeriodicSaving(); + + // CRITICAL: Clean up all UI indicators first + messageManager.cleanupAllActiveIndicators(); + + // Update session status + if (appState.currentSessionData) { + appState.currentSessionData.status = 'interrupted'; + appState.currentSessionData.chatHistory = messageManager.getCurrentChatHistory(); + appState.currentSessionData.lastUsed = new Date().toISOString(); + this.saveCurrentSessionToStorage(); + this.refreshSessionsList(); + + // Keep inputs read-only for interrupted sessions + this.setInputsReadOnly('This session was interrupted. Start a new session to solve another problem.'); + + console.log(`[DEBUG] Session ${appState.currentSessionData.id} marked as interrupted and saved with ${appState.currentSessionData.chatHistory.length} messages`); + } + } + + // General handler for any session failure or error + handleSolvingError() { + console.log('[DEBUG] Handling solving error - cleaning up UI and saving session'); + + // Stop periodic saving + this.stopPeriodicSaving(); + + // Clean up all UI indicators + messageManager.cleanupAllActiveIndicators(); + + // Update session status + if (appState.currentSessionData) { + appState.currentSessionData.status = 'interrupted'; + appState.currentSessionData.chatHistory = messageManager.getCurrentChatHistory(); + appState.currentSessionData.lastUsed = new Date().toISOString(); + this.saveCurrentSessionToStorage(); + this.refreshSessionsList(); + + // Keep inputs read-only for error sessions + this.setInputsReadOnly('This session encountered an error. Start a new session to solve another problem.'); + + console.log(`[DEBUG] Session ${appState.currentSessionData.id} marked as interrupted due to error and saved with ${appState.currentSessionData.chatHistory.length} messages`); + } + } + + // Emergency cleanup method - can be called from anywhere when things go wrong + emergencyCleanupAndSave() { + console.log('[DEBUG] Emergency cleanup and save triggered'); + + try { + // Clean up all UI indicators + messageManager.cleanupAllActiveIndicators(); + + // Save whatever we have in the current session + if (appState.currentSessionData) { + appState.currentSessionData.chatHistory = messageManager.getCurrentChatHistory(); + appState.currentSessionData.status = 'interrupted'; + appState.currentSessionData.lastUsed = new Date().toISOString(); + this.saveCurrentSessionToStorage(); + this.refreshSessionsList(); + + console.log(`[DEBUG] Emergency save completed for session ${appState.currentSessionData.id} with ${appState.currentSessionData.chatHistory.length} messages`); + } + + // Reset solving state + appState.isSolving = false; + + // Re-enable inputs + this.clearAndEnableInputs(); + + } catch (error) { + console.error('[DEBUG] Error during emergency cleanup:', error); + } + } + + // Periodic saving mechanism + startPeriodicSaving() { + // Clear any existing interval + this.stopPeriodicSaving(); + + // Save every 10 seconds during solving to ensure we don't lose messages + this.periodicSaveInterval = setInterval(() => { + if (appState.currentSessionData && appState.isSolving) { + console.log('[DEBUG] Periodic save triggered during solving'); + appState.currentSessionData.chatHistory = messageManager.getCurrentChatHistory(); + appState.currentSessionData.lastUsed = new Date().toISOString(); + this.saveCurrentSessionToStorage(); + } else { + // Stop saving if we're no longer solving + this.stopPeriodicSaving(); + } + }, 10000); // 10 seconds + + console.log('[DEBUG] Started periodic saving during solving'); + } + + stopPeriodicSaving() { + if (this.periodicSaveInterval) { + clearInterval(this.periodicSaveInterval); + this.periodicSaveInterval = null; + console.log('[DEBUG] Stopped periodic saving'); + } + } + + // Automatically cleanup ghost sessions from storage during refresh + cleanupGhostSessionsFromStorage(sessions) { + let deletedCount = 0; + const sessionIds = Object.keys(sessions); + + sessionIds.forEach(sessionId => { + const session = sessions[sessionId]; + + // Same aggressive filtering logic + const isGhostSession = ( + (!session.title || session.title === 'Untitled Session' || session.title.trim() === '') && + (!session.chatHistory || session.chatHistory.length === 0) && + (!session.problemText || session.problemText.trim() === '') && + (!session.image || session.image === null) + ); + + const isStuckSolvingSession = ( + session.status === 'solving' && + (!session.chatHistory || session.chatHistory.length === 0) && + (!session.problemText || session.problemText.trim() === '') && + Date.now() - new Date(session.createdAt || 0).getTime() > 60000 // 1 minute old + ); + + if (isGhostSession || isStuckSolvingSession) { + console.log(`[DEBUG] Auto-removing ghost session from storage: ${sessionId}`); + delete sessions[sessionId]; + deletedCount++; + } + }); + + if (deletedCount > 0) { + storageManager.saveSessions(sessions); + console.log(`[DEBUG] Auto-cleaned ${deletedCount} ghost sessions from storage`); + } + } + + // Cleanup ghost sessions from storage + cleanupGhostSessions() { + console.log('[DEBUG] Starting ghost session cleanup'); + + try { + const sessions = storageManager.loadSessions(); + const sessionIds = Object.keys(sessions); + let deletedCount = 0; + + sessionIds.forEach(sessionId => { + const session = sessions[sessionId]; + + // Identify ghost sessions + const isGhostSession = ( + (!session.title || session.title === 'Untitled Session') && + (!session.chatHistory || session.chatHistory.length === 0) && + (!session.problemText || session.problemText.trim() === '') && + session.status !== 'solving' // Don't delete actual solving sessions + ); + + if (isGhostSession) { + // Check if it's old (more than 1 hour old) + const sessionAge = Date.now() - new Date(session.createdAt || 0).getTime(); + const oneHour = 60 * 60 * 1000; + + if (sessionAge > oneHour) { + console.log(`[DEBUG] Cleaning up ghost session: ${sessionId}`); + delete sessions[sessionId]; + deletedCount++; + } + } + }); + + if (deletedCount > 0) { + storageManager.saveSessions(sessions); + console.log(`[DEBUG] Cleaned up ${deletedCount} ghost sessions`); + } else { + console.log('[DEBUG] No ghost sessions to clean up'); + } + + } catch (error) { + console.error('[DEBUG] Error during ghost session cleanup:', error); + } + } + + // Enhanced clear all sessions with ghost cleanup + clearAllSessionsEnhanced() { + if (confirm('Are you sure you want to clear all session history? This cannot be undone.')) { + try { + // Also clear current session state + appState.selectedSessionId = null; + appState.currentSessionData = null; + + // Clear storage + storageManager.clearAllSessions(); + + // Clear UI + domManager.clearInputs(); + imageHandler.clearImage(); + messageManager.clearChatAndRestoreWelcome(); + this.clearAndEnableInputs(); + + // Clear any final solution artifacts panels + document.querySelectorAll('.final-artifacts-compact').forEach(panel => { + panel.remove(); + }); + + // Clear DOM elements manually + const sessionsList = domManager.getElement('sessionsList'); + if (sessionsList) { + sessionsList.innerHTML = ''; + } + + this.refreshSessionsList(); + domManager.updateStatus('All sessions cleared', 'success'); + Logger.debug('Session', 'All sessions cleared by user'); + + } catch (error) { + Logger.error('Session', 'Error clearing sessions:', error); + domManager.updateStatus('Error clearing sessions', 'error'); + } + } + } +} + +// Create singleton instance +export const sessionManager = new SessionManager(); + + +================================================ +File: static/js/ui/settings-manager.js +================================================ +/** + * Settings Manager - Handles settings modal, API key management, and form handling + */ +import { Logger } from '../core/logger.js'; +import { storageManager } from '../core/storage.js'; +import { socketManager } from '../network/socket.js'; +import { domManager } from './dom-manager.js'; + +export class SettingsManager { + constructor() { + this.isInitialized = false; + } + + initialize() { + if (this.isInitialized) return; + + this.setupEventListeners(); + this.loadApiKeysFromStorage(); + + // Initialize PIPS mode to default first + this.initializePIPSMode(); + + // Then load user settings (which may override the default) + this.loadUserSettingsFromStorage(); + + this.isInitialized = true; + + Logger.debug('Settings', 'Settings manager initialized'); + } + + setupEventListeners() { + // Settings modal listeners + domManager.getElement('settingsBtn')?.addEventListener('click', () => this.openSettings()); + domManager.getElement('closeBtn')?.addEventListener('click', () => this.closeSettings()); + domManager.getElement('settingsForm')?.addEventListener('submit', (e) => this.saveSettings(e)); + + // Settings form listeners + domManager.getElement('useCodeSwitch')?.addEventListener('change', () => { + this.updateMethodLabel(); + this.autoSaveSettings(); + }); + + // PIPS Mode iOS switch listener + domManager.getElement('pipsModeSwitch')?.addEventListener('change', () => { + this.updateModeIndicator(); + this.autoSaveSettings(); + }); + + // Auto-save on model selection changes + domManager.getElement('generatorModelSelect')?.addEventListener('change', () => this.autoSaveSettings()); + domManager.getElement('criticModelSelect')?.addEventListener('change', () => this.autoSaveSettings()); + + // Auto-save on other setting changes + domManager.getElement('maxIterations')?.addEventListener('change', () => this.autoSaveSettings()); + domManager.getElement('temperature')?.addEventListener('change', () => this.autoSaveSettings()); + domManager.getElement('maxTokens')?.addEventListener('change', () => this.autoSaveSettings()); + domManager.getElement('maxExecutionTime')?.addEventListener('change', () => this.autoSaveSettings()); + // Custom rules handling - different behavior for global vs per-session + // Per-session rules (navbar) - don't auto-save to localStorage + domManager.getElement('customRules')?.addEventListener('input', () => { + // Per-session rules are not saved to localStorage + Logger.debug('Settings', 'Per-session custom rules updated'); + }); + + // Global rules (settings modal) - auto-save to localStorage + domManager.getElement('customRulesSettings')?.addEventListener('input', () => { + this.autoSaveSettings(); + }); + + // Settings tabs listeners + const tabButtons = document.querySelectorAll('.tab-button'); + tabButtons.forEach(button => { + button.addEventListener('click', () => this.switchTab(button.dataset.tab)); + }); + + // Modal click-outside-to-close + window.addEventListener('click', (event) => { + if (event.target === domManager.getElement('settingsModal')) { + this.closeSettings(); + } + }); + + // Clear all sessions button with retry mechanism + const setupClearAllButton = () => { + const clearAllBtn = document.getElementById('clearAllSessionsBtn'); + console.log('[DEBUG] Clear all sessions button:', clearAllBtn); + if (clearAllBtn) { + clearAllBtn.addEventListener('click', (e) => { + e.preventDefault(); + console.log('[DEBUG] Clear all sessions button clicked'); + this.clearAllSessions(); + }); + console.log('[DEBUG] Clear all sessions button listener added'); + return true; + } else { + console.error('[DEBUG] Clear all sessions button not found'); + return false; + } + }; + + // Try immediately + if (!setupClearAllButton()) { + // If not found, try again after a delay + setTimeout(() => { + setupClearAllButton(); + }, 100); + } + + // Also add a global click handler as backup + document.addEventListener('click', (e) => { + if (e.target && e.target.id === 'clearAllSessionsBtn') { + e.preventDefault(); + console.log('[DEBUG] Clear all sessions button clicked via global handler'); + this.clearAllSessions(); + } + }); + + Logger.debug('Settings', 'Event listeners set up'); + } + + initializePIPSMode() { + const pipsModeSwitch = domManager.getElement('pipsModeSwitch'); + const agentRadio = domManager.getElement('pipsModeAgent'); + const interactiveRadio = domManager.getElement('pipsModeInteractive'); + + // Set Agent mode as default (will be overridden by loadUserSettingsFromStorage if user has saved settings) + if (pipsModeSwitch) { + pipsModeSwitch.checked = false; // Agent mode (unchecked state) + } + + // Ensure radio buttons are in sync with switch + if (agentRadio && interactiveRadio && pipsModeSwitch) { + const isInteractive = pipsModeSwitch.checked; + agentRadio.checked = !isInteractive; + interactiveRadio.checked = isInteractive; + } + + // Update the mode indicator + this.updateModeIndicator(); + + Logger.debug('Settings', 'PIPS mode initialized to default (Agent)'); + } + + openSettings() { + domManager.getElement('settingsModal').style.display = 'block'; + Logger.debug('Settings', 'Settings modal opened'); + } + + closeSettings() { + domManager.getElement('settingsModal').style.display = 'none'; + Logger.debug('Settings', 'Settings modal closed'); + } + + saveSettings(e) { + e.preventDefault(); + + try { + this.saveApiKeysToStorage(); + // Persist non-sensitive user settings (exclude API keys and session rules) to localStorage + const { openai_api_key, google_api_key, anthropic_api_key, session_rules, ...nonSensitive } = this.getCurrentSettings(); + storageManager.saveUserSettings(nonSensitive); + this.sendCurrentSettingsToServer(); + Logger.debug('Settings', 'Settings saved successfully'); + } catch (error) { + Logger.error('Settings', 'Error saving settings:', error); + domManager.updateStatus('Error saving settings', 'error'); + } + } + + // Auto-save settings to localStorage (without sending to server or showing status) + autoSaveSettings() { + try { + // Only save non-sensitive settings to localStorage + const { openai_api_key, google_api_key, anthropic_api_key, session_rules, ...nonSensitive } = this.getCurrentSettings(); + // Remove session_rules from saved settings - they should not persist + storageManager.saveUserSettings(nonSensitive); + Logger.debug('Settings', 'Settings auto-saved to localStorage (excluding per-session rules)'); + } catch (error) { + Logger.error('Settings', 'Error auto-saving settings:', error); + } + } + + loadApiKeysFromStorage() { + try { + const apiKeys = storageManager.loadApiKeys(); + + if (apiKeys.openai_api_key) { + domManager.getElement('openaiApiKeyInput').value = apiKeys.openai_api_key; + } + if (apiKeys.google_api_key) { + domManager.getElement('googleApiKeyInput').value = apiKeys.google_api_key; + } + if (apiKeys.anthropic_api_key) { + domManager.getElement('anthropicApiKeyInput').value = apiKeys.anthropic_api_key; + } + + Logger.debug('Settings', 'API keys loaded from storage'); + } catch (error) { + Logger.error('Settings', 'Error loading API keys from storage:', error); + } + } + + saveApiKeysToStorage() { + try { + const apiKeys = { + openai_api_key: domManager.getElement('openaiApiKeyInput').value.trim(), + google_api_key: domManager.getElement('googleApiKeyInput').value.trim(), + anthropic_api_key: domManager.getElement('anthropicApiKeyInput').value.trim() + }; + + storageManager.saveApiKeys(apiKeys); + Logger.debug('Settings', 'API keys saved to storage'); + } catch (error) { + Logger.error('Settings', 'Error saving API keys to storage:', error); + } + } + + sendCurrentSettingsToServer() { + try { + const pipsModeSwitch = domManager.getElement('pipsModeSwitch'); + const pipsMode = pipsModeSwitch?.checked ? 'INTERACTIVE' : 'AGENT'; + + const settings = { + model: domManager.getElement('generatorModelSelect')?.value || 'gpt-4o-mini', + openai_api_key: domManager.getElement('openaiApiKeyInput').value.trim(), + google_api_key: domManager.getElement('googleApiKeyInput').value.trim(), + anthropic_api_key: domManager.getElement('anthropicApiKeyInput').value.trim(), + use_code: domManager.getElement('useCodeSwitch').checked, + max_iterations: parseInt(domManager.getElement('maxIterations').value), + temperature: parseFloat(domManager.getElement('temperature').value), + max_tokens: parseInt(domManager.getElement('maxTokens').value), + max_execution_time: parseInt(domManager.getElement('maxExecutionTime').value), + // New PIPS interactive mode settings + pips_mode: pipsMode, + generator_model: domManager.getElement('generatorModelSelect')?.value || 'gpt-4o-mini', + critic_model: domManager.getElement('criticModelSelect')?.value || 'gpt-4o-mini', + // Send combined rules to backend and separate fields for internal tracking + custom_rules: this.getCombinedRulesForBackend(), + global_rules: domManager.getElement('customRulesSettings')?.value?.trim() || '', + session_rules: domManager.getElement('customRules')?.value?.trim() || '' + }; + + socketManager.send('update_settings', settings); + Logger.debug('Settings', 'Settings sent to server:', settings); + } catch (error) { + Logger.error('Settings', 'Error sending settings to server:', error); + } + } + + updateMethodLabel() { + const methodLabel = domManager.getElement('methodLabel'); + const useCodeSwitch = domManager.getElement('useCodeSwitch'); + + if (methodLabel && useCodeSwitch) { + methodLabel.textContent = useCodeSwitch.checked ? + 'Iterative Code Generation' : 'Chain of Thought'; + } + } + + updateModeIndicator() { + const pipsModeSwitch = domManager.getElement('pipsModeSwitch'); + const modeDescription = domManager.getElement('modeDescription'); + const agentRadio = domManager.getElement('pipsModeAgent'); + const interactiveRadio = domManager.getElement('pipsModeInteractive'); + + if (pipsModeSwitch && modeDescription) { + const isInteractive = pipsModeSwitch.checked; + const selectedMode = isInteractive ? 'INTERACTIVE' : 'AGENT'; + + // Update description text + modeDescription.textContent = isInteractive + ? 'Collaborate with AI at each step' + : 'Automatic solving without user intervention'; + + // Sync with hidden radio buttons for backend compatibility + if (agentRadio && interactiveRadio) { + agentRadio.checked = !isInteractive; + interactiveRadio.checked = isInteractive; + } + + Logger.debug('Settings', 'PIPS mode updated to:', selectedMode); + } + } + + switchTab(tabName) { + // Remove active class from all tab buttons and content + document.querySelectorAll('.tab-button').forEach(btn => btn.classList.remove('active')); + document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active')); + + // Add active class to clicked tab button and corresponding content + document.querySelector(`[data-tab="${tabName}"]`)?.classList.add('active'); + document.querySelector(`#${tabName}-tab`)?.classList.add('active'); + + Logger.debug('Settings', 'Switched to tab:', tabName); + } + + // Handle settings update response from server + handleSettingsUpdated(data) { + Logger.debug('Settings', 'Settings update response:', data); + + if (data.status === 'success') { + domManager.updateStatus('Settings saved successfully!', 'success'); + this.closeSettings(); + } else { + domManager.updateStatus(`Settings error: ${data.message}`, 'error'); + } + } + + // Load saved API keys and send to server (called on app initialization) + initializeServerSettings() { + const apiKeys = storageManager.loadApiKeys(); + + if (apiKeys.openai_api_key || apiKeys.google_api_key) { + Logger.debug('Settings', 'Loading saved API keys and sending to server'); + this.sendCurrentSettingsToServer(); + domManager.updateStatus('API keys loaded from browser storage', 'success'); + } + } + + // Get current settings snapshot + getCurrentSettings() { + const pipsModeSwitch = domManager.getElement('pipsModeSwitch'); + const pipsMode = pipsModeSwitch?.checked ? 'INTERACTIVE' : 'AGENT'; + + return { + model: domManager.getElement('generatorModelSelect')?.value || 'gpt-4o-mini', + openai_api_key: domManager.getElement('openaiApiKeyInput')?.value?.trim(), + google_api_key: domManager.getElement('googleApiKeyInput')?.value?.trim(), + anthropic_api_key: domManager.getElement('anthropicApiKeyInput')?.value?.trim(), + use_code: domManager.getElement('useCodeSwitch')?.checked, + max_iterations: parseInt(domManager.getElement('maxIterations')?.value), + temperature: parseFloat(domManager.getElement('temperature')?.value), + max_tokens: parseInt(domManager.getElement('maxTokens')?.value), + max_execution_time: parseInt(domManager.getElement('maxExecutionTime')?.value), + // PIPS interactive mode settings + pips_mode: pipsMode, + generator_model: domManager.getElement('generatorModelSelect')?.value || 'gpt-4o-mini', + critic_model: domManager.getElement('criticModelSelect')?.value || 'gpt-4o-mini', + // Send combined rules to backend and separate fields for internal tracking + custom_rules: this.getCombinedRulesForBackend(), + global_rules: domManager.getElement('customRulesSettings')?.value?.trim() || '', + session_rules: domManager.getElement('customRules')?.value?.trim() || '' + }; + } + + // Update settings programmatically + updateSettings(settings) { + if (settings.openai_api_key && domManager.getElement('openaiApiKeyInput')) { + domManager.getElement('openaiApiKeyInput').value = settings.openai_api_key; + } + if (settings.google_api_key && domManager.getElement('googleApiKeyInput')) { + domManager.getElement('googleApiKeyInput').value = settings.google_api_key; + } + if (settings.anthropic_api_key && domManager.getElement('anthropicApiKeyInput')) { + domManager.getElement('anthropicApiKeyInput').value = settings.anthropic_api_key; + } + if (settings.use_code !== undefined && domManager.getElement('useCodeSwitch')) { + domManager.getElement('useCodeSwitch').checked = settings.use_code; + this.updateMethodLabel(); + } + if (settings.max_iterations && domManager.getElement('maxIterations')) { + domManager.getElement('maxIterations').value = settings.max_iterations; + } + if (settings.temperature !== undefined && domManager.getElement('temperature')) { + domManager.getElement('temperature').value = settings.temperature; + } + if (settings.max_tokens && domManager.getElement('maxTokens')) { + domManager.getElement('maxTokens').value = settings.max_tokens; + } + if (settings.max_execution_time && domManager.getElement('maxExecutionTime')) { + domManager.getElement('maxExecutionTime').value = settings.max_execution_time; + } + + // PIPS interactive mode settings + if (settings.pips_mode !== undefined) { + const pipsModeSwitch = domManager.getElement('pipsModeSwitch'); + if (pipsModeSwitch) { + pipsModeSwitch.checked = settings.pips_mode === 'INTERACTIVE'; + this.updateModeIndicator(); + } + } + + // Model settings - handle both old 'model' field and new separate fields + if (settings.model && domManager.getElement('generatorModelSelect')) { + domManager.getElement('generatorModelSelect').value = settings.model; + } + if (settings.generator_model && domManager.getElement('generatorModelSelect')) { + domManager.getElement('generatorModelSelect').value = settings.generator_model; + } + if (settings.critic_model && domManager.getElement('criticModelSelect')) { + domManager.getElement('criticModelSelect').value = settings.critic_model; + } + // Handle global rules (persistent across sessions) + if (settings.global_rules !== undefined && domManager.getElement('customRulesSettings')) { + domManager.getElement('customRulesSettings').value = settings.global_rules; + } + + // Handle legacy custom_rules field for backward compatibility + if (settings.custom_rules !== undefined && settings.global_rules === undefined) { + if (domManager.getElement('customRulesSettings')) { + domManager.getElement('customRulesSettings').value = settings.custom_rules; + } + } + + // Per-session rules (navbar) are NOT loaded from storage - they reset with each session + + Logger.debug('Settings', 'Settings updated programmatically'); + } + + // Load user-selected settings (e.g., preferred model) from storage and apply them + loadUserSettingsFromStorage() { + try { + const settings = storageManager.loadUserSettings(); + if (settings && Object.keys(settings).length > 0) { + // Load all settings including PIPS mode + this.updateSettings(settings); + Logger.debug('Settings', 'User settings loaded from storage'); + } + } catch (error) { + Logger.error('Settings', 'Error loading user settings from storage:', error); + } + } + + // Clear per-session rules (called when starting a new session) + clearPerSessionRules() { + const navbarElement = domManager.getElement('customRules'); + if (navbarElement) { + navbarElement.value = ''; + Logger.debug('Settings', 'Per-session custom rules cleared for new session'); + } + } + + // Get combined rules for sending to backend + getCombinedRulesForBackend() { + const globalRules = domManager.getElement('customRulesSettings')?.value?.trim() || ''; + const sessionRules = domManager.getElement('customRules')?.value?.trim() || ''; + + // Combine global and session rules + const rules = []; + if (globalRules) { + rules.push(`Global Rules:\n${globalRules}`); + } + if (sessionRules) { + rules.push(`Session Rules:\n${sessionRules}`); + } + + const combined = rules.join('\n\n'); + + Logger.debug('Settings', 'Combined rules for backend:', { + global: globalRules, + session: sessionRules, + combined: combined + }); + + return combined; + } + + // Clear all sessions from the settings panel + clearAllSessions() { + console.log('[DEBUG] clearAllSessions method called'); + if (confirm('Are you sure you want to permanently delete ALL session history? This action cannot be undone.')) { + try { + console.log('[DEBUG] User confirmed, clearing sessions'); + + // Clear storage directly + storageManager.clearAllSessions(); + + // Clear any current session state if accessible + if (window.appState) { + window.appState.selectedSessionId = null; + window.appState.currentSessionData = null; + } + + // Clear UI elements + const sessionsList = document.getElementById('sessionsList'); + if (sessionsList) { + sessionsList.innerHTML = ''; + } + + // Clear inputs + const questionInput = document.getElementById('questionInput'); + if (questionInput) { + questionInput.value = ''; + } + + // Clear image + const imagePreview = document.getElementById('imagePreview'); + if (imagePreview) { + imagePreview.style.display = 'none'; + imagePreview.src = ''; + } + + // Clear chat area + const chatArea = document.getElementById('chatArea'); + if (chatArea) { + chatArea.innerHTML = ` +
+
+
P
+ PIPS System +
+
+ Welcome to PIPS! Enter a problem in the left panel and click "Solve Problem" to get started. + Don't forget to configure your model settings first. +
+
+ `; + } + + domManager.updateStatus('All sessions cleared successfully', 'success'); + Logger.debug('Settings', 'All sessions cleared from settings panel'); + console.log('[DEBUG] All sessions cleared successfully'); + + } catch (error) { + console.error('[DEBUG] Error clearing sessions:', error); + Logger.error('Settings', 'Error clearing sessions from settings:', error); + domManager.updateStatus('Error clearing sessions', 'error'); + } + } else { + console.log('[DEBUG] User cancelled session clearing'); + } + } +} + +// Create singleton instance +export const settingsManager = new SettingsManager(); + + +================================================ +File: templates/index_modular.html +================================================ + + + + + + PIPS - Per-Instance Program Synthesis + + + + + + + + + + + + + + + +
+ +
+
+

PIPS

+

Per-Instance Program Synthesis

+
+ +
+ +
+ +
+ + +
+ + +
+ + +
+ +
+
+ + +
+
+ Automatic solving without user intervention +
+
+ +
+ + +
+
+ + +
+ + +
These rules apply only to the current session and will be cleared when starting a new session
+
+ +
+ +
+ +
+ Or drag and drop an image here +
+ + +
+
Upload an image to include visual context with your problem
+
+ + +
+
+ + +
+
+
+ +
+
+ + +
+
+
Switch between past and current problem-solving sessions
+
+ +
+ + + +
+
+
+ + +
+
+ Ready to solve problems +
+ +
+
+
+
+
+ +
+
+
+
P
+ PIPS System +
+
+ Welcome to PIPS! Enter a problem in the left panel and click "Solve Problem" to get started. + Don't forget to configure your model settings first. +
+
+
+ + +
+ + +
+
+ + + + + + + + + + + + + + +