返回提交历史
Modified
g4f/cli/client.py
+189
-191
XFEstudio/gpt4free
Add -e option to CLI client
adb756ef
代码差异
1 个文件
+189
-191
@@ -7,6 +7,7 @@ import json
7
7
import argparse
8
8
import traceback
9
9
import requests
10
10
11
from pathlib import Path
11
12
from typing import Optional, List, Dict
12
13
from g4f.client import AsyncClient
@@ -17,96 +18,99 @@ from g4f.image import extract_data_uri, is_accepted_format
17
18
from g4f.image.copy_images import get_media_dir
18
19
from g4f.client.helper import filter_markdown
19
20
from g4f.errors import MissingRequirementsError
21
20
22
try:
21
23
from g4f.integration.markitdown import MarkItDown
22
24
has_markitdown = True
23
25
except ImportError:
24
26
has_markitdown = False
27
25
28
from g4f.config import CONFIG_DIR, COOKIES_DIR
26
29
from g4f import debug
27
30
28
31
CONVERSATION_FILE = CONFIG_DIR / "conversation.json"
29
32
33
30
34
class ConversationManager:
31
35
"""Manages conversation history and state."""
32
33
def __init__(self, file_path: Optional[Path] = None, model: Optional[str] = None, provider: Optional[str] = None) -> None:
34
self.file_path: Optional[Path] = file_path
35
self.model: Optional[str] = model
36
self.provider: Optional[str] = provider
37
self.conversation = None
36
def __init__(
37
self,
38
file_path: Optional[Path] = None,
39
model: Optional[str] = None,
40
provider: Optional[str] = None,
41
max_messages: int = 5
42
) -> None:
43
self.file_path = file_path
44
self.model = model
45
self.provider = provider
46
self.max_messages = max_messages
47
self.conversation: Optional[JsonConversation] = None
38
48
self.history: List[Dict[str, str]] = []
39
49
self.data: Dict = {}
40
50
self._load()
41
51
42
52
def _load(self) -> None:
43
"""Load conversation from file."""
44
if self.file_path is None or not self.file_path.is_file():
53
if not self.file_path or not self.file_path.is_file():
45
54
return
46
47
55
try:
48
56
with open(self.file_path, 'r', encoding='utf-8') as f:
49
57
data = json.load(f)
50
self.model = data.get("model") if self.model is None and self.provider is None else self.model
51
self.provider = data.get("provider") if self.provider is None else self.provider
52
if not self.provider:
53
self.provider = None
54
self.data = data.get("data", {})
55
if self.provider and self.data.get(self.provider):
56
self.conversation = JsonConversation(**self.data.get(self.provider))
57
elif not self.provider and self.data:
58
self.conversation = JsonConversation(**self.data)
59
self.history = data.get("items", [])
60
except (json.JSONDecodeError, KeyError) as e:
61
print(f"Error loading conversation: {e}", file=sys.stderr)
58
if self.model is None:
59
self.model = data.get("model")
60
if self.provider is None:
61
self.provider = data.get("provider")
62
self.data = data.get("data", {})
63
if self.provider and self.data.get(self.provider):
64
self.conversation = JsonConversation(**self.data[self.provider])
65
elif not self.provider and self.data:
66
self.conversation = JsonConversation(**self.data)
67
self.history = data.get("items", [])
62
68
except Exception as e:
63
print(f"Unexpected error loading conversation: {e}", file=sys.stderr)
69
print(f"Error loading conversation: {e}", file=sys.stderr)
64
70
65
71
def save(self) -> None:
66
"""Save conversation to file."""
67
if self.file_path is None:
72
if not self.file_path:
68
73
return
69
70
74
try:
75
if self.conversation and self.provider:
76
self.data[self.provider] = self.conversation.get_dict()
77
elif self.conversation:
78
self.data.update(self.conversation.get_dict())
79
payload = {
80
"model": self.model,
81
"provider": self.provider,
82
"data": self.data,
83
"items": self.history
84
}
71
85
with open(self.file_path, 'w', encoding='utf-8') as f:
72
if self.conversation and self.provider:
73
self.data[self.provider] = self.conversation.get_dict()
74
else:
75
self.data = {**self.data, **(self.conversation.get_dict() if self.conversation else {})}
76
json.dump({
77
"model": self.model,
78
"provider": self.provider,
79
"data": self.data,
80
"items": self.history
81
}, f, indent=2, ensure_ascii=False)
86
json.dump(payload, f, indent=2, ensure_ascii=False)
82
87
except Exception as e:
83
88
print(f"Error saving conversation: {e}", file=sys.stderr)
84
89
85
90
def add_message(self, role: str, content: str) -> None:
86
"""Add a message to the conversation."""
87
91
self.history.append({"role": role, "content": content})
88
92
89
93
def get_messages(self) -> List[Dict[str, str]]:
90
"""Get all messages in the conversation."""
91
return self.history
94
result = []
95
for item in self.history[-self.max_messages:]:
96
if item.get("role") in ["user", "system"] or result:
97
result.append(item)
98
return result
92
99
93
100
async def stream_response(
94
101
client: AsyncClient,
95
input_text: str,
102
input_text,
96
103
conversation: ConversationManager,
97
104
output_file: Optional[Path] = None,
98
105
instructions: Optional[str] = None
99
106
) -> None:
100
"""Stream the response from the API and update conversation."""
101
107
media = None
102
108
if isinstance(input_text, tuple):
103
109
media, input_text = input_text
104
110
105
111
if instructions:
106
# Add system instructions to conversation if provided
107
112
conversation.add_message("system", instructions)
108
113
109
# Add user message to conversation
110
114
conversation.add_message("user", input_text)
111
115
112
116
create_args = {
@@ -117,214 +121,208 @@ async def stream_response(
117
121
"conversation": conversation.conversation,
118
122
}
119
123
120
response_content = []
124
response_tokens = []
121
125
last_chunk = None
122
126
async for chunk in client.chat.completions.create(**create_args):
123
127
last_chunk = chunk
124
token = chunk.choices[0].delta.content
125
if not token:
128
delta = chunk.choices[0].delta.content
129
if not delta:
126
130
continue
127
if is_content(token):
128
response_content.append(token)
129
try:
130
print(token, end="", flush=True)
131
except (IOError, BrokenPipeError) as e:
132
print(f"\nError writing to stdout: {e}", file=sys.stderr)
133
break
134
print("\n", end="")
131
if is_content(delta):
132
response_tokens.append(delta)
133
print(delta, end="", flush=True)
134
print()
135
136
if last_chunk and hasattr(last_chunk, "conversation"):
137
conversation.conversation = last_chunk.conversation
138
139
media_chunk = next((t for t in response_tokens if isinstance(t, MediaResponse)), None)
140
text_response = ""
141
if media_chunk:
142
text_response = response_tokens[0] if len(response_tokens) == 1 else "".join(str(t) for t in response_tokens)
143
else:
144
text_response = "".join(str(t) for t in response_tokens)
135
145
136
conversation.conversation = getattr(last_chunk, "conversation", conversation.conversation)
137
media_content = next(iter([chunk for chunk in response_content if isinstance(chunk, MediaResponse)]), None)
138
response_content = response_content[0] if len(response_content) == 1 else "".join([str(chunk) for chunk in response_content])
139
146
if output_file:
140
if save_content(response_content, media_content, output_file):
141
print(f"\nResponse saved to {output_file}")
147
if save_content(text_response, media_chunk, str(output_file)):
148
print(f"\n→ Response saved to '{output_file}'")
142
149
143
if response_content:
144
# Add assistant message to conversation
145
conversation.add_message("assistant", str(response_content))
150
if text_response:
151
conversation.add_message("assistant", text_response)
146
152
else:
147
raise RuntimeError("No response received from the API")
153
raise RuntimeError("No response received")
148
154
149
def save_content(content, media_content: Optional[MediaResponse], filepath: str, allowed_types = None):
150
if media_content is not None:
151
for url in media_content.urls:
152
if url.startswith("http://") or url.startswith("https://"):
155
156
def save_content(content, media: Optional[MediaResponse], filepath: str, allowed_types=None) -> bool:
157
if media:
158
for url in media.urls:
159
if url.startswith(("http://", "https://")):
153
160
try:
154
response = requests.get(url, cookies=media_content.get("cookies"), headers=media_content.get("headers"))
155
if response.status_code == 200:
161
resp = requests.get(url, cookies=media.get("cookies"), headers=media.get("headers"))
162
if resp.status_code == 200:
156
163
with open(filepath, "wb") as f:
157
f.write(response.content)
164
f.write(resp.content)
158
165
return True
159
except requests.RequestException as e:
160
print(f"Error downloading {url}: {e}", file=sys.stderr)
166
except Exception as e:
167
print(f"Error fetching media '{url}': {e}", file=sys.stderr)
161
168
return False
162
169
else:
163
170
content = url
164
171
break
165
elif hasattr(content, "data"):
172
if hasattr(content, "data"):
166
173
content = content.data
167
174
if not content:
168
175
print("\nNo content to save.", file=sys.stderr)
169
176
return False
170
if content.startswith("/media/"):
171
os.rename(content.replace("/media", get_media_dir()).split("?")[0], filepath)
172
return True
173
elif content.startswith("data:"):
177
if content.startswith("data:"):
174
178
with open(filepath, "wb") as f:
175
179
f.write(extract_data_uri(content))
176
180
return True
177
content = filter_markdown(content, allowed_types)
178
if content:
179
with open(filepath, "w") as f:
180
f.write(content)
181
return True
182
else:
183
print("\nNo valid content to save.", file=sys.stderr)
184
return False
181
if content.startswith("/media/"):
182
src = content.replace("/media", get_media_dir()).split("?")[0]
183
os.rename(src, filepath)
184
return True
185
filtered = filter_markdown(content, allowed_types)
186
if filtered:
187
with open(filepath, "w", encoding="utf-8") as f:
188
f.write(filtered)
189
return True
190
print("\nUnable to save content.", file=sys.stderr)
191
return False
185
192
186
193
def get_parser():
187
"""Parse command line arguments."""
188
194
parser = argparse.ArgumentParser(
189
195
description="G4F CLI client with conversation history",
190
196
formatter_class=argparse.ArgumentDefaultsHelpFormatter
191
197
)
192
parser.add_argument("--debug", "-d", action="store_true", help="Enable verbose logging.")
198
parser.add_argument('-d', '--debug', action='store_true', help="Verbose debug")
199
parser.add_argument('-p', '--provider', default=None,
200
help=f"Provider to use: {', '.join(k for k,v in ProviderUtils.convert.items() if v.working)}")
201
parser.add_argument('-m', '--model', help="Model name")
202
parser.add_argument('-O', '--output', type=Path,
203
help="Save assistant output to FILE (text or media)")
204
parser.add_argument('-i', '--instructions', help="System instructions")
205
parser.add_argument('-c', '--cookies-dir', type=Path, default=COOKIES_DIR,
206
help="Cookies/HAR directory")
207
parser.add_argument('--conversation-file', type=Path, default=CONVERSATION_FILE,
208
help="Conversation JSON")
209
parser.add_argument('-C', '--clear-history', action='store_true', help="Wipe history")
210
parser.add_argument('-N', '--no-config', action='store_true', help="Skip loading history")
211
# <-- updated -e/--edit to take an optional filename
193
212
parser.add_argument(
194
'-p', '--provider',
195
default=None,
196
help=f"Provider to use. Available: {', '.join([key for key, provider in ProviderUtils.convert.items() if provider.working])}."
197
)
198
parser.add_argument(
199
'-m', '--model',
200
help="Model to use (provider-specific)"
201
)
202
parser.add_argument(
203
'-O', '--output',
204
default=None,
213
'-e', '--edit',
205
214
type=Path,
206
215
metavar='FILE',
207
help="Output file to save the response file."
216
help="If FILE given: send its contents and overwrite it with AI's reply."
208
217
)
209
parser.add_argument(
210
'-i', '--instructions',
211
default=None,
212
help="Add custom system instructions."
213
)
214
parser.add_argument(
215
'-c', '--cookies-dir',
216
type=Path,
217
default=COOKIES_DIR,
218
help="Directory containing cookies for authenticated providers"
219
)
220
parser.add_argument(
221
'--conversation-file',
222
type=Path,
223
metavar='FILE',
224
default=CONVERSATION_FILE,
225
help="File to store/load conversation state"
226
)
227
parser.add_argument(
228
'-C', '--clear-history',
229
action='store_true',
230
help="Clear conversation history before starting"
231
)
232
parser.add_argument(
233
'-N', '--no-config',
234
action='store_true',
235
help="Do not load configuration from conversation file"
236
)
237
parser.add_argument(
238
'input',
239
nargs='*',
240
help="Input urls, files and text (or read from stdin)"
241
)
242
218
parser.add_argument('--max-messages', type=int, default=5,
219
help="Max user+assistant turns in context")
220
parser.add_argument('input', nargs='*',
221
help="URLs, image paths or plain text")
243
222
return parser
244
223
245
async def run_args(input_text: str, args):
224
225
async def run_args(input_val, args):
246
226
try:
247
# Ensure directories exist
227
# ensure dirs
248
228
if args.output:
249
229
args.output.parent.mkdir(parents=True, exist_ok=True)
250
args.conversation_file.parent.mkdir(parents=True, exist_ok=True)
230
if args.conversation_file:
231
args.conversation_file.parent.mkdir(parents=True, exist_ok=True)
251
232
args.cookies_dir.mkdir(parents=True, exist_ok=True)
252
233
253
234
if args.debug:
254
235
debug.logging = True
255
256
# Initialize conversation manager
257
conversation = ConversationManager(None if args.no_config else args.conversation_file, args.model, args.provider)
236
237
conv = ConversationManager(
238
None if args.no_config else args.conversation_file,
239
model=args.model,
240
provider=args.provider,
241
max_messages=args.max_messages
242
)
258
243
if args.clear_history:
259
conversation.history = []
260
conversation.conversation = None
244
conv.history = []
245
conv.conversation = None
261
246
262
# Set cookies directory if specified
263
247
set_cookies_dir(str(args.cookies_dir))
264
248
read_cookie_files()
265
266
# Initialize client with selected provider
267
client = AsyncClient(provider=conversation.provider)
268
269
# Stream response and update conversation
270
await stream_response(client, input_text, conversation, args.output, args.instructions)
271
272
# Save conversation state
273
conversation.save()
274
except:
249
250
client = AsyncClient(provider=conv.provider)
251
252
if isinstance(args.edit, Path):
253
file_to_edit = args.edit
254
if not file_to_edit.exists():
255
print(f"ERROR: file not found: {file_to_edit}", file=sys.stderr)
256
sys.exit(1)
257
text = file_to_edit.read_text(encoding="utf-8")
258
# we will both send and overwrite this file
259
input_val = f"```file: {file_to_edit}\n{text}\n```\n" + (input_val[1] if isinstance(input_val, tuple) else input_val)
260
output_target = file_to_edit
261
else:
262
# normal, non-edit mode
263
output_target = args.output
264
265
await stream_response(client, input_val, conv, output_target, args.instructions)
266
conv.save()
267
268
except Exception:
275
269
print(traceback.format_exc(), file=sys.stderr)
276
270
sys.exit(1)
277
271
272
278
273
def run_client_args(args):
279
input_text = ""
274
input_txt = ""
280
275
media = []
281
276
rest = 0
282
for idx, input_value in enumerate(args.input):
283
if input_value.startswith("http://") or input_value.startswith("https://"):
284
response = requests.head(input_value)
285
if not response.ok:
286
print(f"Error accessing URL {input_value}: {response.status_code}", file=sys.stderr)
287
break
288
if response.headers.get('Content-Type', '').startswith('image/'):
289
media.append(input_value)
277
278
for idx, tok in enumerate(args.input):
279
if tok.startswith(("http://","https://")):
280
# same URL logic...
281
resp = requests.head(tok, allow_redirects=True)
282
if resp.ok and resp.headers.get("Content-Type","").startswith("image"):
283
media.append(tok)
290
284
else:
291
try:
292
if not has_markitdown:
293
raise MissingRequirementsError("MarkItDown is not installed. Install it with `pip install -U markitdown`.")
294
md = MarkItDown()
295
text_content = md.convert_url(input_value).text_content
296
input_text += f"\n```\n{text_content}\n\nSource: {input_value}\n```\n"
297
except Exception as e:
298
print(f"Error processing URL {input_value}: {type(e).__name__}: {e}", file=sys.stderr)
299
break
300
elif os.path.isfile(input_value):
285
if not has_markitdown:
286
raise MissingRequirementsError("Install markitdown")
287
md = MarkItDown()
288
txt = md.convert_url(tok).text_content
289
input_txt += f"\n```source: {tok}\n{txt}\n```\n"
290
elif os.path.isfile(tok):
291
head = Path(tok).read_bytes()[:12]
301
292
try:
302
with open(input_value, 'rb') as f:
303
if is_accepted_format(f.read(12)):
304
media.append(Path(input_value))
293
if is_accepted_format(head):
294
media.append(Path(tok))
295
is_img = True
296
else:
297
is_img = False
305
298
except ValueError:
306
# If not a valid image, read as text
307
try:
308
with open(input_value, 'r', encoding='utf-8') as f:
309
file_content = f.read().strip()
310
except UnicodeDecodeError:
311
print(f"Error reading file {input_value} as text. Ensure it is a valid text file.", file=sys.stderr)
312
break
313
input_text += f"\n```{input_value}\n{file_content}\n```\n"
299
is_img = False
300
if not is_img:
301
txt = Path(tok).read_text(encoding="utf-8")
302
input_txt += f"\n```file: {tok}\n{txt}\n```\n"
314
303
else:
304
rest = idx
315
305
break
316
306
rest = idx + 1
317
input_text = (" ".join(args.input[rest:])).strip() + input_text
307
308
tail = args.input[rest:]
309
if tail:
310
input_txt = " ".join(tail) + "\n" + input_txt
311
312
if not sys.stdin.isatty() and not input_txt:
313
input_txt = sys.stdin.read()
314
318
315
if media:
319
input_text = (media, input_text)
320
if not sys.stdin.isatty() and not input_text:
321
input_text = sys.stdin.read().strip()
322
if not input_text:
323
print("No input provided. Use -h for help.", file=sys.stderr)
316
val = (media, input_txt)
317
else:
318
val = input_txt.strip()
319
320
if not val:
321
print("No input provided. Use -h.", file=sys.stderr)
324
322
sys.exit(1)
325
# Run the client with provided arguments
326
asyncio.run(run_args(input_text, args))
323
324
asyncio.run(run_args(val, args))
325
327
326
328
327
if __name__ == "__main__":
329
# Run the client with command line arguments
330
328
run_client_args(get_parser().parse_args())