multimind 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. multimind-0.1.0/PKG-INFO +85 -0
  2. multimind-0.1.0/README.md +73 -0
  3. multimind-0.1.0/multimind/__init__.py +3 -0
  4. multimind-0.1.0/multimind/__main__.py +5 -0
  5. multimind-0.1.0/multimind/config.py +35 -0
  6. multimind-0.1.0/multimind/discovery.py +76 -0
  7. multimind-0.1.0/multimind/llm_client.py +116 -0
  8. multimind-0.1.0/multimind/main.py +178 -0
  9. multimind-0.1.0/multimind/markdown_render.py +64 -0
  10. multimind-0.1.0/multimind/pipeline.py +184 -0
  11. multimind-0.1.0/multimind/static/app.js +402 -0
  12. multimind-0.1.0/multimind/static/styles.css +557 -0
  13. multimind-0.1.0/multimind/static/vendor/katex/README.md +125 -0
  14. multimind-0.1.0/multimind/static/vendor/katex/contrib/auto-render.js +341 -0
  15. multimind-0.1.0/multimind/static/vendor/katex/contrib/auto-render.min.js +1 -0
  16. multimind-0.1.0/multimind/static/vendor/katex/contrib/auto-render.mjs +244 -0
  17. multimind-0.1.0/multimind/static/vendor/katex/contrib/copy-tex.js +127 -0
  18. multimind-0.1.0/multimind/static/vendor/katex/contrib/copy-tex.min.js +1 -0
  19. multimind-0.1.0/multimind/static/vendor/katex/contrib/copy-tex.mjs +105 -0
  20. multimind-0.1.0/multimind/static/vendor/katex/contrib/mathtex-script-type.js +112 -0
  21. multimind-0.1.0/multimind/static/vendor/katex/contrib/mathtex-script-type.min.js +1 -0
  22. multimind-0.1.0/multimind/static/vendor/katex/contrib/mathtex-script-type.mjs +24 -0
  23. multimind-0.1.0/multimind/static/vendor/katex/contrib/mhchem.js +3216 -0
  24. multimind-0.1.0/multimind/static/vendor/katex/contrib/mhchem.min.js +1 -0
  25. multimind-0.1.0/multimind/static/vendor/katex/contrib/mhchem.mjs +3109 -0
  26. multimind-0.1.0/multimind/static/vendor/katex/contrib/render-a11y-string.js +890 -0
  27. multimind-0.1.0/multimind/static/vendor/katex/contrib/render-a11y-string.min.js +1 -0
  28. multimind-0.1.0/multimind/static/vendor/katex/contrib/render-a11y-string.mjs +800 -0
  29. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_AMS-Regular.ttf +0 -0
  30. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_AMS-Regular.woff +0 -0
  31. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  32. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
  33. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
  34. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  35. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
  36. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
  37. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  38. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
  39. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff +0 -0
  40. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  41. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
  42. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff +0 -0
  43. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  44. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Main-Bold.ttf +0 -0
  45. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Main-Bold.woff +0 -0
  46. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  47. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
  48. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff +0 -0
  49. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  50. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Main-Italic.ttf +0 -0
  51. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Main-Italic.woff +0 -0
  52. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  53. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Main-Regular.ttf +0 -0
  54. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Main-Regular.woff +0 -0
  55. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  56. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
  57. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff +0 -0
  58. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  59. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Math-Italic.ttf +0 -0
  60. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Math-Italic.woff +0 -0
  61. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  62. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
  63. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff +0 -0
  64. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  65. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
  66. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff +0 -0
  67. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  68. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
  69. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff +0 -0
  70. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  71. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Script-Regular.ttf +0 -0
  72. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Script-Regular.woff +0 -0
  73. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  74. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Size1-Regular.ttf +0 -0
  75. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Size1-Regular.woff +0 -0
  76. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  77. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Size2-Regular.ttf +0 -0
  78. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Size2-Regular.woff +0 -0
  79. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  80. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Size3-Regular.ttf +0 -0
  81. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Size3-Regular.woff +0 -0
  82. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  83. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Size4-Regular.ttf +0 -0
  84. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Size4-Regular.woff +0 -0
  85. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  86. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
  87. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff +0 -0
  88. multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  89. multimind-0.1.0/multimind/static/vendor/katex/katex.css +1209 -0
  90. multimind-0.1.0/multimind/static/vendor/katex/katex.js +18998 -0
  91. multimind-0.1.0/multimind/static/vendor/katex/katex.min.css +1 -0
  92. multimind-0.1.0/multimind/static/vendor/katex/katex.min.js +1 -0
  93. multimind-0.1.0/multimind/static/vendor/katex/katex.mjs +18458 -0
  94. multimind-0.1.0/multimind/templates/index.html +133 -0
  95. multimind-0.1.0/multimind.egg-info/PKG-INFO +85 -0
  96. multimind-0.1.0/multimind.egg-info/SOURCES.txt +100 -0
  97. multimind-0.1.0/multimind.egg-info/dependency_links.txt +1 -0
  98. multimind-0.1.0/multimind.egg-info/entry_points.txt +2 -0
  99. multimind-0.1.0/multimind.egg-info/requires.txt +5 -0
  100. multimind-0.1.0/multimind.egg-info/top_level.txt +1 -0
  101. multimind-0.1.0/pyproject.toml +29 -0
  102. multimind-0.1.0/setup.cfg +4 -0
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.4
2
+ Name: multimind
3
+ Version: 0.1.0
4
+ Summary: Local-first reasoning pipeline wrapper for Ollama and LM Studio
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: bleach<7.0.0,>=6.2.0
8
+ Requires-Dist: fastapi<1.0.0,>=0.116.0
9
+ Requires-Dist: httpx<1.0.0,>=0.28.0
10
+ Requires-Dist: markdown<4.0.0,>=3.8.0
11
+ Requires-Dist: uvicorn<1.0.0,>=0.35.0
12
+
13
+ <div align="center">
14
+ <h1>🧠 MultiMind AI</h1>
15
+ <p>
16
+ <b>A local-first web UI that adds a reasoning pipeline on top of small local models.</b>
17
+ </p>
18
+
19
+ [![Python Version](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/)
20
+ [![Local First](https://img.shields.io/badge/Local-First-success.svg)](https://github.com/features)
21
+ [![Ollama Supported](https://img.shields.io/badge/Ollama-Supported-purple.svg)](https://ollama.com/)
22
+ [![LM Studio Supported](https://img.shields.io/badge/LM_Studio-Supported-ff69b4.svg)](https://lmstudio.ai/)
23
+ </div>
24
+
25
+ <br/>
26
+
27
+ MultiMind AI acts as an intelligent reasoning pipeline for your local AI models. It effortlessly auto-discovers endpoints like Ollama and LM Studio (OpenAI-compatible) and lets you orchestrate dedicated models for different logical phases: **Planning**, **Execution**, and **Critique**.
28
+
29
+ ---
30
+
31
+ ## ✨ Features
32
+
33
+ - **🧠 Adaptive Reasoning Modes**: Toggle between _Off_, _Medium_, and _Hard_ modes to dictate the depth of the model's reflection.
34
+ - **🔌 Zero-Config Auto-Discovery**:
35
+ - Automatically hooks into local **Ollama** endpoints (`http://127.0.0.1:11434`).
36
+ - Supports optional discovery for **LM Studio** (`http://127.0.0.1:1234`).
37
+ - **🎯 Precision Model Mapping**: Assign distinct models to handle the different stages of thought (`plan`, `execute`, and `critique`).
38
+ - **💬 Immersive UI**: Enjoy a streaming timeline interface with collapsible "thought blocks" to keep your UI clean while the AI thinks.
39
+ - **📝 Native Markdown & Math Support**:
40
+ - Final outputs are beautifully rendered as HTML in the chat view.
41
+ - Inline and block math equations are flawlessly typeset using a bundled local **KaTeX** build.
42
+ - **⚡ Frictionless Setup**: Purely in-memory settings. Zero `.env` setup required for your first run.
43
+
44
+ ## 🚀 Quick Start
45
+
46
+ Get up and running in your local environment in seconds:
47
+
48
+ ```bash
49
+ # 1. Create a virtual environment
50
+ python3 -m venv .venv
51
+
52
+ # 2. Activate the virtual environment
53
+ # On macOS / Linux:
54
+ source .venv/bin/activate
55
+
56
+ # On Windows:
57
+ .venv\Scripts\activate
58
+
59
+ # 3. Install the package
60
+ pip install -e .
61
+
62
+ # 4. Launch the application
63
+ multimind AI
64
+ ```
65
+
66
+ > **Next:** Open your browser and navigate to [http://127.0.0.1:8000](http://127.0.0.1:8000) 🎉
67
+
68
+ ## 🔌 Supported Backends
69
+
70
+ MultiMind AI works seamlessly with standard local APIs:
71
+
72
+ - **Ollama**: Connects via `/api/chat` and `/api/tags`
73
+ - **OpenAI-Compatible Servers (e.g., LM Studio)**: Connects via `/v1/chat/completions` and `/v1/models`
74
+
75
+ _If no provider is automatically detected, you can easily point the backend to your local OpenAI-compatible endpoint using the application's settings panel._
76
+
77
+ ## 💡 How It Works
78
+
79
+ MultiMind AI splits inference into modular steps, elevating the capabilities of standard models:
80
+
81
+ 1. **Plan**: Formulates a structured approach to the prompt.
82
+ 2. **Execute**: Generates the primary response.
83
+ 3. **Critique (Hard Mode)**: Evaluates the execution pass as a rough draft and streams refined, critiqued output as the final answer.
84
+
85
+ > 📝 **Note:** Chat history is intentionally in-memory only for the current MVP.
@@ -0,0 +1,73 @@
1
+ <div align="center">
2
+ <h1>🧠 MultiMind AI</h1>
3
+ <p>
4
+ <b>A local-first web UI that adds a reasoning pipeline on top of small local models.</b>
5
+ </p>
6
+
7
+ [![Python Version](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/)
8
+ [![Local First](https://img.shields.io/badge/Local-First-success.svg)](https://github.com/features)
9
+ [![Ollama Supported](https://img.shields.io/badge/Ollama-Supported-purple.svg)](https://ollama.com/)
10
+ [![LM Studio Supported](https://img.shields.io/badge/LM_Studio-Supported-ff69b4.svg)](https://lmstudio.ai/)
11
+ </div>
12
+
13
+ <br/>
14
+
15
+ MultiMind AI acts as an intelligent reasoning pipeline for your local AI models. It effortlessly auto-discovers endpoints like Ollama and LM Studio (OpenAI-compatible) and lets you orchestrate dedicated models for different logical phases: **Planning**, **Execution**, and **Critique**.
16
+
17
+ ---
18
+
19
+ ## ✨ Features
20
+
21
+ - **🧠 Adaptive Reasoning Modes**: Toggle between _Off_, _Medium_, and _Hard_ modes to dictate the depth of the model's reflection.
22
+ - **🔌 Zero-Config Auto-Discovery**:
23
+ - Automatically hooks into local **Ollama** endpoints (`http://127.0.0.1:11434`).
24
+ - Supports optional discovery for **LM Studio** (`http://127.0.0.1:1234`).
25
+ - **🎯 Precision Model Mapping**: Assign distinct models to handle the different stages of thought (`plan`, `execute`, and `critique`).
26
+ - **💬 Immersive UI**: Enjoy a streaming timeline interface with collapsible "thought blocks" to keep your UI clean while the AI thinks.
27
+ - **📝 Native Markdown & Math Support**:
28
+ - Final outputs are beautifully rendered as HTML in the chat view.
29
+ - Inline and block math equations are flawlessly typeset using a bundled local **KaTeX** build.
30
+ - **⚡ Frictionless Setup**: Purely in-memory settings. Zero `.env` setup required for your first run.
31
+
32
+ ## 🚀 Quick Start
33
+
34
+ Get up and running in your local environment in seconds:
35
+
36
+ ```bash
37
+ # 1. Create a virtual environment
38
+ python3 -m venv .venv
39
+
40
+ # 2. Activate the virtual environment
41
+ # On macOS / Linux:
42
+ source .venv/bin/activate
43
+
44
+ # On Windows:
45
+ .venv\Scripts\activate
46
+
47
+ # 3. Install the package
48
+ pip install -e .
49
+
50
+ # 4. Launch the application
51
+ multimind AI
52
+ ```
53
+
54
+ > **Next:** Open your browser and navigate to [http://127.0.0.1:8000](http://127.0.0.1:8000) 🎉
55
+
56
+ ## 🔌 Supported Backends
57
+
58
+ MultiMind AI works seamlessly with standard local APIs:
59
+
60
+ - **Ollama**: Connects via `/api/chat` and `/api/tags`
61
+ - **OpenAI-Compatible Servers (e.g., LM Studio)**: Connects via `/v1/chat/completions` and `/v1/models`
62
+
63
+ _If no provider is automatically detected, you can easily point the backend to your local OpenAI-compatible endpoint using the application's settings panel._
64
+
65
+ ## 💡 How It Works
66
+
67
+ MultiMind AI splits inference into modular steps, elevating the capabilities of standard models:
68
+
69
+ 1. **Plan**: Formulates a structured approach to the prompt.
70
+ 2. **Execute**: Generates the primary response.
71
+ 3. **Critique (Hard Mode)**: Evaluates the execution pass as a rough draft and streams refined, critiqued output as the final answer.
72
+
73
+ > 📝 **Note:** Chat history is intentionally in-memory only for the current MVP.
@@ -0,0 +1,3 @@
1
+ __all__ = ["__version__"]
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ from thinking_wrapper.main import run
2
+
3
+
4
+ if __name__ == "__main__":
5
+ run()
@@ -0,0 +1,35 @@
1
+ from dataclasses import dataclass
2
+ from pathlib import Path
3
+
4
+
5
+ APP_NAME = "Thinking-Wrapper"
6
+ DEFAULT_HOST = "127.0.0.1"
7
+ DEFAULT_PORT = 8000
8
+ REQUEST_TIMEOUT_SECONDS = 90.0
9
+ DISCOVERY_TIMEOUT_SECONDS = 1.2
10
+
11
+ BASE_DIR = Path(__file__).resolve().parent
12
+ STATIC_DIR = BASE_DIR / "static"
13
+ TEMPLATE_DIR = BASE_DIR / "templates"
14
+ PIPELINE_STEPS = ("plan", "execute", "critique")
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class ProviderCandidate:
19
+ name: str
20
+ kind: str
21
+ base_url: str
22
+
23
+
24
+ DISCOVERY_CANDIDATES = (
25
+ ProviderCandidate(
26
+ name="Ollama",
27
+ kind="ollama",
28
+ base_url="http://127.0.0.1:11434",
29
+ ),
30
+ ProviderCandidate(
31
+ name="LM Studio",
32
+ kind="openai",
33
+ base_url="http://127.0.0.1:1234",
34
+ ),
35
+ )
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass
4
+
5
+ import httpx
6
+
7
+ from thinking_wrapper.config import DISCOVERY_CANDIDATES, DISCOVERY_TIMEOUT_SECONDS, ProviderCandidate
8
+
9
+
10
+ @dataclass
11
+ class ProviderInfo:
12
+ name: str
13
+ kind: str
14
+ base_url: str
15
+ available: bool
16
+ models: list[str]
17
+ error: str | None = None
18
+
19
+ def to_dict(self) -> dict:
20
+ return asdict(self)
21
+
22
+
23
+ def normalize_base_url(base_url: str) -> str:
24
+ return base_url.rstrip("/")
25
+
26
+
27
+ async def discover_providers() -> list[ProviderInfo]:
28
+ providers: list[ProviderInfo] = []
29
+ async with httpx.AsyncClient(timeout=DISCOVERY_TIMEOUT_SECONDS) as client:
30
+ for candidate in DISCOVERY_CANDIDATES:
31
+ providers.append(await probe_provider(client, candidate))
32
+ return providers
33
+
34
+
35
+ async def probe_provider(client: httpx.AsyncClient, candidate: ProviderCandidate) -> ProviderInfo:
36
+ base_url = normalize_base_url(candidate.base_url)
37
+
38
+ try:
39
+ if candidate.kind == "ollama":
40
+ response = await client.get(f"{base_url}/api/tags")
41
+ response.raise_for_status()
42
+ payload = response.json()
43
+ models = [item.get("model") or item.get("name") for item in payload.get("models", [])]
44
+ else:
45
+ response = await client.get(f"{base_url}/v1/models")
46
+ response.raise_for_status()
47
+ payload = response.json()
48
+ models = [item.get("id") for item in payload.get("data", [])]
49
+
50
+ clean_models = [model for model in models if model]
51
+ return ProviderInfo(
52
+ name=candidate.name,
53
+ kind=candidate.kind,
54
+ base_url=base_url,
55
+ available=True,
56
+ models=clean_models,
57
+ )
58
+ except Exception as exc:
59
+ return ProviderInfo(
60
+ name=candidate.name,
61
+ kind=candidate.kind,
62
+ base_url=base_url,
63
+ available=False,
64
+ models=[],
65
+ error=str(exc),
66
+ )
67
+
68
+
69
+ def select_default_provider(providers: list[ProviderInfo]) -> ProviderInfo | None:
70
+ for provider in providers:
71
+ if provider.available and provider.models:
72
+ return provider
73
+ for provider in providers:
74
+ if provider.available:
75
+ return provider
76
+ return None
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import AsyncIterator
5
+
6
+ import httpx
7
+
8
+ from thinking_wrapper.config import REQUEST_TIMEOUT_SECONDS
9
+ from thinking_wrapper.discovery import normalize_base_url
10
+
11
+
12
+ class LocalLLMClient:
13
+ def __init__(self) -> None:
14
+ self._client = httpx.AsyncClient(timeout=REQUEST_TIMEOUT_SECONDS)
15
+
16
+ async def aclose(self) -> None:
17
+ await self._client.aclose()
18
+
19
+ async def stream_chat(
20
+ self,
21
+ *,
22
+ provider_kind: str,
23
+ base_url: str,
24
+ model: str,
25
+ messages: list[dict[str, str]],
26
+ temperature: float = 0.2,
27
+ ) -> AsyncIterator[str]:
28
+ if provider_kind == "ollama":
29
+ async for token in self._stream_ollama(
30
+ base_url=base_url,
31
+ model=model,
32
+ messages=messages,
33
+ temperature=temperature,
34
+ ):
35
+ yield token
36
+ return
37
+
38
+ async for token in self._stream_openai(
39
+ base_url=base_url,
40
+ model=model,
41
+ messages=messages,
42
+ temperature=temperature,
43
+ ):
44
+ yield token
45
+
46
+ async def _stream_ollama(
47
+ self,
48
+ *,
49
+ base_url: str,
50
+ model: str,
51
+ messages: list[dict[str, str]],
52
+ temperature: float,
53
+ ) -> AsyncIterator[str]:
54
+ payload = {
55
+ "model": model,
56
+ "messages": messages,
57
+ "stream": True,
58
+ "options": {"temperature": temperature},
59
+ }
60
+
61
+ async with self._client.stream(
62
+ "POST",
63
+ f"{normalize_base_url(base_url)}/api/chat",
64
+ json=payload,
65
+ ) as response:
66
+ response.raise_for_status()
67
+ async for line in response.aiter_lines():
68
+ if not line:
69
+ continue
70
+
71
+ payload = json.loads(line)
72
+ message = payload.get("message") or {}
73
+ content = message.get("content")
74
+ if content:
75
+ yield content
76
+ if payload.get("done"):
77
+ break
78
+
79
+ async def _stream_openai(
80
+ self,
81
+ *,
82
+ base_url: str,
83
+ model: str,
84
+ messages: list[dict[str, str]],
85
+ temperature: float,
86
+ ) -> AsyncIterator[str]:
87
+ payload = {
88
+ "model": model,
89
+ "messages": messages,
90
+ "stream": True,
91
+ "temperature": temperature,
92
+ }
93
+
94
+ async with self._client.stream(
95
+ "POST",
96
+ f"{normalize_base_url(base_url)}/v1/chat/completions",
97
+ json=payload,
98
+ ) as response:
99
+ response.raise_for_status()
100
+ async for line in response.aiter_lines():
101
+ if not line or not line.startswith("data:"):
102
+ continue
103
+
104
+ data = line.removeprefix("data:").strip()
105
+ if data == "[DONE]":
106
+ break
107
+
108
+ payload = json.loads(data)
109
+ choices = payload.get("choices") or []
110
+ if not choices:
111
+ continue
112
+
113
+ delta = choices[0].get("delta") or {}
114
+ content = delta.get("content")
115
+ if content:
116
+ yield content
@@ -0,0 +1,178 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import uvicorn
9
+ from fastapi import FastAPI, HTTPException
10
+ from fastapi.middleware.cors import CORSMiddleware
11
+ from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
12
+ from fastapi.staticfiles import StaticFiles
13
+ from pydantic import BaseModel, Field
14
+
15
+ from thinking_wrapper.config import APP_NAME, DEFAULT_HOST, DEFAULT_PORT, PIPELINE_STEPS, STATIC_DIR, TEMPLATE_DIR
16
+ from thinking_wrapper.discovery import ProviderInfo, discover_providers, normalize_base_url, select_default_provider
17
+ from thinking_wrapper.llm_client import LocalLLMClient
18
+ from thinking_wrapper.pipeline import run_pipeline
19
+
20
+
21
+ class SettingsPayload(BaseModel):
22
+ provider_name: str = ""
23
+ provider_kind: str = "ollama"
24
+ base_url: str = "http://127.0.0.1:11434"
25
+ model_map: dict[str, str] = Field(default_factory=dict)
26
+
27
+
28
+ class ChatRequest(BaseModel):
29
+ message: str = Field(min_length=1)
30
+ mode: str = Field(default="hard")
31
+
32
+
33
+ @dataclass
34
+ class RuntimeState:
35
+ providers: list[ProviderInfo] = field(default_factory=list)
36
+ settings: SettingsPayload = field(default_factory=SettingsPayload)
37
+
38
+ def to_dict(self) -> dict[str, Any]:
39
+ return {
40
+ "providers": [provider.to_dict() for provider in self.providers],
41
+ "settings": self.settings.model_dump(),
42
+ }
43
+
44
+
45
+ app = FastAPI(title=APP_NAME)
46
+ app.add_middleware(
47
+ CORSMiddleware,
48
+ allow_origins=["*"],
49
+ allow_methods=["*"],
50
+ allow_headers=["*"],
51
+ )
52
+ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
53
+ app.state.runtime = RuntimeState()
54
+ app.state.llm_client = LocalLLMClient()
55
+
56
+
57
+ def _default_model_map(provider: ProviderInfo | None) -> dict[str, str]:
58
+ model = provider.models[0] if provider and provider.models else ""
59
+ return {step: model for step in PIPELINE_STEPS}
60
+
61
+
62
+ def _merge_model_map(existing: dict[str, str], fallback: dict[str, str]) -> dict[str, str]:
63
+ merged: dict[str, str] = {}
64
+ for step in PIPELINE_STEPS:
65
+ merged[step] = existing.get(step) or fallback.get(step) or ""
66
+ return merged
67
+
68
+
69
+ async def _refresh_runtime() -> None:
70
+ providers = await discover_providers()
71
+ runtime: RuntimeState = app.state.runtime
72
+ runtime.providers = providers
73
+ selected_provider = select_default_provider(providers)
74
+
75
+ if not runtime.settings.provider_name:
76
+ if selected_provider:
77
+ runtime.settings.provider_name = selected_provider.name
78
+ runtime.settings.provider_kind = selected_provider.kind
79
+ runtime.settings.base_url = selected_provider.base_url
80
+ runtime.settings.model_map = _default_model_map(selected_provider)
81
+ return
82
+
83
+ matching_provider = next(
84
+ (
85
+ provider
86
+ for provider in providers
87
+ if provider.name == runtime.settings.provider_name
88
+ and normalize_base_url(provider.base_url) == normalize_base_url(runtime.settings.base_url)
89
+ ),
90
+ None,
91
+ )
92
+
93
+ fallback_map = _default_model_map(matching_provider or selected_provider)
94
+ runtime.settings.model_map = _merge_model_map(runtime.settings.model_map, fallback_map)
95
+
96
+
97
+ @app.on_event("startup")
98
+ async def startup_event() -> None:
99
+ await _refresh_runtime()
100
+
101
+
102
+ @app.on_event("shutdown")
103
+ async def shutdown_event() -> None:
104
+ await app.state.llm_client.aclose()
105
+
106
+
107
+ @app.get("/")
108
+ async def index() -> FileResponse:
109
+ return FileResponse(Path(TEMPLATE_DIR) / "index.html")
110
+
111
+
112
+ @app.get("/api/health")
113
+ async def health() -> JSONResponse:
114
+ return JSONResponse({"ok": True})
115
+
116
+
117
+ @app.get("/api/settings")
118
+ async def get_settings() -> JSONResponse:
119
+ runtime: RuntimeState = app.state.runtime
120
+ return JSONResponse(runtime.to_dict())
121
+
122
+
123
+ @app.post("/api/providers/refresh")
124
+ async def refresh_providers() -> JSONResponse:
125
+ await _refresh_runtime()
126
+ runtime: RuntimeState = app.state.runtime
127
+ return JSONResponse(runtime.to_dict())
128
+
129
+
130
+ @app.post("/api/settings")
131
+ async def update_settings(payload: SettingsPayload) -> JSONResponse:
132
+ runtime: RuntimeState = app.state.runtime
133
+ runtime.settings = SettingsPayload(
134
+ provider_name=payload.provider_name,
135
+ provider_kind=payload.provider_kind,
136
+ base_url=normalize_base_url(payload.base_url),
137
+ model_map=_merge_model_map(payload.model_map, runtime.settings.model_map),
138
+ )
139
+ return JSONResponse(runtime.to_dict())
140
+
141
+
142
+ @app.post("/api/chat/stream")
143
+ async def chat_stream(request: ChatRequest) -> StreamingResponse:
144
+ runtime: RuntimeState = app.state.runtime
145
+ settings = runtime.settings
146
+ message = request.message.strip()
147
+ mode = request.mode.strip().lower()
148
+
149
+ if mode not in {"off", "medium", "hard"}:
150
+ raise HTTPException(status_code=400, detail="Mode must be one of off, medium, or hard.")
151
+
152
+ if not message:
153
+ raise HTTPException(status_code=400, detail="Message cannot be empty.")
154
+
155
+ async def event_stream():
156
+ try:
157
+ async for event in run_pipeline(
158
+ client=app.state.llm_client,
159
+ provider_kind=settings.provider_kind,
160
+ base_url=settings.base_url,
161
+ model_map=settings.model_map,
162
+ user_message=message,
163
+ mode=mode,
164
+ ):
165
+ yield json.dumps(event) + "\n"
166
+ except Exception as exc:
167
+ yield json.dumps({"type": "error", "message": str(exc)}) + "\n"
168
+
169
+ return StreamingResponse(event_stream(), media_type="application/x-ndjson")
170
+
171
+
172
+ def run() -> None:
173
+ uvicorn.run(
174
+ "thinking_wrapper.main:app",
175
+ host=DEFAULT_HOST,
176
+ port=DEFAULT_PORT,
177
+ reload=False,
178
+ )
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ import bleach
6
+ import markdown
7
+
8
+
9
+ _ALLOWED_TAGS = [
10
+ "a",
11
+ "blockquote",
12
+ "br",
13
+ "code",
14
+ "em",
15
+ "h1",
16
+ "h2",
17
+ "h3",
18
+ "h4",
19
+ "h5",
20
+ "h6",
21
+ "hr",
22
+ "li",
23
+ "ol",
24
+ "p",
25
+ "pre",
26
+ "strong",
27
+ "table",
28
+ "tbody",
29
+ "td",
30
+ "th",
31
+ "thead",
32
+ "tr",
33
+ "ul",
34
+ ]
35
+
36
+ _ALLOWED_ATTRIBUTES = {
37
+ "a": ["href", "rel", "target", "title"],
38
+ }
39
+
40
+ _ALLOWED_PROTOCOLS = ["http", "https", "mailto"]
41
+
42
+
43
+ def _normalize_markdown(text: str) -> str:
44
+ normalized = text.replace("\r\n", "\n").replace("\r", "\n")
45
+ normalized = re.sub(r"(?m)^(\s*(?:[-*+]|\d+[.)]))\s*$\n+(\S)", r"\1 \2", normalized)
46
+ normalized = re.sub(r"\n{3,}", "\n\n", normalized)
47
+ return normalized.strip()
48
+
49
+
50
+ def render_markdown_to_html(text: str) -> str:
51
+ normalized = _normalize_markdown(text)
52
+ raw_html = markdown.markdown(
53
+ normalized,
54
+ extensions=["extra", "sane_lists"],
55
+ output_format="html5",
56
+ )
57
+
58
+ return bleach.clean(
59
+ raw_html,
60
+ tags=_ALLOWED_TAGS,
61
+ attributes=_ALLOWED_ATTRIBUTES,
62
+ protocols=_ALLOWED_PROTOCOLS,
63
+ strip=True,
64
+ )