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 +1 -1
- yaicli/api.py +14 -26
- yaicli/chat_manager.py +290 -0
- yaicli/cli.py +271 -56
- yaicli/config.py +51 -65
- yaicli/console.py +66 -0
- yaicli/const.py +70 -9
- yaicli/entry.py +178 -39
- yaicli/exceptions.py +46 -0
- yaicli/printer.py +113 -57
- yaicli/render.py +19 -0
- yaicli/roles.py +248 -0
- yaicli/utils.py +21 -2
- {yaicli-0.1.0.dist-info → yaicli-0.3.0.dist-info}/METADATA +199 -68
- yaicli-0.3.0.dist-info/RECORD +20 -0
- yaicli-0.1.0.dist-info/RECORD +0 -15
- {yaicli-0.1.0.dist-info → yaicli-0.3.0.dist-info}/WHEEL +0 -0
- {yaicli-0.1.0.dist-info → yaicli-0.3.0.dist-info}/entry_points.txt +0 -0
- {yaicli-0.1.0.dist-info → yaicli-0.3.0.dist-info}/licenses/LICENSE +0 -0
pyproject.toml
CHANGED
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
|
-
|
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
|
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
|
73
|
-
self.client = client or httpx.Client(timeout=self.config
|
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
|
82
|
-
"top_p": self.config
|
83
|
-
"max_tokens": self.config
|
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
|
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
|
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"
|
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
|