yaicli 0.0.19__py3-none-any.whl → 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.
- pyproject.toml +10 -4
- yaicli/__init__.py +0 -0
- yaicli/api.py +324 -0
- yaicli/chat_manager.py +263 -0
- yaicli/cli.py +514 -0
- yaicli/config.py +153 -0
- yaicli/const.py +135 -0
- yaicli/entry.py +111 -0
- yaicli/history.py +72 -0
- yaicli/printer.py +244 -0
- yaicli/utils.py +112 -0
- {yaicli-0.0.19.dist-info → yaicli-0.2.0.dist-info}/METADATA +386 -224
- yaicli-0.2.0.dist-info/RECORD +16 -0
- yaicli-0.2.0.dist-info/entry_points.txt +3 -0
- yaicli-0.0.19.dist-info/RECORD +0 -7
- yaicli-0.0.19.dist-info/entry_points.txt +0 -2
- yaicli.py +0 -667
- {yaicli-0.0.19.dist-info → yaicli-0.2.0.dist-info}/WHEEL +0 -0
- {yaicli-0.0.19.dist-info → yaicli-0.2.0.dist-info}/licenses/LICENSE +0 -0
pyproject.toml
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
[project]
|
2
2
|
name = "yaicli"
|
3
|
-
version = "0.0
|
3
|
+
version = "0.2.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"
|
@@ -44,13 +44,19 @@ Repository = "https://github.com/belingud/yaicli"
|
|
44
44
|
Documentation = "https://github.com/belingud/yaicli"
|
45
45
|
|
46
46
|
[project.scripts]
|
47
|
-
ai = "yaicli:app"
|
47
|
+
ai = "yaicli.entry:app"
|
48
|
+
yai = "yaicli.entry:app"
|
48
49
|
|
49
50
|
[tool.uv]
|
50
51
|
resolution = "highest"
|
51
52
|
|
52
53
|
[dependency-groups]
|
53
|
-
dev = [
|
54
|
+
dev = [
|
55
|
+
"bump2version>=1.0.1",
|
56
|
+
"pytest>=8.3.5",
|
57
|
+
"pytest-cov>=6.1.1",
|
58
|
+
"ruff>=0.11.2",
|
59
|
+
]
|
54
60
|
|
55
61
|
[tool.isort]
|
56
62
|
profile = "black"
|
@@ -65,4 +71,4 @@ build-backend = "hatchling.build"
|
|
65
71
|
|
66
72
|
[tool.hatch.build]
|
67
73
|
exclude = ["test_*.py", "tests/*", ".gitignore"]
|
68
|
-
include = ["yaicli
|
74
|
+
include = ["yaicli", "pyproject.toml"]
|
yaicli/__init__.py
ADDED
File without changes
|
yaicli/api.py
ADDED
@@ -0,0 +1,324 @@
|
|
1
|
+
import json
|
2
|
+
from typing import Any, Dict, Iterator, List, Optional, Tuple, Union
|
3
|
+
|
4
|
+
import httpx
|
5
|
+
import jmespath
|
6
|
+
from rich.console import Console
|
7
|
+
|
8
|
+
from yaicli.const import (
|
9
|
+
DEFAULT_BASE_URL,
|
10
|
+
DEFAULT_COMPLETION_PATH,
|
11
|
+
DEFAULT_MAX_TOKENS,
|
12
|
+
DEFAULT_MODEL,
|
13
|
+
DEFAULT_TEMPERATURE,
|
14
|
+
DEFAULT_TIMEOUT,
|
15
|
+
DEFAULT_TOP_P,
|
16
|
+
EventTypeEnum,
|
17
|
+
)
|
18
|
+
|
19
|
+
|
20
|
+
def parse_stream_line(line: Union[bytes, str], console: Console, verbose: bool) -> Optional[dict]:
|
21
|
+
"""(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
|
34
|
+
if verbose:
|
35
|
+
console.print(f"Warning: Received unexpected line type: {type(line)}", style="yellow")
|
36
|
+
return None
|
37
|
+
|
38
|
+
line_str = line_str.strip()
|
39
|
+
if not line_str or not line_str.startswith("data: "):
|
40
|
+
return None
|
41
|
+
|
42
|
+
data_part = line_str[6:]
|
43
|
+
if data_part.lower() == "[done]":
|
44
|
+
return {"done": True} # Use a specific dictionary to signal DONE
|
45
|
+
|
46
|
+
try:
|
47
|
+
json_data = json.loads(data_part)
|
48
|
+
if not isinstance(json_data, dict) or "choices" not in json_data:
|
49
|
+
if verbose:
|
50
|
+
console.print(f"Warning: Invalid stream data format (missing 'choices'): {data_part}", style="yellow")
|
51
|
+
return None
|
52
|
+
return json_data
|
53
|
+
except json.JSONDecodeError:
|
54
|
+
console.print("Error decoding response JSON", style="red")
|
55
|
+
if verbose:
|
56
|
+
console.print(f"Invalid JSON data: {data_part}", style="red")
|
57
|
+
return None
|
58
|
+
|
59
|
+
|
60
|
+
class ApiClient:
|
61
|
+
"""Handles communication with the LLM API."""
|
62
|
+
|
63
|
+
def __init__(self, config: Dict[str, Any], console: Console, verbose: bool, client: Optional[httpx.Client] = None):
|
64
|
+
"""Initialize the API client with configuration."""
|
65
|
+
self.config = config
|
66
|
+
self.console = console
|
67
|
+
self.verbose = verbose
|
68
|
+
self.base_url = str(config.get("BASE_URL", DEFAULT_BASE_URL))
|
69
|
+
self.completion_path = str(config.get("COMPLETION_PATH", DEFAULT_COMPLETION_PATH))
|
70
|
+
self.api_key = str(config.get("API_KEY", ""))
|
71
|
+
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))
|
74
|
+
|
75
|
+
def _prepare_request_body(self, messages: List[Dict[str, str]], stream: bool) -> Dict[str, Any]:
|
76
|
+
"""Prepare the common request body for API calls."""
|
77
|
+
return {
|
78
|
+
"messages": messages,
|
79
|
+
"model": self.model,
|
80
|
+
"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),
|
84
|
+
}
|
85
|
+
|
86
|
+
def _handle_api_error(self, e: httpx.HTTPError) -> None:
|
87
|
+
"""Handle and print HTTP errors consistently."""
|
88
|
+
if isinstance(e, httpx.TimeoutException):
|
89
|
+
self.console.print(f"Error: API request timed out after {self.timeout} seconds. {e}", style="red")
|
90
|
+
elif isinstance(e, httpx.HTTPStatusError):
|
91
|
+
self.console.print(f"Error calling API: {e.response.status_code} {e.response.reason_phrase}", style="red")
|
92
|
+
if self.verbose:
|
93
|
+
self.console.print(f"Response Text: {e.response.text}")
|
94
|
+
elif isinstance(e, httpx.RequestError):
|
95
|
+
api_url = self.get_completion_url()
|
96
|
+
self.console.print(f"Error: Could not connect to API endpoint '{api_url}'. {e}", style="red")
|
97
|
+
else:
|
98
|
+
self.console.print(f"An unexpected HTTP error occurred: {e}", style="red")
|
99
|
+
|
100
|
+
def get_completion_url(self) -> str:
|
101
|
+
"""Get the full completion URL."""
|
102
|
+
base_url = self.base_url.rstrip("/")
|
103
|
+
completion_path = self.completion_path.lstrip("/")
|
104
|
+
return f"{base_url}/{completion_path}"
|
105
|
+
|
106
|
+
def get_headers(self) -> Dict[str, str]:
|
107
|
+
"""Get the request headers."""
|
108
|
+
return {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}
|
109
|
+
|
110
|
+
def _process_completion_response(self, response_json: Dict[str, Any]) -> Tuple[Optional[str], Optional[str]]:
|
111
|
+
"""Process the JSON response from a non-streamed completion request."""
|
112
|
+
answer_path = self.config["ANSWER_PATH"]
|
113
|
+
message_path = answer_path.rsplit(".", 1)[0]
|
114
|
+
|
115
|
+
# Extract content and reasoning using JMESPath
|
116
|
+
content = jmespath.search(answer_path, response_json)
|
117
|
+
message = jmespath.search(message_path, response_json)
|
118
|
+
reasoning = self._get_reasoning_content(
|
119
|
+
message
|
120
|
+
) # Reuse reasoning extraction if applicable to the whole message
|
121
|
+
|
122
|
+
# Process string content and extract reasoning from <think> tags if present
|
123
|
+
if isinstance(content, str):
|
124
|
+
content = content.lstrip()
|
125
|
+
if content.startswith("<think>"):
|
126
|
+
think_end = content.find("</think>")
|
127
|
+
if think_end != -1:
|
128
|
+
# Extract reasoning from <think> tag only if not already found via message path
|
129
|
+
if reasoning is None:
|
130
|
+
reasoning = content[7:think_end].strip() # Start after <think>
|
131
|
+
# Remove the <think> block from the main content
|
132
|
+
content = content[think_end + 8 :].strip() # Start after </think>
|
133
|
+
# If it doesn't start with <think>, or if </think> wasn't found, return content as is
|
134
|
+
return content, reasoning
|
135
|
+
elif content:
|
136
|
+
self.console.print(
|
137
|
+
f"Warning: Unexpected content type from API: {type(content)}. Path: {answer_path}", style="yellow"
|
138
|
+
)
|
139
|
+
# Attempt to convert unexpected content to string, return existing reasoning
|
140
|
+
return str(content), reasoning
|
141
|
+
else:
|
142
|
+
self.console.print(f"Warning: Could not extract content using JMESPath '{answer_path}'.", style="yellow")
|
143
|
+
if self.verbose:
|
144
|
+
self.console.print(f"API Response: {response_json}")
|
145
|
+
return None, reasoning
|
146
|
+
|
147
|
+
def completion(self, messages: List[Dict[str, str]]) -> Tuple[Optional[str], Optional[str]]:
|
148
|
+
"""Get a complete non-streamed response from the API."""
|
149
|
+
url = self.get_completion_url()
|
150
|
+
body = self._prepare_request_body(messages, stream=False)
|
151
|
+
headers = self.get_headers()
|
152
|
+
|
153
|
+
try:
|
154
|
+
response = self.client.post(url, json=body, headers=headers)
|
155
|
+
response.raise_for_status()
|
156
|
+
response_json = response.json()
|
157
|
+
# Delegate processing to the helper method
|
158
|
+
return self._process_completion_response(response_json)
|
159
|
+
|
160
|
+
except httpx.HTTPError as e:
|
161
|
+
self._handle_api_error(e)
|
162
|
+
return None, None
|
163
|
+
|
164
|
+
def _handle_http_error(self, e: httpx.HTTPStatusError) -> Dict[str, Any]:
|
165
|
+
"""Handle HTTP errors during streaming and return an error event.
|
166
|
+
|
167
|
+
Args:
|
168
|
+
e: The HTTP status error that occurred
|
169
|
+
|
170
|
+
Returns:
|
171
|
+
An error event dictionary to be yielded to the client
|
172
|
+
"""
|
173
|
+
error_body = e.response.read()
|
174
|
+
self._handle_api_error(e)
|
175
|
+
|
176
|
+
try:
|
177
|
+
error_json = json.loads(error_body)
|
178
|
+
error_message = error_json.get("error", {}).get("message")
|
179
|
+
except (json.JSONDecodeError, AttributeError):
|
180
|
+
error_message = None
|
181
|
+
|
182
|
+
if not error_message:
|
183
|
+
error_message = error_body.decode() if error_body else str(e)
|
184
|
+
|
185
|
+
return {"type": EventTypeEnum.ERROR, "message": error_message}
|
186
|
+
|
187
|
+
def _process_stream_chunk(
|
188
|
+
self, parsed_data: Dict[str, Any], in_reasoning: bool
|
189
|
+
) -> Iterator[Tuple[Dict[str, Any], bool]]:
|
190
|
+
"""Process a single chunk from the stream and yield events with updated reasoning state.
|
191
|
+
|
192
|
+
Args:
|
193
|
+
parsed_data: The parsed JSON data from a stream line
|
194
|
+
in_reasoning: Whether we're currently in a reasoning state
|
195
|
+
|
196
|
+
Yields:
|
197
|
+
A tuple containing:
|
198
|
+
- An event dictionary to yield to the client
|
199
|
+
- The updated reasoning state
|
200
|
+
"""
|
201
|
+
# Handle stream errors
|
202
|
+
if "error" in parsed_data:
|
203
|
+
error_msg = parsed_data["error"].get("message", "Unknown error in stream data")
|
204
|
+
self.console.print(f"Error in stream data: {error_msg}", style="red")
|
205
|
+
yield {"type": EventTypeEnum.ERROR, "message": error_msg}, in_reasoning
|
206
|
+
return
|
207
|
+
|
208
|
+
# Get and validate the choice
|
209
|
+
choices = parsed_data.get("choices", [])
|
210
|
+
if not choices or not isinstance(choices, list):
|
211
|
+
if self.verbose:
|
212
|
+
self.console.print(f"Skipping stream chunk with no choices: {parsed_data}", style="dim")
|
213
|
+
return
|
214
|
+
|
215
|
+
choice = choices[0]
|
216
|
+
if not isinstance(choice, dict):
|
217
|
+
if self.verbose:
|
218
|
+
self.console.print(f"Skipping stream chunk with invalid choice structure: {choice}", style="dim")
|
219
|
+
return
|
220
|
+
|
221
|
+
# Get content from delta
|
222
|
+
delta = choice.get("delta", {})
|
223
|
+
if not isinstance(delta, dict):
|
224
|
+
if self.verbose:
|
225
|
+
self.console.print(f"Skipping stream chunk with invalid delta structure: {delta}", style="dim")
|
226
|
+
return
|
227
|
+
|
228
|
+
# Process content
|
229
|
+
reason = self._get_reasoning_content(delta)
|
230
|
+
content_chunk = delta.get("content", "")
|
231
|
+
finish_reason = choice.get("finish_reason")
|
232
|
+
|
233
|
+
# Yield events based on content type
|
234
|
+
if reason is not None:
|
235
|
+
in_reasoning = True
|
236
|
+
yield {"type": EventTypeEnum.REASONING, "chunk": reason}, in_reasoning
|
237
|
+
elif in_reasoning and content_chunk and isinstance(content_chunk, str):
|
238
|
+
# Signal the end of reasoning before yielding content
|
239
|
+
in_reasoning = False
|
240
|
+
yield {"type": EventTypeEnum.REASONING_END, "chunk": ""}, in_reasoning
|
241
|
+
yield {"type": EventTypeEnum.CONTENT, "chunk": content_chunk}, in_reasoning
|
242
|
+
elif content_chunk and isinstance(content_chunk, str):
|
243
|
+
yield {"type": EventTypeEnum.CONTENT, "chunk": content_chunk}, in_reasoning
|
244
|
+
|
245
|
+
if finish_reason:
|
246
|
+
yield {"type": EventTypeEnum.FINISH, "reason": finish_reason}, in_reasoning
|
247
|
+
|
248
|
+
def stream_completion(self, messages: List[Dict[str, str]]) -> Iterator[Dict[str, Any]]:
|
249
|
+
"""Connect to the API and yield parsed stream events.
|
250
|
+
|
251
|
+
This method handles the streaming API connection and processes the response,
|
252
|
+
yielding events that can be consumed by the client. It handles various types
|
253
|
+
of content including regular content and reasoning content.
|
254
|
+
|
255
|
+
Args:
|
256
|
+
messages: The list of message dictionaries to send to the API
|
257
|
+
|
258
|
+
Yields:
|
259
|
+
Event dictionaries with the following structure:
|
260
|
+
- type: The event type (from EventTypeEnum)
|
261
|
+
- chunk/message/reason: The content of the event
|
262
|
+
"""
|
263
|
+
url = self.get_completion_url()
|
264
|
+
body = self._prepare_request_body(messages, stream=True)
|
265
|
+
headers = self.get_headers()
|
266
|
+
in_reasoning = False
|
267
|
+
|
268
|
+
try:
|
269
|
+
with self.client.stream("POST", url, json=body, headers=headers) as response:
|
270
|
+
try:
|
271
|
+
response.raise_for_status()
|
272
|
+
except httpx.HTTPStatusError as e:
|
273
|
+
yield self._handle_http_error(e)
|
274
|
+
return
|
275
|
+
|
276
|
+
# Process the stream line by line
|
277
|
+
for line in response.iter_lines():
|
278
|
+
parsed_data = parse_stream_line(line, self.console, self.verbose)
|
279
|
+
if parsed_data is None:
|
280
|
+
continue
|
281
|
+
if parsed_data.get("done"):
|
282
|
+
break
|
283
|
+
|
284
|
+
# Process chunks and yield events
|
285
|
+
for event, updated_state in self._process_stream_chunk(parsed_data, in_reasoning):
|
286
|
+
in_reasoning = updated_state
|
287
|
+
# event: {type: str, Optional[chunk]: str, Optional[message]: str, Optional[reason]: str}
|
288
|
+
yield event
|
289
|
+
|
290
|
+
except httpx.HTTPError as e:
|
291
|
+
self._handle_api_error(e)
|
292
|
+
yield {"type": EventTypeEnum.ERROR, "message": str(e)}
|
293
|
+
except Exception as e:
|
294
|
+
self.console.print(f"An unexpected error occurred during streaming: {e}", style="red")
|
295
|
+
if self.verbose:
|
296
|
+
import traceback
|
297
|
+
|
298
|
+
traceback.print_exc()
|
299
|
+
yield {"type": EventTypeEnum.ERROR, "message": f"Unexpected stream error: {e}"}
|
300
|
+
|
301
|
+
def _get_reasoning_content(self, delta: dict) -> Optional[str]:
|
302
|
+
"""Extract reasoning content from delta if available based on specific keys.
|
303
|
+
|
304
|
+
This method checks for various keys that might contain reasoning content
|
305
|
+
in different API implementations.
|
306
|
+
|
307
|
+
Args:
|
308
|
+
delta: The delta dictionary from the API response
|
309
|
+
|
310
|
+
Returns:
|
311
|
+
The reasoning content string if found, None otherwise
|
312
|
+
"""
|
313
|
+
if not delta:
|
314
|
+
return None
|
315
|
+
# reasoning_content: deepseek/infi-ai
|
316
|
+
# reasoning: openrouter
|
317
|
+
# <think> block implementation not in here
|
318
|
+
for key in ("reasoning_content", "reasoning", "metadata"):
|
319
|
+
# Check if the key exists and its value is a non-empty string
|
320
|
+
value = delta.get(key)
|
321
|
+
if isinstance(value, str) and value:
|
322
|
+
return value
|
323
|
+
|
324
|
+
return None # Return None if no relevant key with a string value is found
|
yaicli/chat_manager.py
ADDED
@@ -0,0 +1,263 @@
|
|
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
|
7
|
+
|
8
|
+
from rich import get_console
|
9
|
+
from rich.console import Console
|
10
|
+
|
11
|
+
from yaicli.config import Config
|
12
|
+
|
13
|
+
|
14
|
+
class ChatsMap(TypedDict):
|
15
|
+
title: Dict[str, dict]
|
16
|
+
index: Dict[int, dict]
|
17
|
+
|
18
|
+
|
19
|
+
class ChatManager(ABC):
|
20
|
+
"""Abstract base class that defines the chat manager interface"""
|
21
|
+
|
22
|
+
@abstractmethod
|
23
|
+
def make_chat_title(self, prompt: Optional[str] = None) -> str:
|
24
|
+
"""Make a chat title from a given full prompt"""
|
25
|
+
pass
|
26
|
+
|
27
|
+
@abstractmethod
|
28
|
+
def save_chat(self, history: List[Dict[str, Any]], title: Optional[str] = None) -> str:
|
29
|
+
"""Save a chat and return the chat title"""
|
30
|
+
pass
|
31
|
+
|
32
|
+
@abstractmethod
|
33
|
+
def list_chats(self) -> List[Dict[str, Any]]:
|
34
|
+
"""List all saved chats and return the chat list"""
|
35
|
+
pass
|
36
|
+
|
37
|
+
@abstractmethod
|
38
|
+
def refresh_chats(self) -> None:
|
39
|
+
"""Force refresh the chat list"""
|
40
|
+
pass
|
41
|
+
|
42
|
+
@abstractmethod
|
43
|
+
def load_chat_by_index(self, index: int) -> Dict[str, Any]:
|
44
|
+
"""Load a chat by index and return the chat data"""
|
45
|
+
pass
|
46
|
+
|
47
|
+
@abstractmethod
|
48
|
+
def load_chat_by_title(self, title: str) -> Dict[str, Any]:
|
49
|
+
"""Load a chat by title and return the chat data"""
|
50
|
+
pass
|
51
|
+
|
52
|
+
@abstractmethod
|
53
|
+
def delete_chat(self, index: int) -> bool:
|
54
|
+
"""Delete a chat by index and return success status"""
|
55
|
+
pass
|
56
|
+
|
57
|
+
@abstractmethod
|
58
|
+
def validate_chat_index(self, index: int) -> bool:
|
59
|
+
"""Validate a chat index and return success status"""
|
60
|
+
pass
|
61
|
+
|
62
|
+
|
63
|
+
class FileChatManager(ChatManager):
|
64
|
+
"""File system based chat manager implementation"""
|
65
|
+
|
66
|
+
def __init__(self, config: Config, console: Optional[Console] = None):
|
67
|
+
self.config = config
|
68
|
+
self.chat_dir = Path(self.config["CHAT_HISTORY_DIR"])
|
69
|
+
self.chat_dir.mkdir(parents=True, exist_ok=True)
|
70
|
+
self.max_saved_chats = self.config["MAX_SAVED_CHATS"]
|
71
|
+
self._chats_map: Optional[ChatsMap] = None # Cache for chat map
|
72
|
+
self.console = console or get_console()
|
73
|
+
|
74
|
+
@property
|
75
|
+
def chats_map(self) -> ChatsMap:
|
76
|
+
"""Get the map of chats, loading from disk only when needed"""
|
77
|
+
if self._chats_map is None:
|
78
|
+
self._load_chats()
|
79
|
+
return self._chats_map or {"index": {}, "title": {}}
|
80
|
+
|
81
|
+
def make_chat_title(self, prompt: Optional[str] = None) -> str:
|
82
|
+
"""Make a chat title from a given full prompt"""
|
83
|
+
if prompt:
|
84
|
+
return prompt[:100]
|
85
|
+
else:
|
86
|
+
return f"Chat-{int(time.time())}"
|
87
|
+
|
88
|
+
def validate_chat_index(self, index: int) -> bool:
|
89
|
+
"""Validate a chat index and return success status"""
|
90
|
+
return index > 0 and index in self.chats_map["index"]
|
91
|
+
|
92
|
+
def refresh_chats(self) -> None:
|
93
|
+
"""Force refresh the chat list from disk"""
|
94
|
+
self._load_chats()
|
95
|
+
|
96
|
+
def _parse_filename(self, chat_file: Path, index: int) -> Dict[str, Any]:
|
97
|
+
"""Parse a chat filename and extract metadata"""
|
98
|
+
# filename: "20250421-214005-title-meaning of life"
|
99
|
+
filename = chat_file.stem
|
100
|
+
parts = filename.split("-")
|
101
|
+
title_str_len = 6 # "title-" marker length
|
102
|
+
|
103
|
+
# Check if the filename has the expected format
|
104
|
+
if len(parts) >= 4 and "title" in parts:
|
105
|
+
str_title_index = filename.find("title")
|
106
|
+
if str_title_index == -1:
|
107
|
+
# If "title" is not found, use full filename as the title
|
108
|
+
# Just in case, fallback to use fullname, but this should never happen when `len(parts) >= 4 and "title" in parts`
|
109
|
+
str_title_index = 0
|
110
|
+
title_str_len = 0
|
111
|
+
|
112
|
+
# "20250421-214005-title-meaning of life" ==> "meaning of life"
|
113
|
+
title = filename[str_title_index + title_str_len :]
|
114
|
+
date_ = parts[0]
|
115
|
+
time_ = parts[1]
|
116
|
+
# Format date
|
117
|
+
date_str = f"{date_[:4]}-{date_[4:6]}-{date_[6:]} {time_[:2]}:{time_[2:4]}"
|
118
|
+
|
119
|
+
# Calculate timestamp from date parts
|
120
|
+
try:
|
121
|
+
date_time_str = f"{date_}{time_}"
|
122
|
+
timestamp = int(datetime.strptime(date_time_str, "%Y%m%d%H%M%S").timestamp())
|
123
|
+
except ValueError:
|
124
|
+
timestamp = 0
|
125
|
+
else:
|
126
|
+
# Fallback for files that don't match expected format
|
127
|
+
title = filename
|
128
|
+
date_str = ""
|
129
|
+
timestamp = 0
|
130
|
+
|
131
|
+
# The actual title is stored in the JSON file, so we'll use that when loading
|
132
|
+
# This is just for the initial listing before the file is opened
|
133
|
+
return {
|
134
|
+
"index": index,
|
135
|
+
"path": str(chat_file),
|
136
|
+
"title": title,
|
137
|
+
"date": date_str,
|
138
|
+
"timestamp": timestamp,
|
139
|
+
}
|
140
|
+
|
141
|
+
def _load_chats(self) -> None:
|
142
|
+
"""Load chats from disk into memory"""
|
143
|
+
chat_files = sorted(list(self.chat_dir.glob("*.json")), reverse=True)
|
144
|
+
chats_map: ChatsMap = {"title": {}, "index": {}}
|
145
|
+
|
146
|
+
for i, chat_file in enumerate(chat_files[: self.max_saved_chats]):
|
147
|
+
try:
|
148
|
+
info = self._parse_filename(chat_file, i + 1)
|
149
|
+
chats_map["title"][info["title"]] = info
|
150
|
+
chats_map["index"][i + 1] = info
|
151
|
+
except Exception as e:
|
152
|
+
# Log the error but continue processing other files
|
153
|
+
self.console.print(f"Error parsing session file {chat_file}: {e}", style="dim")
|
154
|
+
continue
|
155
|
+
|
156
|
+
self._chats_map = chats_map
|
157
|
+
|
158
|
+
def list_chats(self) -> List[Dict[str, Any]]:
|
159
|
+
"""List all saved chats and return the chat list"""
|
160
|
+
return list(self.chats_map["index"].values())
|
161
|
+
|
162
|
+
def save_chat(self, history: List[Dict[str, Any]], title: Optional[str] = None) -> str:
|
163
|
+
"""Save chat history to the file system, overwriting existing chats with the same title.
|
164
|
+
|
165
|
+
If no title is provided, the chat will be saved with a default title.
|
166
|
+
The default title is "Chat-{current timestamp}".
|
167
|
+
|
168
|
+
Args:
|
169
|
+
history (List[Dict[str, Any]]): The chat history to save
|
170
|
+
title (Optional[str]): The title of the chat provided by the user
|
171
|
+
|
172
|
+
Returns:
|
173
|
+
str: The title of the saved chat
|
174
|
+
"""
|
175
|
+
history = history or []
|
176
|
+
|
177
|
+
save_title = title or f"Chat-{int(time.time())}"
|
178
|
+
save_title = self.make_chat_title(save_title)
|
179
|
+
|
180
|
+
# Check for existing session with the same title and delete it
|
181
|
+
existing_chat = self.chats_map["title"].get(save_title)
|
182
|
+
if existing_chat:
|
183
|
+
try:
|
184
|
+
existing_path = Path(existing_chat["path"])
|
185
|
+
existing_path.unlink()
|
186
|
+
except OSError as e:
|
187
|
+
self.console.print(
|
188
|
+
f"Warning: Could not delete existing chat file {existing_chat['path']}: {e}",
|
189
|
+
style="dim",
|
190
|
+
)
|
191
|
+
|
192
|
+
timestamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S")
|
193
|
+
filename = f"{timestamp}-title-{save_title}.json"
|
194
|
+
filepath = self.chat_dir / filename
|
195
|
+
|
196
|
+
try:
|
197
|
+
with open(filepath, "w", encoding="utf-8") as f:
|
198
|
+
json.dump({"history": history, "title": save_title}, f, ensure_ascii=False, indent=2)
|
199
|
+
# Force refresh the chat list after saving
|
200
|
+
self.refresh_chats()
|
201
|
+
return save_title
|
202
|
+
except Exception as e:
|
203
|
+
self.console.print(f"Error saving chat '{save_title}': {e}", style="dim")
|
204
|
+
return ""
|
205
|
+
|
206
|
+
def _load_chat_data(self, chat_info: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
207
|
+
"""Common method to load chat data from a chat info dict"""
|
208
|
+
if not chat_info:
|
209
|
+
return {}
|
210
|
+
|
211
|
+
try:
|
212
|
+
chat_file = Path(chat_info["path"])
|
213
|
+
with open(chat_file, "r", encoding="utf-8") as f:
|
214
|
+
chat_data = json.load(f)
|
215
|
+
|
216
|
+
return {
|
217
|
+
"title": chat_data.get("title", chat_info["title"]),
|
218
|
+
"timestamp": chat_info["timestamp"],
|
219
|
+
"history": chat_data.get("history", []),
|
220
|
+
}
|
221
|
+
except FileNotFoundError:
|
222
|
+
self.console.print(f"Chat file not found: {chat_info['path']}", style="dim")
|
223
|
+
return {}
|
224
|
+
except json.JSONDecodeError as e:
|
225
|
+
self.console.print(f"Invalid JSON in chat file {chat_info['path']}: {e}", style="dim")
|
226
|
+
return {}
|
227
|
+
except Exception as e:
|
228
|
+
self.console.print(f"Error loading chat from {chat_info['path']}: {e}", style="dim")
|
229
|
+
return {}
|
230
|
+
|
231
|
+
def load_chat_by_index(self, index: int) -> Dict[str, Any]:
|
232
|
+
"""Load a chat by index and return the chat data"""
|
233
|
+
if not self.validate_chat_index(index):
|
234
|
+
return {}
|
235
|
+
chat_info = self.chats_map.get("index", {}).get(index)
|
236
|
+
return self._load_chat_data(chat_info)
|
237
|
+
|
238
|
+
def load_chat_by_title(self, title: str) -> Dict[str, Any]:
|
239
|
+
"""Load a chat by title and return the chat data"""
|
240
|
+
chat_info = self.chats_map.get("title", {}).get(title)
|
241
|
+
return self._load_chat_data(chat_info)
|
242
|
+
|
243
|
+
def delete_chat(self, index: int) -> bool:
|
244
|
+
"""Delete a chat by index and return success status"""
|
245
|
+
if not self.validate_chat_index(index):
|
246
|
+
return False
|
247
|
+
|
248
|
+
chat_info = self.chats_map["index"].get(index)
|
249
|
+
if not chat_info:
|
250
|
+
return False
|
251
|
+
|
252
|
+
try:
|
253
|
+
chat_file = Path(chat_info["path"])
|
254
|
+
chat_file.unlink()
|
255
|
+
# Force refresh the chat list
|
256
|
+
self.refresh_chats()
|
257
|
+
return True
|
258
|
+
except FileNotFoundError:
|
259
|
+
self.console.print(f"Chat file not found: {chat_info['path']}", style="dim")
|
260
|
+
return False
|
261
|
+
except Exception as e:
|
262
|
+
self.console.print(f"Error deleting chat {index}: {e}", style="dim")
|
263
|
+
return False
|