replio 0.5.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.
- replio-0.5.0/LICENSE +21 -0
- replio-0.5.0/PKG-INFO +8 -0
- replio-0.5.0/README.md +91 -0
- replio-0.5.0/pyproject.toml +16 -0
- replio-0.5.0/setup.cfg +4 -0
- replio-0.5.0/src/replio/__init__.py +0 -0
- replio-0.5.0/src/replio/__main__.py +3 -0
- replio-0.5.0/src/replio/chat.py +452 -0
- replio-0.5.0/src/replio/commands/__init__.py +0 -0
- replio-0.5.0/src/replio/commands/builtins.py +149 -0
- replio-0.5.0/src/replio/commands/registry.py +33 -0
- replio-0.5.0/src/replio/config.py +51 -0
- replio-0.5.0/src/replio/main.py +33 -0
- replio-0.5.0/src/replio/providers/__init__.py +0 -0
- replio-0.5.0/src/replio/providers/base.py +19 -0
- replio-0.5.0/src/replio/providers/ollama.py +109 -0
- replio-0.5.0/src/replio/sessions/__init__.py +0 -0
- replio-0.5.0/src/replio/sessions/manager.py +64 -0
- replio-0.5.0/src/replio/tools/__init__.py +0 -0
- replio-0.5.0/src/replio/tools/builtins.py +97 -0
- replio-0.5.0/src/replio/tools/registry.py +38 -0
- replio-0.5.0/src/replio/utils/__init__.py +0 -0
- replio-0.5.0/src/replio/utils/http.py +36 -0
- replio-0.5.0/src/replio/web/__init__.py +0 -0
- replio-0.5.0/src/replio/web/display.py +25 -0
- replio-0.5.0/src/replio/web/search.py +68 -0
- replio-0.5.0/src/replio.egg-info/PKG-INFO +8 -0
- replio-0.5.0/src/replio.egg-info/SOURCES.txt +30 -0
- replio-0.5.0/src/replio.egg-info/dependency_links.txt +1 -0
- replio-0.5.0/src/replio.egg-info/entry_points.txt +2 -0
- replio-0.5.0/src/replio.egg-info/top_level.txt +1 -0
- replio-0.5.0/tests/test_tool_calling.py +203 -0
replio-0.5.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Evgenij Myasnikov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
replio-0.5.0/PKG-INFO
ADDED
replio-0.5.0/README.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# REPL.io
|
|
2
|
+
|
|
3
|
+
<p>
|
|
4
|
+
<img src="https://img.shields.io/badge/python-%3E%3D3.10-blue" alt="Python >=3.10">
|
|
5
|
+
<img src="https://img.shields.io/badge/license-MIT-green" alt="MIT License">
|
|
6
|
+
<img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="Zero dependencies">
|
|
7
|
+
</p>
|
|
8
|
+
|
|
9
|
+
A zero-dependency AI chat REPL for your terminal.
|
|
10
|
+
|
|
11
|
+
## Why?
|
|
12
|
+
|
|
13
|
+
REPL.io uses **nothing but the Python standard library**.
|
|
14
|
+
No dependencies and gigabytes of `node_modules`.
|
|
15
|
+
Clone and run.
|
|
16
|
+
|
|
17
|
+
## Features
|
|
18
|
+
|
|
19
|
+
- **Zero external dependencies** — Python stdlib only
|
|
20
|
+
- **Multi-provider** — Ollama, OpenAI, Anthropic, Groq, any OpenAI-compatible API
|
|
21
|
+
- **Streaming responses** — live token-by-token output via SSE
|
|
22
|
+
- **Web search** — DuckDuckGo integration, page fetching, auto-query refinement
|
|
23
|
+
- **Tool calling** — models search the web and fetch pages on the fly
|
|
24
|
+
- **Sessions** — save, load, switch, and auto-name conversations
|
|
25
|
+
- **Slash commands** — `/help`, `/model`, `/provider`, `/connect`, `/session`, `/config`, `/exit`
|
|
26
|
+
- **Dual config** — global `~/.config/replio/` + per-project `.replio/` JSON merge
|
|
27
|
+
- **Input history** — readline-based up/down recall + tab completion
|
|
28
|
+
- **Thinking/reasoning display** — see model reasoning tokens (DeepSeek R1, o1, etc.)
|
|
29
|
+
|
|
30
|
+
## Quick Start
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install replio
|
|
34
|
+
replio
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Or from source:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
git clone https://github.com/emyasnikov/replio && cd replio
|
|
41
|
+
python3 -m venv .venv && .venv/bin/pip install -e .
|
|
42
|
+
.venv/bin/replio
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### First-time setup
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
>>> /connect
|
|
49
|
+
Provider [ollama]:
|
|
50
|
+
Base URL [https://ollama.com]:
|
|
51
|
+
API key: sk-...
|
|
52
|
+
Model [gpt-oss:20b-cloud]:
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
>>> /help list all commands
|
|
59
|
+
>>> /model <model> switch model
|
|
60
|
+
>>> /search <query> search the web
|
|
61
|
+
>>> /session list saved conversations
|
|
62
|
+
>>> /exit goodbye
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Type any message to chat. Tab-complete `/` commands. Arrow keys for history.
|
|
66
|
+
|
|
67
|
+
## Project Structure
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
src/replio/
|
|
71
|
+
├── chat.py REPL loop + streaming display
|
|
72
|
+
├── config.py Config load/merge/save
|
|
73
|
+
├── commands/ Slash command system
|
|
74
|
+
├── providers/ LLM provider abstraction
|
|
75
|
+
├── sessions/ Session CRUD
|
|
76
|
+
├── tools/ Web search, page fetch, tool registry
|
|
77
|
+
├── web/ DuckDuckGo search + formatting
|
|
78
|
+
└── utils/ HTTP SSE streaming
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Adding a Provider
|
|
82
|
+
|
|
83
|
+
Create a subclass of `BaseProvider` implementing `chat()` and `list_models()` using the OpenAI-compatible `/v1/chat/completions` format, then register it in `ChatLoop._reinit_provider()`.
|
|
84
|
+
|
|
85
|
+
## Contributing
|
|
86
|
+
|
|
87
|
+
See [TODO.md](TODO.md) for open tasks and [CHANGELOG.md](CHANGELOG.md) for release history.
|
|
88
|
+
|
|
89
|
+
## License
|
|
90
|
+
|
|
91
|
+
MIT
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=64"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "replio"
|
|
7
|
+
version = "0.5.0"
|
|
8
|
+
description = "A terminal-based REPL AI chat application allowing web search alongside other tool calling"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
authors = [{name = "Contributors"}]
|
|
11
|
+
|
|
12
|
+
[project.scripts]
|
|
13
|
+
replio = "replio.main:main"
|
|
14
|
+
|
|
15
|
+
[tool.setuptools.packages.find]
|
|
16
|
+
where = ["src"]
|
replio-0.5.0/setup.cfg
ADDED
|
File without changes
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import json
|
|
3
|
+
import readline
|
|
4
|
+
import os
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
from .config import Config
|
|
8
|
+
from .providers.ollama import OllamaProvider
|
|
9
|
+
from .sessions.manager import SessionManager
|
|
10
|
+
from .commands.registry import CommandRegistry
|
|
11
|
+
from .commands.builtins import register_builtins
|
|
12
|
+
|
|
13
|
+
HISTFILE = '.replio_history'
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ChatLoop:
|
|
17
|
+
def __init__(self, config: Config):
|
|
18
|
+
self.config = config
|
|
19
|
+
self.provider = None
|
|
20
|
+
self._reinit_provider()
|
|
21
|
+
|
|
22
|
+
sessions_dir = config.local_path.parent / 'sessions'
|
|
23
|
+
self.sessions = SessionManager(sessions_dir)
|
|
24
|
+
self.current_session = self.sessions.create()
|
|
25
|
+
self._load_history(config)
|
|
26
|
+
self.registry = CommandRegistry(self)
|
|
27
|
+
register_builtins(self.registry)
|
|
28
|
+
self._setup_readline()
|
|
29
|
+
|
|
30
|
+
def _reinit_provider(self):
|
|
31
|
+
provider_name = self.config.get('provider', 'ollama')
|
|
32
|
+
if provider_name == 'ollama':
|
|
33
|
+
self.provider = OllamaProvider(
|
|
34
|
+
base_url=self.config.get('base_url'),
|
|
35
|
+
api_key=self.config.get('api_key'),
|
|
36
|
+
model=self.config.get('model'),
|
|
37
|
+
temperature=self.config.get('temperature'),
|
|
38
|
+
max_tokens=self.config.get('max_tokens'),
|
|
39
|
+
)
|
|
40
|
+
else:
|
|
41
|
+
print(f'Unknown provider "{provider_name}", falling back to ollama')
|
|
42
|
+
self.config.set('provider', 'ollama')
|
|
43
|
+
self._reinit_provider()
|
|
44
|
+
|
|
45
|
+
def _load_history(self, config):
|
|
46
|
+
hist = config.local_path.parent / HISTFILE
|
|
47
|
+
if hist.exists():
|
|
48
|
+
try:
|
|
49
|
+
readline.read_history_file(str(hist))
|
|
50
|
+
except OSError:
|
|
51
|
+
pass
|
|
52
|
+
readline.set_history_length(1000)
|
|
53
|
+
|
|
54
|
+
def _save_history(self):
|
|
55
|
+
hist = self.config.local_path.parent / HISTFILE
|
|
56
|
+
try:
|
|
57
|
+
hist.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
readline.write_history_file(str(hist))
|
|
59
|
+
except OSError:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
def _setup_readline(self):
|
|
63
|
+
commands = sorted(set(self.registry.commands.keys()))
|
|
64
|
+
|
|
65
|
+
def completer(text, state):
|
|
66
|
+
if text.startswith('/'):
|
|
67
|
+
options = [c for c in commands if c.startswith(text)]
|
|
68
|
+
if state < len(options):
|
|
69
|
+
return options[state] + ' '
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
readline.set_completer(completer)
|
|
73
|
+
readline.parse_and_bind('tab: complete')
|
|
74
|
+
|
|
75
|
+
def session_auto_save(self):
|
|
76
|
+
if self.current_session and self.current_session.messages:
|
|
77
|
+
self.sessions.save(self.current_session)
|
|
78
|
+
|
|
79
|
+
def run(self):
|
|
80
|
+
system_prompt = self.config.get('system_prompt', '')
|
|
81
|
+
if system_prompt:
|
|
82
|
+
self.current_session.add_message('system', system_prompt)
|
|
83
|
+
|
|
84
|
+
model_str = self.config.get('model', '?')
|
|
85
|
+
provider_str = self.config.get('provider', '?')
|
|
86
|
+
print(f'REPL.io ({provider_str}: {model_str}) /help for commands')
|
|
87
|
+
|
|
88
|
+
while True:
|
|
89
|
+
try:
|
|
90
|
+
line = input('\001\033[36m\002>>>\001\033[0m\002 ').strip()
|
|
91
|
+
except (EOFError, KeyboardInterrupt):
|
|
92
|
+
print()
|
|
93
|
+
self.session_auto_save()
|
|
94
|
+
self._save_history()
|
|
95
|
+
break
|
|
96
|
+
|
|
97
|
+
if not line:
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
if line.startswith('/'):
|
|
101
|
+
self.current_session.add_message('command', line)
|
|
102
|
+
self.registry.dispatch(line)
|
|
103
|
+
self.session_auto_save()
|
|
104
|
+
else:
|
|
105
|
+
self._handle_message(line)
|
|
106
|
+
|
|
107
|
+
self._save_history()
|
|
108
|
+
|
|
109
|
+
def _stream_response(self) -> str | None:
|
|
110
|
+
messages = self.current_session.messages
|
|
111
|
+
full_response = ''
|
|
112
|
+
start = datetime.now(timezone.utc)
|
|
113
|
+
first_content = True
|
|
114
|
+
show_thinking = self.config.get('show_thinking', True)
|
|
115
|
+
thinking = False
|
|
116
|
+
md_state = {'code_block': False, 'inline_code': False, 'bold': False}
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
for event in self.provider.chat(messages):
|
|
120
|
+
t = event.get('type', '')
|
|
121
|
+
if t == 'thinking':
|
|
122
|
+
if show_thinking:
|
|
123
|
+
if first_content:
|
|
124
|
+
sys.stdout.write('\001\033[33m\002<<< \001\033[0m\002')
|
|
125
|
+
sys.stdout.flush()
|
|
126
|
+
first_content = False
|
|
127
|
+
sys.stdout.write('\001\033[90m\002' + event['content'] + '\001\033[0m\002')
|
|
128
|
+
sys.stdout.flush()
|
|
129
|
+
full_response += event['content']
|
|
130
|
+
elif t == 'token':
|
|
131
|
+
token = event['content']
|
|
132
|
+
while token:
|
|
133
|
+
if not thinking:
|
|
134
|
+
idx = -1
|
|
135
|
+
marker = ''
|
|
136
|
+
for m in ('<thinking>',):
|
|
137
|
+
pos = token.find(m)
|
|
138
|
+
if pos != -1 and (idx == -1 or pos < idx):
|
|
139
|
+
idx = pos
|
|
140
|
+
marker = m
|
|
141
|
+
if idx != -1:
|
|
142
|
+
before = token[:idx]
|
|
143
|
+
if before:
|
|
144
|
+
if first_content:
|
|
145
|
+
sys.stdout.write('\001\033[33m\002<<< \001\033[0m\002')
|
|
146
|
+
sys.stdout.flush()
|
|
147
|
+
first_content = False
|
|
148
|
+
sys.stdout.write(before)
|
|
149
|
+
sys.stdout.flush()
|
|
150
|
+
full_response += before
|
|
151
|
+
if first_content:
|
|
152
|
+
sys.stdout.write('\001\033[33m\002<<< \001\033[0m\002')
|
|
153
|
+
sys.stdout.flush()
|
|
154
|
+
first_content = False
|
|
155
|
+
if show_thinking:
|
|
156
|
+
sys.stdout.write('\001\033[90m\002' + marker + '\001\033[0m\002')
|
|
157
|
+
sys.stdout.flush()
|
|
158
|
+
full_response += marker
|
|
159
|
+
token = token[idx + len(marker):]
|
|
160
|
+
thinking = True
|
|
161
|
+
else:
|
|
162
|
+
if self.config.get('markdown_streaming'):
|
|
163
|
+
segments = self._render_token(token, md_state)
|
|
164
|
+
for text, ansi in segments:
|
|
165
|
+
if first_content:
|
|
166
|
+
sys.stdout.write('\001\033[33m\002<<< \001\033[0m\002')
|
|
167
|
+
sys.stdout.flush()
|
|
168
|
+
first_content = False
|
|
169
|
+
sys.stdout.write(f'\001{ansi}\002{text}\001\033[0m\002')
|
|
170
|
+
sys.stdout.flush()
|
|
171
|
+
else:
|
|
172
|
+
if first_content:
|
|
173
|
+
sys.stdout.write('\001\033[33m\002<<< \001\033[0m\002')
|
|
174
|
+
sys.stdout.flush()
|
|
175
|
+
first_content = False
|
|
176
|
+
sys.stdout.write(token)
|
|
177
|
+
sys.stdout.flush()
|
|
178
|
+
full_response += token
|
|
179
|
+
token = ''
|
|
180
|
+
else:
|
|
181
|
+
closer = ''
|
|
182
|
+
closer_pos = -1
|
|
183
|
+
for c in ('</thinking>',):
|
|
184
|
+
pos = token.find(c)
|
|
185
|
+
if pos != -1 and (closer_pos == -1 or pos < closer_pos):
|
|
186
|
+
closer_pos = pos
|
|
187
|
+
closer = c
|
|
188
|
+
if closer_pos != -1:
|
|
189
|
+
before = token[:closer_pos]
|
|
190
|
+
if before and show_thinking:
|
|
191
|
+
sys.stdout.write('\001\033[90m\002' + before + '\001\033[0m\002')
|
|
192
|
+
sys.stdout.flush()
|
|
193
|
+
full_response += before
|
|
194
|
+
sys.stdout.write(closer)
|
|
195
|
+
sys.stdout.flush()
|
|
196
|
+
full_response += closer
|
|
197
|
+
token = token[closer_pos + len(closer):]
|
|
198
|
+
thinking = False
|
|
199
|
+
else:
|
|
200
|
+
if show_thinking:
|
|
201
|
+
sys.stdout.write('\001\033[90m\002' + token + '\001\033[0m\002')
|
|
202
|
+
sys.stdout.flush()
|
|
203
|
+
full_response += token
|
|
204
|
+
token = ''
|
|
205
|
+
elif t == 'error':
|
|
206
|
+
code = event.get('code', '')
|
|
207
|
+
msg = event.get('message', 'Unknown error')
|
|
208
|
+
print(f'\001\033[91m\002[Error {code}]\001\033[0m\002 {msg}')
|
|
209
|
+
return None
|
|
210
|
+
elif t == 'done':
|
|
211
|
+
elapsed = (datetime.now(timezone.utc) - start).total_seconds()
|
|
212
|
+
print()
|
|
213
|
+
print(f'\001\033[90m\002({elapsed:.1f}s)\001\033[0m\002')
|
|
214
|
+
break
|
|
215
|
+
|
|
216
|
+
finally:
|
|
217
|
+
if full_response:
|
|
218
|
+
end = datetime.now(timezone.utc)
|
|
219
|
+
duration = (end - start).total_seconds()
|
|
220
|
+
self.current_session.add_message(
|
|
221
|
+
'assistant', full_response,
|
|
222
|
+
timestamp=end.isoformat(timespec='seconds'),
|
|
223
|
+
duration=round(duration, 1),
|
|
224
|
+
model=self.config.get('model'),
|
|
225
|
+
provider=self.config.get('provider'),
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
self.session_auto_save()
|
|
229
|
+
|
|
230
|
+
return full_response
|
|
231
|
+
|
|
232
|
+
def _perform_search(self, query: str, silent: bool = False) -> str | None:
|
|
233
|
+
from .web.search import search as web_search
|
|
234
|
+
from .web.display import format_results, format_context
|
|
235
|
+
|
|
236
|
+
num = self.config.get('search_results', 5)
|
|
237
|
+
results = web_search(query, num)
|
|
238
|
+
|
|
239
|
+
if not results:
|
|
240
|
+
if not silent:
|
|
241
|
+
print('\001\033[90m\002(no search results)\001\033[0m\002')
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
if not silent:
|
|
245
|
+
print()
|
|
246
|
+
print(format_results(query, results))
|
|
247
|
+
|
|
248
|
+
return format_context(query, results)
|
|
249
|
+
|
|
250
|
+
def _init_tooling(self):
|
|
251
|
+
if not self.config.get('tool_calling'):
|
|
252
|
+
self._tool_registry = None
|
|
253
|
+
return None
|
|
254
|
+
from .tools.registry import ToolRegistry
|
|
255
|
+
from .tools.builtins import register_tools
|
|
256
|
+
self._tool_registry = ToolRegistry()
|
|
257
|
+
register_tools(self._tool_registry)
|
|
258
|
+
return self._tool_registry.schema()
|
|
259
|
+
|
|
260
|
+
def _show_tool_status(self, name, arguments):
|
|
261
|
+
args_str = ', '.join(f'{k}={v!r}' for k, v in arguments.items())
|
|
262
|
+
print(f'\001\033[90m\002[{name}: {args_str}]\001\033[0m\002')
|
|
263
|
+
|
|
264
|
+
def _output_content(self, content):
|
|
265
|
+
end = datetime.now(timezone.utc)
|
|
266
|
+
elapsed = round((end - self._response_start).total_seconds(), 1)
|
|
267
|
+
|
|
268
|
+
print(f'\001\033[33m\002<<<\001\033[0m\002 {content}')
|
|
269
|
+
print(f'\001\033[90m\002({elapsed:.1f}s)\001\033[0m\002')
|
|
270
|
+
|
|
271
|
+
self.current_session.add_message(
|
|
272
|
+
'assistant', content,
|
|
273
|
+
timestamp=end.isoformat(timespec='seconds'),
|
|
274
|
+
duration=elapsed,
|
|
275
|
+
model=self.config.get('model'),
|
|
276
|
+
provider=self.config.get('provider'),
|
|
277
|
+
)
|
|
278
|
+
self.session_auto_save()
|
|
279
|
+
|
|
280
|
+
def _refine_query(self, query: str) -> str:
|
|
281
|
+
context_count = self.config.get('query_refine_context', 4)
|
|
282
|
+
context_msgs = self.current_session.messages[-context_count:] if context_count > 0 else []
|
|
283
|
+
refine_sys = "You are a search query optimizer. Rewrite the user's query to be more specific and standalone based on the conversation context. Return ONLY the rewritten query, nothing else."
|
|
284
|
+
refined = self.provider.chat_nonstreaming(
|
|
285
|
+
[{'role': 'system', 'content': refine_sys}] + context_msgs + [{'role': 'user', 'content': query}],
|
|
286
|
+
tools=None,
|
|
287
|
+
)
|
|
288
|
+
refined_query = (refined.get('content') or query).strip().strip('"\'')
|
|
289
|
+
return refined_query if refined_query else query
|
|
290
|
+
|
|
291
|
+
def _render_token(self, token: str, state: dict) -> list[tuple[str, str]]:
|
|
292
|
+
segments = []
|
|
293
|
+
while token:
|
|
294
|
+
if state['code_block']:
|
|
295
|
+
idx = token.find('```')
|
|
296
|
+
if idx != -1:
|
|
297
|
+
before = token[:idx]
|
|
298
|
+
if before:
|
|
299
|
+
segments.append((before, '\033[36m'))
|
|
300
|
+
state['code_block'] = False
|
|
301
|
+
token = token[idx + 3:]
|
|
302
|
+
else:
|
|
303
|
+
segments.append((token, '\033[36m'))
|
|
304
|
+
token = ''
|
|
305
|
+
elif state['inline_code']:
|
|
306
|
+
idx = token.find('`')
|
|
307
|
+
if idx != -1:
|
|
308
|
+
before = token[:idx]
|
|
309
|
+
if before:
|
|
310
|
+
segments.append((before, '\033[32m'))
|
|
311
|
+
state['inline_code'] = False
|
|
312
|
+
token = token[idx + 1:]
|
|
313
|
+
else:
|
|
314
|
+
segments.append((token, '\033[32m'))
|
|
315
|
+
token = ''
|
|
316
|
+
elif state['bold']:
|
|
317
|
+
idx = token.find('**')
|
|
318
|
+
if idx != -1:
|
|
319
|
+
before = token[:idx]
|
|
320
|
+
if before:
|
|
321
|
+
segments.append((before, '\033[1m'))
|
|
322
|
+
state['bold'] = False
|
|
323
|
+
token = token[idx + 2:]
|
|
324
|
+
else:
|
|
325
|
+
segments.append((token, '\033[1m'))
|
|
326
|
+
token = ''
|
|
327
|
+
else:
|
|
328
|
+
idx = -1
|
|
329
|
+
marker = ''
|
|
330
|
+
for m in ('```', '**', '`'):
|
|
331
|
+
pos = token.find(m)
|
|
332
|
+
if pos != -1 and (idx == -1 or pos < idx):
|
|
333
|
+
idx = pos
|
|
334
|
+
marker = m
|
|
335
|
+
if idx != -1:
|
|
336
|
+
before = token[:idx]
|
|
337
|
+
if before:
|
|
338
|
+
segments.append((before, ''))
|
|
339
|
+
if marker == '```':
|
|
340
|
+
state['code_block'] = True
|
|
341
|
+
elif marker == '**':
|
|
342
|
+
state['bold'] = True
|
|
343
|
+
elif marker == '`':
|
|
344
|
+
state['inline_code'] = True
|
|
345
|
+
token = token[idx + len(marker):]
|
|
346
|
+
else:
|
|
347
|
+
segments.append((token, ''))
|
|
348
|
+
token = ''
|
|
349
|
+
return segments
|
|
350
|
+
|
|
351
|
+
def _chat_with_tools(self, force_search: str | None = None):
|
|
352
|
+
messages = self.current_session.messages
|
|
353
|
+
tools_schema = self._init_tooling()
|
|
354
|
+
self._response_start = datetime.now(timezone.utc)
|
|
355
|
+
|
|
356
|
+
if force_search:
|
|
357
|
+
context = self._perform_search(force_search, silent=False)
|
|
358
|
+
if context:
|
|
359
|
+
messages.append({
|
|
360
|
+
'role': 'tool',
|
|
361
|
+
'tool_call_id': 'forced',
|
|
362
|
+
'content': context,
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
try:
|
|
366
|
+
while True:
|
|
367
|
+
result = self.provider.chat_nonstreaming(messages, tools=tools_schema)
|
|
368
|
+
|
|
369
|
+
if 'error' in result:
|
|
370
|
+
err = result['error']
|
|
371
|
+
print(f'\001\033[91m\002[Error {err["code"]}]\001\033[0m\002 {err["message"]}')
|
|
372
|
+
break
|
|
373
|
+
|
|
374
|
+
tcs = result.get('tool_calls')
|
|
375
|
+
if tcs:
|
|
376
|
+
messages.append({
|
|
377
|
+
'role': 'assistant',
|
|
378
|
+
'content': result.get('content'),
|
|
379
|
+
'tool_calls': tcs,
|
|
380
|
+
'timestamp': datetime.now(timezone.utc).isoformat(timespec='seconds'),
|
|
381
|
+
})
|
|
382
|
+
for tc in tcs:
|
|
383
|
+
name = tc['function']['name']
|
|
384
|
+
args = json.loads(tc['function']['arguments'])
|
|
385
|
+
if (self.config.get('query_refine')
|
|
386
|
+
and name == 'web_search'
|
|
387
|
+
and len(args.get('query', '').split()) <= self.config.get('query_refine_min_words', 3)):
|
|
388
|
+
original = args['query']
|
|
389
|
+
args['query'] = self._refine_query(args['query'])
|
|
390
|
+
if args['query'] != original:
|
|
391
|
+
print(f'\001\033[90m\002[refine: "{original}" → "{args["query"]}"]\001\033[0m\002')
|
|
392
|
+
if self.config.get('tool_status_visible', True):
|
|
393
|
+
self._show_tool_status(name, args)
|
|
394
|
+
output = self._tool_registry.execute(name, args)
|
|
395
|
+
messages.append({
|
|
396
|
+
'role': 'tool',
|
|
397
|
+
'tool_call_id': tc['id'],
|
|
398
|
+
'content': output,
|
|
399
|
+
'timestamp': datetime.now(timezone.utc).isoformat(timespec='seconds'),
|
|
400
|
+
})
|
|
401
|
+
continue
|
|
402
|
+
|
|
403
|
+
response = self._stream_response()
|
|
404
|
+
if not response and result.get('content'):
|
|
405
|
+
end = datetime.now(timezone.utc)
|
|
406
|
+
duration = round((end - self._response_start).total_seconds(), 1)
|
|
407
|
+
self.current_session.add_message(
|
|
408
|
+
'assistant', result['content'],
|
|
409
|
+
timestamp=end.isoformat(timespec='seconds'),
|
|
410
|
+
duration=duration,
|
|
411
|
+
model=self.config.get('model'),
|
|
412
|
+
provider=self.config.get('provider'),
|
|
413
|
+
)
|
|
414
|
+
break
|
|
415
|
+
finally:
|
|
416
|
+
self.session_auto_save()
|
|
417
|
+
|
|
418
|
+
def _handle_message(self, content):
|
|
419
|
+
now = datetime.now(timezone.utc)
|
|
420
|
+
self.current_session.add_message(
|
|
421
|
+
'user', content, timestamp=now.isoformat(timespec='seconds')
|
|
422
|
+
)
|
|
423
|
+
self.session_auto_save()
|
|
424
|
+
|
|
425
|
+
user_msgs = [m for m in self.current_session.messages if m['role'] == 'user']
|
|
426
|
+
if len(user_msgs) == 1:
|
|
427
|
+
ts = self.current_session.name
|
|
428
|
+
truncated = content[:40]
|
|
429
|
+
space = truncated.rfind(' ')
|
|
430
|
+
if space > 0:
|
|
431
|
+
truncated = truncated[:space]
|
|
432
|
+
msg_part = ''.join(c if c.isalnum() or c in '-_ ' else '' for c in truncated).strip().replace(' ', '_')
|
|
433
|
+
if msg_part:
|
|
434
|
+
old = self.sessions.sessions_dir / f'{self.current_session.name}.json'
|
|
435
|
+
self.current_session.name = f'{ts}_{msg_part.lower()}'
|
|
436
|
+
new = self.sessions.sessions_dir / f'{self.current_session.name}.json'
|
|
437
|
+
if old.exists() and old != new:
|
|
438
|
+
old.rename(new)
|
|
439
|
+
self.session_auto_save()
|
|
440
|
+
|
|
441
|
+
if self.config.get('tool_calling'):
|
|
442
|
+
self._chat_with_tools()
|
|
443
|
+
elif self.config.get('web_search'):
|
|
444
|
+
context = self._perform_search(content, silent=True)
|
|
445
|
+
if context:
|
|
446
|
+
self.current_session.add_message('system', context)
|
|
447
|
+
else:
|
|
448
|
+
print('\001\033[90m\002(Skipping AI — no search results)\001\033[0m\002')
|
|
449
|
+
return
|
|
450
|
+
self._stream_response()
|
|
451
|
+
else:
|
|
452
|
+
self._stream_response()
|
|
File without changes
|