ffai-cli 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.
- ffai/__init__.py +5 -0
- ffai/__main__.py +5 -0
- ffai/cli.py +97 -0
- ffai/command_builder.py +287 -0
- ffai/config.py +251 -0
- ffai/errors.py +31 -0
- ffai/executor.py +255 -0
- ffai/i18n.py +400 -0
- ffai/llm.py +181 -0
- ffai/models.py +454 -0
- ffai/pandoc_builder.py +254 -0
- ffai/parser.py +112 -0
- ffai/prompt.py +296 -0
- ffai/providers.py +122 -0
- ffai/repl.py +933 -0
- ffai/state.py +291 -0
- ffai/tools.py +177 -0
- ffai/utils.py +91 -0
- ffai_cli-0.2.0.dist-info/METADATA +620 -0
- ffai_cli-0.2.0.dist-info/RECORD +24 -0
- ffai_cli-0.2.0.dist-info/WHEEL +5 -0
- ffai_cli-0.2.0.dist-info/entry_points.txt +2 -0
- ffai_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- ffai_cli-0.2.0.dist-info/top_level.txt +1 -0
ffai/repl.py
ADDED
|
@@ -0,0 +1,933 @@
|
|
|
1
|
+
"""Interactive REPL and Rich presentation for ffai."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shlex
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import assert_never, cast
|
|
10
|
+
|
|
11
|
+
from pydantic import SecretStr, ValidationError
|
|
12
|
+
from rich import box
|
|
13
|
+
from rich.console import Console, Group
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
from rich.prompt import Confirm
|
|
16
|
+
from rich.syntax import Syntax
|
|
17
|
+
from rich.table import Table
|
|
18
|
+
from rich.text import Text
|
|
19
|
+
|
|
20
|
+
from ffai import __version__
|
|
21
|
+
from ffai.config import (
|
|
22
|
+
API_KEY_ENV,
|
|
23
|
+
MODEL_ENV,
|
|
24
|
+
Settings,
|
|
25
|
+
env_file_path,
|
|
26
|
+
save_llm_settings,
|
|
27
|
+
)
|
|
28
|
+
from ffai.errors import CommandBuildError, ConfigError, FFAIError, ParseError
|
|
29
|
+
from ffai.executor import ExecutionResult, execute_tool
|
|
30
|
+
from ffai.i18n import Translator, resolve_language
|
|
31
|
+
from ffai.llm import LLMClient
|
|
32
|
+
from ffai.models import LLMDecision, ToolName
|
|
33
|
+
from ffai.parser import BuiltinCommand, NaturalLanguage, ParsedInput, parse_input
|
|
34
|
+
from ffai.providers import (
|
|
35
|
+
CUSTOM_PROVIDER,
|
|
36
|
+
PROVIDER_CHOICES,
|
|
37
|
+
ProviderPreset,
|
|
38
|
+
identify_provider,
|
|
39
|
+
)
|
|
40
|
+
from ffai.state import SessionState, TaskChange
|
|
41
|
+
from ffai.tools import TOOL_SPECS, missing_tools
|
|
42
|
+
from ffai.utils import (
|
|
43
|
+
format_command,
|
|
44
|
+
format_file_size,
|
|
45
|
+
probe_duration,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
PROMPT = "[bold cyan]ffai[/bold cyan][bright_black]>[/bright_black] "
|
|
49
|
+
|
|
50
|
+
_FIELD_LABEL_KEYS = {
|
|
51
|
+
"tool": "field.tool",
|
|
52
|
+
"video_filters": "field.video_filters",
|
|
53
|
+
"audio_filters": "field.audio_filters",
|
|
54
|
+
"filter_complex": "field.filter_complex",
|
|
55
|
+
"maps": "field.maps",
|
|
56
|
+
"video_codec": "field.video_codec",
|
|
57
|
+
"audio_codec": "field.audio_codec",
|
|
58
|
+
"crf": "field.crf",
|
|
59
|
+
"preset": "field.preset",
|
|
60
|
+
"video_bitrate": "field.video_bitrate",
|
|
61
|
+
"audio_bitrate": "field.audio_bitrate",
|
|
62
|
+
"extra_options": "field.extra_options",
|
|
63
|
+
"description": "field.description",
|
|
64
|
+
"from_format": "field.from_format",
|
|
65
|
+
"to_format": "field.to_format",
|
|
66
|
+
"standalone": "field.standalone",
|
|
67
|
+
"toc": "field.toc",
|
|
68
|
+
"toc_depth": "field.toc_depth",
|
|
69
|
+
"number_sections": "field.number_sections",
|
|
70
|
+
"shift_heading_level_by": "field.shift_heading_level_by",
|
|
71
|
+
"metadata": "field.metadata",
|
|
72
|
+
"template": "field.template",
|
|
73
|
+
"reference_doc": "field.reference_doc",
|
|
74
|
+
"pdf_engine": "field.pdf_engine",
|
|
75
|
+
"highlight_style": "field.highlight_style",
|
|
76
|
+
"embed_resources": "field.embed_resources",
|
|
77
|
+
"citeproc": "field.citeproc",
|
|
78
|
+
"bibliography": "field.bibliography",
|
|
79
|
+
"csl": "field.csl",
|
|
80
|
+
"resource_path": "field.resource_path",
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
_HELP_ROWS = [
|
|
84
|
+
("add <file> [file ...]", "help.add"),
|
|
85
|
+
("tool <ffmpeg|pandoc>", "help.tool"),
|
|
86
|
+
("setoutput <file>", "help.setoutput"),
|
|
87
|
+
("show", "help.show"),
|
|
88
|
+
("preview", "help.preview"),
|
|
89
|
+
("exec", "help.exec"),
|
|
90
|
+
("reset", "help.reset"),
|
|
91
|
+
("config [show|set]", "help.config"),
|
|
92
|
+
("help", "help.help"),
|
|
93
|
+
("exit / quit", "help.exit"),
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class FFaiRepl:
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
settings: Settings,
|
|
101
|
+
console: Console | None = None,
|
|
102
|
+
translator: Translator | None = None,
|
|
103
|
+
) -> None:
|
|
104
|
+
self.settings = settings
|
|
105
|
+
self.console = console or Console(highlight=False)
|
|
106
|
+
self.t = translator or Translator(resolve_language())
|
|
107
|
+
self.state = SessionState()
|
|
108
|
+
self._llm_client: LLMClient | None = None
|
|
109
|
+
self._running = True
|
|
110
|
+
|
|
111
|
+
def run(self) -> None:
|
|
112
|
+
self._render_banner()
|
|
113
|
+
self._require_llm_configuration()
|
|
114
|
+
self._render_startup_notes()
|
|
115
|
+
first_prompt = True
|
|
116
|
+
while self._running:
|
|
117
|
+
if not first_prompt:
|
|
118
|
+
# Keep the prompt visually distinct from every command's output.
|
|
119
|
+
self.console.print()
|
|
120
|
+
first_prompt = False
|
|
121
|
+
try:
|
|
122
|
+
raw = self.console.input(PROMPT)
|
|
123
|
+
except EOFError:
|
|
124
|
+
self.console.print()
|
|
125
|
+
break
|
|
126
|
+
except KeyboardInterrupt:
|
|
127
|
+
self.console.print()
|
|
128
|
+
self.console.print(Text(self.t("loop.quit_hint"), style="dim"))
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
if not raw.strip():
|
|
132
|
+
continue
|
|
133
|
+
try:
|
|
134
|
+
parsed = parse_input(raw)
|
|
135
|
+
except ParseError as exc:
|
|
136
|
+
self._show_error(self.t("error.input_title"), exc)
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
self._dispatch(parsed)
|
|
141
|
+
except FFAIError as exc:
|
|
142
|
+
self._show_error(self.t("error.title"), exc)
|
|
143
|
+
except KeyboardInterrupt:
|
|
144
|
+
self.console.print()
|
|
145
|
+
self._show_warning(self.t("loop.interrupt_kept"))
|
|
146
|
+
except BrokenPipeError:
|
|
147
|
+
break
|
|
148
|
+
|
|
149
|
+
self.console.print(Text(self.t("loop.goodbye"), style="dim"))
|
|
150
|
+
|
|
151
|
+
def _dispatch(self, parsed: ParsedInput) -> None:
|
|
152
|
+
if isinstance(parsed, NaturalLanguage):
|
|
153
|
+
self._handle_natural_language(parsed)
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
if not isinstance(parsed, BuiltinCommand):
|
|
157
|
+
assert_never(parsed)
|
|
158
|
+
|
|
159
|
+
match parsed.name:
|
|
160
|
+
case "add":
|
|
161
|
+
self._cmd_add(parsed.args)
|
|
162
|
+
case "tool":
|
|
163
|
+
self._cmd_tool(cast(ToolName, parsed.args[0]))
|
|
164
|
+
case "setoutput":
|
|
165
|
+
self._cmd_setoutput(parsed.args[0])
|
|
166
|
+
case "show":
|
|
167
|
+
self._cmd_show()
|
|
168
|
+
case "preview":
|
|
169
|
+
self._cmd_preview()
|
|
170
|
+
case "exec":
|
|
171
|
+
self._cmd_exec()
|
|
172
|
+
case "reset":
|
|
173
|
+
self._cmd_reset()
|
|
174
|
+
case "config":
|
|
175
|
+
self._cmd_config(parsed.args)
|
|
176
|
+
case "help":
|
|
177
|
+
self._cmd_help()
|
|
178
|
+
case "exit":
|
|
179
|
+
self._running = False
|
|
180
|
+
case _ as unreachable:
|
|
181
|
+
raise AssertionError(f"Unhandled built-in command: {unreachable}")
|
|
182
|
+
|
|
183
|
+
def _cmd_add(self, paths: Sequence[str]) -> None:
|
|
184
|
+
result = self.state.add_inputs(paths)
|
|
185
|
+
for path in result.added:
|
|
186
|
+
self._success(self.t("action.added", path=path))
|
|
187
|
+
for path in result.duplicates:
|
|
188
|
+
self._show_warning(self.t("action.duplicate", path=path))
|
|
189
|
+
if result.switched_tool is not None:
|
|
190
|
+
self.console.print(
|
|
191
|
+
Text(
|
|
192
|
+
self.t(
|
|
193
|
+
"action.tool_detected",
|
|
194
|
+
tool=TOOL_SPECS[result.switched_tool].label,
|
|
195
|
+
),
|
|
196
|
+
style="cyan",
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
if result.added:
|
|
200
|
+
self.console.print(Text(self.t("action.add_tip"), style="dim"))
|
|
201
|
+
elif not result.duplicates:
|
|
202
|
+
self._show_warning(self.t("action.no_files"))
|
|
203
|
+
|
|
204
|
+
def _cmd_tool(self, tool: ToolName) -> None:
|
|
205
|
+
label = TOOL_SPECS[tool].label
|
|
206
|
+
if self.state.select_tool(tool):
|
|
207
|
+
self._success(self.t("action.tool_set", tool=label))
|
|
208
|
+
else:
|
|
209
|
+
self.console.print(Text(self.t("action.tool_unchanged", tool=label), style="dim"))
|
|
210
|
+
|
|
211
|
+
def _cmd_setoutput(self, path: str) -> None:
|
|
212
|
+
output = self.state.set_output(path)
|
|
213
|
+
self._success(self.t("action.output_set", path=output))
|
|
214
|
+
|
|
215
|
+
def _cmd_show(self) -> None:
|
|
216
|
+
self.console.print(self._task_panel())
|
|
217
|
+
|
|
218
|
+
def _cmd_preview(self) -> None:
|
|
219
|
+
spec = self.state.spec
|
|
220
|
+
task = self.state.task
|
|
221
|
+
try:
|
|
222
|
+
command = spec.builder(task)
|
|
223
|
+
except CommandBuildError as exc:
|
|
224
|
+
self._show_error(self.t("error.cannot_build_title"), exc)
|
|
225
|
+
return
|
|
226
|
+
self.console.print(self._command_panel(command, preview=True))
|
|
227
|
+
self._show_overwrite_notice(task.output)
|
|
228
|
+
|
|
229
|
+
def _cmd_exec(self) -> None:
|
|
230
|
+
spec = self.state.spec
|
|
231
|
+
task = self.state.task
|
|
232
|
+
try:
|
|
233
|
+
command = spec.builder(task)
|
|
234
|
+
except CommandBuildError as exc:
|
|
235
|
+
self._show_error(self.t("error.cannot_build_title"), exc)
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
self.console.print(self._command_panel(command, preview=False))
|
|
239
|
+
self._show_overwrite_notice(task.output)
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
confirmed = Confirm.ask(
|
|
243
|
+
self.t("panel.confirm_execute"),
|
|
244
|
+
console=self.console,
|
|
245
|
+
default=False,
|
|
246
|
+
)
|
|
247
|
+
except (EOFError, KeyboardInterrupt):
|
|
248
|
+
self.console.print()
|
|
249
|
+
self._show_warning(self.t("execution.cancelled"))
|
|
250
|
+
return
|
|
251
|
+
if not confirmed:
|
|
252
|
+
self._show_warning(self.t("execution.cancelled"))
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
assert task.output is not None
|
|
256
|
+
duration = (
|
|
257
|
+
probe_duration(task.inputs[0])
|
|
258
|
+
if spec.supports_progress and task.inputs
|
|
259
|
+
else None
|
|
260
|
+
)
|
|
261
|
+
try:
|
|
262
|
+
result = execute_tool(
|
|
263
|
+
command,
|
|
264
|
+
expected_output=task.output,
|
|
265
|
+
console=self.console,
|
|
266
|
+
tool_label=spec.label,
|
|
267
|
+
total_duration=duration,
|
|
268
|
+
show_progress=spec.supports_progress,
|
|
269
|
+
)
|
|
270
|
+
except KeyboardInterrupt:
|
|
271
|
+
self.console.print()
|
|
272
|
+
self._show_warning(self.t("execution.interrupted"))
|
|
273
|
+
return
|
|
274
|
+
|
|
275
|
+
self._render_execution_result(result, tool_label=spec.label)
|
|
276
|
+
|
|
277
|
+
def _cmd_reset(self) -> None:
|
|
278
|
+
self.state.reset()
|
|
279
|
+
self._success(self.t("action.reset"))
|
|
280
|
+
|
|
281
|
+
def _cmd_config(self, args: Sequence[str]) -> None:
|
|
282
|
+
action = args[0] if args else "show"
|
|
283
|
+
if action == "set":
|
|
284
|
+
if self._run_config_wizard():
|
|
285
|
+
self.console.print(self._config_panel())
|
|
286
|
+
return
|
|
287
|
+
|
|
288
|
+
self.console.print(self._config_panel())
|
|
289
|
+
self.console.print(Text(self.t("config.show_hint"), style="dim"))
|
|
290
|
+
|
|
291
|
+
def _can_prompt(self) -> bool:
|
|
292
|
+
return self.console.is_terminal and sys.stdin.isatty()
|
|
293
|
+
|
|
294
|
+
def _require_llm_configuration(self) -> None:
|
|
295
|
+
if self.settings.llm_configured:
|
|
296
|
+
return
|
|
297
|
+
|
|
298
|
+
missing = []
|
|
299
|
+
if self.settings.api_key is None:
|
|
300
|
+
missing.append(API_KEY_ENV)
|
|
301
|
+
if self.settings.model is None:
|
|
302
|
+
missing.append(MODEL_ENV)
|
|
303
|
+
|
|
304
|
+
if not self._can_prompt():
|
|
305
|
+
raise ConfigError(self.t("startup.noninteractive", missing=", ".join(missing)))
|
|
306
|
+
|
|
307
|
+
intro = Text()
|
|
308
|
+
intro.append(self.t("startup.setup_required_intro"), style="bold")
|
|
309
|
+
intro.append(self.t("startup.missing_label"), style="bold")
|
|
310
|
+
intro.append(", ".join(missing))
|
|
311
|
+
intro.append(self.t("startup.setup_hint"), style="dim")
|
|
312
|
+
self.console.print(
|
|
313
|
+
Panel(
|
|
314
|
+
intro,
|
|
315
|
+
title=self.t("config.setup_required_title"),
|
|
316
|
+
border_style="yellow",
|
|
317
|
+
box=box.ROUNDED,
|
|
318
|
+
padding=(1, 2),
|
|
319
|
+
)
|
|
320
|
+
)
|
|
321
|
+
if not self._run_config_wizard():
|
|
322
|
+
raise ConfigError(self.t("startup.setup_cancelled"))
|
|
323
|
+
|
|
324
|
+
def _run_config_wizard(self) -> bool:
|
|
325
|
+
if not self._can_prompt():
|
|
326
|
+
self._show_error(
|
|
327
|
+
self.t("error.config_title"),
|
|
328
|
+
ConfigError(self.t("config.interactive_required")),
|
|
329
|
+
)
|
|
330
|
+
return False
|
|
331
|
+
|
|
332
|
+
instructions = Text()
|
|
333
|
+
instructions.append(self.t("config.wizard_intro"), style="bold")
|
|
334
|
+
instructions.append(self.t("config.wizard_hint"), style="dim")
|
|
335
|
+
instructions.append(self.t("config.save_location", path=env_file_path()), style="dim")
|
|
336
|
+
self.console.print(
|
|
337
|
+
Panel(
|
|
338
|
+
instructions,
|
|
339
|
+
title=self.t("config.title"),
|
|
340
|
+
border_style="cyan",
|
|
341
|
+
box=box.ROUNDED,
|
|
342
|
+
padding=(1, 2),
|
|
343
|
+
)
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
has_existing_settings = bool(
|
|
347
|
+
self.settings.api_key or self.settings.base_url or self.settings.model
|
|
348
|
+
)
|
|
349
|
+
current_provider = (
|
|
350
|
+
(identify_provider(self.settings.base_url) or CUSTOM_PROVIDER)
|
|
351
|
+
if has_existing_settings
|
|
352
|
+
else None
|
|
353
|
+
)
|
|
354
|
+
self._render_provider_menu(current_provider)
|
|
355
|
+
current_key = self.settings.api_key.get_secret_value() if self.settings.api_key else None
|
|
356
|
+
current_base_url = self.settings.base_url
|
|
357
|
+
current_model = self.settings.model
|
|
358
|
+
|
|
359
|
+
try:
|
|
360
|
+
provider = self._ask_provider(current_provider)
|
|
361
|
+
provider_changed = current_provider is None or provider.id != current_provider.id
|
|
362
|
+
self._render_provider_details(provider)
|
|
363
|
+
if provider is CUSTOM_PROVIDER:
|
|
364
|
+
base_url = self._ask_custom_base_url(
|
|
365
|
+
current_base_url if not provider_changed else None
|
|
366
|
+
)
|
|
367
|
+
else:
|
|
368
|
+
base_url = provider.base_url
|
|
369
|
+
api_key = self._ask_required_secret(
|
|
370
|
+
self._provider_name(provider),
|
|
371
|
+
current_key if not provider_changed else None,
|
|
372
|
+
)
|
|
373
|
+
model = self._ask_model(
|
|
374
|
+
current_model if not provider_changed else None,
|
|
375
|
+
provider.default_model,
|
|
376
|
+
)
|
|
377
|
+
except (EOFError, KeyboardInterrupt):
|
|
378
|
+
self.console.print()
|
|
379
|
+
self._show_warning(self.t("config.setup_cancelled"))
|
|
380
|
+
return False
|
|
381
|
+
|
|
382
|
+
try:
|
|
383
|
+
candidate = Settings(api_key=api_key, base_url=base_url, model=model)
|
|
384
|
+
candidate.require_llm()
|
|
385
|
+
except (ValidationError, ConfigError) as exc:
|
|
386
|
+
self._show_error(
|
|
387
|
+
self.t("error.invalid_config_title"), ConfigError(_validation_message(exc))
|
|
388
|
+
)
|
|
389
|
+
return False
|
|
390
|
+
|
|
391
|
+
try:
|
|
392
|
+
saved = save_llm_settings(candidate)
|
|
393
|
+
except ConfigError as exc:
|
|
394
|
+
self._show_error(self.t("error.config_title"), exc)
|
|
395
|
+
return False
|
|
396
|
+
|
|
397
|
+
self.settings = candidate
|
|
398
|
+
self._llm_client = None
|
|
399
|
+
self._success(self.t("action.config_saved", path=saved))
|
|
400
|
+
return True
|
|
401
|
+
|
|
402
|
+
def _provider_name(self, provider: ProviderPreset) -> str:
|
|
403
|
+
if self.t.language == "zh" and provider.name_zh:
|
|
404
|
+
return provider.name_zh
|
|
405
|
+
return provider.name
|
|
406
|
+
|
|
407
|
+
def _provider_note(self, provider: ProviderPreset) -> str | None:
|
|
408
|
+
if self.t.language == "zh" and provider.note_zh:
|
|
409
|
+
return provider.note_zh
|
|
410
|
+
return provider.note
|
|
411
|
+
|
|
412
|
+
def _current_provider(self) -> ProviderPreset:
|
|
413
|
+
return identify_provider(self.settings.base_url) or CUSTOM_PROVIDER
|
|
414
|
+
|
|
415
|
+
def _render_provider_menu(self, current: ProviderPreset | None) -> None:
|
|
416
|
+
table = Table(
|
|
417
|
+
box=box.SIMPLE_HEAD,
|
|
418
|
+
show_edge=False,
|
|
419
|
+
pad_edge=False,
|
|
420
|
+
expand=True,
|
|
421
|
+
padding=(0, 1),
|
|
422
|
+
)
|
|
423
|
+
table.add_column(self.t("config.column_number"), style="bold cyan", no_wrap=True)
|
|
424
|
+
table.add_column(self.t("config.column_provider"), no_wrap=True)
|
|
425
|
+
table.add_column(self.t("config.column_base_url"))
|
|
426
|
+
table.add_column(self.t("config.column_model"), no_wrap=True)
|
|
427
|
+
for index, provider in enumerate(PROVIDER_CHOICES, start=1):
|
|
428
|
+
label = self._provider_name(provider)
|
|
429
|
+
if current is not None and provider.id == current.id:
|
|
430
|
+
label = self.t("config.current_marker", name=label)
|
|
431
|
+
table.add_row(
|
|
432
|
+
str(index),
|
|
433
|
+
label,
|
|
434
|
+
provider.base_url or self.t("config.custom_url_placeholder"),
|
|
435
|
+
provider.default_model or self.t("config.custom_model_placeholder"),
|
|
436
|
+
)
|
|
437
|
+
self.console.print(table)
|
|
438
|
+
|
|
439
|
+
def _render_provider_details(self, provider: ProviderPreset) -> None:
|
|
440
|
+
details = Text()
|
|
441
|
+
details.append(self._provider_name(provider), style="bold")
|
|
442
|
+
note = self._provider_note(provider)
|
|
443
|
+
if note:
|
|
444
|
+
details.append(f" — {note}", style="dim")
|
|
445
|
+
self.console.print(details)
|
|
446
|
+
if provider.docs_url:
|
|
447
|
+
docs = Text(self.t("config.docs_label"), style="dim")
|
|
448
|
+
docs.append(provider.docs_url, style="dim")
|
|
449
|
+
self.console.print(docs)
|
|
450
|
+
if provider.api_key_url:
|
|
451
|
+
keys = Text(self.t("config.api_keys_label"), style="dim")
|
|
452
|
+
keys.append(provider.api_key_url, style="dim")
|
|
453
|
+
self.console.print(keys)
|
|
454
|
+
|
|
455
|
+
def _ask_provider(self, current: ProviderPreset | None) -> ProviderPreset:
|
|
456
|
+
aliases = {
|
|
457
|
+
"openai": "openai",
|
|
458
|
+
"openrouter": "openrouter",
|
|
459
|
+
"deepseek": "deepseek",
|
|
460
|
+
"glm": "glm",
|
|
461
|
+
"zhipu": "glm",
|
|
462
|
+
"kimi": "kimi",
|
|
463
|
+
"moonshot": "kimi",
|
|
464
|
+
"qwen": "qwen",
|
|
465
|
+
"dashscope": "qwen",
|
|
466
|
+
"custom": "custom",
|
|
467
|
+
}
|
|
468
|
+
prompt = (
|
|
469
|
+
self.t("config.provider_prompt_keep", current=self._provider_name(current))
|
|
470
|
+
if current is not None
|
|
471
|
+
else self.t("config.provider_prompt_new", count=len(PROVIDER_CHOICES))
|
|
472
|
+
)
|
|
473
|
+
while True:
|
|
474
|
+
raw = self.console.input(prompt).strip()
|
|
475
|
+
if not raw:
|
|
476
|
+
if current:
|
|
477
|
+
return current
|
|
478
|
+
self._show_warning(self.t("config.provider_required"))
|
|
479
|
+
continue
|
|
480
|
+
if raw.isdigit():
|
|
481
|
+
index = int(raw) - 1
|
|
482
|
+
if 0 <= index < len(PROVIDER_CHOICES):
|
|
483
|
+
return PROVIDER_CHOICES[index]
|
|
484
|
+
provider = next(
|
|
485
|
+
(
|
|
486
|
+
item
|
|
487
|
+
for item in PROVIDER_CHOICES
|
|
488
|
+
if item.id == aliases.get(raw.lower(), raw.lower())
|
|
489
|
+
),
|
|
490
|
+
None,
|
|
491
|
+
)
|
|
492
|
+
if provider is not None:
|
|
493
|
+
return provider
|
|
494
|
+
self._show_warning(self.t("config.provider_unknown"))
|
|
495
|
+
|
|
496
|
+
def _ask_required_secret(self, provider: str, current: str | None) -> str:
|
|
497
|
+
if current:
|
|
498
|
+
prompt = self.t("config.api_key_prompt_keep", provider=provider)
|
|
499
|
+
else:
|
|
500
|
+
prompt = self.t("config.api_key_prompt_new", provider=provider)
|
|
501
|
+
while True:
|
|
502
|
+
value = self.console.input(prompt, password=True).strip()
|
|
503
|
+
if value:
|
|
504
|
+
return value
|
|
505
|
+
if current:
|
|
506
|
+
return current
|
|
507
|
+
self._show_warning(self.t("config.api_key_required", provider=provider))
|
|
508
|
+
|
|
509
|
+
def _ask_model(self, current: str | None, suggested: str | None) -> str:
|
|
510
|
+
if current:
|
|
511
|
+
prompt = self.t("config.model_prompt_keep")
|
|
512
|
+
elif suggested:
|
|
513
|
+
prompt = self.t("config.model_prompt_default", model=suggested)
|
|
514
|
+
else:
|
|
515
|
+
prompt = self.t("config.model_prompt_new")
|
|
516
|
+
while True:
|
|
517
|
+
value = self.console.input(prompt).strip()
|
|
518
|
+
if value:
|
|
519
|
+
return value
|
|
520
|
+
if current:
|
|
521
|
+
return current
|
|
522
|
+
if suggested:
|
|
523
|
+
return suggested
|
|
524
|
+
self._show_warning(self.t("config.model_required"))
|
|
525
|
+
|
|
526
|
+
def _ask_custom_base_url(self, current: str | None) -> str:
|
|
527
|
+
prompt = (
|
|
528
|
+
self.t("config.base_url_prompt_keep")
|
|
529
|
+
if current
|
|
530
|
+
else self.t("config.base_url_prompt_new")
|
|
531
|
+
)
|
|
532
|
+
while True:
|
|
533
|
+
value = self.console.input(prompt).strip()
|
|
534
|
+
if not value:
|
|
535
|
+
if current:
|
|
536
|
+
return current
|
|
537
|
+
self._show_warning(self.t("config.base_url_required"))
|
|
538
|
+
continue
|
|
539
|
+
try:
|
|
540
|
+
validated = Settings(base_url=value).base_url
|
|
541
|
+
except ValidationError as exc:
|
|
542
|
+
self._show_error(
|
|
543
|
+
self.t("error.invalid_base_url_title"),
|
|
544
|
+
ConfigError(_validation_message(exc)),
|
|
545
|
+
)
|
|
546
|
+
continue
|
|
547
|
+
if validated is None:
|
|
548
|
+
self._show_warning(self.t("config.base_url_required"))
|
|
549
|
+
continue
|
|
550
|
+
return validated
|
|
551
|
+
|
|
552
|
+
def _config_panel(self) -> Panel:
|
|
553
|
+
table = Table(
|
|
554
|
+
box=box.SIMPLE,
|
|
555
|
+
show_header=False,
|
|
556
|
+
show_edge=False,
|
|
557
|
+
pad_edge=False,
|
|
558
|
+
expand=True,
|
|
559
|
+
padding=(0, 1),
|
|
560
|
+
)
|
|
561
|
+
table.add_column("Field", style="bold cyan", no_wrap=True, width=18)
|
|
562
|
+
table.add_column("Value")
|
|
563
|
+
table.add_row(
|
|
564
|
+
self.t("config.provider"),
|
|
565
|
+
Text(self._provider_name(self._current_provider())),
|
|
566
|
+
)
|
|
567
|
+
table.add_row(
|
|
568
|
+
self.t("config.api_key"),
|
|
569
|
+
Text(_mask_secret(self.settings.api_key, self.t("config.not_set"))),
|
|
570
|
+
)
|
|
571
|
+
table.add_row(
|
|
572
|
+
self.t("config.model"),
|
|
573
|
+
Text(self.settings.model or self.t("config.not_set")),
|
|
574
|
+
)
|
|
575
|
+
table.add_row(
|
|
576
|
+
self.t("config.base_url"),
|
|
577
|
+
Text(self.settings.base_url or self.t("config.openai_default")),
|
|
578
|
+
)
|
|
579
|
+
config_path = env_file_path()
|
|
580
|
+
path_label = (
|
|
581
|
+
str(config_path)
|
|
582
|
+
if config_path.exists()
|
|
583
|
+
else self.t("config.file_not_created", path=config_path)
|
|
584
|
+
)
|
|
585
|
+
table.add_row(self.t("config.config_file"), Text(path_label))
|
|
586
|
+
|
|
587
|
+
return Panel(
|
|
588
|
+
table,
|
|
589
|
+
title=self.t("config.title"),
|
|
590
|
+
border_style="cyan",
|
|
591
|
+
box=box.ROUNDED,
|
|
592
|
+
padding=(1, 2),
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
def _cmd_help(self) -> None:
|
|
596
|
+
table = Table(
|
|
597
|
+
box=box.SIMPLE_HEAD,
|
|
598
|
+
show_edge=False,
|
|
599
|
+
pad_edge=False,
|
|
600
|
+
expand=True,
|
|
601
|
+
padding=(0, 1),
|
|
602
|
+
)
|
|
603
|
+
table.add_column(self.t("help.command"), style="bold cyan", no_wrap=True)
|
|
604
|
+
table.add_column(self.t("help.description"), style="white")
|
|
605
|
+
for command, description_key in _HELP_ROWS:
|
|
606
|
+
table.add_row(command, self.t(description_key))
|
|
607
|
+
|
|
608
|
+
examples = Text()
|
|
609
|
+
examples.append(self.t("help.examples_title"), style="bold")
|
|
610
|
+
examples.append(self.t("help.example_crop"), style="dim")
|
|
611
|
+
examples.append(self.t("help.example_codec"), style="dim")
|
|
612
|
+
examples.append(self.t("help.example_audio"), style="dim")
|
|
613
|
+
examples.append(self.t("help.example_doc"), style="dim")
|
|
614
|
+
examples.append(self.t("help.example_pdf"), style="dim")
|
|
615
|
+
examples.append(self.t("help.llm_note"), style="dim")
|
|
616
|
+
examples.append("\n")
|
|
617
|
+
examples.append(self.t("help.aliases"), style="dim")
|
|
618
|
+
|
|
619
|
+
self.console.print(
|
|
620
|
+
Panel(
|
|
621
|
+
Group(table, examples),
|
|
622
|
+
title=self.t("help.title"),
|
|
623
|
+
border_style="cyan",
|
|
624
|
+
box=box.ROUNDED,
|
|
625
|
+
padding=(1, 2),
|
|
626
|
+
)
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
def _handle_natural_language(self, request: NaturalLanguage) -> None:
|
|
630
|
+
config = self.settings.require_llm()
|
|
631
|
+
client = self._llm_client
|
|
632
|
+
if client is None:
|
|
633
|
+
client = LLMClient(config)
|
|
634
|
+
self._llm_client = client
|
|
635
|
+
with self.console.status(f"[cyan]{self.t('status.thinking')}[/cyan]", spinner="dots"):
|
|
636
|
+
decision = client.analyze(self.state.llm_context(), request.text)
|
|
637
|
+
self._render_decision(decision)
|
|
638
|
+
|
|
639
|
+
def _render_decision(self, decision: LLMDecision) -> None:
|
|
640
|
+
if decision.intent == "apply":
|
|
641
|
+
changes = self.state.apply_patch(decision.patch)
|
|
642
|
+
self.console.print(Panel(Text(decision.explanation), title="ffai", border_style="cyan"))
|
|
643
|
+
if changes:
|
|
644
|
+
self._render_changes(changes)
|
|
645
|
+
self.console.print(Text(self.t("changes.hint"), style="dim"))
|
|
646
|
+
else:
|
|
647
|
+
self._show_warning(self.t("changes.none"))
|
|
648
|
+
if decision.question:
|
|
649
|
+
self.console.print()
|
|
650
|
+
self.console.print(
|
|
651
|
+
Panel(
|
|
652
|
+
Text(decision.question),
|
|
653
|
+
title=self.t("decision.question_title"),
|
|
654
|
+
border_style="yellow",
|
|
655
|
+
)
|
|
656
|
+
)
|
|
657
|
+
return
|
|
658
|
+
|
|
659
|
+
if decision.intent == "clarify":
|
|
660
|
+
self.console.print(Panel(Text(decision.explanation), title="ffai", border_style="cyan"))
|
|
661
|
+
if decision.question:
|
|
662
|
+
self.console.print(
|
|
663
|
+
Panel(
|
|
664
|
+
Text(decision.question),
|
|
665
|
+
title=self.t("decision.clarification_title"),
|
|
666
|
+
border_style="yellow",
|
|
667
|
+
)
|
|
668
|
+
)
|
|
669
|
+
return
|
|
670
|
+
|
|
671
|
+
if decision.intent == "noop":
|
|
672
|
+
self.console.print(Panel(Text(decision.explanation), title="ffai", border_style="cyan"))
|
|
673
|
+
return
|
|
674
|
+
|
|
675
|
+
assert_never(decision.intent)
|
|
676
|
+
|
|
677
|
+
def _render_execution_result(self, result: ExecutionResult, *, tool_label: str) -> None:
|
|
678
|
+
if result.interrupted:
|
|
679
|
+
self._show_warning(self.t("execution.interrupted_partial"))
|
|
680
|
+
return
|
|
681
|
+
if result.succeeded:
|
|
682
|
+
text = Text()
|
|
683
|
+
text.append(self.t("execution.success"), style="bold green")
|
|
684
|
+
text.append(self.t("execution.output_label"), style="dim")
|
|
685
|
+
text.append(result.output_path)
|
|
686
|
+
size = format_file_size(result.output_path)
|
|
687
|
+
if size:
|
|
688
|
+
text.append(f" ({size})", style="dim")
|
|
689
|
+
text.append(
|
|
690
|
+
self.t("execution.elapsed", seconds=result.elapsed_seconds),
|
|
691
|
+
style="dim",
|
|
692
|
+
)
|
|
693
|
+
self.console.print(
|
|
694
|
+
Panel(text, title=self.t("execution.done_title"), border_style="green")
|
|
695
|
+
)
|
|
696
|
+
return
|
|
697
|
+
|
|
698
|
+
details = Text()
|
|
699
|
+
details.append(
|
|
700
|
+
self.t("execution.exit_code", tool=tool_label, code=result.exit_code),
|
|
701
|
+
style="bold red",
|
|
702
|
+
)
|
|
703
|
+
if result.stderr_tail:
|
|
704
|
+
details.append("\n\n")
|
|
705
|
+
details.append(result.stderr_tail[-6_000:], style="dim")
|
|
706
|
+
self.console.print(
|
|
707
|
+
Panel(
|
|
708
|
+
details,
|
|
709
|
+
title=self.t("execution.failed_title", tool=tool_label),
|
|
710
|
+
border_style="red",
|
|
711
|
+
)
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
def _task_panel(self) -> Panel:
|
|
715
|
+
task = self.state.task
|
|
716
|
+
spec = self.state.spec
|
|
717
|
+
table = Table(
|
|
718
|
+
box=box.SIMPLE,
|
|
719
|
+
show_header=False,
|
|
720
|
+
show_edge=False,
|
|
721
|
+
pad_edge=False,
|
|
722
|
+
expand=True,
|
|
723
|
+
padding=(0, 1),
|
|
724
|
+
)
|
|
725
|
+
table.add_column("Field", style="bold cyan", no_wrap=True, width=18)
|
|
726
|
+
table.add_column("Value")
|
|
727
|
+
|
|
728
|
+
if task.inputs:
|
|
729
|
+
inputs = Text()
|
|
730
|
+
for index, media in enumerate(task.inputs):
|
|
731
|
+
if index:
|
|
732
|
+
inputs.append("\n")
|
|
733
|
+
inputs.append(f"{index + 1}. ", style="dim")
|
|
734
|
+
inputs.append(media)
|
|
735
|
+
table.add_row(self.t("task.inputs"), inputs)
|
|
736
|
+
else:
|
|
737
|
+
table.add_row(self.t("task.inputs"), Text(self.t("task.none"), style="dim"))
|
|
738
|
+
|
|
739
|
+
table.add_row(
|
|
740
|
+
self.t("task.output"),
|
|
741
|
+
Text(task.output) if task.output else Text(self.t("task.none"), style="dim"),
|
|
742
|
+
)
|
|
743
|
+
table.add_row(self.t("task.tool"), Text(spec.label))
|
|
744
|
+
description = task.description
|
|
745
|
+
if description == spec.default_description:
|
|
746
|
+
description = self.t("task.default_description", tool=spec.label)
|
|
747
|
+
table.add_row(
|
|
748
|
+
self.t("task.description"),
|
|
749
|
+
Text(description or self.t("task.none")),
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
for field in spec.display_fields:
|
|
753
|
+
if field == "description":
|
|
754
|
+
continue
|
|
755
|
+
value = getattr(task, field)
|
|
756
|
+
table.add_row(
|
|
757
|
+
self.t(_FIELD_LABEL_KEYS[field]),
|
|
758
|
+
_format_field(
|
|
759
|
+
field,
|
|
760
|
+
value,
|
|
761
|
+
self.t("task.not_set"),
|
|
762
|
+
yes=self.t("task.yes"),
|
|
763
|
+
no=self.t("task.no"),
|
|
764
|
+
),
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
return Panel(
|
|
768
|
+
table,
|
|
769
|
+
title=self.t("task.title", tool=spec.label, revision=self.state.revision),
|
|
770
|
+
border_style="cyan",
|
|
771
|
+
box=box.ROUNDED,
|
|
772
|
+
padding=(1, 2),
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
def _command_panel(self, command: Sequence[str], *, preview: bool) -> Panel:
|
|
776
|
+
title = self.t("panel.preview_title") if preview else self.t("panel.exec_title")
|
|
777
|
+
syntax = Syntax(
|
|
778
|
+
format_command(command),
|
|
779
|
+
"bash",
|
|
780
|
+
theme="monokai",
|
|
781
|
+
word_wrap=True,
|
|
782
|
+
padding=0,
|
|
783
|
+
background_color="default",
|
|
784
|
+
)
|
|
785
|
+
return Panel(
|
|
786
|
+
syntax,
|
|
787
|
+
title=title,
|
|
788
|
+
border_style="bright_blue" if preview else "yellow",
|
|
789
|
+
box=box.ROUNDED,
|
|
790
|
+
padding=(1, 2),
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
def _render_changes(self, changes: Sequence[TaskChange]) -> None:
|
|
794
|
+
table = Table(
|
|
795
|
+
title=self.t("changes.title"),
|
|
796
|
+
box=box.SIMPLE_HEAD,
|
|
797
|
+
show_edge=False,
|
|
798
|
+
header_style="bold",
|
|
799
|
+
padding=(0, 1),
|
|
800
|
+
)
|
|
801
|
+
table.add_column(self.t("changes.field"), style="cyan", no_wrap=True)
|
|
802
|
+
table.add_column(self.t("changes.before"), style="dim")
|
|
803
|
+
table.add_column(self.t("changes.after"), style="white")
|
|
804
|
+
not_set = self.t("task.not_set")
|
|
805
|
+
for change in changes:
|
|
806
|
+
label = self.t(_FIELD_LABEL_KEYS.get(change.field, "changes.field"))
|
|
807
|
+
if change.field == "tool":
|
|
808
|
+
before = _tool_label(change.before)
|
|
809
|
+
after = _tool_label(change.after)
|
|
810
|
+
else:
|
|
811
|
+
before = _format_change_value(
|
|
812
|
+
change.field,
|
|
813
|
+
change.before,
|
|
814
|
+
not_set,
|
|
815
|
+
yes=self.t("task.yes"),
|
|
816
|
+
no=self.t("task.no"),
|
|
817
|
+
)
|
|
818
|
+
after = _format_change_value(
|
|
819
|
+
change.field,
|
|
820
|
+
change.after,
|
|
821
|
+
not_set,
|
|
822
|
+
yes=self.t("task.yes"),
|
|
823
|
+
no=self.t("task.no"),
|
|
824
|
+
)
|
|
825
|
+
table.add_row(label, Text(before, style="dim"), Text(after))
|
|
826
|
+
self.console.print(table)
|
|
827
|
+
|
|
828
|
+
def _show_overwrite_notice(self, output: str | None) -> None:
|
|
829
|
+
if output and Path(output).exists():
|
|
830
|
+
self.console.print(Text(self.t("panel.overwrite_warning"), style="yellow"))
|
|
831
|
+
|
|
832
|
+
def _render_banner(self) -> None:
|
|
833
|
+
self.console.print(f"[bold cyan]ffai[/bold cyan] [dim]v{__version__}[/dim]")
|
|
834
|
+
self.console.print(Text(self.t("banner.tagline"), style="dim"))
|
|
835
|
+
self.console.print()
|
|
836
|
+
|
|
837
|
+
def _render_startup_notes(self) -> None:
|
|
838
|
+
provider = self._provider_name(self._current_provider())
|
|
839
|
+
endpoint = self.settings.base_url or self.t("config.openai_default")
|
|
840
|
+
api_line = Text()
|
|
841
|
+
api_line.append(self.t("startup.api_label"), style="dim")
|
|
842
|
+
api_line.append(
|
|
843
|
+
self.t(
|
|
844
|
+
"startup.api_value",
|
|
845
|
+
provider=provider,
|
|
846
|
+
model=self.settings.model,
|
|
847
|
+
endpoint=endpoint,
|
|
848
|
+
),
|
|
849
|
+
style="dim",
|
|
850
|
+
)
|
|
851
|
+
self.console.print(api_line)
|
|
852
|
+
for tool in missing_tools():
|
|
853
|
+
self.console.print(
|
|
854
|
+
Text(
|
|
855
|
+
self.t("startup.tool_missing", tool=TOOL_SPECS[tool].label),
|
|
856
|
+
style="yellow",
|
|
857
|
+
)
|
|
858
|
+
)
|
|
859
|
+
self.console.print(Text(self.t("startup.hint"), style="dim"))
|
|
860
|
+
self.console.print()
|
|
861
|
+
|
|
862
|
+
def _success(self, message: str) -> None:
|
|
863
|
+
text = Text()
|
|
864
|
+
text.append("✓ ", style="green")
|
|
865
|
+
text.append(message)
|
|
866
|
+
self.console.print(text)
|
|
867
|
+
|
|
868
|
+
def _show_warning(self, message: str) -> None:
|
|
869
|
+
text = Text()
|
|
870
|
+
text.append("! ", style="yellow")
|
|
871
|
+
text.append(message, style="yellow")
|
|
872
|
+
self.console.print(text)
|
|
873
|
+
|
|
874
|
+
def _show_error(self, title: str, exc: FFAIError) -> None:
|
|
875
|
+
self.console.print(
|
|
876
|
+
Panel(
|
|
877
|
+
Text(str(exc), style="red"),
|
|
878
|
+
title=f"[bold red]{title}[/bold red]",
|
|
879
|
+
border_style="red",
|
|
880
|
+
box=box.ROUNDED,
|
|
881
|
+
)
|
|
882
|
+
)
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def _tool_label(name: object) -> str:
|
|
886
|
+
spec = TOOL_SPECS.get(name) if isinstance(name, str) else None
|
|
887
|
+
return spec.label if spec is not None else str(name)
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def _format_field(field: str, value: object, not_set: str, *, yes: str, no: str) -> Text:
|
|
891
|
+
if isinstance(value, bool):
|
|
892
|
+
return Text(yes if value else no)
|
|
893
|
+
if value is None or value == [] or value == "":
|
|
894
|
+
return Text(not_set, style="dim")
|
|
895
|
+
if isinstance(value, list):
|
|
896
|
+
if field == "extra_options":
|
|
897
|
+
return Text(shlex.join(str(item) for item in value))
|
|
898
|
+
return Text(", ".join(str(item) for item in value))
|
|
899
|
+
return Text(str(value))
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def _format_change_value(
|
|
903
|
+
field: str,
|
|
904
|
+
value: object,
|
|
905
|
+
not_set: str,
|
|
906
|
+
*,
|
|
907
|
+
yes: str,
|
|
908
|
+
no: str,
|
|
909
|
+
) -> str:
|
|
910
|
+
if isinstance(value, bool):
|
|
911
|
+
return yes if value else no
|
|
912
|
+
if value is None or value == [] or value == "":
|
|
913
|
+
return not_set
|
|
914
|
+
if isinstance(value, list):
|
|
915
|
+
if field == "extra_options":
|
|
916
|
+
return shlex.join(str(item) for item in value)
|
|
917
|
+
return ", ".join(str(item) for item in value)
|
|
918
|
+
return str(value)
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def _mask_secret(secret: SecretStr | None, not_set: str) -> str:
|
|
922
|
+
if secret is None:
|
|
923
|
+
return not_set
|
|
924
|
+
value = secret.get_secret_value()
|
|
925
|
+
if len(value) <= 8:
|
|
926
|
+
return "********"
|
|
927
|
+
return f"********{value[-4:]}"
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def _validation_message(exc: ValidationError | ConfigError) -> str:
|
|
931
|
+
if isinstance(exc, ConfigError):
|
|
932
|
+
return str(exc)
|
|
933
|
+
return "; ".join(error["msg"] for error in exc.errors())
|