yaicli 0.1.0__py3-none-any.whl → 0.3.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.
pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "yaicli"
3
- version = "0.1.0"
3
+ version = "0.3.0"
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"
yaicli/api.py CHANGED
@@ -8,33 +8,18 @@ from rich.console import Console
8
8
  from yaicli.const import (
9
9
  DEFAULT_BASE_URL,
10
10
  DEFAULT_COMPLETION_PATH,
11
- DEFAULT_MAX_TOKENS,
12
11
  DEFAULT_MODEL,
13
- DEFAULT_TEMPERATURE,
14
- DEFAULT_TIMEOUT,
15
- DEFAULT_TOP_P,
16
12
  EventTypeEnum,
17
13
  )
18
14
 
19
15
 
20
16
  def parse_stream_line(line: Union[bytes, str], console: Console, verbose: bool) -> Optional[dict]:
21
17
  """(Helper Function) Parse a single line from the SSE stream response."""
22
- line_str: str
23
- if isinstance(line, bytes):
24
- try:
25
- line_str = line.decode("utf-8")
26
- except UnicodeDecodeError:
27
- if verbose:
28
- console.print(f"Warning: Could not decode stream line bytes: {line!r}", style="yellow")
29
- return None
30
- elif isinstance(line, str):
31
- line_str = line
32
- else:
33
- # Handle unexpected line types
18
+ if not isinstance(line, (bytes, str)):
34
19
  if verbose:
35
- console.print(f"Warning: Received unexpected line type: {type(line)}", style="yellow")
20
+ console.print(f"Warning: Received non-string/bytes line: {line!r}", style="yellow")
36
21
  return None
37
-
22
+ line_str: str = line.decode("utf-8") if isinstance(line, bytes) else line
38
23
  line_str = line_str.strip()
39
24
  if not line_str or not line_str.startswith("data: "):
40
25
  return None
@@ -69,8 +54,8 @@ class ApiClient:
69
54
  self.completion_path = str(config.get("COMPLETION_PATH", DEFAULT_COMPLETION_PATH))
70
55
  self.api_key = str(config.get("API_KEY", ""))
71
56
  self.model = str(config.get("MODEL", DEFAULT_MODEL))
72
- self.timeout = self.config.get("TIMEOUT", DEFAULT_TIMEOUT)
73
- self.client = client or httpx.Client(timeout=self.config.get("TIMEOUT", DEFAULT_TIMEOUT))
57
+ self.timeout = self.config["TIMEOUT"]
58
+ self.client = client or httpx.Client(timeout=self.config["TIMEOUT"])
74
59
 
75
60
  def _prepare_request_body(self, messages: List[Dict[str, str]], stream: bool) -> Dict[str, Any]:
76
61
  """Prepare the common request body for API calls."""
@@ -78,9 +63,12 @@ class ApiClient:
78
63
  "messages": messages,
79
64
  "model": self.model,
80
65
  "stream": stream,
81
- "temperature": self.config.get("TEMPERATURE", DEFAULT_TEMPERATURE),
82
- "top_p": self.config.get("TOP_P", DEFAULT_TOP_P),
83
- "max_tokens": self.config.get("MAX_TOKENS", DEFAULT_MAX_TOKENS),
66
+ "temperature": self.config["TEMPERATURE"],
67
+ "top_p": self.config["TOP_P"],
68
+ "max_tokens": self.config[
69
+ "MAX_TOKENS"
70
+ ], # Openai: This value is now deprecated in favor of max_completion_tokens
71
+ "max_completion_tokens": self.config["MAX_TOKENS"],
84
72
  }
85
73
 
86
74
  def _handle_api_error(self, e: httpx.HTTPError) -> None:
