dragon-cli 0.2.0__py3-none-any.whl
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.
- dragon_cli/__init__.py +5 -0
- dragon_cli/__main__.py +4 -0
- dragon_cli/_cuda.py +42 -0
- dragon_cli/app.py +123 -0
- dragon_cli/banner.py +96 -0
- dragon_cli/chat/__init__.py +4 -0
- dragon_cli/chat/commands.py +175 -0
- dragon_cli/chat/session.py +97 -0
- dragon_cli/chat/streaming.py +18 -0
- dragon_cli/cli.py +147 -0
- dragon_cli/config.py +28 -0
- dragon_cli/hardware.py +361 -0
- dragon_cli/mcp/__init__.py +10 -0
- dragon_cli/mcp/manager.py +148 -0
- dragon_cli/mcp/tool_call.py +17 -0
- dragon_cli/menu.py +1140 -0
- dragon_cli/model_hub.py +252 -0
- dragon_cli/models/__init__.py +11 -0
- dragon_cli/models/catalog.py +205 -0
- dragon_cli/models/loader.py +187 -0
- dragon_cli/recommendation.py +295 -0
- dragon_cli/runtime.py +252 -0
- dragon_cli/server.py +144 -0
- dragon_cli/setup_llm.py +197 -0
- dragon_cli/storage/__init__.py +3 -0
- dragon_cli/storage/conversations.py +134 -0
- dragon_cli/storage/database.py +32 -0
- dragon_cli/web.py +112 -0
- dragon_cli-0.2.0.dist-info/METADATA +147 -0
- dragon_cli-0.2.0.dist-info/RECORD +34 -0
- dragon_cli-0.2.0.dist-info/WHEEL +5 -0
- dragon_cli-0.2.0.dist-info/entry_points.txt +3 -0
- dragon_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- dragon_cli-0.2.0.dist-info/top_level.txt +1 -0
dragon_cli/__init__.py
ADDED
dragon_cli/__main__.py
ADDED
dragon_cli/_cuda.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Preload CUDA runtime shared libraries shipped via pip wheels.
|
|
2
|
+
|
|
3
|
+
The GPU-enabled llama-cpp-python wheel links against libcudart/libcublas/
|
|
4
|
+
libnvrtc, which the pip ``nvidia-*-cu12`` packages install under
|
|
5
|
+
``site-packages/nvidia/*/lib`` (not on the system library path). Loading them
|
|
6
|
+
here by absolute path with ``RTLD_GLOBAL`` lets libllama.so resolve them
|
|
7
|
+
without LD_LIBRARY_PATH. No-op when the libs are already resolvable or absent
|
|
8
|
+
(e.g. the CPU-only wheel).
|
|
9
|
+
|
|
10
|
+
Kept dependency-free (stdlib only) so it can run at package import time
|
|
11
|
+
without risking circular imports.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import ctypes
|
|
15
|
+
import glob
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
_CUDA_SONAMES = (
|
|
20
|
+
"libcudart.so.12",
|
|
21
|
+
"libcublas.so.12",
|
|
22
|
+
"libcublasLt.so.12",
|
|
23
|
+
"libnvrtc.so.12",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def preload_cuda_runtime() -> None:
|
|
28
|
+
loaded: set[str] = set()
|
|
29
|
+
for entry in sys.path:
|
|
30
|
+
nvidia_dir = os.path.join(entry, "nvidia")
|
|
31
|
+
if not entry or not os.path.isdir(nvidia_dir):
|
|
32
|
+
continue
|
|
33
|
+
for soname in _CUDA_SONAMES:
|
|
34
|
+
if soname in loaded:
|
|
35
|
+
continue
|
|
36
|
+
for match in glob.glob(os.path.join(nvidia_dir, "*", "lib", soname)):
|
|
37
|
+
try:
|
|
38
|
+
ctypes.CDLL(match, mode=ctypes.RTLD_GLOBAL)
|
|
39
|
+
loaded.add(soname)
|
|
40
|
+
break
|
|
41
|
+
except OSError:
|
|
42
|
+
continue
|
dragon_cli/app.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Async orchestration: model selection, MCP startup, and the chat session."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .chat import ChatSession, chat_loop
|
|
6
|
+
from .config import C_RED, C_RESET
|
|
7
|
+
from .mcp import MCPManager
|
|
8
|
+
from .models import MODEL_CATALOG, available_ram_gb, download_model, list_catalog, load_llm
|
|
9
|
+
from .storage import ConversationStore
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _gpu_layers_arg(args) -> int | None:
|
|
13
|
+
"""Translate ``--n-gpu-layers`` into a ``load_llm`` override.
|
|
14
|
+
|
|
15
|
+
The CLI default is -1 (auto). Any value >= 0 is an explicit override
|
|
16
|
+
(0 = CPU only, >0 = exact layer count). None means "let the runtime
|
|
17
|
+
auto-decide".
|
|
18
|
+
"""
|
|
19
|
+
n = getattr(args, "n_gpu_layers", -1)
|
|
20
|
+
return n if n is not None and n >= 0 else None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def choose_model(args) -> dict | None:
|
|
24
|
+
# Handle --auto flag for automatic model selection
|
|
25
|
+
if getattr(args, 'auto', False):
|
|
26
|
+
from .recommendation import ModelRecommender
|
|
27
|
+
recommender = ModelRecommender()
|
|
28
|
+
best_model = recommender.recommend_best(priority=getattr(args, 'priority', 'balanced'))
|
|
29
|
+
|
|
30
|
+
if best_model:
|
|
31
|
+
print(f"\n{C_RESET}Auto-selected model based on your hardware:")
|
|
32
|
+
print(f" → {best_model['name']}")
|
|
33
|
+
reason = recommender.get_recommendation_reason(best_model)
|
|
34
|
+
print(f" {C_DIM}Why: {reason}{C_RESET}\n")
|
|
35
|
+
return best_model
|
|
36
|
+
else:
|
|
37
|
+
print(f"{C_RED}[error]{C_RESET} No compatible models found for your hardware.")
|
|
38
|
+
# Fall back to manual selection
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
# Skip the plain catalog print when a model was already chosen upstream
|
|
42
|
+
# (e.g. by the rich interactive menu), so the user doesn't see it twice.
|
|
43
|
+
if not args.model:
|
|
44
|
+
list_catalog()
|
|
45
|
+
if args.model:
|
|
46
|
+
choice = args.model
|
|
47
|
+
else:
|
|
48
|
+
try:
|
|
49
|
+
choice = int(input("\nChoose a model number: ").strip())
|
|
50
|
+
except (ValueError, EOFError, KeyboardInterrupt):
|
|
51
|
+
print("Invalid choice.")
|
|
52
|
+
return None
|
|
53
|
+
if not (1 <= choice <= len(MODEL_CATALOG)):
|
|
54
|
+
print("Number not in the catalog.")
|
|
55
|
+
return None
|
|
56
|
+
model = MODEL_CATALOG[choice - 1]
|
|
57
|
+
ram = available_ram_gb()
|
|
58
|
+
if 0 < ram < model["ram_min_gb"]:
|
|
59
|
+
ok = input(f"{C_RED}[warning]{C_RESET} Model needs ~{model['ram_min_gb']} GB, "
|
|
60
|
+
f"you have {ram:.1f} GB free. Continue? (y/N) ").strip().lower()
|
|
61
|
+
if ok != "y":
|
|
62
|
+
return None
|
|
63
|
+
return model
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def print_conversations(store: ConversationStore) -> None:
|
|
67
|
+
conversations = store.list()
|
|
68
|
+
if not conversations:
|
|
69
|
+
print("No conversations yet.")
|
|
70
|
+
return
|
|
71
|
+
for c in conversations:
|
|
72
|
+
print(f" #{c.id} {c.title} ({c.message_count} msgs, updated {c.updated_at})")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def run(args) -> None:
|
|
76
|
+
if args.serve:
|
|
77
|
+
model = choose_model(args)
|
|
78
|
+
if not model:
|
|
79
|
+
return
|
|
80
|
+
path = download_model(model)
|
|
81
|
+
llm = load_llm(path, model["ctx"], model_info=model,
|
|
82
|
+
gpu_layers=_gpu_layers_arg(args),
|
|
83
|
+
force_cpu=getattr(args, "cpu", False),
|
|
84
|
+
force_gpu=getattr(args, "gpu", False))
|
|
85
|
+
from .server import run_server
|
|
86
|
+
await run_server(llm, model["name"], args.host, args.port)
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
store = ConversationStore(Path(args.db))
|
|
90
|
+
|
|
91
|
+
if args.list_conversations:
|
|
92
|
+
print_conversations(store)
|
|
93
|
+
store.close()
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
model = choose_model(args)
|
|
97
|
+
if not model:
|
|
98
|
+
store.close()
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
path = download_model(model)
|
|
102
|
+
llm = load_llm(path, model["ctx"], model_info=model,
|
|
103
|
+
gpu_layers=_gpu_layers_arg(args),
|
|
104
|
+
force_cpu=getattr(args, "cpu", False),
|
|
105
|
+
force_gpu=getattr(args, "gpu", False))
|
|
106
|
+
|
|
107
|
+
mcp = MCPManager()
|
|
108
|
+
if not args.no_mcp:
|
|
109
|
+
await mcp.start()
|
|
110
|
+
|
|
111
|
+
session = ChatSession(llm, mcp, store, model["name"])
|
|
112
|
+
if args.resume is not None:
|
|
113
|
+
if not session.resume(args.resume):
|
|
114
|
+
print(f"{C_RED}[error]{C_RESET} No conversation with id {args.resume}, starting a new one.")
|
|
115
|
+
session.start_new()
|
|
116
|
+
else:
|
|
117
|
+
session.start_new()
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
await chat_loop(session)
|
|
121
|
+
finally:
|
|
122
|
+
await mcp.stop()
|
|
123
|
+
store.close()
|
dragon_cli/banner.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""ASCII dragon banner shown at startup."""
|
|
2
|
+
|
|
3
|
+
BANNER = r'''
|
|
4
|
+
_,_
|
|
5
|
+
/7/Y/^\
|
|
6
|
+
vuVV|C)| __ _
|
|
7
|
+
\|^ / .' Y '>,
|
|
8
|
+
)| \) / _ _ \
|
|
9
|
+
//)|\\ )(_) (_)(|}
|
|
10
|
+
/ ^| \ \ { 4A } /
|
|
11
|
+
//^| || \\ \uLuJJ/\l
|
|
12
|
+
>//||| |\\\| |3 p)/
|
|
13
|
+
| """"" 7/>l__ _____ ____ /nnm_n//
|
|
14
|
+
L>_ _-< 7/|_-__,__-)\,__)(". \_>-<_/D
|
|
15
|
+
)D" Y "c) 9) //V \_"-._.__G<D __
|
|
16
|
+
\ || . _/ ( \ "-._"> G<D-_.-"_=)_
|
|
17
|
+
) __ / / " _/.__ />_c__.-__<"/ ( \
|
|
18
|
+
| | | |(| < "-._"> _.G_.___)\ \7\
|
|
19
|
+
\"=" // | (,"-.__.|\ \<.__.-" ) \ \
|
|
20
|
+
'---' | |,"-.__"| \!"-.__.-".\ \ \
|
|
21
|
+
|_;._/ (_"-.__"'\ \"-.__.-".| \_\
|
|
22
|
+
)(" V \"-.__"'|\ \-.__.-".) \ \
|
|
23
|
+
( "-.__'"\_\ \.__.-"./ \ l
|
|
24
|
+
) ".__"">>G\ \__.-"> V )
|
|
25
|
+
AI PROVIDERS "" G<\ \_.-" ./7
|
|
26
|
+
G<\ \ ///
|
|
27
|
+
___ G<D\ \ /7^
|
|
28
|
+
/".-_"-"G"<\ \__ ///))
|
|
29
|
+
||,-_ " '.\_"- /// (\
|
|
30
|
+
| (_) _ "/ __7 |X/ V
|
|
31
|
+
/\__""-/ "\ _""c_"GBV
|
|
32
|
+
_)( ""' "--.c__>D'
|
|
33
|
+
/ / )E_>"
|
|
34
|
+
_/ ( | \()
|
|
35
|
+
/ \ / | |
|
|
36
|
+
/\\| | |
|
|
37
|
+
/ '(( | |
|
|
38
|
+
/ / )\ \ |
|
|
39
|
+
/ y \y |Y |
|
|
40
|
+
/ / ( | |
|
|
41
|
+
L ." | | /
|
|
42
|
+
| \( | |
|
|
43
|
+
\_| | |
|
|
44
|
+
| \ { "|
|
|
45
|
+
| || | |
|
|
46
|
+
|x|| \_,/
|
|
47
|
+
} || / \'
|
|
48
|
+
| || |_/
|
|
49
|
+
| (| | }\
|
|
50
|
+
| || } ||
|
|
51
|
+
| || | ||
|
|
52
|
+
| || |\||
|
|
53
|
+
/ || | ||
|
|
54
|
+
| || ( |!
|
|
55
|
+
| |/ ) ||
|
|
56
|
+
_/ \ | }|
|
|
57
|
+
_.-"_ ( )| jjs ! ||
|
|
58
|
+
c_"-_-"_ ) | ||
|
|
59
|
+
c,-_-'_--"" { ||
|
|
60
|
+
"C(_/" \ /|
|
|
61
|
+
(! )
|
|
62
|
+
/| \
|
|
63
|
+
/ |(
|
|
64
|
+
/7||\\
|
|
65
|
+
()U cUu"
|
|
66
|
+
'''
|
|
67
|
+
|
|
68
|
+
SUBTITLE = " DRAGON CLI — local LLM + MCP | /help for commands"
|
|
69
|
+
|
|
70
|
+
# Compact mascot icon shown as a splash on the interactive menu. Raw string so
|
|
71
|
+
# the backslashes in the art are preserved verbatim.
|
|
72
|
+
ICON = r""",--.
|
|
73
|
+
{ }
|
|
74
|
+
K, }
|
|
75
|
+
/ `Y`
|
|
76
|
+
_ / /
|
|
77
|
+
{_'-K.__/
|
|
78
|
+
`/-.__L._
|
|
79
|
+
/ ' /`\_}
|
|
80
|
+
/ ' / -AI PROVIDERS-
|
|
81
|
+
____ / ' /
|
|
82
|
+
,-'~~~~ ~~/ ' /_
|
|
83
|
+
,' ``~~~%%',
|
|
84
|
+
( % Y
|
|
85
|
+
{ %% I
|
|
86
|
+
{ - % `.
|
|
87
|
+
| ', % )
|
|
88
|
+
| | ,..__ __. Y
|
|
89
|
+
| .,_./ Y ' / ^Y J )|
|
|
90
|
+
\ |' / | | ||
|
|
91
|
+
\ L_/ . _ (_,.'(
|
|
92
|
+
\, , ^^""' / | )
|
|
93
|
+
\_ \ /,L] /
|
|
94
|
+
'-_`-, ` ` ./`
|
|
95
|
+
`-(_ )
|
|
96
|
+
^^\..___,.--`"""
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Slash-command dispatch for the chat loop: help, model info, and
|
|
2
|
+
conversation management (list/new/resume/rename/delete/search/export)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING, Callable
|
|
8
|
+
|
|
9
|
+
from ..config import C_DIM, C_GREEN, C_RED, C_RESET, MCP_CONFIG_FILE
|
|
10
|
+
from ..models import list_catalog
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from .session import ChatSession
|
|
14
|
+
|
|
15
|
+
HELP_TEXT = """Commands:
|
|
16
|
+
/help show this help
|
|
17
|
+
/models list the model catalog
|
|
18
|
+
/tools list connected MCP tools
|
|
19
|
+
/mcp show MCP server connection status
|
|
20
|
+
/clear clear the current in-memory history
|
|
21
|
+
/new [title] start a new conversation (saves and switches)
|
|
22
|
+
/list list saved conversations
|
|
23
|
+
/resume <id> resume a saved conversation
|
|
24
|
+
/rename <id> <title> rename a saved conversation
|
|
25
|
+
/delete <id> delete a saved conversation
|
|
26
|
+
/search <text> search saved messages for text
|
|
27
|
+
/export <id> [md|json] export a conversation to a file
|
|
28
|
+
/exit, /quit end the program"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def cmd_help(session: "ChatSession", arg: str) -> None:
|
|
32
|
+
print(HELP_TEXT)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def cmd_models(session: "ChatSession", arg: str) -> None:
|
|
36
|
+
list_catalog()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cmd_tools(session: "ChatSession", arg: str) -> None:
|
|
40
|
+
if not session.mcp.tools:
|
|
41
|
+
print("No MCP tools connected.")
|
|
42
|
+
return
|
|
43
|
+
for name, info in session.mcp.tools.items():
|
|
44
|
+
print(f" - {name}: {info['description'][:90]}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def cmd_mcp(session: "ChatSession", arg: str) -> None:
|
|
48
|
+
servers = list(session.mcp.sessions.keys()) or "none"
|
|
49
|
+
print(f"Connected servers: {servers}")
|
|
50
|
+
print(f"Config: {MCP_CONFIG_FILE.resolve()}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def cmd_clear(session: "ChatSession", arg: str) -> None:
|
|
54
|
+
session.clear()
|
|
55
|
+
print("History cleared.")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def cmd_new(session: "ChatSession", arg: str) -> None:
|
|
59
|
+
session.start_new(arg.strip() or None)
|
|
60
|
+
print(f"{C_GREEN}[ok]{C_RESET} Started conversation #{session.conv_id}.")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def cmd_list(session: "ChatSession", arg: str) -> None:
|
|
64
|
+
conversations = session.store.list()
|
|
65
|
+
if not conversations:
|
|
66
|
+
print("No saved conversations yet.")
|
|
67
|
+
return
|
|
68
|
+
for c in conversations:
|
|
69
|
+
marker = " *" if c.id == session.conv_id else ""
|
|
70
|
+
print(f" #{c.id}{marker} {c.title} ({c.message_count} msgs, updated {c.updated_at})")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def cmd_resume(session: "ChatSession", arg: str) -> None:
|
|
74
|
+
try:
|
|
75
|
+
conv_id = int(arg.strip())
|
|
76
|
+
except ValueError:
|
|
77
|
+
print("Usage: /resume <id>")
|
|
78
|
+
return
|
|
79
|
+
if session.resume(conv_id):
|
|
80
|
+
print(f"{C_GREEN}[ok]{C_RESET} Resumed conversation #{conv_id}.")
|
|
81
|
+
else:
|
|
82
|
+
print(f"{C_RED}[error]{C_RESET} No conversation with id {conv_id}.")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def cmd_rename(session: "ChatSession", arg: str) -> None:
|
|
86
|
+
parts = arg.strip().split(maxsplit=1)
|
|
87
|
+
if len(parts) != 2 or not parts[0].isdigit():
|
|
88
|
+
print("Usage: /rename <id> <new title>")
|
|
89
|
+
return
|
|
90
|
+
conv_id, title = int(parts[0]), parts[1]
|
|
91
|
+
if session.store.rename(conv_id, title):
|
|
92
|
+
print(f"{C_GREEN}[ok]{C_RESET} Renamed #{conv_id} to '{title}'.")
|
|
93
|
+
else:
|
|
94
|
+
print(f"{C_RED}[error]{C_RESET} No conversation with id {conv_id}.")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def cmd_delete(session: "ChatSession", arg: str) -> None:
|
|
98
|
+
if not arg.strip().isdigit():
|
|
99
|
+
print("Usage: /delete <id>")
|
|
100
|
+
return
|
|
101
|
+
conv_id = int(arg.strip())
|
|
102
|
+
confirm = input(f"Delete conversation #{conv_id}? This cannot be undone. (y/N) ").strip().lower()
|
|
103
|
+
if confirm != "y":
|
|
104
|
+
print("Cancelled.")
|
|
105
|
+
return
|
|
106
|
+
if session.store.delete(conv_id):
|
|
107
|
+
print(f"{C_GREEN}[ok]{C_RESET} Deleted #{conv_id}.")
|
|
108
|
+
if conv_id == session.conv_id:
|
|
109
|
+
session.start_new()
|
|
110
|
+
else:
|
|
111
|
+
print(f"{C_RED}[error]{C_RESET} No conversation with id {conv_id}.")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def cmd_search(session: "ChatSession", arg: str) -> None:
|
|
115
|
+
text = arg.strip()
|
|
116
|
+
if not text:
|
|
117
|
+
print("Usage: /search <text>")
|
|
118
|
+
return
|
|
119
|
+
results = session.store.search(text)
|
|
120
|
+
if not results:
|
|
121
|
+
print("No matches.")
|
|
122
|
+
return
|
|
123
|
+
for conv_id, title, role, content in results[:20]:
|
|
124
|
+
snippet = content.replace("\n", " ")[:80]
|
|
125
|
+
print(f" #{conv_id} {title} [{role}]: {snippet}")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def cmd_export(session: "ChatSession", arg: str) -> None:
|
|
129
|
+
parts = arg.strip().split()
|
|
130
|
+
if not parts or not parts[0].isdigit():
|
|
131
|
+
print("Usage: /export <id> [md|json]")
|
|
132
|
+
return
|
|
133
|
+
conv_id = int(parts[0])
|
|
134
|
+
fmt = parts[1] if len(parts) > 1 else "md"
|
|
135
|
+
if fmt not in ("md", "json"):
|
|
136
|
+
print("Format must be 'md' or 'json'.")
|
|
137
|
+
return
|
|
138
|
+
path = Path(f"conversation_{conv_id}.{fmt}")
|
|
139
|
+
result = session.store.export(conv_id, path, fmt)
|
|
140
|
+
if result is None:
|
|
141
|
+
print(f"{C_RED}[error]{C_RESET} No conversation with id {conv_id}.")
|
|
142
|
+
else:
|
|
143
|
+
print(f"{C_GREEN}[ok]{C_RESET} Exported to {result.resolve()}")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def cmd_exit(session: "ChatSession", arg: str) -> bool:
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
COMMANDS: dict[str, Callable[["ChatSession", str], bool | None]] = {
|
|
151
|
+
"help": cmd_help,
|
|
152
|
+
"models": cmd_models,
|
|
153
|
+
"tools": cmd_tools,
|
|
154
|
+
"mcp": cmd_mcp,
|
|
155
|
+
"clear": cmd_clear,
|
|
156
|
+
"new": cmd_new,
|
|
157
|
+
"list": cmd_list,
|
|
158
|
+
"resume": cmd_resume,
|
|
159
|
+
"rename": cmd_rename,
|
|
160
|
+
"delete": cmd_delete,
|
|
161
|
+
"search": cmd_search,
|
|
162
|
+
"export": cmd_export,
|
|
163
|
+
"exit": cmd_exit,
|
|
164
|
+
"quit": cmd_exit,
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def dispatch(session: "ChatSession", raw_input: str) -> bool | None:
|
|
169
|
+
"""Runs a slash command. Returns False if the chat loop should stop."""
|
|
170
|
+
name, _, arg = raw_input[1:].partition(" ")
|
|
171
|
+
handler = COMMANDS.get(name.lower())
|
|
172
|
+
if handler is None:
|
|
173
|
+
print(f"{C_DIM}Unknown command '{raw_input.split()[0]}'. Try /help.{C_RESET}")
|
|
174
|
+
return True
|
|
175
|
+
return handler(session, arg)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Chat session state: history, current conversation, and the main loop."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from ..config import C_DIM, C_GREEN, C_YELLOW, C_RESET
|
|
6
|
+
from ..mcp import MCPManager, extract_tool_call
|
|
7
|
+
from ..storage import ConversationStore
|
|
8
|
+
from .commands import dispatch
|
|
9
|
+
from .streaming import generate_streaming
|
|
10
|
+
|
|
11
|
+
SYSTEM_PROMPT = "You are a helpful assistant running locally on the user's machine."
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ChatSession:
|
|
15
|
+
"""Bundles the LLM, MCP manager, conversation store, and in-memory history."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, llm, mcp: MCPManager, store: ConversationStore, model_name: str):
|
|
18
|
+
self.llm = llm
|
|
19
|
+
self.mcp = mcp
|
|
20
|
+
self.store = store
|
|
21
|
+
self.model_name = model_name
|
|
22
|
+
self.conv_id: int | None = None
|
|
23
|
+
self.history: list[dict] = []
|
|
24
|
+
|
|
25
|
+
def _system_message(self) -> str:
|
|
26
|
+
return SYSTEM_PROMPT + self.mcp.tools_prompt()
|
|
27
|
+
|
|
28
|
+
def start_new(self, title: str | None = None) -> None:
|
|
29
|
+
title = title or "New conversation"
|
|
30
|
+
self.conv_id = self.store.create(title, self.model_name)
|
|
31
|
+
self.history = [{"role": "system", "content": self._system_message()}]
|
|
32
|
+
self.store.append_message(self.conv_id, "system", self.history[0]["content"])
|
|
33
|
+
|
|
34
|
+
def resume(self, conv_id: int) -> bool:
|
|
35
|
+
found = self.store.get(conv_id)
|
|
36
|
+
if found is None:
|
|
37
|
+
return False
|
|
38
|
+
conv, messages = found
|
|
39
|
+
self.conv_id = conv.id
|
|
40
|
+
self.history = [{"role": m.role, "content": m.content} for m in messages]
|
|
41
|
+
if not self.history or self.history[0]["role"] != "system":
|
|
42
|
+
self.history.insert(0, {"role": "system", "content": self._system_message()})
|
|
43
|
+
return True
|
|
44
|
+
|
|
45
|
+
def clear(self) -> None:
|
|
46
|
+
self.history = self.history[:1]
|
|
47
|
+
|
|
48
|
+
async def turn(self, user_input: str) -> None:
|
|
49
|
+
self.history.append({"role": "user", "content": user_input})
|
|
50
|
+
self.store.append_message(self.conv_id, "user", user_input)
|
|
51
|
+
|
|
52
|
+
for _ in range(5):
|
|
53
|
+
response = await asyncio.to_thread(generate_streaming, self.llm, self.history)
|
|
54
|
+
self.history.append({"role": "assistant", "content": response})
|
|
55
|
+
self.store.append_message(self.conv_id, "assistant", response)
|
|
56
|
+
|
|
57
|
+
call = extract_tool_call(response)
|
|
58
|
+
if not call or not self.mcp.tools:
|
|
59
|
+
break
|
|
60
|
+
tool_name, arguments = call
|
|
61
|
+
print(f"{C_DIM}[tool] running {tool_name}({arguments}){C_RESET}")
|
|
62
|
+
result = await self.mcp.call(tool_name, arguments)
|
|
63
|
+
print(f"{C_DIM}[tool] result: {result[:200]}...{C_RESET}")
|
|
64
|
+
followup = (
|
|
65
|
+
f"[TOOL RESULT for {tool_name}]:\n{result}\n\n"
|
|
66
|
+
f"Now answer the user's original question based on this."
|
|
67
|
+
)
|
|
68
|
+
self.history.append({"role": "user", "content": followup})
|
|
69
|
+
self.store.append_message(self.conv_id, "user", followup)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def chat_loop(session: ChatSession) -> None:
|
|
73
|
+
if session.conv_id is None:
|
|
74
|
+
session.start_new()
|
|
75
|
+
|
|
76
|
+
backend = getattr(session.llm, "exec_mode", "CPU")
|
|
77
|
+
n_tools = len(session.mcp.tools)
|
|
78
|
+
tools_msg = f" | {n_tools} MCP tool{'s' if n_tools != 1 else ''}" if n_tools else " | MCP off"
|
|
79
|
+
print(f"\n{C_GREEN}=== Chat started — {session.model_name} on {backend}{tools_msg} "
|
|
80
|
+
f"— /help for commands, /exit to quit ==={C_RESET}\n")
|
|
81
|
+
while True:
|
|
82
|
+
try:
|
|
83
|
+
user_input = (await asyncio.to_thread(input, f"{C_YELLOW}You:{C_RESET} ")).strip()
|
|
84
|
+
except (EOFError, KeyboardInterrupt):
|
|
85
|
+
break
|
|
86
|
+
if not user_input:
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
if user_input.startswith("/"):
|
|
90
|
+
keep_going = dispatch(session, user_input)
|
|
91
|
+
if keep_going is False:
|
|
92
|
+
break
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
await session.turn(user_input)
|
|
96
|
+
|
|
97
|
+
print("Shutting down. See you next time!")
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Streaming text generation via llama.cpp."""
|
|
2
|
+
|
|
3
|
+
from ..config import C_CYAN, C_RESET
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def generate_streaming(llm, history: list[dict]) -> str:
|
|
7
|
+
print(f"{C_CYAN}Model:{C_RESET} ", end="", flush=True)
|
|
8
|
+
response = ""
|
|
9
|
+
stream = llm.create_chat_completion(
|
|
10
|
+
messages=history, max_tokens=1024, temperature=0.7, stream=True,
|
|
11
|
+
)
|
|
12
|
+
for chunk in stream:
|
|
13
|
+
token = chunk["choices"][0]["delta"].get("content", "")
|
|
14
|
+
if token:
|
|
15
|
+
print(token, end="", flush=True)
|
|
16
|
+
response += token
|
|
17
|
+
print()
|
|
18
|
+
return response
|