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 CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "yaicli"
3
- version = "0.4.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
- yai = "yaicli.entry:app"
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()