@@ -190,7 +178,7 @@ class ApiClient:
190
178
  """Process a single chunk from the stream and yield events with updated reasoning state.
191
179
 
192
180
  Args:
193
- parsed_data: The parsed JSON data from a stream line
181
+ parsed_data: The parsed JSON data from a streamline
194
182
  in_reasoning: Whether we're currently in a reasoning state
195
183
 
196
184
  Yields:
@@ -273,7 +261,7 @@ class ApiClient:
273
261
  yield self._handle_http_error(e)
274
262
  return
275
263
 
276
- # Process the stream line by line
264
+ # Process the streamline by line
277
265
  for line in response.iter_lines():
278
266
  parsed_data = parse_stream_line(line, self.console, self.verbose)
279
267
  if parsed_data is None:
@@ -315,7 +303,7 @@ class ApiClient:
315
303
  # reasoning_content: deepseek/infi-ai
316
304
  # reasoning: openrouter
317
305
  # <think> block implementation not in here
318
- for key in ("reasoning_content", "reasoning", "metadata"):
306
+ for key in ("reasoning_content", "reasoning"):
319
307
  # Check if the key exists and its value is a non-empty string
320
308
  value = delta.get(key)
321
309
  if isinstance(value, str) and value:
yaicli/chat_manager.py ADDED
@@ -0,0 +1,290 @@
1
+ import json
2
+ import time
3
+ from abc import ABC, abstractmethod
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from typing import Any, Dict, List, Optional, TypedDict, Union
7
+
8
+ from rich.console import Console
9
+
10
+ from yaicli.config import Config, cfg
11
+ from yaicli.console import get_console
12
+ from yaicli.utils import option_callback
13
+
14
+
15
+ class ChatFileInfo(TypedDict):
16
+ """Chat info, parse chat filename and store metadata"""
17
+
18
+ index: int
19
+ path: str
20
+ title: str
21
+ date: str
22
+ timestamp: int
23
+
24
+
25
+ class ChatsMap(TypedDict):
26
+ """Chat info cache for chat manager"""
27
+
28
+ title: Dict[str, ChatFileInfo]
29
+ index: Dict[int, ChatFileInfo]
30
+
31
+
32
+ class ChatManager(ABC):
33
+ """Abstract base class that defines the chat manager interface"""
34
+
35
+ @abstractmethod
36
+ def make_chat_title(self, prompt: Optional[str] = None) -> str:
37
+ """Make a chat title from a given full prompt"""
38
+ pass
39
+
40
+ @abstractmethod
41
+ def save_chat(self, history: List[Dict[str, Any]], title: Optional[str] = None) -> str:
42
+ """Save a chat and return the chat title"""
43
+ pass
44
+
45
+ @abstractmethod
46
+ def list_chats(self) -> List[ChatFileInfo]:
47
+ """List all saved chats and return the chat list"""
48
+ pass
49
+
50
+ @abstractmethod
51
+ def refresh_chats(self) -> None:
52
+ """Force refresh the chat list"""
53
+ pass
54
+
55
+ @abstractmethod
56
+ def load_chat_by_index(self, index: int) -> Union[ChatFileInfo, Dict]:
57
+ """Load a chat by index and return the chat data"""
58
+ pass
59
+
60
+ @abstractmethod
61
+ def load_chat_by_title(self, title: str) -> Union[ChatFileInfo, Dict]:
62
+ """Load a chat by title and return the chat data"""
63
+ pass
64
+
65
+ @abstractmethod
66
+ def delete_chat(self, index: int) -> bool:
67
+ """Delete a chat by index and return success status"""
68
+ pass
69
+
70
+ @abstractmethod
71
+ def validate_chat_index(self, index: int) -> bool:
72
+ """Validate a chat index and return success status"""
73
+ pass
74
+
75
+
76
+ class FileChatManager(ChatManager):
77
+ """File system based chat manager implementation"""
78
+
79
+ console: Console = get_console()
80
+ config: Config = cfg
81
+ chat_dir = Path(config["CHAT_HISTORY_DIR"])
82
+ max_saved_chats = config["MAX_SAVED_CHATS"]
83
+ chat_dir.mkdir(parents=True, exist_ok=True)
84
+
85
+ def __init__(self):
86
+ self._chats_map: Optional[ChatsMap] = None # Cache for chat map
87
+
88
+ @property
89
+ def chats_map(self) -> ChatsMap:
90
+ """Get the map of chats, loading from disk only when needed"""
91
+ if self._chats_map is None:
92
+ self._load_chats()
93
+ return self._chats_map or {"index": {}, "title": {}}
94
+
95
+ @classmethod
96
+ @option_callback
97
+ def print_list_option(cls, _: Any):
98
+ """Print the list of chats"""
99
+ cls.console.print("Finding Chats...")
100
+ c = -1
101
+ for c, file in enumerate(sorted(cls.chat_dir.glob("*.json"), key=lambda f: f.stat().st_mtime)):
102
+ info: ChatFileInfo = cls._parse_filename(file, c + 1)
103
+ cls.console.print(f"{c + 1}. {info['title']} ({info['date']})")
104
+ if c == -1:
105
+ cls.console.print("No chats found", style="dim")
106
+
107
+ def make_chat_title(self, prompt: Optional[str] = None) -> str:
108
+ """Make a chat title from a given full prompt"""
109
+ if prompt:
110
+ return prompt[:100]
111
+ else:
112
+ return f"Chat-{int(time.time())}"
113
+
114
+ def validate_chat_index(self, index: int) -> bool:
115
+ """Validate a chat index and return success status"""
116
+ return index > 0 and index in self.chats_map["index"]
117
+
118
+ def refresh_chats(self) -> None:
119
+ """Force refresh the chat list from disk"""
120
+ self._load_chats()
121
+
122
+ @staticmethod
123
+ def _parse_filename(chat_file: Path, index: int) -> ChatFileInfo:
124
+ """Parse a chat filename and extract metadata"""
125
+ # filename: "20250421-214005-title-meaning of life"
126
+ filename = chat_file.stem
127
+ parts = filename.split("-")
128
+ title_str_len = 6 # "title-" marker length
129
+
130
+ # Check if the filename has the expected format
131
+ if len(parts) >= 4 and "title" in parts:
132
+ str_title_index = filename.find("title")
133
+ if str_title_index == -1:
134
+ # If "title" is not found, use full filename as the title
135
+ # Just in case, fallback to use fullname, but this should never happen when `len(parts) >= 4 and "title" in parts`
136
+ str_title_index = 0
137
+ title_str_len = 0
138
+
139
+ # "20250421-214005-title-meaning of life" ==> "meaning of life"
140
+ title = filename[str_title_index + title_str_len :]
141
+ date_ = parts[0]
142
+ time_ = parts[1]
143
+ # Format date
144
+ date_str = f"{date_[:4]}-{date_[4:6]}-{date_[6:]} {time_[:2]}:{time_[2:4]}"
145
+
146
+ # Calculate timestamp from date parts
147
+ try:
148
+ date_time_str = f"{date_}{time_}"
149
+ timestamp = int(datetime.strptime(date_time_str, "%Y%m%d%H%M%S").timestamp())
150
+ except ValueError:
151
+ timestamp = 0
152
+ else:
153
+ # Fallback for files that don't match expected format
154
+ title = filename
155
+ date_str = ""
156
+ timestamp = 0
157
+
158
+ # The actual title is stored in the JSON file, so we'll use that when loading
159
+ # This is just for the initial listing before the file is opened
160
+ return {
161
+ "index": index,
162
+ "path": str(chat_file),
163
+ "title": title,
164
+ "date": date_str,
165
+ "timestamp": timestamp,
166
+ }
167
+
168
+ def _load_chats(self) -> None:
169
+ """Load chats from disk into memory"""
170
+ chat_files = sorted(list(self.chat_dir.glob("*.json")), reverse=True)
171
+ chats_map: ChatsMap = {"title": {}, "index": {}}
172
+
173
+ for i, chat_file in enumerate(chat_files[: self.max_saved_chats]):
174
+ try:
175
+ info = self._parse_filename(chat_file, i + 1)
176
+ chats_map["title"][info["title"]] = info
177
+ chats_map["index"][i + 1] = info
178
+ except Exception as e:
179
+ # Log the error but continue processing other files
180
+ self.console.print(f"Error parsing session file {chat_file}: {e}", style="dim")
181
+ continue
182
+
183
+ self._chats_map = chats_map
184
+
185
+ def list_chats(self) -> List[ChatFileInfo]:
186
+ """List all saved chats and return the chat list"""
187
+ return list(self.chats_map["index"].values())
188
+
189
+ def save_chat(self, history: List[Dict[str, Any]], title: Optional[str] = None) -> str:
190
+ """Save chat history to the file system, overwriting existing chats with the same title.
191
+
192
+ If no title is provided, the chat will be saved with a default title.
193
+ The default title is "Chat-{current timestamp}".
194
+
195
+ Args:
196
+ history (List[Dict[str, Any]]): The chat history to save
197
+ title (Optional[str]): The title of the chat provided by the user
198
+
199
+ Returns:
200
+ str: The title of the saved chat
201
+ """
202
+ history = history or []
203
+
204
+ save_title = title or f"Chat-{int(time.time())}"
205
+ save_title = self.make_chat_title(save_title)
206
+
207
+ # Check for existing session with the same title and delete it
208
+ existing_chat = self.chats_map["title"].get(save_title)
209
+ if existing_chat:
210
+ try:
211
+ existing_path = Path(existing_chat["path"])
212
+ existing_path.unlink()
213
+ except OSError as e:
214
+ self.console.print(
215
+ f"Warning: Could not delete existing chat file {existing_chat['path']}: {e}",
216
+ style="dim",
217
+ )
218
+
219
+ timestamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S")
220
+ filename = f"{timestamp}-title-{save_title}.json"
221
+ filepath = self.chat_dir / filename
222
+
223
+ try:
224
+ with open(filepath, "w", encoding="utf-8") as f:
225
+ json.dump({"history": history, "title": save_title}, f, ensure_ascii=False, indent=2)
226
+ # Force refresh the chat list after saving
227
+ self.refresh_chats()
228
+ return save_title
229
+ except Exception as e:
230
+ self.console.print(f"Error saving chat '{save_title}': {e}", style="dim")
231
+ return ""
232
+
233
+ def _load_chat_data(self, chat_info: Optional[ChatFileInfo]) -> Union[ChatFileInfo, Dict]:
234
+ """Common method to load chat data from a chat info dict"""
235
+ if not chat_info:
236
+ return {}
237
+
238
+ try:
239
+ chat_file = Path(chat_info["path"])
240
+ with open(chat_file, "r", encoding="utf-8") as f:
241
+ chat_data = json.load(f)
242
+
243
+ return {
244
+ "title": chat_data.get("title", chat_info["title"]),
245
+ "timestamp": chat_info["timestamp"],
246
+ "history": chat_data.get("history", []),
247
+ }
248
+ except FileNotFoundError:
249
+ self.console.print(f"Chat file not found: {chat_info['path']}", style="dim")
250
+ return {}
251
+ except json.JSONDecodeError as e:
252
+ self.console.print(f"Invalid JSON in chat file {chat_info['path']}: {e}", style="dim")
253
+ return {}
254
+ except Exception as e:
255
+ self.console.print(f"Error loading chat from {chat_info['path']}: {e}", style="dim")
256
+ return {}
257
+
258
+ def load_chat_by_index(self, index: int) -> Union[ChatFileInfo, Dict]:
259
+ """Load a chat by index and return the chat data"""
260
+ if not self.validate_chat_index(index):
261
+ return {}
262
+ chat_info = self.chats_map.get("index", {}).get(index)
263
+ return self._load_chat_data(chat_info)
264
+
265
+ def load_chat_by_title(self, title: str) -> Union[ChatFileInfo, Dict]:
266
+ """Load a chat by title and return the chat data"""
267
+ chat_info = self.chats_map.get("title", {}).get(title)
268
+ return self._load_chat_data(chat_info)
269
+
270
+ def delete_chat(self, index: int) -> bool:
271
+ """Delete a chat by index and return success status"""
272
+ if not self.validate_chat_index(index):
273
+ return False
274
+
275
+ chat_info = self.chats_map["index"].get(index)
276
+ if not chat_info:
277
+ return False
278
+
279
+ try:
280
+ chat_file = Path(chat_info["path"])
281
+ chat_file.unlink()
282
+ # Force refresh the chat list
283
+ self.refresh_chats()
284
+ return True
285
+ except FileNotFoundError:
286
+ self.console.print(f"Chat file not found: {chat_info['path']}", style="dim")
287
+ return False
288
+ except Exception as e:
289
+ self.console.print(f"Error deleting chat {index}: {e}", style="dim")
290
+ return False