diff --git a/_delete_nul.py b/_delete_nul.py deleted file mode 100644 index fa5a2ae..0000000 --- a/_delete_nul.py +++ /dev/null @@ -1,4 +0,0 @@ -import os -path = r"\\?\C:\Users\Josh\OneDrive\Documents\Code_Projects\ThreadBear\nul" -os.remove(path) -print("Deleted successfully") diff --git a/chat_manager.py b/chat_manager.py index 8b70ee7..b9004ac 100644 --- a/chat_manager.py +++ b/chat_manager.py @@ -414,6 +414,49 @@ def update_title(self, new_title: str) -> bool: self.save_current_chat(force_save=True) return True + def rename_current_chat(self, new_title: str) -> Optional[str]: + """Rename the current chat file to match new_title. Returns new filename or None on failure.""" + filename = self.current_chat_file + if not filename: + return None + try: + safe = re.sub(r"[^\w\s-]", "", new_title) + safe = re.sub(r"[-\s]+", "_", safe).strip("_") + base = os.path.splitext(filename)[0] + m = re.search(r"_(\d{8}_\d{6})$", base) + ts = m.group(1) if m else datetime.now().strftime("%Y%m%d_%H%M%S") + new_fn = f"{safe}_{ts}.json" + + old_path = os.path.join(self.chats_directory, filename) + new_path = os.path.join(self.chats_directory, new_fn) + + if not os.path.exists(old_path): + return None + + with open(old_path, "r", encoding="utf-8") as f: + chat_data = json.load(f) + + if isinstance(chat_data, dict): + chat_data["title"] = new_title + if "chat_id" not in chat_data: + chat_data["chat_id"] = str(uuid.uuid4()) + if "root_chat_id" not in chat_data: + chat_data["root_chat_id"] = chat_data["chat_id"] + if "parent_chat_id" not in chat_data: + chat_data["parent_chat_id"] = "" + + with open(new_path, "w", encoding="utf-8") as f: + json.dump(chat_data, f, indent=2, ensure_ascii=False) + os.remove(old_path) + + self.current_chat_file = new_fn + if isinstance(self.current_chat, dict): + self.current_chat["title"] = new_title + + return new_fn + except Exception: + return None + def clear_current_chat(self, auto_save: bool = True) -> None: self.current_chat = { "chat_history": [], diff --git a/cli/app.py b/cli/app.py index f74dc2a..ee6bce6 100644 --- a/cli/app.py +++ b/cli/app.py @@ -17,7 +17,7 @@ from textual.app import App, ComposeResult from textual.binding import Binding from textual.screen import Screen -from textual.widgets import Static, Input, Select, Tree +from textual.widgets import Static, Input, Select, Tree, Markdown, TextArea from textual.containers import VerticalScroll, Horizontal, Container, Vertical from textual import on, work from textual.events import Click @@ -47,6 +47,13 @@ ) from tools import tool_registry, ToolSafetyManager +from threadbear_services import ( + BUILTIN_PROVIDERS, + KNOWN_OPENAI_COMPAT_PROVIDERS, + get_known_providers_with_api_keys, + inject_endpoint_config, + truncate_tool_result, +) from cli.themes import get_theme_css @@ -231,6 +238,54 @@ """ +class MultilineInputScreen(Screen): + """Modal screen for composing multiline messages.""" + + BINDINGS = [ + Binding("ctrl+s", "submit", "Send"), + Binding("escape", "cancel", "Cancel"), + ] + + DEFAULT_CSS = """ + MultilineInputScreen { + align: center middle; + } + #multiline-dialog { + width: 80%; + height: 60%; + background: #16213e; + border: solid #5f87ff; + padding: 1 2; + } + #multiline-hint { + color: #888888; + height: 1; + margin-bottom: 1; + } + #multiline-area { + height: 1fr; + background: #0b0b0f; + border: solid #444444; + color: #e0e0e0; + } + """ + + def compose(self) -> ComposeResult: + with Vertical(id="multiline-dialog"): + yield Static("Ctrl+S to send • Esc to cancel", id="multiline-hint") + yield TextArea(id="multiline-area") + + def on_mount(self): + self.query_one("#multiline-area", TextArea).focus() + + def action_submit(self): + text = self.query_one("#multiline-area", TextArea).text.strip() + self.dismiss(text if text else None) + + def action_cancel(self): + self.dismiss(None) + + class ThreadBearApp(App): CSS = CSS BINDINGS = [ @@ -238,6 +293,7 @@ class ThreadBearApp(App): Binding("ctrl+r", "toggle_right", "Toggle Settings"), Binding("ctrl+d", "quit", "Quit"), Binding("escape", "focus_input", "Focus Input"), + Binding("ctrl+e", "multiline_input", "Multiline Input"), ] def __init__(self): @@ -255,12 +311,8 @@ def __init__(self): self.chat_manager.branch_db = self.branch_db self.branch_db.migrate_from_json(self.chat_manager.chats_directory) - self.builtin_providers = ["groq", "google", "mistral", "openrouter", "llamacpp"] - - # Known OpenAI-compatible providers: name slug → base_url + context_window defaults - self.known_providers = { - "cerebras": {"base_url": "https://api.cerebras.ai/v1", "context_window": 131072}, - } + self.builtin_providers = list(BUILTIN_PROVIDERS) + self.known_providers = dict(KNOWN_OPENAI_COMPAT_PROVIDERS) self._show_left = True self._show_right = False @@ -306,9 +358,28 @@ def action_focus_input(self): except Exception: pass + def _update_header_display(self): + """Update the header bar. Safe to call from the main thread only.""" + try: + header = self.screen.query_one("#header", HeaderBar) + header.update_header() + except Exception: + pass + + def action_multiline_input(self): + def on_result(text): + if text: + try: + screen = self.screen + screen._send_message(text) + except Exception: + pass + self.push_screen(MultilineInputScreen(), on_result) + @property def available_providers(self): - return self.builtin_providers + list(self.known_providers.keys()) + list(self.config.get("custom_endpoints", {}).keys()) + keyed_known = get_known_providers_with_api_keys(self.config) + return self.builtin_providers + list(keyed_known.keys()) + list(self.config.get("custom_endpoints", {}).keys()) def _get_stream_func(self, provider): builtin = { @@ -327,19 +398,7 @@ def _get_stream_func(self, provider): return None def _inject_endpoint_config(self, provider, merged_cfg): - if provider in self.known_providers: - ep = self.known_providers[provider] - api_key = self.config.get_api_key(provider) - merged_cfg["_endpoint_base_url"] = ep["base_url"] - merged_cfg["_endpoint_api_key"] = api_key - merged_cfg["_endpoint_provider"] = provider - endpoints = self.config.get("custom_endpoints", {}) - if provider in endpoints: - ep = endpoints[provider] - api_key = self.config.get_api_key(provider) - merged_cfg["_endpoint_base_url"] = ep["base_url"] - merged_cfg["_endpoint_api_key"] = api_key - merged_cfg["_endpoint_provider"] = provider + inject_endpoint_config(provider, merged_cfg, self.config) def _get_llamacpp_url(self): return self.config.get("llamacpp_url", "http://localhost:8080") @@ -496,9 +555,9 @@ def run_chat_turn(self, user_message: str): api_messages, provider, model ) api_messages = compacted - print(f"[Compaction] Applied: {summary[:50]}") + self.log(f"Compaction applied: {summary[:50]}") except Exception as compact_err: - print(f"Pre-LLM compaction failed (non-blocking): {compact_err}") + self.log(f"Pre-LLM compaction failed: {compact_err}") for chunk in stream_func(api_messages, merged_cfg, tools=tool_schemas): if self._cancel_event.is_set(): @@ -582,7 +641,7 @@ def run_chat_turn(self, user_message: str): ctx_window = 8192 budget_chars = int(ctx_window * 0.4 * 4) // max(len(tool_calls_this_round), 1) budget_chars = max(budget_chars, 2000) - llm_result = self._truncate_tool_result(result, max_chars=budget_chars) + llm_result = truncate_tool_result(result, max_chars=budget_chars) api_messages.append({ 'role': 'tool', 'tool_call_id': tc.get('id', ''), @@ -600,7 +659,7 @@ def run_chat_turn(self, user_message: str): full_response += chunk self.call_from_thread(self._append_streaming, chunk) except Exception as synth_err: - print(f"Synthesis call failed: {synth_err}") + self.log(f"Synthesis call failed: {synth_err}") if not full_response: full_response = "\n\n".join(working_texts) if working_texts else "(Tool results above)" except LLMApiError as api_err: @@ -634,7 +693,7 @@ def run_chat_turn(self, user_message: str): msgs[-1]["provider"] = provider self.chat_manager.save_current_chat() - self.call_from_thread(self._maybe_generate_title, provider) + self._maybe_generate_title(provider) self.call_from_thread(self._render_final_response, full_response) if stream_usage: @@ -755,82 +814,18 @@ def _maybe_generate_title(self, provider: str): if generated: generated = generated[:60] self._rename_chat_file(generated) + self.call_from_thread(self._update_header_display) except Exception as title_err: - print(f"Auto-title generation failed: {title_err}") + self.log(f"Auto-title generation failed: {title_err}") def _rename_chat_file(self, new_title: str): - try: - filename = self.chat_manager.current_chat_file - if not filename: - return - - safe = re.sub(r"[^\w\s-]", "", new_title) - safe = re.sub(r"[-\s]+", "_", safe).strip("_") - base = os.path.splitext(filename)[0] - m = re.search(r"_(\d{8}_\d{6})$", base) - ts = m.group(1) if m else datetime.now().strftime("%Y%m%d_%H%M%S") - new_fn = f"{safe}_{ts}.json" - - old_path = os.path.join(self.chat_manager.chats_directory, filename) - new_path = os.path.join(self.chat_manager.chats_directory, new_fn) - - if os.path.exists(old_path): - with open(old_path, "r", encoding="utf-8") as f: - chat_data = json.load(f) - - if isinstance(chat_data, dict): - chat_data["title"] = new_title - if "chat_id" not in chat_data: - chat_data["chat_id"] = str(uuid.uuid4()) - if "root_chat_id" not in chat_data: - chat_data["root_chat_id"] = chat_data["chat_id"] - if "parent_chat_id" not in chat_data: - chat_data["parent_chat_id"] = "" - - with open(new_path, "w", encoding="utf-8") as f: - json.dump(chat_data, f, indent=2, ensure_ascii=False) - os.remove(old_path) - - self.chat_manager.current_chat_file = new_fn - if isinstance(self.chat_manager.current_chat, dict): - self.chat_manager.current_chat["title"] = new_title - - folder_id = self.folder_manager.get_chat_folder(filename) - if folder_id: - self.folder_manager.remove_chat_from_folder(filename) - self.folder_manager.assign_chat_to_folder(new_fn, folder_id) - - try: - header = self.screen.query_one("#header", HeaderBar) - header.update_header() - except Exception: - pass - except Exception as e: - print(f"Failed to rename chat: {e}") - - def _truncate_tool_result(self, result: dict, max_chars: int = 3000) -> dict: - truncated = dict(result) - text_fields = ['stdout', 'stderr', 'content', 'data'] - for field in text_fields: - if field in truncated and isinstance(truncated[field], str): - val = truncated[field] - if len(val) > max_chars: - half = max_chars // 2 - head = val[:half] - tail = val[-half:] - nl = head.rfind('\n') - if nl > half // 2: - head = head[:nl] - nl = tail.find('\n') - if nl != -1 and nl < half // 2: - tail = tail[nl + 1:] - omitted = len(val) - len(head) - len(tail) - total_lines = val.count('\n') + 1 - truncated[field] = f"{head}\n\n[... {omitted} chars omitted, {total_lines} total lines ...]\n\n{tail}" - if 'result' in truncated and isinstance(truncated['result'], dict): - truncated['result'] = self._truncate_tool_result(truncated['result'], max_chars) - return truncated - + old_filename = self.chat_manager.current_chat_file + new_fn = self.chat_manager.rename_current_chat(new_title) + if new_fn and old_filename: + folder_id = self.folder_manager.get_chat_folder(old_filename) + if folder_id: + self.folder_manager.remove_chat_from_folder(old_filename) + self.folder_manager.assign_chat_to_folder(new_fn, folder_id) class HeaderBar(Horizontal): DEFAULT_CSS = """ @@ -1004,6 +999,7 @@ def _sync_settings_model(self, model): except Exception: pass + @work(thread=True) def refresh_models_from_api(self, provider): models = self._fetch_models_from_api(provider) if models: @@ -1012,7 +1008,7 @@ def refresh_models_from_api(self, provider): if current not in models: self.app.config.set(f"{provider}_model", models[0]) self.app.config.save_config() - self.call_from_thread(self._on_models_fetched, provider) + self.app.call_from_thread(self._on_models_fetched, provider) def _on_models_fetched(self, provider): self._refresh_model_select(provider) @@ -1037,7 +1033,7 @@ def _fetch_models_from_api(self, provider): elif provider == "openrouter": return self._fetch_openrouter_models() except Exception as e: - print(f"Failed to fetch models for {provider}: {e}") + self.log(f"Failed to fetch models for {provider}: {e}") return [] def _fetch_groq_models(self): @@ -1276,7 +1272,7 @@ def _refresh_chat(self): container = Vertical(classes="message-turn") self.mount(container) container.mount(Static(label, classes="assistant-label")) - container.mount(Static(content if content else "(empty message)", classes="assistant-content")) + container.mount(Markdown(content if content else "(empty message)")) elif role == "tool": # Keep tool messages from creating blank rows in history rendering. continue @@ -1331,7 +1327,7 @@ def append_message(self, role: str, content: str): elif role == "assistant": label = self._get_model_label() container.mount(Static(label, classes="assistant-label")) - container.mount(Static(content if content else "(empty message)", classes="assistant-content")) + container.mount(Markdown(content if content else "(empty message)")) self.mount(container) self.scroll_end() @@ -1344,7 +1340,7 @@ def render_final_response(self, content: str): label = self._get_model_label() container = Vertical(classes="message-turn") container.mount(Static(label, classes="assistant-label")) - container.mount(Static(content if content else "(empty message)", classes="assistant-content")) + container.mount(Markdown(content if content else "(empty message)")) self.mount(container) self.scroll_end() @@ -1600,6 +1596,7 @@ def _handle_command(self, text: str): chat_display.mount(Static( "\n[bold]Commands:[/bold]\n" " /new - Start a new chat\n" + " /load [n|name] - Load chat by number or partial name\n" " /quit - Exit ThreadBear\n" " /help - Show this help\n" " /clear - Clear chat display\n" @@ -1654,6 +1651,82 @@ def _handle_command(self, text: str): sidebar._build_chat_list() except Exception: pass + + elif cmd == "load": + chats_dir = self.app.chat_manager.chats_directory + try: + files = sorted( + [f for f in os.listdir(chats_dir) if f.endswith(".json")], + key=lambda f: os.path.getmtime(os.path.join(chats_dir, f)), + reverse=True, + ) + except Exception: + files = [] + + arg = " ".join(args).strip() + + if not arg: + # Show numbered list + if not files: + chat_display.mount(Static("\n[dim]No chats found[/dim]", markup=True)) + else: + lines = ["\n[bold]Chats (most recent first):[/bold]"] + for i, fn in enumerate(files[:30], 1): + path = os.path.join(chats_dir, fn) + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + title = data.get("title", fn.replace(".json", "").replace("_", " ")) + except Exception: + title = fn.replace(".json", "").replace("_", " ") + lines.append(f" [dim]{i:2}.[/dim] {title}") + lines.append("\n[dim]Use /load or /load [/dim]") + chat_display.mount(Static("\n".join(lines), markup=True)) + elif arg.isdigit(): + idx = int(arg) - 1 + if 0 <= idx < len(files): + filename = files[idx] + if self.app.chat_manager.load_chat(filename): + chat_display._refresh_chat() + self.app._update_header_display() + try: + sidebar = self.query_one("#left-sidebar", Sidebar) + sidebar._build_chat_list() + except Exception: + pass + else: + chat_display.mount(Static(f"\n[bold red]Failed to load chat[/bold red]", markup=True)) + else: + chat_display.mount(Static(f"\n[bold red]No chat at index {arg}[/bold red]", markup=True)) + else: + # Fuzzy match by title or filename + arg_lower = arg.lower() + match = None + for fn in files: + path = os.path.join(chats_dir, fn) + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + title = data.get("title", "") + except Exception: + title = "" + if arg_lower in title.lower() or arg_lower in fn.lower(): + match = fn + break + if match: + if self.app.chat_manager.load_chat(match): + chat_display._refresh_chat() + self.app._update_header_display() + try: + sidebar = self.query_one("#left-sidebar", Sidebar) + sidebar._build_chat_list() + except Exception: + pass + else: + chat_display.mount(Static(f"\n[bold red]Failed to load chat[/bold red]", markup=True)) + else: + chat_display.mount(Static(f"\n[bold red]No chat matching: {arg}[/bold red]", markup=True)) + elif cmd == "provider": providers = self.app.available_providers current = self.app.config.get("provider", "unknown") @@ -1673,6 +1746,7 @@ def _handle_command(self, text: str): chat_display.mount(Static("\n[bold red]Usage: /rename [/bold red]", markup=True)) else: self.app._rename_chat_file(new_title) + self.app._update_header_display() try: sidebar = self.query_one("#left-sidebar", Sidebar) sidebar._build_chat_list() diff --git a/docs/cli_flask_audit_plan.md b/docs/cli_flask_audit_plan.md new file mode 100644 index 0000000..28f410c --- /dev/null +++ b/docs/cli_flask_audit_plan.md @@ -0,0 +1,182 @@ +# CLI + Flask Audit and Recovery Plan + +## Executive Summary + +ThreadBear currently has **two orchestration surfaces**: +- `flask_chat_app.py` (web app + API middle layer). +- `cli/app.py` (Textual terminal UI with direct service calls). + +Both paths duplicate core chat orchestration logic (provider selection, compaction, tool loop, context injection, streaming handling). This duplication has drifted and is now the primary reason the CLI is unstable and incomplete. + +The fastest path to a reliable CLI with full ThreadBear feature parity is: +1. Keep Flask as the canonical middle-layer implementation for business logic. +2. Refactor common app operations into a shared service layer used by both Flask routes and CLI commands. +3. Slim the CLI into a presentation layer that invokes shared service calls instead of owning independent orchestration logic. + +--- + +## What Exists Today + +### Flask app capabilities (source of truth) + +`flask_chat_app.py` exposes a broad API surface including: +- Chat lifecycle + streaming/cancel APIs. +- Tool configuration and toolbox/toolbelt execution APIs. +- Folder graph, workspace, memory notes, prompt assignment, and edge management APIs. +- Context document ingestion/selection/highlights APIs. +- Prompt CRUD, endpoint CRUD, model catalogs/settings, llama.cpp URL/state APIs. + +This is effectively a full middle-tier and state manager for ThreadBear features. + +### CLI capabilities + +`cli/app.py` contains: +- A Textual UI shell (sidebar/chat/settings panes). +- Direct invocation of low-level managers (`ChatManager`, `ConfigManager`, folder/doc/tool modules). +- A large command parser with many commands implemented as partial, local-only versions. + +The CLI does implement some important behaviors (streaming, provider/model selection, docs, folders, tool toggles), but it does not cleanly consume the full feature model that the Flask app already provides. + +--- + +## Audit Findings (Root Causes) + +## 1) Architectural duplication and drift + +The CLI has an independent chat-turn executor (`run_chat_turn`) that reimplements: +- system prompt composition, +- tool iteration loop, +- overflow compaction, +- usage/cost handling, +- title generation. + +This same category of logic also exists in the Flask route flow, creating permanent drift risk. + +**Impact:** fixes and new features must be shipped twice; parity failures are expected. + +## 2) Feature-surface mismatch (Flask > CLI) + +Flask includes rich APIs for folder memory notes, workspace/status transitions, prompt CRUD, document highlights/full doc retrieval, endpoint tests, browse catalog toggles, and more. The CLI command set only exposes a subset and often only basic list/create variants. + +**Impact:** “CLI should have all ThreadBear features” is currently not achievable without major reconciliation. + +## 3) Config-path inconsistencies + +The CLI uses direct config key writes in command handlers (e.g., tool toggles) while other paths rely on structured accessors (`get_tool_config`, model settings helpers). This split increases probability of subtle behavior differences between UI surfaces. + +**Impact:** settings can appear set in one surface but behave differently in another. + +## 4) UI and business logic are tightly coupled in CLI + +`MainScreen` command handlers mutate storage and domain state directly (chat files, folders, docs, toolbelt). There is no service boundary. + +**Impact:** hard to test; fragile command behavior; difficult to add parity features without further complexity. + +## 5) Missing contract-level parity tests + +There is no automated parity check that compares CLI-visible operations against Flask-backed behavior. + +**Impact:** regressions accumulate unnoticed until manual use. + +--- + +## Recommended Target Architecture + +### Principle: one domain core, two UIs + +Create a shared application service layer (example package: `threadbear_services/`) with explicit use-cases: +- `ChatService` (new chat, send/stream, cancel, summarize, delete message, branch). +- `ProviderService` (providers, models, model settings, catalog refresh, endpoint integrations). +- `DocsService` (upload/url ingest/list/select/delete/highlights/full retrieval). +- `ToolingService` (tools config, toolbox CRUD, toolbelt CRUD/run/scan/permissions). +- `FolderService` (CRUD, assign chat/file, memory notes, prompts, workspace/status transitions, edges). +- `PromptService` (prompt CRUD and selection). +- `LlamaService` (status, URL management, model refresh). + +Then: +- Flask routes become thin HTTP adapters calling services. +- CLI commands become thin command adapters calling those same services. + +--- + +## Implementation Plan + +## Phase 0 — Stabilization baseline (1-2 days) + +1. Freeze current behavior with smoke tests: + - CLI app boot test. + - Flask app boot test. + - One send/stream round-trip smoke per provider mock. +2. Add “feature parity checklist” document derived from Flask API inventory. +3. Add structured logging around CLI command failures. + +**Deliverable:** reproducible baseline and failure visibility. + +## Phase 1 — Extract shared orchestration (3-5 days) + +1. Move chat-turn orchestration out of CLI into `ChatService`. +2. Move tool-loop + compaction + usage/cost logic into shared functions. +3. Wire Flask `send/stream` flow to `ChatService`. +4. Wire CLI `/send` flow to `ChatService`. + +**Deliverable:** one implementation for the highest-risk logic. + +## Phase 2 — Command/API parity bridge (4-7 days) + +1. Map every Flask capability to one CLI command family (or explicit “not in CLI by design”). +2. Add missing CLI command handlers for: + - prompt CRUD, + - endpoint test/refresh/catalog browse controls, + - folder memory/workspace/status/edges, + - doc highlights/full-view ops, + - toolbox/toolbelt parity operations. +3. Route all handlers through services (no direct file mutations in UI layer). + +**Deliverable:** CLI can execute all supported ThreadBear features. + +## Phase 3 — Reliability hardening (3-5 days) + +1. Add contract tests for services (unit). +2. Add adapter tests: + - Flask route → service call mapping. + - CLI command → service call mapping. +3. Add parity regression test suite using the Phase 2 mapping matrix. + +**Deliverable:** parity breaks fail CI before release. + +## Phase 4 — UX simplification (2-4 days) + +1. Simplify CLI command grammar and help output (grouped subcommands). +2. Standardize result rendering and error messaging. +3. Add guided command discovery (e.g., `/help docs`, `/help folders`). + +**Deliverable:** streamlined CLI without sacrificing capability. + +--- + +## Priority Backlog (Ordered) + +1. **P0:** extract shared chat-turn engine from CLI. +2. **P0:** add parity matrix and missing-feature inventory. +3. **P1:** move doc/folder/tool/prompt/endpoint mutations behind services. +4. **P1:** implement missing CLI command families for Flask parity. +5. **P1:** add service + adapter tests. +6. **P2:** CLI UX cleanup and command discoverability improvements. + +--- + +## Suggested Acceptance Criteria + +A release is considered successful when all are true: + +1. CLI startup and first message send succeed on clean repo state. +2. For every feature in the Flask parity matrix, a corresponding CLI command path passes. +3. Shared service unit tests and CLI/Flask adapter tests are green. +4. No direct persistence writes occur inside CLI view classes except via services. +5. New feature additions require only service changes plus thin adapter wiring. + +--- + +## Immediate Next Step + +Start with **Phase 1 extraction of chat-turn orchestration** from `cli/app.py` into a shared service module and update both Flask and CLI to call it. This removes the highest-risk duplication first and creates the seam needed for full feature parity work. diff --git a/flask_chat_app.py b/flask_chat_app.py index fa8e81c..8c585ec 100644 --- a/flask_chat_app.py +++ b/flask_chat_app.py @@ -23,6 +23,12 @@ from typing import Dict, List import requests from api_clients import estimate_tokens, get_llamacpp_context_size +from threadbear_services import ( + BUILTIN_PROVIDERS, + KNOWN_OPENAI_COMPAT_PROVIDERS, + inject_endpoint_config, + truncate_tool_result, +) # No-proxy session for llama.cpp LAN/local calls _local_session = requests.Session() @@ -102,45 +108,6 @@ def _cancel_generation(message_id: int): _request_contexts[message_id]['cancel_generation'] = True -def _truncate_text_head_tail(text: str, max_chars: int) -> str: - """Truncate text keeping head + tail so the LLM sees how it started and ended.""" - if len(text) <= max_chars: - return text - half = max_chars // 2 - # Break at newlines so we don't cut mid-line - head = text[:half] - tail = text[-half:] - # Snap to last newline in head - nl = head.rfind('\n') - if nl > half // 2: - head = head[:nl] - # Snap to first newline in tail - nl = tail.find('\n') - if nl != -1 and nl < half // 2: - tail = tail[nl + 1:] - omitted = len(text) - len(head) - len(tail) - total_lines = text.count('\n') + 1 - return f"{head}\n\n[... {omitted} chars omitted, {total_lines} total lines ...]\n\n{tail}" - - -def _truncate_tool_result(result: dict, max_chars: int = 3000) -> dict: - """ - Smart-truncate a tool result dict for the LLM context. - Truncates individual text fields (stdout, content, etc.) using head+tail, - preserving structure so JSON remains valid. - """ - truncated = dict(result) - # Fields that can be large - text_fields = ['stdout', 'stderr', 'content', 'data'] - for field in text_fields: - if field in truncated and isinstance(truncated[field], str): - truncated[field] = _truncate_text_head_tail(truncated[field], max_chars) - # Handle nested {success, result} wrapper - if 'result' in truncated and isinstance(truncated['result'], dict): - truncated['result'] = _truncate_tool_result(truncated['result'], max_chars) - return truncated - - class FlaskChatApp: def __init__(self): # figure out where the repo root is (same folder that has templates/, static/, prompts/) @@ -169,21 +136,8 @@ def __init__(self): self.temporary_mode = False self.incognito_mode = False - self.builtin_providers = ["groq", "google", "mistral", "openrouter", "llamacpp"] - - # Known OpenAI-compatible providers: name slug → base_url + context_window defaults - self.known_providers = { - "cerebras": {"base_url": "https://api.cerebras.ai/v1", "context_window": 131072}, - "together": {"base_url": "https://api.together.xyz/v1", "context_window": 131072}, - "togetherai": {"base_url": "https://api.together.xyz/v1", "context_window": 131072}, - "deepseek": {"base_url": "https://api.deepseek.com/v1", "context_window": 65536}, - "xai": {"base_url": "https://api.x.ai/v1", "context_window": 131072}, - "fireworks": {"base_url": "https://api.fireworks.ai/inference/v1", "context_window": 131072}, - "perplexity": {"base_url": "https://api.perplexity.ai", "context_window": 131072}, - "nvidia": {"base_url": "https://integrate.api.nvidia.com/v1", "context_window": 32768}, - "ollama": {"base_url": "http://localhost:11434/v1", "context_window": 8192}, - "lmstudio": {"base_url": "http://localhost:1234/v1", "context_window": 8192}, - } + self.builtin_providers = list(BUILTIN_PROVIDERS) + self.known_providers = dict(KNOWN_OPENAI_COMPAT_PROVIDERS) self.pending_messages: Dict[int, Dict[str, str]] = {} @@ -210,14 +164,8 @@ def _get_stream_func(self, provider): return None def _inject_endpoint_config(self, provider, merged_cfg): - """For custom endpoints, inject base_url and api_key into the config dict.""" - endpoints = self.config.get("custom_endpoints", {}) - if provider in endpoints: - ep = endpoints[provider] - api_key = self.config.get_api_key(provider) - merged_cfg["_endpoint_base_url"] = ep["base_url"] - merged_cfg["_endpoint_api_key"] = api_key - merged_cfg["_endpoint_provider"] = provider + """Inject base URL + API key for known/custom OpenAI-compatible endpoints.""" + inject_endpoint_config(provider, merged_cfg, self.config) # ---------------- Routes ---------------- def setup_routes(self): @@ -1244,7 +1192,7 @@ def generate(): ctx_window = 8192 budget_chars = int(ctx_window * 0.4 * 4) // max(len(tool_calls_this_round), 1) budget_chars = max(budget_chars, 2000) # floor - llm_result = _truncate_tool_result(result, max_chars=budget_chars) + llm_result = truncate_tool_result(result, max_chars=budget_chars) api_messages.append({ 'role': 'tool', 'tool_call_id': tc.get('id', ''), diff --git a/pyproject.toml b/pyproject.toml index 346339a..b937726 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ include = [ "agent*", "tools*", "readers*", + "threadbear_services*", ] [tool.setuptools] diff --git a/threadbear_services/__init__.py b/threadbear_services/__init__.py new file mode 100644 index 0000000..6fcaa24 --- /dev/null +++ b/threadbear_services/__init__.py @@ -0,0 +1,18 @@ +"""Shared application services used by both Flask and CLI surfaces.""" + +from .providers import ( + BUILTIN_PROVIDERS, + KNOWN_OPENAI_COMPAT_PROVIDERS, + get_known_providers_with_api_keys, + inject_endpoint_config, +) +from .text_utils import truncate_text_head_tail, truncate_tool_result + +__all__ = [ + "BUILTIN_PROVIDERS", + "KNOWN_OPENAI_COMPAT_PROVIDERS", + "get_known_providers_with_api_keys", + "inject_endpoint_config", + "truncate_text_head_tail", + "truncate_tool_result", +] diff --git a/threadbear_services/providers.py b/threadbear_services/providers.py new file mode 100644 index 0000000..85b15e8 --- /dev/null +++ b/threadbear_services/providers.py @@ -0,0 +1,49 @@ +"""Provider catalog helpers shared across ThreadBear front-ends.""" + +from __future__ import annotations + +from typing import Dict, Any + +BUILTIN_PROVIDERS = ["groq", "google", "mistral", "openrouter", "llamacpp"] + +# name slug -> endpoint metadata +KNOWN_OPENAI_COMPAT_PROVIDERS: Dict[str, Dict[str, Any]] = { + "cerebras": {"base_url": "https://api.cerebras.ai/v1", "context_window": 131072}, + "together": {"base_url": "https://api.together.xyz/v1", "context_window": 131072}, + "togetherai": {"base_url": "https://api.together.xyz/v1", "context_window": 131072}, + "deepseek": {"base_url": "https://api.deepseek.com/v1", "context_window": 65536}, + "xai": {"base_url": "https://api.x.ai/v1", "context_window": 131072}, + "fireworks": {"base_url": "https://api.fireworks.ai/inference/v1", "context_window": 131072}, + "perplexity": {"base_url": "https://api.perplexity.ai", "context_window": 131072}, + "nvidia": {"base_url": "https://integrate.api.nvidia.com/v1", "context_window": 32768}, + "ollama": {"base_url": "http://localhost:11434/v1", "context_window": 8192}, + "lmstudio": {"base_url": "http://localhost:1234/v1", "context_window": 8192}, +} + + +def inject_endpoint_config(provider: str, merged_cfg: Dict[str, Any], config_manager) -> None: + """Inject base_url + API key for known/custom OpenAI-compatible providers.""" + if provider in KNOWN_OPENAI_COMPAT_PROVIDERS: + ep = KNOWN_OPENAI_COMPAT_PROVIDERS[provider] + merged_cfg["_endpoint_base_url"] = ep["base_url"] + merged_cfg["_endpoint_api_key"] = config_manager.get_api_key(provider) + merged_cfg["_endpoint_provider"] = provider + + endpoints = config_manager.get("custom_endpoints", {}) + if provider in endpoints: + ep = endpoints[provider] + merged_cfg["_endpoint_base_url"] = ep["base_url"] + merged_cfg["_endpoint_api_key"] = config_manager.get_api_key(provider) + merged_cfg["_endpoint_provider"] = provider + + +def get_known_providers_with_api_keys(config_manager) -> Dict[str, Dict[str, Any]]: + """ + Return only known OpenAI-compatible providers that currently have a configured API key. + """ + enabled: Dict[str, Dict[str, Any]] = {} + for provider, cfg in KNOWN_OPENAI_COMPAT_PROVIDERS.items(): + key = (config_manager.get_api_key(provider) or "").strip() + if key: + enabled[provider] = cfg + return enabled diff --git a/threadbear_services/text_utils.py b/threadbear_services/text_utils.py new file mode 100644 index 0000000..23fc803 --- /dev/null +++ b/threadbear_services/text_utils.py @@ -0,0 +1,37 @@ +"""Text truncation utilities for tool and context payloads.""" + +from __future__ import annotations + + +def truncate_text_head_tail(text: str, max_chars: int) -> str: + """Truncate text while preserving the beginning and end segments.""" + if len(text) <= max_chars: + return text + half = max_chars // 2 + head = text[:half] + tail = text[-half:] + + nl = head.rfind("\n") + if nl > half // 2: + head = head[:nl] + + nl = tail.find("\n") + if nl != -1 and nl < half // 2: + tail = tail[nl + 1 :] + + omitted = len(text) - len(head) - len(tail) + total_lines = text.count("\n") + 1 + return f"{head}\n\n[... {omitted} chars omitted, {total_lines} total lines ...]\n\n{tail}" + + +def truncate_tool_result(result: dict, max_chars: int = 3000) -> dict: + """Truncate large tool-result text fields recursively while preserving JSON shape.""" + truncated = dict(result) + for field in ["stdout", "stderr", "content", "data"]: + if field in truncated and isinstance(truncated[field], str): + truncated[field] = truncate_text_head_tail(truncated[field], max_chars) + + if "result" in truncated and isinstance(truncated["result"], dict): + truncated["result"] = truncate_tool_result(truncated["result"], max_chars) + + return truncated