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.
- multimind-0.1.0/PKG-INFO +85 -0
- multimind-0.1.0/README.md +73 -0
- multimind-0.1.0/multimind/__init__.py +3 -0
- multimind-0.1.0/multimind/__main__.py +5 -0
- multimind-0.1.0/multimind/config.py +35 -0
- multimind-0.1.0/multimind/discovery.py +76 -0
- multimind-0.1.0/multimind/llm_client.py +116 -0
- multimind-0.1.0/multimind/main.py +178 -0
- multimind-0.1.0/multimind/markdown_render.py +64 -0
- multimind-0.1.0/multimind/pipeline.py +184 -0
- multimind-0.1.0/multimind/static/app.js +402 -0
- multimind-0.1.0/multimind/static/styles.css +557 -0
- multimind-0.1.0/multimind/static/vendor/katex/README.md +125 -0
- multimind-0.1.0/multimind/static/vendor/katex/contrib/auto-render.js +341 -0
- multimind-0.1.0/multimind/static/vendor/katex/contrib/auto-render.min.js +1 -0
- multimind-0.1.0/multimind/static/vendor/katex/contrib/auto-render.mjs +244 -0
- multimind-0.1.0/multimind/static/vendor/katex/contrib/copy-tex.js +127 -0
- multimind-0.1.0/multimind/static/vendor/katex/contrib/copy-tex.min.js +1 -0
- multimind-0.1.0/multimind/static/vendor/katex/contrib/copy-tex.mjs +105 -0
- multimind-0.1.0/multimind/static/vendor/katex/contrib/mathtex-script-type.js +112 -0
- multimind-0.1.0/multimind/static/vendor/katex/contrib/mathtex-script-type.min.js +1 -0
- multimind-0.1.0/multimind/static/vendor/katex/contrib/mathtex-script-type.mjs +24 -0
- multimind-0.1.0/multimind/static/vendor/katex/contrib/mhchem.js +3216 -0
- multimind-0.1.0/multimind/static/vendor/katex/contrib/mhchem.min.js +1 -0
- multimind-0.1.0/multimind/static/vendor/katex/contrib/mhchem.mjs +3109 -0
- multimind-0.1.0/multimind/static/vendor/katex/contrib/render-a11y-string.js +890 -0
- multimind-0.1.0/multimind/static/vendor/katex/contrib/render-a11y-string.min.js +1 -0
- multimind-0.1.0/multimind/static/vendor/katex/contrib/render-a11y-string.mjs +800 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_AMS-Regular.ttf +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_AMS-Regular.woff +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Main-Bold.ttf +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Main-Bold.woff +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Main-Italic.ttf +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Main-Italic.woff +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Main-Regular.ttf +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Main-Regular.woff +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Math-Italic.ttf +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Math-Italic.woff +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Script-Regular.ttf +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Script-Regular.woff +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Size1-Regular.ttf +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Size1-Regular.woff +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Size2-Regular.ttf +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Size2-Regular.woff +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Size3-Regular.ttf +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Size3-Regular.woff +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Size4-Regular.ttf +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Size4-Regular.woff +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- multimind-0.1.0/multimind/static/vendor/katex/katex.css +1209 -0
- multimind-0.1.0/multimind/static/vendor/katex/katex.js +18998 -0
- multimind-0.1.0/multimind/static/vendor/katex/katex.min.css +1 -0
- multimind-0.1.0/multimind/static/vendor/katex/katex.min.js +1 -0
- multimind-0.1.0/multimind/static/vendor/katex/katex.mjs +18458 -0
- multimind-0.1.0/multimind/templates/index.html +133 -0
- multimind-0.1.0/multimind.egg-info/PKG-INFO +85 -0
- multimind-0.1.0/multimind.egg-info/SOURCES.txt +100 -0
- multimind-0.1.0/multimind.egg-info/dependency_links.txt +1 -0
- multimind-0.1.0/multimind.egg-info/entry_points.txt +2 -0
- multimind-0.1.0/multimind.egg-info/requires.txt +5 -0
- multimind-0.1.0/multimind.egg-info/top_level.txt +1 -0
- multimind-0.1.0/pyproject.toml +29 -0
- multimind-0.1.0/setup.cfg +4 -0
multimind-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://www.python.org/downloads/)
|
|
20
|
+
[](https://github.com/features)
|
|
21
|
+
[](https://ollama.com/)
|
|
22
|
+
[](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
|
+
[](https://www.python.org/downloads/)
|
|
8
|
+
[](https://github.com/features)
|
|
9
|
+
[](https://ollama.com/)
|
|
10
|
+
[](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,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
|
+
)
|