commandchat 0.0.12__tar.gz → 0.0.14__tar.gz

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.
Files changed (37) hide show
  1. {commandchat-0.0.12 → commandchat-0.0.14}/PKG-INFO +4 -1
  2. {commandchat-0.0.12 → commandchat-0.0.14}/commandchat.egg-info/PKG-INFO +4 -1
  3. {commandchat-0.0.12 → commandchat-0.0.14}/commandchat.egg-info/SOURCES.txt +9 -0
  4. {commandchat-0.0.12 → commandchat-0.0.14}/commandchat.egg-info/requires.txt +3 -0
  5. commandchat-0.0.14/occ/CommandChat.py +418 -0
  6. commandchat-0.0.14/occ/command/__main__.py +43 -0
  7. commandchat-0.0.14/occ/command/commands/__init__.py +2 -0
  8. commandchat-0.0.14/occ/command/commands/chat.py +143 -0
  9. commandchat-0.0.14/occ/command/commands/image.py +30 -0
  10. commandchat-0.0.14/occ/command/commands/profile.py +112 -0
  11. commandchat-0.0.14/occ/command/commands/prompt.py +226 -0
  12. commandchat-0.0.14/occ/command/interactive/__init__.py +2 -0
  13. commandchat-0.0.14/occ/command/interactive/profile_menu.py +91 -0
  14. commandchat-0.0.14/occ/command/interactive/prompt_menu.py +176 -0
  15. commandchat-0.0.14/occ/commons/config.py +167 -0
  16. commandchat-0.0.14/occ/commons/prompts.py +146 -0
  17. commandchat-0.0.14/occ/configuration/profile_config.py +296 -0
  18. {commandchat-0.0.12 → commandchat-0.0.14}/pyproject.toml +4 -1
  19. commandchat-0.0.12/occ/CommandChat.py +0 -209
  20. commandchat-0.0.12/occ/command/__main__.py +0 -114
  21. commandchat-0.0.12/occ/commons/config.py +0 -99
  22. commandchat-0.0.12/occ/configuration/profile_config.py +0 -46
  23. {commandchat-0.0.12 → commandchat-0.0.14}/LICENSE +0 -0
  24. {commandchat-0.0.12 → commandchat-0.0.14}/README.md +0 -0
  25. {commandchat-0.0.12 → commandchat-0.0.14}/commandchat.egg-info/dependency_links.txt +0 -0
  26. {commandchat-0.0.12 → commandchat-0.0.14}/commandchat.egg-info/entry_points.txt +0 -0
  27. {commandchat-0.0.12 → commandchat-0.0.14}/commandchat.egg-info/top_level.txt +0 -0
  28. {commandchat-0.0.12 → commandchat-0.0.14}/occ/ConvertLogToMarkDown.py +0 -0
  29. {commandchat-0.0.12 → commandchat-0.0.14}/occ/__init__.py +0 -0
  30. {commandchat-0.0.12 → commandchat-0.0.14}/occ/command/__init__.py +0 -0
  31. {commandchat-0.0.12 → commandchat-0.0.14}/occ/commons/__init__.py +0 -0
  32. {commandchat-0.0.12 → commandchat-0.0.14}/occ/configuration/__init__.py +0 -0
  33. {commandchat-0.0.12 → commandchat-0.0.14}/occ/utils/CommonUtil.py +0 -0
  34. {commandchat-0.0.12 → commandchat-0.0.14}/occ/utils/__init__.py +0 -0
  35. {commandchat-0.0.12 → commandchat-0.0.14}/occ/utils/logger.py +0 -0
  36. {commandchat-0.0.12 → commandchat-0.0.14}/setup.cfg +0 -0
  37. {commandchat-0.0.12 → commandchat-0.0.14}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: commandchat
3
- Version: 0.0.12
3
+ Version: 0.0.14
4
4
  Summary: use command to chat with openai models
5
5
  Home-page: https://github.com/
6
6
  Author: xoto
@@ -17,7 +17,10 @@ Requires-Dist: click
17
17
  Requires-Dist: Image
18
18
  Requires-Dist: openai
19
19
  Requires-Dist: prompt_toolkit
20
+ Requires-Dist: pyperclip
20
21
  Requires-Dist: rich
22
+ Requires-Dist: requests
23
+ Requires-Dist: questionary
21
24
  Dynamic: author
22
25
  Dynamic: home-page
