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 CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "yaicli"
3
- version = "0.0.19"
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 = ["bump2version>=1.0.1", "pytest>=8.3.5", "ruff>=0.11.2"]
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.py", "pyproject.toml"]
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