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.
- {commandchat-0.0.12 → commandchat-0.0.14}/PKG-INFO +4 -1
- {commandchat-0.0.12 → commandchat-0.0.14}/commandchat.egg-info/PKG-INFO +4 -1
- {commandchat-0.0.12 → commandchat-0.0.14}/commandchat.egg-info/SOURCES.txt +9 -0
- {commandchat-0.0.12 → commandchat-0.0.14}/commandchat.egg-info/requires.txt +3 -0
- commandchat-0.0.14/occ/CommandChat.py +418 -0
- commandchat-0.0.14/occ/command/__main__.py +43 -0
- commandchat-0.0.14/occ/command/commands/__init__.py +2 -0
- commandchat-0.0.14/occ/command/commands/chat.py +143 -0
- commandchat-0.0.14/occ/command/commands/image.py +30 -0
- commandchat-0.0.14/occ/command/commands/profile.py +112 -0
- commandchat-0.0.14/occ/command/commands/prompt.py +226 -0
- commandchat-0.0.14/occ/command/interactive/__init__.py +2 -0
- commandchat-0.0.14/occ/command/interactive/profile_menu.py +91 -0
- commandchat-0.0.14/occ/command/interactive/prompt_menu.py +176 -0
- commandchat-0.0.14/occ/commons/config.py +167 -0
- commandchat-0.0.14/occ/commons/prompts.py +146 -0
- commandchat-0.0.14/occ/configuration/profile_config.py +296 -0
- {commandchat-0.0.12 → commandchat-0.0.14}/pyproject.toml +4 -1
- commandchat-0.0.12/occ/CommandChat.py +0 -209
- commandchat-0.0.12/occ/command/__main__.py +0 -114
- commandchat-0.0.12/occ/commons/config.py +0 -99
- commandchat-0.0.12/occ/configuration/profile_config.py +0 -46
- {commandchat-0.0.12 → commandchat-0.0.14}/LICENSE +0 -0
- {commandchat-0.0.12 → commandchat-0.0.14}/README.md +0 -0
- {commandchat-0.0.12 → commandchat-0.0.14}/commandchat.egg-info/dependency_links.txt +0 -0
- {commandchat-0.0.12 → commandchat-0.0.14}/commandchat.egg-info/entry_points.txt +0 -0
- {commandchat-0.0.12 → commandchat-0.0.14}/commandchat.egg-info/top_level.txt +0 -0
- {commandchat-0.0.12 → commandchat-0.0.14}/occ/ConvertLogToMarkDown.py +0 -0
- {commandchat-0.0.12 → commandchat-0.0.14}/occ/__init__.py +0 -0
- {commandchat-0.0.12 → commandchat-0.0.14}/occ/command/__init__.py +0 -0
- {commandchat-0.0.12 → commandchat-0.0.14}/occ/commons/__init__.py +0 -0
- {commandchat-0.0.12 → commandchat-0.0.14}/occ/configuration/__init__.py +0 -0
- {commandchat-0.0.12 → commandchat-0.0.14}/occ/utils/CommonUtil.py +0 -0
- {commandchat-0.0.12 → commandchat-0.0.14}/occ/utils/__init__.py +0 -0
- {commandchat-0.0.12 → commandchat-0.0.14}/occ/utils/logger.py +0 -0
- {commandchat-0.0.12 → commandchat-0.0.14}/setup.cfg +0 -0
- {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.
|
|
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.
|
|
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
|
|
@@ -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,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
|
+
|