23
26
  Dynamic: license-file
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: commandchat
3
- Version: 0.0.12
3
+ Version: 0.0.14
4
4
  Summary: use command to chat with openai models
5
5
  Home-page: https://github.com/
6
6
  Author: xoto
@@ -17,7 +17,10 @@ Requires-Dist: click
17
17
  Requires-Dist: Image
18
18
  Requires-Dist: openai
19
19
  Requires-Dist: prompt_toolkit
20
+ Requires-Dist: pyperclip
20
21
  Requires-Dist: rich
22
+ Requires-Dist: requests
23
+ Requires-Dist: questionary
21
24
  Dynamic: author
22
25
  Dynamic: home-page
23
26
  Dynamic: license-file
@@ -13,8 +13,17 @@ occ/ConvertLogToMarkDown.py
13
13
  occ/__init__.py
14
14
  occ/command/__init__.py
15
15
  occ/command/__main__.py
16
+ occ/command/commands/__init__.py
17
+ occ/command/commands/chat.py
18
+ occ/command/commands/image.py
19
+ occ/command/commands/profile.py
20
+ occ/command/commands/prompt.py
21
+ occ/command/interactive/__init__.py
22
+ occ/command/interactive/profile_menu.py
23
+ occ/command/interactive/prompt_menu.py
16
24
  occ/commons/__init__.py
17
25
  occ/commons/config.py
26
+ occ/commons/prompts.py
18
27
  occ/configuration/__init__.py
19
28
  occ/configuration/profile_config.py
20
29
  occ/utils/CommonUtil.py
@@ -2,4 +2,7 @@ click
2
2
  Image
3
3
  openai
4
4
  prompt_toolkit
5
+ pyperclip
5
6
  rich
