yaicli 0.4.0__py3-none-any.whl → 0.5.1__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.
- pyproject.toml +5 -3
- yaicli/chat.py +396 -0
- yaicli/cli.py +250 -251
- yaicli/client.py +385 -0
- yaicli/config.py +31 -24
- yaicli/console.py +2 -2
- yaicli/const.py +28 -2
- yaicli/entry.py +68 -40
- yaicli/exceptions.py +8 -36
- yaicli/functions/__init__.py +39 -0
- yaicli/functions/buildin/execute_shell_command.py +47 -0
- yaicli/printer.py +145 -225
- yaicli/render.py +1 -1
- yaicli/role.py +231 -0
- yaicli/schemas.py +31 -0
- yaicli/tools.py +103 -0
- yaicli/utils.py +5 -2
- {yaicli-0.4.0.dist-info → yaicli-0.5.1.dist-info}/METADATA +166 -87
- yaicli-0.5.1.dist-info/RECORD +24 -0
- {yaicli-0.4.0.dist-info → yaicli-0.5.1.dist-info}/entry_points.txt +1 -1
- yaicli/chat_manager.py +0 -290
- yaicli/providers/__init__.py +0 -34
- yaicli/providers/base.py +0 -51
- yaicli/providers/cohere.py +0 -136
- yaicli/providers/openai.py +0 -176
- yaicli/roles.py +0 -276
- yaicli-0.4.0.dist-info/RECORD +0 -23
- {yaicli-0.4.0.dist-info → yaicli-0.5.1.dist-info}/WHEEL +0 -0
- {yaicli-0.4.0.dist-info → yaicli-0.5.1.dist-info}/licenses/LICENSE +0 -0
pyproject.toml
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
[project]
|
2
2
|
name = "yaicli"
|
3
|
-
version = "0.
|
3
|
+
version = "0.5.1"
|
4
4
|
description = "A simple CLI tool to interact with LLM"
|
5
5
|
authors = [{ name = "belingud", email = "im.victor@qq.com" }]
|
6
6
|
readme = "README.md"
|
@@ -30,9 +30,11 @@ keywords = [
|
|
30
30
|
"interact with llms",
|
31
31
|
]
|
32
32
|
dependencies = [
|
33
|
-
"cohere>=5.15.0",
|
34
33
|
"distro>=1.9.0",
|
35
34
|
"httpx>=0.28.1",
|
35
|
+
"instructor>=1.7.9",
|
36
|
+
"json-repair>=0.44.1",
|
37
|
+
"litellm>=1.67.5",
|
36
38
|
"openai>=1.76.0",
|
37
39
|
"prompt-toolkit>=3.0.50",
|
38
40
|
"rich>=13.9.4",
|
@@ -46,7 +48,7 @@ Documentation = "https://github.com/belingud/yaicli"
|
|
46
48
|
|
47
49
|
[project.scripts]
|
48
50
|
ai = "yaicli.entry:app"
|
49
|
-
|
51
|
+
yaicli = "yaicli.entry:app"
|
50
52
|
|
51
53
|
[tool.uv]
|
52
54
|
resolution = "highest"
|
yaicli/chat.py
ADDED
@@ -0,0 +1,396 @@
|
|
1
|
+
import json
|
2
|
+
import os
|
3
|
+
import time
|
4
|
+
from dataclasses import dataclass, field
|
5
|
+
from datetime import datetime
|
6
|
+
from pathlib import Path
|
7
|
+
from typing import Dict, List, Optional, Union
|
8
|
+
|
9
|
+
from rich.table import Table
|
10
|
+
|
11
|
+
from .config import cfg
|
12
|
+
from .console import YaiConsole, get_console
|
13
|
+
from .schemas import ChatMessage
|
14
|
+
from .exceptions import ChatDeleteError, ChatLoadError, ChatSaveError
|
15
|
+
from .utils import option_callback
|
16
|
+
|
17
|
+
console: YaiConsole = get_console()
|
18
|
+
|
19
|
+
|
20
|
+
@dataclass
|
21
|
+
class Chat:
|
22
|
+
"""Single chat session"""
|
23
|
+
|
24
|
+
idx: Optional[str] = None
|
25
|
+
title: str = field(default_factory=lambda: f"Chat {datetime.now().strftime('%Y%m%d-%H%M%S')}")
|
26
|
+
history: List[ChatMessage] = field(default_factory=list)
|
27
|
+
date: str = field(default_factory=lambda: datetime.now().isoformat())
|
28
|
+
path: Optional[Path] = None
|
29
|
+
|
30
|
+
def add_message(self, role: str, content: str) -> None:
|
31
|
+
"""Add message to the session"""
|
32
|
+
self.history.append(ChatMessage(role=role, content=content))
|
33
|
+
|
34
|
+
def to_dict(self) -> Dict:
|
35
|
+
"""Convert to dictionary representation"""
|
36
|
+
return {
|
37
|
+
"title": self.title,
|
38
|
+
"date": self.date,
|
39
|
+
"history": [{"role": msg.role, "content": msg.content} for msg in self.history],
|
40
|
+
}
|
41
|
+
|
42
|
+
@classmethod
|
43
|
+
def from_dict(cls, data: Dict) -> "Chat":
|
44
|
+
"""Create Chat instance from dictionary"""
|
45
|
+
chat = cls(
|
46
|
+
idx=data.get("idx", None),
|
47
|
+
title=data.get("title", None),
|
48
|
+
date=data.get("date", datetime.now().isoformat()),
|
49
|
+
path=data.get("path", None),
|
50
|
+
)
|
51
|
+
|
52
|
+
for msg_data in data.get("history", []):
|
53
|
+
chat.add_message(msg_data["role"], msg_data["content"])
|
54
|
+
|
55
|
+
return chat
|
56
|
+
|
57
|
+
def load(self) -> bool:
|
58
|
+
"""Load chat history from file
|
59
|
+
|
60
|
+
Returns:
|
61
|
+
bool: True if successful, False otherwise
|
62
|
+
"""
|
63
|
+
if self.path is None or not self.path.exists():
|
64
|
+
return False
|
65
|
+
|
66
|
+
try:
|
67
|
+
with open(self.path, "r", encoding="utf-8") as f:
|
68
|
+
data = json.load(f)
|
69
|
+
self.title = data.get("title", self.title)
|
70
|
+
self.date = data.get("date", self.date)
|
71
|
+
self.history = [ChatMessage(role=msg["role"], content=msg["content"]) for msg in data.get("history", [])]
|
72
|
+
return True
|
73
|
+
except (json.JSONDecodeError, OSError) as e:
|
74
|
+
raise ChatLoadError(f"Error loading chat: {e}") from e
|
75
|
+
|
76
|
+
def save(self, chat_dir: Path) -> bool:
|
77
|
+
"""Save chat to file
|
78
|
+
|
79
|
+
Args:
|
80
|
+
chat_dir: Directory to save chat file
|
81
|
+
|
82
|
+
Returns:
|
83
|
+
bool: True if successful, False otherwise
|
84
|
+
|
85
|
+
Raises:
|
86
|
+
ChatSaveError: If there's an error saving the chat
|
87
|
+
"""
|
88
|
+
if not self.history:
|
89
|
+
raise ChatSaveError("No history in chat to save")
|
90
|
+
|
91
|
+
# Ensure chat has a title
|
92
|
+
if not self.title:
|
93
|
+
self.title = f"Chat-{int(time.time())}"
|
94
|
+
|
95
|
+
# Update timestamp if not set
|
96
|
+
if not self.date:
|
97
|
+
self.date = datetime.now().isoformat()
|
98
|
+
|
99
|
+
# Create a descriptive filename with timestamp and title
|
100
|
+
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
101
|
+
filename = f"{timestamp}-title-{self.title}.json"
|
102
|
+
chat_path = chat_dir / filename
|
103
|
+
|
104
|
+
try:
|
105
|
+
# Save the chat as JSON
|
106
|
+
with open(chat_path, "w", encoding="utf-8") as f:
|
107
|
+
json.dump(self.to_dict(), f, indent=2, ensure_ascii=False)
|
108
|
+
|
109
|
+
# Update chat's path to the new file
|
110
|
+
self.path = chat_path
|
111
|
+
return True
|
112
|
+
except Exception as e:
|
113
|
+
error_msg = f"Error saving chat '{self.title}': {e}"
|
114
|
+
raise ChatSaveError(error_msg) from e
|
115
|
+
|
116
|
+
|
117
|
+
@dataclass
|
118
|
+
class FileChatManager:
|
119
|
+
"""File system chat manager"""
|
120
|
+
|
121
|
+
chat_dir: Path = field(default_factory=lambda: Path(cfg["CHAT_HISTORY_DIR"]))
|
122
|
+
max_saved_chats: int = field(default_factory=lambda: cfg["MAX_SAVED_CHATS"])
|
123
|
+
current_chat: Optional[Chat] = None
|
124
|
+
_chats_map: Optional[Dict[str, Dict[str, Chat]]] = None
|
125
|
+
|
126
|
+
def __post_init__(self) -> None:
|
127
|
+
if not isinstance(self.chat_dir, Path):
|
128
|
+
self.chat_dir = Path(self.chat_dir)
|
129
|
+
if not self.chat_dir.exists():
|
130
|
+
self.chat_dir.mkdir(parents=True, exist_ok=True)
|
131
|
+
|
132
|
+
@property
|
133
|
+
def chats_map(self) -> Dict[str, Dict[str, Chat]]:
|
134
|
+
"""Get the map of chats, loading from disk only when needed"""
|
135
|
+
if self._chats_map is None:
|
136
|
+
self._load_chats()
|
137
|
+
return self._chats_map or {"index": {}, "title": {}}
|
138
|
+
|
139
|
+
def _load_chats(self) -> None:
|
140
|
+
"""Load chats from disk into memory"""
|
141
|
+
chat_files = sorted(list(self.chat_dir.glob("*.json")), key=lambda f: f.stat().st_mtime, reverse=True)
|
142
|
+
chats_map = {"title": {}, "index": {}}
|
143
|
+
|
144
|
+
for i, chat_file in enumerate(chat_files[: self.max_saved_chats]):
|
145
|
+
try:
|
146
|
+
# Parse basic chat info from filename
|
147
|
+
chat = self._parse_filename(chat_file)
|
148
|
+
chat.idx = str(i + 1)
|
149
|
+
|
150
|
+
# Add to maps
|
151
|
+
chats_map["title"][chat.title] = chat
|
152
|
+
chats_map["index"][str(i + 1)] = chat
|
153
|
+
except Exception as e:
|
154
|
+
# Log the error but continue processing other files
|
155
|
+
raise ChatLoadError(f"Error parsing session file {chat_file}: {e}") from e
|
156
|
+
|
157
|
+
self._chats_map = chats_map
|
158
|
+
|
159
|
+
def new_chat(self, title: str = "") -> Chat:
|
160
|
+
"""Create a new chat session"""
|
161
|
+
chat_id = str(int(time.time()))
|
162
|
+
self.current_chat = Chat(idx=chat_id, title=title)
|
163
|
+
return self.current_chat
|
164
|
+
|
165
|
+
def make_chat_title(self, prompt: Optional[str] = None) -> str:
|
166
|
+
"""Make a chat title from a given full prompt"""
|
167
|
+
if prompt:
|
168
|
+
return prompt[:100]
|
169
|
+
else:
|
170
|
+
return f"Chat-{int(time.time())}"
|
171
|
+
|
172
|
+
def save_chat(self, chat: Optional[Chat] = None) -> str:
|
173
|
+
"""Save chat session to file
|
174
|
+
|
175
|
+
Args:
|
176
|
+
chat (Optional[Chat], optional): The chat to save. If None, uses current_chat.
|
177
|
+
|
178
|
+
Returns:
|
179
|
+
str: The title of the saved chat
|
180
|
+
|
181
|
+
Raises:
|
182
|
+
ChatSaveError: If there's an error saving the chat
|
183
|
+
"""
|
184
|
+
if chat is None:
|
185
|
+
chat = self.current_chat
|
186
|
+
|
187
|
+
if chat is None:
|
188
|
+
raise ChatSaveError("No chat found")
|
189
|
+
|
190
|
+
# Check for existing chat with the same title and delete it
|
191
|
+
if chat.title:
|
192
|
+
self._delete_existing_chat_with_title(chat.title)
|
193
|
+
|
194
|
+
# Save the chat using its own method - this will throw ChatSaveError if it fails
|
195
|
+
chat.save(self.chat_dir)
|
196
|
+
|
197
|
+
# If we get here, the save was successful
|
198
|
+
# Clean up old chats if we exceed the maximum
|
199
|
+
self._cleanup_old_chats()
|
200
|
+
|
201
|
+
# Reset the chats map to force a refresh on next access
|
202
|
+
self._chats_map = None
|
203
|
+
|
204
|
+
return chat.title
|
205
|
+
|
206
|
+
def _delete_existing_chat_with_title(self, title: str) -> None:
|
207
|
+
"""Delete any existing chat with the given title"""
|
208
|
+
if not title:
|
209
|
+
return
|
210
|
+
|
211
|
+
# Use chats_map to find the chat by title
|
212
|
+
if title in self.chats_map["title"]:
|
213
|
+
chat = self.chats_map["title"][title]
|
214
|
+
if chat.path and chat.path.exists():
|
215
|
+
try:
|
216
|
+
chat.path.unlink()
|
217
|
+
# Reset the chats map to force a refresh
|
218
|
+
self._chats_map = None
|
219
|
+
except OSError as e:
|
220
|
+
raise ChatDeleteError(f"Warning: Failed to delete existing chat file {chat.path}: {e}") from e
|
221
|
+
|
222
|
+
def _cleanup_old_chats(self) -> None:
|
223
|
+
"""Clean up expired chat files"""
|
224
|
+
chat_files = []
|
225
|
+
|
226
|
+
for filename in self.chat_dir.glob("*.json"):
|
227
|
+
chat_files.append((os.path.getmtime(filename), filename))
|
228
|
+
|
229
|
+
# Sort, the oldest is in the front
|
230
|
+
chat_files.sort()
|
231
|
+
|
232
|
+
# If over the maximum number, delete the oldest
|
233
|
+
while len(chat_files) > self.max_saved_chats:
|
234
|
+
_, oldest_file = chat_files.pop(0)
|
235
|
+
try:
|
236
|
+
oldest_file.unlink()
|
237
|
+
except (OSError, IOError):
|
238
|
+
pass
|
239
|
+
|
240
|
+
def load_chat(self, chat_id: str) -> Chat:
|
241
|
+
"""Load a chat session by ID"""
|
242
|
+
chat_path = self.chat_dir / f"{chat_id}.json"
|
243
|
+
|
244
|
+
if not chat_path.exists():
|
245
|
+
return Chat(idx=chat_id)
|
246
|
+
|
247
|
+
# Create a chat object with the path and load its history
|
248
|
+
chat = Chat(idx=chat_id, path=chat_path)
|
249
|
+
if chat.load():
|
250
|
+
self.current_chat = chat
|
251
|
+
return chat
|
252
|
+
else:
|
253
|
+
return Chat(idx=chat_id)
|
254
|
+
|
255
|
+
def load_chat_by_index(self, index: str) -> Chat:
|
256
|
+
"""Load a chat session by index"""
|
257
|
+
if index not in self.chats_map["index"]:
|
258
|
+
return Chat(idx=index)
|
259
|
+
|
260
|
+
chat = self.chats_map["index"][index]
|
261
|
+
if chat.path is None:
|
262
|
+
return chat
|
263
|
+
|
264
|
+
# Load the chat history using the Chat class's load method
|
265
|
+
if chat.load():
|
266
|
+
self.current_chat = chat
|
267
|
+
return chat
|
268
|
+
|
269
|
+
def load_chat_by_title(self, title: str) -> Chat:
|
270
|
+
"""Load a chat session by title"""
|
271
|
+
if title not in self.chats_map["title"]:
|
272
|
+
return Chat(title=title)
|
273
|
+
|
274
|
+
chat = self.chats_map["title"][title]
|
275
|
+
if chat.path is None:
|
276
|
+
return chat
|
277
|
+
|
278
|
+
# Load the chat history using the Chat class's load method
|
279
|
+
if chat.load():
|
280
|
+
self.current_chat = chat
|
281
|
+
return chat
|
282
|
+
|
283
|
+
def validate_chat_index(self, index: Union[str, int]) -> bool:
|
284
|
+
"""Validate a chat index and return success status"""
|
285
|
+
return index in self.chats_map["index"]
|
286
|
+
|
287
|
+
def refresh_chats(self) -> None:
|
288
|
+
"""Force refresh the chat list from disk"""
|
289
|
+
self._chats_map = None
|
290
|
+
# This will trigger a reload on next access
|
291
|
+
|
292
|
+
def list_chats(self) -> List[Chat]:
|
293
|
+
"""List all saved chat sessions"""
|
294
|
+
return list(self.chats_map["index"].values())
|
295
|
+
|
296
|
+
def delete_chat(self, chat_path: os.PathLike) -> bool:
|
297
|
+
"""Delete a chat session by path"""
|
298
|
+
path = Path(chat_path)
|
299
|
+
if not path.exists():
|
300
|
+
return False
|
301
|
+
|
302
|
+
try:
|
303
|
+
path.unlink()
|
304
|
+
|
305
|
+
# If the current chat is deleted, set it to None
|
306
|
+
if self.current_chat and self.current_chat.path == path:
|
307
|
+
self.current_chat = None
|
308
|
+
|
309
|
+
# Reset the chats map to force a refresh on next access
|
310
|
+
self._chats_map = None
|
311
|
+
|
312
|
+
return True
|
313
|
+
except (OSError, IOError) as e:
|
314
|
+
raise ChatDeleteError(f"Error deleting chat: {e}") from e
|
315
|
+
|
316
|
+
def delete_chat_by_index(self, index: str) -> bool:
|
317
|
+
"""Delete a chat session by index"""
|
318
|
+
if not self.validate_chat_index(index):
|
319
|
+
return False
|
320
|
+
|
321
|
+
chat = self.chats_map["index"][index]
|
322
|
+
if chat.path is None:
|
323
|
+
return False
|
324
|
+
|
325
|
+
return self.delete_chat(chat.path)
|
326
|
+
|
327
|
+
def print_chats(self) -> None:
|
328
|
+
"""Print all saved chat sessions"""
|
329
|
+
chats = self.list_chats()
|
330
|
+
|
331
|
+
if not chats:
|
332
|
+
console.print("No saved chats found.", style="yellow")
|
333
|
+
return
|
334
|
+
|
335
|
+
table = Table("ID", "Created At", "Messages", "Title", title="Saved Chats")
|
336
|
+
|
337
|
+
for i, chat in enumerate(chats):
|
338
|
+
created_at = datetime.fromisoformat(chat.date).strftime("%Y-%m-%d %H:%M:%S") if chat.date else "Unknown"
|
339
|
+
table.add_row(str(i + 1), created_at, str(len(chat.history)), chat.title)
|
340
|
+
|
341
|
+
console.print(table)
|
342
|
+
|
343
|
+
@classmethod
|
344
|
+
@option_callback
|
345
|
+
def print_list_option(cls, value: bool) -> bool:
|
346
|
+
"""Print all chat sessions as a typer option callback"""
|
347
|
+
if not value:
|
348
|
+
return value
|
349
|
+
|
350
|
+
chat_manager = FileChatManager()
|
351
|
+
chats = chat_manager.list_chats()
|
352
|
+
if not chats:
|
353
|
+
console.print("No saved chats found.", style="yellow")
|
354
|
+
return value
|
355
|
+
|
356
|
+
for i, chat in enumerate(chats):
|
357
|
+
created_at = datetime.fromisoformat(chat.date).strftime("%Y-%m-%d %H:%M:%S") if chat.date else "Unknown"
|
358
|
+
console.print(f"{i + 1}. {chat.title} ({created_at})")
|
359
|
+
return value
|
360
|
+
|
361
|
+
@staticmethod
|
362
|
+
def _parse_filename(chat_file: Path) -> Chat:
|
363
|
+
"""Parse a chat filename and extract metadata"""
|
364
|
+
# filename: "20250421-214005-title-meaning of life"
|
365
|
+
filename = chat_file.stem
|
366
|
+
parts = filename.split("-")
|
367
|
+
title_str_len = 6 # "title-" marker length
|
368
|
+
|
369
|
+
# Check if the filename has the expected format
|
370
|
+
if len(parts) >= 4 and "title" in parts:
|
371
|
+
str_title_index = filename.find("title")
|
372
|
+
if str_title_index == -1:
|
373
|
+
# If "title" is not found, use full filename as the title
|
374
|
+
# Just in case, fallback to use fullname, but this should never happen when `len(parts) >= 4 and "title" in parts`
|
375
|
+
str_title_index = 0
|
376
|
+
title_str_len = 0
|
377
|
+
|
378
|
+
# "20250421-214005-title-meaning of life" ==> "meaning of life"
|
379
|
+
title = filename[str_title_index + title_str_len :]
|
380
|
+
date_ = parts[0]
|
381
|
+
time_ = parts[1]
|
382
|
+
# Format date
|
383
|
+
date_str = f"{date_[:4]}-{date_[4:6]}-{date_[6:]} {time_[:2]}:{time_[2:4]}"
|
384
|
+
|
385
|
+
else:
|
386
|
+
# Fallback for files that don't match expected format
|
387
|
+
title = filename
|
388
|
+
date_str = ""
|
389
|
+
# timestamp = 0
|
390
|
+
|
391
|
+
# Create a minimal Chat object with the parsed info
|
392
|
+
return Chat(title=title, date=date_str, path=chat_file)
|
393
|
+
|
394
|
+
|
395
|
+
# Create a global chat manager instance
|
396
|
+
chat_mgr = FileChatManager()
|