7
+ requests
8
+ questionary
@@ -0,0 +1,418 @@
1
+ import asyncio
2
+ import json
3
+ import os
4
+ import sys
5
+ import time
6
+ from pathlib import Path
7
+ from typing import AsyncGenerator, Optional
8
+ from dataclasses import dataclass
9
+
10
+ from openai import AzureOpenAI
11
+ from openai import OpenAI
12
+ from openai.types.chat.chat_completion_chunk import Choice
13
+ from prompt_toolkit import print_formatted_text, HTML, Application
14
+ from prompt_toolkit.clipboard.pyperclip import PyperclipClipboard
15
+ from prompt_toolkit.layout import Layout, HSplit
16
+ from prompt_toolkit.widgets import TextArea
17
+ from rich.console import Console
18
+ from rich.live import Live
19
+ from rich.markdown import Markdown
20
+
21
+ from occ.commons.config import get_env
22
+
23
+
24
+ @dataclass
25
+ class StreamChunk:
26
+ """Unified streaming chunk for both chat.completions and responses API"""
27
+ content: Optional[str] = None
28
+ role: Optional[str] = None
29
+ finish_reason: Optional[str] = None
30
+ event_type: Optional[str] = None
31
+
32
+
33
+ DEFAULT_CHAT_LOG_ID = "chat-1"
34
+ DEFAULT_PROFILE = "default"
35
+ USER_COLOR = "ansiyellow"
36
+ ASSISTANT_COLOR = "ansicyan"
37
+ TYPING_DELAY = 0.01 # 打字速度(秒/字符)
38
+ SEPARATOR = "─" * 30
39
+
40
+
41
+ def get_home_path():
42
+ homedir = os.environ.get('HOME', None)
43
+ if os.name == 'nt':
44
+ homedir = os.path.expanduser('~')
45
+ return homedir
46
+
47
+
48
+ clip = PyperclipClipboard()
49
+ console = Console()
50
+
51
+ def print_formatted(content: str, live: Live):
52
+ md = Markdown(content)
53
+ live.update(md)
54
+ sys.stdout.flush()
55
+
56
+
57
+ class CommandChat:
58
+ partial_text = []
59
+ role = None
60
+
61
+ def __init__(self, profile=None, chat_log_id=None, model=None, system_message=None):
62
+ now = time.strftime("%Y%m%d", time.localtime())
63
+ self.profile = profile or DEFAULT_PROFILE
64
+ self.api_server_type = get_env(self.profile, "api_server_type")
65
+
66
+ if not self.api_server_type:
67
+ raise ValueError(f"Profile '{self.profile}' is not configured. Please run 'occ configure -p {self.profile}' first.")
68
+
69
+ self.limit_history = int(get_env(self.profile, "limit_history") or 4)
70
+ self.chat_log_id = chat_log_id or DEFAULT_CHAT_LOG_ID
71
+ self.folder_path = os.path.join(get_home_path(), ".occ", self.profile)
72
+ self.image_folder_path = os.path.join(self.folder_path, "images")
73
+ self.file_name = os.path.join(self.folder_path, f"{self.chat_log_id}.log")
74
+ os.makedirs(self.folder_path, exist_ok=True)
75
+ os.makedirs(self.image_folder_path, exist_ok=True)
76
+ self.model = model
77
+ self.current_model_config = None
78
+
79
+ if not os.path.exists(self.file_name):
80
+ open(self.file_name, 'w').close()
81
+ self.history_path = Path(self.folder_path, self.chat_log_id) / f"md_history_{now}.md"
82
+ # Load messages and filter out invalid ones (with null role)
83
+ self.messages = []
84
+ for line in open(self.file_name):
85
+ line = line.strip()
86
+ if line:
87
+ try:
88
+ msg = json.loads(line)
89
+ # Ensure role is valid
90
+ if msg.get('role') in ['system', 'assistant', 'user', 'function', 'tool', 'developer']:
91
+ self.messages.append(msg)
92
+ except json.JSONDecodeError:
93
+ continue
94
+
95
+ # Add system message if provided
96
+ if system_message:
97
+ # Check if there's already a system message at the beginning
98
+ has_system = len(self.messages) > 0 and self.messages[0].get('role') == 'system'
99
+ if has_system:
100
+ # Replace existing system message
101
+ self.messages[0] = {"role": "system", "content": system_message}
102
+ else:
103
+ # Insert system message at the beginning
104
+ self.messages.insert(0, {"role": "system", "content": system_message})
105
+
106
+ # Initialize client based on API server type
107
+ if self.api_server_type == "azure-openai":
108
+ # For Azure OpenAI, we'll initialize client per model in chat method
109
+ self.client = None
110
+ elif self.api_server_type == "openai":
111
+ self.api_key = get_env(self.profile, "api_key")
112
+ self.api_base = get_env(self.profile, "api_base_url")
113
+
114
+ if not self.api_key:
115
+ raise ValueError(f"API key not configured for profile '{self.profile}'. Please run 'occ configure -p {self.profile}' first.")
116
+
117
+ os.environ.setdefault("OPENAI_API_KEY", self.api_key)
118
+ os.environ.setdefault("OPENAI_BASE_URL", self.api_base)
119
+ self.client = OpenAI()
120
+ else:
121
+ # Fallback for legacy "azure" type
122
+ self.api_key = get_env(self.profile, "api_key")
123
+ self.api_base = get_env(self.profile, "api_base_url")
124
+
125
+ if not self.api_key:
126
+ raise ValueError(f"API key not configured for profile '{self.profile}'. Please run 'occ configure -p {self.profile}' first.")
127
+
128
+ os.environ.setdefault("OPENAI_API_KEY", self.api_key)
129
+ os.environ.setdefault("OPENAI_BASE_URL", self.api_base)
130
+ if "azure" == self.api_server_type:
131
+ self.client = AzureOpenAI(api_key=self.api_key,
132
+ api_version=get_env(self.profile, "api_version"),
133
+ azure_endpoint=self.api_base)
134
+ else:
135
+ self.client = OpenAI()
136
+
137
+ def _get_azure_client(self, model):
138
+ """Get Azure OpenAI client for a specific model"""
139
+ from occ.commons.config import get_model_config
140
+
141
+ model_config = get_model_config(self.profile, model)
142
+ if not model_config:
143
+ raise ValueError(f"Model '{model}' not found in profile '{self.profile}'")
144
+
145
+ self.current_model_config = model_config
146
+ return AzureOpenAI(
147
+ api_key=model_config['api_key'],
148
+ api_version=model_config['api_version'],
149
+ azure_endpoint=model_config['api_base_url']
150
+ )
151
+
152
+ def _is_completions_model(self, model):
153
+ """Check if model uses completions API instead of chat completions API"""
154
+ # Azure OpenAI behavior is different from standard OpenAI
155
+ # For Azure, most models (including codex) use chat completions API
156
+ if self.api_server_type in ["azure-openai", "azure"]:
157
+ # Only specific instruct models use completions API in Azure
158
+ azure_completions_models = [
159
+ 'gpt-35-turbo-instruct',
160
+ 'text-davinci-003',
161
+ 'text-davinci-002',
162
+ ]
163
+ return model in azure_completions_models
164
+
165
+ # For standard OpenAI
166
+ completions_models = [
167
+ 'gpt-35-turbo-instruct',
168
+ 'text-davinci-003',
169
+ 'text-davinci-002',
170
+ 'text-curie-001',
171
+ 'text-babbage-001',
172
+ 'text-ada-001',
173
+ ]
174
+
175
+ # Check exact match
176
+ if model in completions_models:
177
+ return True
178
+
179
+ # Check if model contains 'instruct' or 'davinci' (but not codex for standard OpenAI)
180
+ # Note: Codex models behavior varies, so we only check by keyword for OpenAI
181
+ model_lower = model.lower()
182
+ if any(keyword in model_lower for keyword in ['instruct', 'davinci']):
183
+ return True
184
+
185
+ return False
186
+
187
+ def image_create(self, description, size, num):
188
+ raise NotImplementedError
189
+
190
+ def chat(self, message, model):
191
+ # Initialize Azure client if needed
192
+ if self.api_server_type == "azure-openai":
193
+ self.client = self._get_azure_client(model)
194
+
195
+ print_formatted_text(HTML(f"<{ASSISTANT_COLOR}>🤖 Assistant: </{ASSISTANT_COLOR}>"))
196
+
197
+ # Check if model requires completions API instead of chat completions
198
+ # Models like gpt-35-turbo-instruct, text-davinci-003, codex variants use completions API
199
+ if self._is_completions_model(model):
200
+ self.completions(message, model)
201
+ else:
202
+ self.chat_completions(message, model)
203
+
204
+ def completions(self, message, model):
205
+ stream = self.client.completions.create(
206
+ model=model,
207
+ prompt=message,
208
+ max_tokens=4090 - len(message),
209
+ temperature=0.1,
210
+ stream=True
211
+ )
212
+ completion_text = ''
213
+ with Live(console=console, refresh_per_second=8) as live:
214
+ for completion in stream:
215
+ for choice in completion.choices:
216
+ completion_text += choice.text
217
+ print_formatted(completion_text, live)
218
+ clip.set_text(completion_text)
219
+ print("\n")
220
+
221
+ def chat_completions(self, message, model):
222
+ message = {"role": "user", "content": message}
223
+ self.messages.append(message)
224
+ self.model = model
225
+ # Reset role for this chat session
226
+ self.role = None
227
+ loop = asyncio.new_event_loop()
228
+ asyncio.set_event_loop(loop)
229
+ try:
230
+ final_text = loop.run_until_complete(self.print_streaming(self.async_stream))
231
+ except KeyboardInterrupt:
232
+ final_text = None
233
+ finally:
234
+ loop.close()
235
+
236
+ if final_text is None:
237
+ console.print("\n[bold red]Stream was interrupted or user exited (no final output).[/bold red]")
238
+ sys.exit(0)
239
+ md = Markdown(final_text)
240
+ self.append_to_history(final_text)
241
+ console.print(md)
242
+ clip.set_text(final_text)
243
+ # Ensure role is always set (default to 'assistant' if not returned by model)
244
+ response_role = self.role if self.role else "assistant"
245
+ self.record_chat_logs(message, {"role": response_role, "content": final_text.replace("\n\n", "")})
246
+
247
+ async def async_stream(self) -> AsyncGenerator[StreamChunk, None]:
248
+ """
249
+ Unified streaming generator that returns StreamChunk objects.
250
+ Handles both responses API (o1, codex) and chat.completions API (gpt-4, etc.)
251
+ """
252
+ # Detect which API to use
253
+ model_lower = self.model.lower()
254
+ use_responses_api = (
255
+ self.model.startswith('o1-') or
256
+ self.model.startswith('o1') or
257
+ 'codex' in model_lower
258
+ )
259
+
260
+ if use_responses_api:
261
+ # Use responses API for o1 and codex models
262
+ response = self.client.responses.create(
263
+ model=self.model,
264
+ input=self.messages,
265
+ stream=True
266
+ )
267
+
268
+ # Handle responses API streaming with event types
269
+ for event in response:
270
+ if hasattr(event, "type"):
271
+ match event.type:
272
+ case "response.output_text.delta":
273
+ # Incremental text output
274
+ yield StreamChunk(
275
+ content=event.delta,
276
+ event_type=event.type
277
+ )
278
+ await asyncio.sleep(0.01)
279
+
280
+ case "response.output_text.done":
281
+ # Text output completed
282
+ yield StreamChunk(
283
+ finish_reason="stop",
284
+ event_type=event.type
285
+ )
286
+
287
+ case "response.output_item.done":
288
+ # Item completed - only extract role if status is completed
289
+ if hasattr(event, "item"):
290
+ if hasattr(event.item, "status") and event.item.status == 'completed':
291
+ # Only get role when status is completed
292
+ if hasattr(event.item, "role"):
293
+ yield StreamChunk(
294
+ role=event.item.role,
295
+ finish_reason="completed",
296
+ event_type=event.type
297
+ )
298
+ else:
299
+ yield StreamChunk(
300
+ finish_reason="completed",
301
+ event_type=event.type
302
+ )
303
+
304
+ case _:
305
+ # Other event types, just pass through
306
+ pass
307
+ else:
308
+ # Use chat.completions API for regular models
309
+ params = {
310
+ 'model': self.model,
311
+ 'messages': self.messages,
312
+ 'temperature': 1,
313
+ 'top_p': 1,
314
+ 'frequency_penalty': 0.0,
315
+ 'stream': True
316
+ }
317
+
318
+ response = self.client.chat.completions.create(**params)
319
+
320
+ for chunk in response:
321
+ if chunk.choices is None or len(chunk.choices) == 0:
322
+ continue
323
+
324
+ choice = chunk.choices[0]
325
+ delta = choice.delta
326
+
327
+ # Convert to unified StreamChunk format
328
+ yield StreamChunk(
329
+ content=delta.content if hasattr(delta, 'content') else None,
330
+ role=delta.role if hasattr(delta, 'role') else None,
331
+ finish_reason=choice.finish_reason
332
+ )
333
+ await asyncio.sleep(0.01)
334
+
335
+ async def print_streaming(self, async_stream):
336
+ self.partial_text = []
337
+ text_area = TextArea(
338
+ text="",
339
+ wrap_lines=True,
340
+ read_only=True,
341
+ )
342
+ app = Application(layout=Layout(HSplit([text_area])), full_screen=False)
343
+
344
+ async def producer():
345
+ """
346
+ Process streaming chunks from either API in a unified way.
347
+ Handles StreamChunk objects regardless of source API.
348
+ """
349
+ try:
350
+ async for chunk in async_stream():
351
+ # Handle finish conditions
352
+ if chunk.finish_reason in ("stop", "completed"):
353
+ # Extract role before finishing (for responses API)
354
+ if chunk.role and self.role is None:
355
+ self.role = chunk.role
356
+ break
357
+
358
+ # Extract role if provided (usually first chunk for chat.completions)
359
+ if chunk.role and self.role is None:
360
+ self.role = chunk.role
361
+
362
+ # Append content if available
363
+ if chunk.content:
364
+ self.partial_text.append(chunk.content)
365
+ joined = "".join(self.partial_text)
366
+ text_area.text = joined
367
+ text_area.buffer.cursor_position = len(text_area.buffer.text)
368
+ app.invalidate()
369
+
370
+ # Clear text area and exit
371
+ text_area.text = ""
372
+ app.invalidate()
373
+ app.exit()
374
+ except asyncio.CancelledError:
375
+ app.exit(result=None)
376
+ except Exception as e:
377
+ self.partial_text.append(f"\n\n[ERROR] {e}")
378
+ app.exit(result="".join(self.partial_text))
379
+
380
+ app.create_background_task(producer())
381
+ await app.run_async()
382
+ return "".join(self.partial_text)
383
+
384
+ def record_chat_logs(self, content, completion_text):
385
+ with open(self.file_name, 'r+') as f:
386
+ lines = f.readlines()
387
+ if len(lines) >= self.limit_history:
388
+ limit_history_ = (len(lines) + 2 - self.limit_history)
389
+ with open(os.path.join(self.folder_path, self.chat_log_id + '_history.log'), 'a+') as hf:
390
+ hf.writelines("\n")
391
+ hf.writelines(lines[:limit_history_])
392
+ lines = lines[limit_history_:]
393
+ if len(lines) == 0:
394
+ lines.append('{}\n{}'.format(json.dumps(content, ensure_ascii=False),
395
+ json.dumps(completion_text, ensure_ascii=False)))
396
+ else:
397
+ lines.append('\n{}\n{}'.format(json.dumps(content, ensure_ascii=False),
398
+ json.dumps(completion_text, ensure_ascii=False)))
399
+ f.seek(0)
400
+ f.truncate()
401
+ f.writelines(lines)
402
+
403
+ def append_to_history(self, md_text: str):
404
+ self.history_path.parent.mkdir(parents=True, exist_ok=True)
405
+ # 追加分隔符 + markdown 内容
406
+ with self.history_path.open("a", encoding="utf-8") as f:
407
+ f.write("\n\n---\n\n")
408
+ f.write(md_text)
409
+
410
+ def read_history(self) -> str:
411
+ if not self.history_path.exists():
412
+ return ""
413
+ return self.history_path.read_text(encoding="utf-8")
414
+
415
+
416
+ if __name__ == '__main__':
417
+ command_chat = CommandChat()
418
+ command_chat.chat("帮我写一个python的冒泡排序算法", "o1-mini")
@@ -0,0 +1,43 @@
1
+ """
2
+ OpenAI CommandChat - Command Line Interface
3
+
4
+ Main entry point for the occ CLI tool.
5
+ """
6
+
7
+ from __future__ import absolute_import
8
+
9
+ import importlib.metadata
10
+ import click
11
+
12
+ # Import command modules
13
+ from occ.command.commands.prompt import prompt
14
+ from occ.command.commands.profile import configure
15
+ from occ.command.commands.chat import chat
16
+ from occ.command.commands.image import image
17
+
18
+
19
+ VERSION = importlib.metadata.version("commandchat")
20
+
21
+
22
+ @click.group()
23
+ @click.version_option(version=VERSION, prog_name='openai-commandchat')
24
+ def cli():
25
+ """OpenAI CommandChat - AI-powered command-line chat tool"""
26
+ pass
27
+
28
+
29
+ # Register commands
30
+ cli.add_command(configure)
31
+ cli.add_command(chat)
32
+ cli.add_command(prompt)
33
+ cli.add_command(image)
34
+
35
+
36
+ def main():
37
+ """Main entry point"""
38
+ cli()
39
+
40
+
41
+ if __name__ == '__main__':
42
+ main()
43
+
@@ -0,0 +1,2 @@
1
+ """Command modules for occ CLI"""
2
+
@@ -0,0 +1,143 @@
1
+ """Chat command"""
2
+
3
+ import sys
4
+ import click
5
+ from prompt_toolkit import PromptSession, print_formatted_text, HTML
6
+ from prompt_toolkit.cursor_shapes import ModalCursorShapeConfig
7
+ from prompt_toolkit.styles import style_from_pygments_cls
8
+ from pygments.styles.tango import TangoStyle
9
+
10
+ import occ.utils.logger as logger
11
+ from occ.CommandChat import CommandChat
12
+
13
+
14
+ @click.command()
15
+ @click.argument('message', required=False)
16
+ @click.option('-id', help='Enter chat id, something like context')
17
+ @click.option('--profile', '-p', envvar="OCC_PROFILE", help='Enable profile name')
18
+ @click.option("--model", "-m", envvar="OCC_MODEL", default="o1-mini",
19
+ help="Specify the model to use for this chat session")
20
+ @click.option('--file', '-f', type=click.Path(exists=True), help='The prompt or message is from a file')
21
+ @click.option('--prompt', '-pt', help='Use a predefined prompt template (e.g., translate, improve). Use "occ prompt list" to see available prompts.')
22
+ def chat(message, id, profile, model, file, prompt):
23
+ """Start a chat session with the AI"""
24
+ try:
25
+ import questionary
26
+ from occ.commons import config as cfg
27
+ from questionary import Style
28
+
29
+ custom_style = Style([
30
+ ('qmark', 'fg:#673ab7 bold'),
31
+ ('question', 'bold'),
32
+ ('answer', 'fg:#f44336 bold'),
33
+ ('pointer', 'fg:#673ab7 bold'),
34
+ ('highlighted', 'fg:#673ab7 bold'),
35
+ ])
36
+
37
+ # Use default profile if none specified
38
+ active_profile = profile or "default"
39
+
40
+ # Check if profile exists
41
+ if not cfg.profile_exists(active_profile):
42
+ logger.log_r(f"Profile '{active_profile}' does not exist. Please run 'occ configure' first.")
43
+ return
44
+
45
+ # Check API server type
46
+ api_server_type = cfg.get_env(active_profile, 'api_server_type')
47
+
48
+ # For azure-openai, check for multiple models
49
+ if api_server_type == 'azure-openai':
50
+ available_models = cfg.get_profile_models(active_profile)
51
+
52
+ if not available_models:
53
+ logger.log_r(f"No models configured for profile '{active_profile}'. Please run 'occ configure' first.")
54
+ return
55
+
56
+ # If user didn't specify model and there are multiple models, let them choose
57
+ if not model or model == "o1-mini": # o1-mini is the default, treat as not specified
58
+ if len(available_models) > 1:
59
+ # Interactive mode - let user select model
60
+ if not message and not file and sys.stdin.isatty():
61
+ model = questionary.select(
62
+ "Select a model:",
63
+ choices=available_models,
64
+ style=custom_style
65
+ ).ask()
66
+
67
+ if model is None:
68
+ logger.log_r("Model selection cancelled.")
69
+ return
70
+ else:
71
+ # Non-interactive mode - use first available model
72
+ model = available_models[0]
73
+ logger.log_g(f"Using model: {model}")
74
+ else:
75
+ # Only one model, use it
76
+ model = available_models[0]
77
+ else:
78
+ # User specified a model, verify it exists
79
+ if model not in available_models:
80
+ logger.log_r(f"Model '{model}' not found in profile '{active_profile}'. Available models: {', '.join(available_models)}")
81
+ return
82
+
83
+ # Handle prompt template - command line -pt has highest priority
84
+ system_message = None
85
+ prompt_source = None # Track where the prompt came from
86
+
87
+ if prompt:
88
+ # Command line -pt parameter has highest priority
89
+ from occ.commons.prompts import get_prompt_system_message, list_prompts
90
+ system_message = get_prompt_system_message(prompt)
91
+ if not system_message:
92
+ logger.log_r(f"Prompt '{prompt}' not found. Available prompts:")
93
+ for key, value in list_prompts().items():
94
+ logger.log_g(f" - {key}: {value['description']}")
95
+ return
96
+ prompt_source = "command line"
97
+ logger.log_g(f"Using prompt template: {prompt} (from {prompt_source})")
98
+ else:
99
+ # Check for profile default prompt
100
+ profile_default_prompt = cfg.get_profile_default_prompt(active_profile)
101
+ if profile_default_prompt:
102
+ from occ.commons.prompts import get_prompt_system_message
103
+ system_message = get_prompt_system_message(profile_default_prompt)
104
+ if system_message:
105
+ prompt_source = f"profile '{active_profile}'"
106
+ logger.log_g(f"Using default prompt: {profile_default_prompt} (from {prompt_source})")
107
+
108
+ if file:
109
+ with open(file, 'r') as f:
110
+ message = f.read()
111
+ elif not message and not sys.stdin.isatty():
112
+ message = sys.stdin.read()
113
+ elif not message:
114
+ session = PromptSession(
115
+ show_frame=True,
116
+ style=style_from_pygments_cls(TangoStyle), multiline=True, wrap_lines=True,
117
+ cursor=ModalCursorShapeConfig(),
118
+ )
119
+ while True:
120
+ try:
121
+ message = session.prompt("👤 You: \n")
122
+ if not message:
123
+ continue
124
+ if message.lower() in {"/help", "/Help"}:
125
+ print_formatted_text(HTML(
126
+ "<b>Help Info: \n</b><ansigreen>Type your message and press ESC+Enter or OPT+Enter to send.\n"
127
+ "Use /exit or /quit or /q to leave the chat.\n"
128
+ "Use /help to show this message again.\n"
129
+ "Use -pt <prompt_key> when starting chat to use a prompt template.\n"
130
+ "Run 'occ prompt list' to see available prompt templates.</ansigreen>\n"))
131
+ continue
132
+ if message.lower() in {"/exit", "/quit", "/q"}:
133
+ print_formatted_text(HTML("<ansired>Bye 👋</ansired>"))
134
+ exit(0)
135
+ CommandChat(profile=active_profile, chat_log_id=id, system_message=system_message).chat(message, model)
136
+ print()
137
+ except KeyboardInterrupt:
138
+ print_formatted_text(HTML("<ansired>\n(Interrupted)</ansired>"))
139
+ exit(0)
140
+ CommandChat(profile=active_profile, chat_log_id=id, system_message=system_message).chat(message, model)
141
+ except Exception as e:
142
+ logger.log_g(str(e))
143
+