lcode-cli 0.40.0__py3-none-any.whl → 0.41.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.
- lcode/__init__.py +1 -1
- lcode/agent.py +4 -0
- lcode/api.py +1 -0
- lcode/cli.py +64 -7
- lcode/comfy.py +13 -19
- lcode/config.py +7 -0
- lcode/media.py +46 -15
- lcode/media.toml +74 -0
- lcode/media_catalog.py +331 -0
- lcode/modes.py +1 -1
- lcode/repl.py +43 -9
- lcode/workflows/flux2-klein-4b-t2i.json +2 -9
- lcode/workflows/z-image-turbo-t2i.json +31 -0
- {lcode_cli-0.40.0.dist-info → lcode_cli-0.41.0.dist-info}/METADATA +1 -1
- {lcode_cli-0.40.0.dist-info → lcode_cli-0.41.0.dist-info}/RECORD +18 -15
- {lcode_cli-0.40.0.dist-info → lcode_cli-0.41.0.dist-info}/WHEEL +0 -0
- {lcode_cli-0.40.0.dist-info → lcode_cli-0.41.0.dist-info}/entry_points.txt +0 -0
- {lcode_cli-0.40.0.dist-info → lcode_cli-0.41.0.dist-info}/licenses/LICENSE +0 -0
lcode/__init__.py
CHANGED
lcode/agent.py
CHANGED
|
@@ -308,6 +308,7 @@ class Settings:
|
|
|
308
308
|
comfyui_url: str = "http://127.0.0.1:8188"
|
|
309
309
|
comfyui_path: str | None = None # a ComfyUI install lcode doesn't find by itself (lcode.comfy)
|
|
310
310
|
media_dir: str = ".lcode/media" # where image and video mode save their files (lcode.media)
|
|
311
|
+
image_model: str = "flux2-klein-4b" # the media catalog's model image mode uses (lcode.media_catalog)
|
|
311
312
|
num_batch: int | None = None
|
|
312
313
|
keep_alive: str = "30m"
|
|
313
314
|
think: bool = True
|
|
@@ -687,6 +688,7 @@ class Agent:
|
|
|
687
688
|
"cwd": str(self.workspace), # where `lcode -c` there finds it, wherever the shell moved
|
|
688
689
|
"model": self.settings.model,
|
|
689
690
|
"mode": self.settings.mode,
|
|
691
|
+
"image_model": self.settings.image_model,
|
|
690
692
|
"name": self.session_name,
|
|
691
693
|
"title": self.session_title,
|
|
692
694
|
"messages": self.messages,
|
|
@@ -735,6 +737,8 @@ class Agent:
|
|
|
735
737
|
self.session_name = data.get("name", "")
|
|
736
738
|
if data.get("mode") in ("code", "image", "video"):
|
|
737
739
|
self.settings.mode = data["mode"] # a session belongs to the mode it was made in
|
|
740
|
+
if isinstance(data.get("image_model"), str):
|
|
741
|
+
self.settings.image_model = data["image_model"]
|
|
738
742
|
self.session_title = data.get("title") or sessions.title_from(self.messages)
|
|
739
743
|
self.tools.read_mtimes.clear() # files may have changed since; the model must read them again
|
|
740
744
|
self.tools.todos = list(data.get("todos") or [])
|
lcode/api.py
CHANGED
|
@@ -200,6 +200,7 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
|
|
|
200
200
|
comfyui_url=cfg["comfyui_url"],
|
|
201
201
|
comfyui_path=cfg["comfyui_path"],
|
|
202
202
|
media_dir=cfg["media_dir"],
|
|
203
|
+
image_model=cfg["image_model"],
|
|
203
204
|
web=switch(options.web, cfg["web"], "on"),
|
|
204
205
|
search_backend=cfg["search_backend"],
|
|
205
206
|
searxng_url=cfg["searxng_url"],
|
lcode/cli.py
CHANGED
|
@@ -777,9 +777,10 @@ def exit_on_hangup() -> None:
|
|
|
777
777
|
|
|
778
778
|
def comfyui_status(cfg: dict) -> tuple[str, bool | None]:
|
|
779
779
|
"""lcode doctor's ComfyUI line: running or found, and whether the image model is there."""
|
|
780
|
-
from lcode import comfy
|
|
780
|
+
from lcode import comfy
|
|
781
|
+
from lcode import media_catalog as catalog
|
|
781
782
|
|
|
782
|
-
|
|
783
|
+
model = catalog.load().find(cfg.get("image_model") or "") or catalog.load().models[catalog.DEFAULT_IMAGE]
|
|
783
784
|
server = comfy.Server(cfg["comfyui_url"])
|
|
784
785
|
stats = server.stats()
|
|
785
786
|
install = None if stats else comfy.find_install(cfg["comfyui_path"])
|
|
@@ -787,14 +788,13 @@ def comfyui_status(cfg: dict) -> tuple[str, bool | None]:
|
|
|
787
788
|
return "not found: image mode offers to install it (or lcode config set comfyui_path <folder>)", None
|
|
788
789
|
if stats:
|
|
789
790
|
where = f"{(stats.get('system') or {}).get('comfyui_version', '?')} running at {server.url}"
|
|
790
|
-
installed = {folder: set(server.models(folder)) for folder in workflow.models}
|
|
791
791
|
else:
|
|
792
792
|
where = f"{install.path} (lcode starts it in image mode)"
|
|
793
|
-
|
|
794
|
-
missing = [f"{f}/{names[0]}" for f, names in workflow.models.items() if not installed[f] & set(names)]
|
|
793
|
+
_, missing = catalog.chosen_files(model, catalog.lister(server if stats else None, install))
|
|
795
794
|
if missing:
|
|
796
|
-
|
|
797
|
-
|
|
795
|
+
size = sum(f.gb for f in missing)
|
|
796
|
+
return f"{where} · {model.name} not installed: lcode media install {model.key} ({size:.1f} GB)", None
|
|
797
|
+
return f"{where} · image model: {model.name}", True
|
|
798
798
|
|
|
799
799
|
|
|
800
800
|
def media_request(agent, prompt: str, out: Console, quiet: bool) -> None:
|
|
@@ -909,6 +909,13 @@ def build_subparsers() -> dict[str, argparse.ArgumentParser]:
|
|
|
909
909
|
subs["setup"] = p
|
|
910
910
|
subs["models"] = argparse.ArgumentParser(prog="lcode models", description="List models and how they fit here.")
|
|
911
911
|
subs["doctor"] = argparse.ArgumentParser(prog="lcode doctor", description="Check the installation.")
|
|
912
|
+
p = argparse.ArgumentParser(
|
|
913
|
+
prog="lcode media", description="Image and video models: list them, with how they fit here, or install one."
|
|
914
|
+
)
|
|
915
|
+
p.add_argument("action", nargs="?", choices=["list", "install"], default="list")
|
|
916
|
+
p.add_argument("model", nargs="?", help="with install: a model key from the list, e.g. z-image-turbo")
|
|
917
|
+
p.add_argument("-y", "--yes", action="store_true", help="don't ask before downloading")
|
|
918
|
+
subs["media"] = p
|
|
912
919
|
p = argparse.ArgumentParser(prog="lcode config", description="Show or change settings.")
|
|
913
920
|
p.add_argument("action", nargs="?", choices=["show", "set", "unset", "path"], default="show")
|
|
914
921
|
p.add_argument("key", nargs="?", choices=list(config.SETTINGS))
|
|
@@ -1065,6 +1072,55 @@ def cmd_index(args) -> None:
|
|
|
1065
1072
|
)
|
|
1066
1073
|
|
|
1067
1074
|
|
|
1075
|
+
def cmd_media(args) -> None:
|
|
1076
|
+
"""lcode media [list] | install <model>: the media catalog, what's installed in ComfyUI, downloads."""
|
|
1077
|
+
from rich.prompt import Confirm
|
|
1078
|
+
|
|
1079
|
+
from lcode import comfy, media
|
|
1080
|
+
from lcode import media_catalog as catalog
|
|
1081
|
+
|
|
1082
|
+
try:
|
|
1083
|
+
cfg = config.load()
|
|
1084
|
+
except ConfigError as e:
|
|
1085
|
+
fail(str(e))
|
|
1086
|
+
cat, hw = catalog.load(), detect()
|
|
1087
|
+
server = comfy.Server(cfg["comfyui_url"])
|
|
1088
|
+
install = comfy.find_install(cfg["comfyui_path"])
|
|
1089
|
+
running = server.stats() is not None
|
|
1090
|
+
installed = catalog.lister(server if running else None, install) if running or install else None
|
|
1091
|
+
if args.action == "install":
|
|
1092
|
+
model = cat.find(args.model or "")
|
|
1093
|
+
if model is None:
|
|
1094
|
+
fail(f"no media model {args.model!r}: lcode media lists them")
|
|
1095
|
+
if not running and install is None:
|
|
1096
|
+
fail(f"lcode needs ComfyUI to install models into. {comfy.install_hint()}")
|
|
1097
|
+
ask = None if args.yes else (lambda q: Confirm.ask(q, default=True, console=console))
|
|
1098
|
+
try:
|
|
1099
|
+
saved = catalog.install(model, server if running else None, install, console, ask)
|
|
1100
|
+
except catalog.CatalogError as e:
|
|
1101
|
+
fail(str(e))
|
|
1102
|
+
how = f" ({len(saved)} file(s) downloaded)." if saved else "."
|
|
1103
|
+
console.print(f"[green]{model.name} is installed[/]{how}")
|
|
1104
|
+
return
|
|
1105
|
+
where = (
|
|
1106
|
+
f"ComfyUI at {server.url}" if running else (f"{install.path} (not running)" if install else "no ComfyUI found")
|
|
1107
|
+
)
|
|
1108
|
+
console.print(f"[dim]{hw.describe()} · {where} · image model: {cfg['image_model']}[/]\n")
|
|
1109
|
+
for mode in ("image", "video"):
|
|
1110
|
+
models = cat.for_mode(mode)
|
|
1111
|
+
console.print(f"[bold]{mode.capitalize()} models[/]")
|
|
1112
|
+
if not models:
|
|
1113
|
+
console.print(f" [dim]{media.SOON[mode]}[/]\n")
|
|
1114
|
+
continue
|
|
1115
|
+
for m in models:
|
|
1116
|
+
line = escape(catalog.describe(m, hw, installed))
|
|
1117
|
+
console.print(f" [bold]{m.key:<16}[/] {escape(m.name)} [dim]· {line}[/]")
|
|
1118
|
+
if m.measured:
|
|
1119
|
+
console.print(f" {'':<16} [dim]{escape(m.measured)}[/]")
|
|
1120
|
+
console.print()
|
|
1121
|
+
console.print("[dim]Install one: lcode media install <model> · choose in image mode: /model <model>[/]")
|
|
1122
|
+
|
|
1123
|
+
|
|
1068
1124
|
def cmd_mcp(args) -> None:
|
|
1069
1125
|
from lcode.mcp.commands import run
|
|
1070
1126
|
|
|
@@ -1075,6 +1131,7 @@ COMMANDS = {
|
|
|
1075
1131
|
"setup": cmd_setup,
|
|
1076
1132
|
"models": cmd_models,
|
|
1077
1133
|
"doctor": cmd_doctor,
|
|
1134
|
+
"media": cmd_media,
|
|
1078
1135
|
"config": cmd_config,
|
|
1079
1136
|
"bench": cmd_bench,
|
|
1080
1137
|
"mcp": cmd_mcp,
|
lcode/comfy.py
CHANGED
|
@@ -254,6 +254,14 @@ class Server:
|
|
|
254
254
|
except (requests.RequestException, ValueError):
|
|
255
255
|
return []
|
|
256
256
|
|
|
257
|
+
def folder_paths(self) -> dict[str, list[str]]:
|
|
258
|
+
"""Where ComfyUI keeps each kind of model (its own folders and extra_model_paths.yaml's)."""
|
|
259
|
+
try:
|
|
260
|
+
r = requests.get(self.url + "/internal/folder_paths", timeout=10)
|
|
261
|
+
return r.json() if r.status_code == 200 else {}
|
|
262
|
+
except (requests.RequestException, ValueError):
|
|
263
|
+
return {}
|
|
264
|
+
|
|
257
265
|
def upload(self, path: Path) -> str:
|
|
258
266
|
"""Upload an input image; the name to use in a workflow."""
|
|
259
267
|
with path.open("rb") as f:
|
|
@@ -420,32 +428,18 @@ class Events:
|
|
|
420
428
|
|
|
421
429
|
@dataclass
|
|
422
430
|
class Workflow:
|
|
431
|
+
"""A graph in ComfyUI's API format with `$name` placeholders: the model's files (by role, from media.toml),
|
|
432
|
+
the prompt, the seed and the settings in `defaults`."""
|
|
433
|
+
|
|
423
434
|
key: str
|
|
424
|
-
name: str
|
|
425
|
-
task: str
|
|
426
|
-
models: dict[str, list[str]] # ComfyUI folder -> file names it can use, preferred first
|
|
427
|
-
defaults: dict
|
|
428
435
|
graph: dict
|
|
436
|
+
defaults: dict = field(default_factory=dict)
|
|
429
437
|
labels: dict[str, str] = field(default_factory=dict) # node id -> what it does, for progress
|
|
430
438
|
|
|
431
439
|
|
|
432
440
|
def load_workflow(key: str) -> Workflow:
|
|
433
441
|
data = json.loads(resources.files("lcode.workflows").joinpath(f"{key}.json").read_text())
|
|
434
|
-
return Workflow(key, data["
|
|
435
|
-
data.get("labels", {})) # fmt: skip
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
def choose_files(server: Server, workflow: Workflow) -> tuple[dict[str, str], list[str]]:
|
|
439
|
-
"""For each model the workflow needs, the installed file to use; and the folders where none is installed."""
|
|
440
|
-
chosen, missing = {}, []
|
|
441
|
-
for folder, names in workflow.models.items():
|
|
442
|
-
installed = set(server.models(folder))
|
|
443
|
-
found = next((n for n in names if n in installed), None)
|
|
444
|
-
if found:
|
|
445
|
-
chosen[folder] = found
|
|
446
|
-
else:
|
|
447
|
-
missing.append(f"{folder}/{names[0]}")
|
|
448
|
-
return chosen, missing
|
|
442
|
+
return Workflow(key, data["graph"], data.get("defaults", {}), data.get("labels", {}))
|
|
449
443
|
|
|
450
444
|
|
|
451
445
|
PLACEHOLDER = re.compile(r"^\$([a-z_]+)$")
|
lcode/config.py
CHANGED
|
@@ -45,6 +45,7 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
|
|
|
45
45
|
"comfyui_url": ("http://127.0.0.1:8188", str, "ComfyUI, for image and video mode"),
|
|
46
46
|
"comfyui_path": (None, str, "your ComfyUI folder, if lcode doesn't find it (lcode starts it when needed)"),
|
|
47
47
|
"media_dir": (".lcode/media", str, "where images and videos go: a folder in the project, or an absolute path"),
|
|
48
|
+
"image_model": ("flux2-klein-4b", str, "the model image mode uses: a key from `lcode media`"),
|
|
48
49
|
"think": (True, bool, "let the model reason before answering (slower, better)"),
|
|
49
50
|
"web": ("on", str, "web search and page fetching for the model: on | ask | off"),
|
|
50
51
|
"search_backend": ("auto", str, "auto | ollama | brave | tavily | searxng (keys come from environment variables)"),
|
|
@@ -210,6 +211,12 @@ def coerce(key: str, value: object) -> object:
|
|
|
210
211
|
raise ConfigError(f"permission_mode must be one of {', '.join(PERMISSION_MODES)}")
|
|
211
212
|
if key == "mode" and value not in WORK_MODES:
|
|
212
213
|
raise ConfigError(f"mode must be one of {', '.join(WORK_MODES)}")
|
|
214
|
+
if key == "image_model":
|
|
215
|
+
from lcode import media_catalog
|
|
216
|
+
|
|
217
|
+
keys = [m.key for m in media_catalog.load().for_mode("image")]
|
|
218
|
+
if value not in keys:
|
|
219
|
+
raise ConfigError(f"image_model must be one of {', '.join(keys)} (see lcode media)")
|
|
213
220
|
if typ is bool and isinstance(value, str):
|
|
214
221
|
if value.lower() not in ("true", "false", "1", "0", "yes", "no", "on", "off"):
|
|
215
222
|
raise ConfigError(f"{key} must be true or false")
|
lcode/media.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Image and video mode: a request becomes media, made by ComfyUI (lcode.comfy) with no language model in between.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
variations and a gallery come with #
|
|
3
|
+
Image mode makes one image per request with the image model chosen from the media catalog (lcode.media_catalog);
|
|
4
|
+
sizes, variations and a gallery come with #151, video with #153.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
@@ -16,11 +16,12 @@ from typing import TYPE_CHECKING
|
|
|
16
16
|
from rich.text import Text
|
|
17
17
|
|
|
18
18
|
from lcode import comfy
|
|
19
|
+
from lcode import media_catalog as catalog
|
|
19
20
|
|
|
20
21
|
if TYPE_CHECKING:
|
|
21
22
|
from lcode.agent import Agent
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
SOON = {"video": "Video models come with video generation (#153)."}
|
|
24
25
|
IGNORED = "# lcode's images and videos: not part of the project unless you add them\n*\n"
|
|
25
26
|
|
|
26
27
|
|
|
@@ -28,8 +29,19 @@ class MediaError(Exception):
|
|
|
28
29
|
pass
|
|
29
30
|
|
|
30
31
|
|
|
31
|
-
def
|
|
32
|
-
|
|
32
|
+
def installed_lister(agent: Agent) -> catalog.Installed | None:
|
|
33
|
+
"""What ComfyUI has installed: asked from it when it's running, else read from its folders; None: unknown."""
|
|
34
|
+
server = comfy.Server(agent.settings.comfyui_url)
|
|
35
|
+
if server.stats():
|
|
36
|
+
return catalog.lister(server)
|
|
37
|
+
install = comfy.find_install(agent.settings.comfyui_path)
|
|
38
|
+
return catalog.lister(install=install) if install else None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def current(agent: Agent) -> catalog.MediaModel:
|
|
42
|
+
"""The image model the agent uses (the image_model setting, or /model in image mode)."""
|
|
43
|
+
cat = catalog.load()
|
|
44
|
+
return cat.find(agent.settings.image_model) or cat.models[catalog.DEFAULT_IMAGE]
|
|
33
45
|
|
|
34
46
|
|
|
35
47
|
@dataclass
|
|
@@ -183,6 +195,28 @@ class Line:
|
|
|
183
195
|
return text
|
|
184
196
|
|
|
185
197
|
|
|
198
|
+
def ensure_installed(
|
|
199
|
+
agent: Agent, model: catalog.MediaModel, server: comfy.Server, ask: Callable[[str], bool] | None
|
|
200
|
+
) -> dict[str, str]:
|
|
201
|
+
"""The model's files (role -> installed name), downloading the missing ones first when the user agrees."""
|
|
202
|
+
files, missing = catalog.chosen_files(model, catalog.lister(server))
|
|
203
|
+
if not missing:
|
|
204
|
+
return files
|
|
205
|
+
size = sum(f.gb for f in missing)
|
|
206
|
+
if ask is None:
|
|
207
|
+
raise MediaError(f"{model.name} isn't installed ({size:.1f} GB to download): lcode media install {model.key}")
|
|
208
|
+
install = comfy.find_install(agent.settings.comfyui_path)
|
|
209
|
+
try:
|
|
210
|
+
catalog.install(model, server=server, install=install, console=agent.console, ask=ask)
|
|
211
|
+
except catalog.CatalogError as e:
|
|
212
|
+
raise MediaError(str(e)) from e
|
|
213
|
+
files, missing = catalog.chosen_files(model, catalog.lister(server))
|
|
214
|
+
if missing:
|
|
215
|
+
raise MediaError(f"ComfyUI doesn't see {', '.join(f.name for f in missing)} yet: restart it and try again")
|
|
216
|
+
agent.say(f"Installed {model.name}.")
|
|
217
|
+
return files
|
|
218
|
+
|
|
219
|
+
|
|
186
220
|
def generate_image(
|
|
187
221
|
agent: Agent, prompt: str, cancelled: Callable[[], bool] = lambda: False, ask: Callable[[str], bool] | None = None
|
|
188
222
|
) -> Made:
|
|
@@ -193,20 +227,17 @@ def generate_image(
|
|
|
193
227
|
server = engine(agent, ask, cancelled)
|
|
194
228
|
for path in comfy.collect(server):
|
|
195
229
|
agent.say(f"Saved {shown(agent, path)}: it finished while lcode wasn't watching.")
|
|
196
|
-
|
|
197
|
-
files
|
|
198
|
-
|
|
199
|
-
raise MediaError(
|
|
200
|
-
f"{workflow.name} isn't installed in ComfyUI (missing {', '.join(missing)}). lcode installs image "
|
|
201
|
-
"models from #150 on; until then put the files in ComfyUI's models folder."
|
|
202
|
-
)
|
|
230
|
+
model = current(agent)
|
|
231
|
+
files = ensure_installed(agent, model, server, ask)
|
|
232
|
+
workflow = comfy.load_workflow(model.workflow)
|
|
203
233
|
free_gpu(agent)
|
|
204
234
|
seed = random.randrange(2**32)
|
|
205
235
|
values = {**workflow.defaults, **files, "prompt": prompt, "seed": seed}
|
|
206
236
|
graph = comfy.fill(workflow.graph, values)
|
|
207
237
|
meta = {
|
|
208
238
|
"prompt": prompt,
|
|
209
|
-
"model":
|
|
239
|
+
"model": model.name,
|
|
240
|
+
"model_key": model.key,
|
|
210
241
|
"workflow": workflow.key,
|
|
211
242
|
"seed": seed,
|
|
212
243
|
"width": values["width"],
|
|
@@ -217,7 +248,7 @@ def generate_image(
|
|
|
217
248
|
}
|
|
218
249
|
out = folder(agent)
|
|
219
250
|
stem = f"{time.strftime('%Y%m%d-%H%M%S')}-{comfy.slug(prompt)}"
|
|
220
|
-
line = Line(
|
|
251
|
+
line = Line(model.name, "Esc or Ctrl+C cancels" if agent.interactive else "")
|
|
221
252
|
job = {"id": ""}
|
|
222
253
|
|
|
223
254
|
def on_event(event: comfy.Event) -> None:
|
|
@@ -252,7 +283,7 @@ def generate_image(
|
|
|
252
283
|
comfy.forget(prompt_id)
|
|
253
284
|
if not saved:
|
|
254
285
|
raise MediaError("ComfyUI finished but returned no image.")
|
|
255
|
-
made = Made(saved,
|
|
286
|
+
made = Made(saved, model.name, seed, values["width"], values["height"], time.monotonic() - line.started)
|
|
256
287
|
for path in saved:
|
|
257
288
|
agent.console.print(
|
|
258
289
|
Text("◆ ", style="green")
|
lcode/media.toml
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Image and video models lcode can run through ComfyUI (lcode.media_catalog).
|
|
2
|
+
#
|
|
3
|
+
# [files.<key>] one file in ComfyUI's model folders, listed once: models share text encoders and VAEs.
|
|
4
|
+
# folder ComfyUI's model folder (diffusion_models, text_encoders, vae, loras, …)
|
|
5
|
+
# name the file name there
|
|
6
|
+
# url where lcode downloads it from (Hugging Face; HF_TOKEN is used for gated files)
|
|
7
|
+
# gb its size in GB (10^9 bytes)
|
|
8
|
+
# alternatives other file names that do the same job: an installed one is used instead of downloading
|
|
9
|
+
#
|
|
10
|
+
# [models.<key>] one model.
|
|
11
|
+
# mode image or video
|
|
12
|
+
# tasks text-to-image, edit, inpaint, text-to-video, image-to-video, first-last-frame, reference-to-video, audio
|
|
13
|
+
# workflow lcode's workflow for it (lcode/workflows/<workflow>.json)
|
|
14
|
+
# files role -> file key; the workflow's $role placeholders get the file names
|
|
15
|
+
# gpu_gb GPU memory it needs, when known better than lcode's estimate (the largest file + 1.5 GB); ComfyUI
|
|
16
|
+
# fills whatever is free with cached models, so nvidia-smi's peak doesn't tell
|
|
17
|
+
# measured what it did on real hardware; models without it haven't been run yet
|
|
18
|
+
|
|
19
|
+
[files.flux-2-klein-4b-fp8]
|
|
20
|
+
folder = "diffusion_models"
|
|
21
|
+
name = "flux-2-klein-4b-fp8.safetensors"
|
|
22
|
+
url = "https://huggingface.co/black-forest-labs/FLUX.2-klein-4b-fp8/resolve/main/flux-2-klein-4b-fp8.safetensors"
|
|
23
|
+
gb = 4.07
|
|
24
|
+
alternatives = ["flux-2-klein-4b.safetensors"]
|
|
25
|
+
|
|
26
|
+
[files.qwen-3-4b]
|
|
27
|
+
folder = "text_encoders"
|
|
28
|
+
name = "qwen_3_4b.safetensors"
|
|
29
|
+
url = "https://huggingface.co/Comfy-Org/flux2-klein/resolve/main/split_files/text_encoders/qwen_3_4b.safetensors"
|
|
30
|
+
gb = 8.04
|
|
31
|
+
alternatives = ["qwen_3_4b_fp8_mixed.safetensors"]
|
|
32
|
+
|
|
33
|
+
[files.flux2-vae]
|
|
34
|
+
folder = "vae"
|
|
35
|
+
name = "flux2-vae.safetensors"
|
|
36
|
+
url = "https://huggingface.co/Comfy-Org/flux2-dev/resolve/main/split_files/vae/flux2-vae.safetensors"
|
|
37
|
+
gb = 0.34
|
|
38
|
+
|
|
39
|
+
[files.z-image-turbo-int8]
|
|
40
|
+
folder = "diffusion_models"
|
|
41
|
+
name = "z_image_turbo_int8_convrot.safetensors"
|
|
42
|
+
url = "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/diffusion_models/z_image_turbo_int8_convrot.safetensors"
|
|
43
|
+
gb = 6.20
|
|
44
|
+
alternatives = ["z_image_turbo_bf16.safetensors"]
|
|
45
|
+
|
|
46
|
+
[files.flux-ae]
|
|
47
|
+
folder = "vae"
|
|
48
|
+
name = "ae.safetensors"
|
|
49
|
+
url = "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/vae/ae.safetensors"
|
|
50
|
+
gb = 0.34
|
|
51
|
+
|
|
52
|
+
[models.flux2-klein-4b]
|
|
53
|
+
name = "FLUX.2 klein 4B"
|
|
54
|
+
mode = "image"
|
|
55
|
+
publisher = "Black Forest Labs"
|
|
56
|
+
license = "Apache 2.0"
|
|
57
|
+
tasks = ["text-to-image"]
|
|
58
|
+
workflow = "flux2-klein-4b-t2i"
|
|
59
|
+
files = { model = "flux-2-klein-4b-fp8", text_encoder = "qwen-3-4b", vae = "flux2-vae" }
|
|
60
|
+
gpu_gb = 0.0
|
|
61
|
+
measured = "1024×1024: 3.5 s per image, 14 s for the first with ComfyUI starting (RTX 4080 Laptop, 12 GB)"
|
|
62
|
+
notes = "fast and small: 4 steps"
|
|
63
|
+
|
|
64
|
+
[models.z-image-turbo]
|
|
65
|
+
name = "Z-Image Turbo"
|
|
66
|
+
mode = "image"
|
|
67
|
+
publisher = "Tongyi-MAI (Alibaba)"
|
|
68
|
+
license = "Apache 2.0"
|
|
69
|
+
tasks = ["text-to-image"]
|
|
70
|
+
workflow = "z-image-turbo-t2i"
|
|
71
|
+
files = { model = "z-image-turbo-int8", text_encoder = "qwen-3-4b", vae = "flux-ae" }
|
|
72
|
+
gpu_gb = 0.0
|
|
73
|
+
measured = "1024×1024: 17 s per image (1.9 s per step), 23 s for the first (RTX 4080 Laptop, 12 GB)"
|
|
74
|
+
notes = "photorealistic, writes text well: 8 steps"
|
lcode/media_catalog.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""The image and video models lcode can run (media.toml): what they need, whether they fit, installing them.
|
|
2
|
+
|
|
3
|
+
A model is a workflow plus the files it uses in ComfyUI's model folders. Files are listed once, because models share
|
|
4
|
+
text encoders and VAEs, and a file counts as installed under its own name or one of its alternatives (an fp8 or a
|
|
5
|
+
full-precision build of the same weights). Installing downloads the missing files into ComfyUI's model folders:
|
|
6
|
+
resumably (a `.part` file grows until it's complete), checked against Hugging Face's SHA-256, after a disk check.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import sys
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from functools import cache
|
|
18
|
+
from importlib import resources
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from urllib.parse import urlsplit
|
|
21
|
+
|
|
22
|
+
import requests
|
|
23
|
+
|
|
24
|
+
from lcode.hardware import GIB, Hardware
|
|
25
|
+
|
|
26
|
+
if sys.version_info >= (3, 11):
|
|
27
|
+
import tomllib
|
|
28
|
+
else: # pragma: no cover
|
|
29
|
+
import tomli as tomllib
|
|
30
|
+
|
|
31
|
+
DEFAULT_IMAGE = "flux2-klein-4b"
|
|
32
|
+
DISK_MARGIN_GB = 2.0 # left free after a download
|
|
33
|
+
GPU_MARGIN_GIB = 0.5
|
|
34
|
+
WORKING_GB = 1.5 # activations and buffers on top of the largest file, for models not measured yet
|
|
35
|
+
CHUNK = 1 << 20
|
|
36
|
+
http_get = requests.get # what downloads use (tests replace it)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class CatalogError(Exception):
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class File:
|
|
45
|
+
key: str
|
|
46
|
+
folder: str # ComfyUI's model folder
|
|
47
|
+
name: str
|
|
48
|
+
url: str
|
|
49
|
+
gb: float
|
|
50
|
+
alternatives: tuple[str, ...] = ()
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def names(self) -> tuple[str, ...]:
|
|
54
|
+
return (self.name, *self.alternatives)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class MediaModel:
|
|
59
|
+
key: str
|
|
60
|
+
name: str
|
|
61
|
+
mode: str # image or video
|
|
62
|
+
publisher: str
|
|
63
|
+
license: str
|
|
64
|
+
tasks: tuple[str, ...]
|
|
65
|
+
workflow: str
|
|
66
|
+
files: dict[str, File] = field(hash=False) # role -> file
|
|
67
|
+
gpu_gb: float = 0.0 # measured peak GPU memory; 0 when not measured yet
|
|
68
|
+
measured: str = ""
|
|
69
|
+
notes: str = ""
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def size_gb(self) -> float:
|
|
73
|
+
return sum(f.gb for f in {f.key: f for f in self.files.values()}.values())
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def need_gb(self) -> float:
|
|
77
|
+
"""GPU memory at the peak: measured, or estimated. ComfyUI loads the text encoder, the model and the VAE
|
|
78
|
+
one after another and moves what it doesn't need off the GPU, so the largest file sets the peak."""
|
|
79
|
+
return self.gpu_gb or max(f.gb for f in self.files.values()) + WORKING_GB
|
|
80
|
+
|
|
81
|
+
def fit(self, hw: Hardware) -> tuple[str, str]:
|
|
82
|
+
"""(fits | slower | too big | cpu, a short note) on this machine."""
|
|
83
|
+
weights_gib = self.size_gb * 1e9 / GIB
|
|
84
|
+
need_gib = self.need_gb * 1e9 / GIB
|
|
85
|
+
if hw.unified:
|
|
86
|
+
if need_gib <= hw.budget_gib - GPU_MARGIN_GIB:
|
|
87
|
+
return "fits", "fits in unified memory"
|
|
88
|
+
return "too big", f"needs ~{need_gib:.0f} GB, ~{hw.budget_gib:.0f} GB available"
|
|
89
|
+
if not hw.gpu or not hw.vram_gib:
|
|
90
|
+
return "cpu", "no GPU lcode can use: very slow on the CPU"
|
|
91
|
+
if need_gib <= hw.vram_gib - GPU_MARGIN_GIB:
|
|
92
|
+
return "fits", f"~{need_gib:.0f} GB of {hw.vram_gib:.0f} GB GPU memory"
|
|
93
|
+
if weights_gib <= hw.vram_gib + 0.6 * hw.ram_gib: # ComfyUI keeps the rest in RAM and swaps it in
|
|
94
|
+
return "slower", f"part runs from RAM ({need_gib:.0f} GB needed, {hw.vram_gib:.0f} GB GPU)"
|
|
95
|
+
return "too big", f"needs ~{weights_gib:.0f} GB, more than GPU and RAM together"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class Catalog:
|
|
100
|
+
files: dict[str, File]
|
|
101
|
+
models: dict[str, MediaModel]
|
|
102
|
+
|
|
103
|
+
def for_mode(self, mode: str) -> list[MediaModel]:
|
|
104
|
+
return [m for m in self.models.values() if m.mode == mode]
|
|
105
|
+
|
|
106
|
+
def find(self, wanted: str, mode: str | None = None) -> MediaModel | None:
|
|
107
|
+
"""A model by key or name (any case), or by its number in the mode's list."""
|
|
108
|
+
candidates = self.for_mode(mode) if mode else list(self.models.values())
|
|
109
|
+
if wanted.isdigit() and 1 <= int(wanted) <= len(candidates):
|
|
110
|
+
return candidates[int(wanted) - 1]
|
|
111
|
+
low = wanted.strip().lower()
|
|
112
|
+
return next((m for m in candidates if low in (m.key, m.name.lower())), None)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@cache
|
|
116
|
+
def load() -> Catalog:
|
|
117
|
+
data = tomllib.loads(resources.files("lcode").joinpath("media.toml").read_text())
|
|
118
|
+
files = {
|
|
119
|
+
key: File(key, f["folder"], f["name"], f["url"], float(f["gb"]), tuple(f.get("alternatives", ())))
|
|
120
|
+
for key, f in data.get("files", {}).items()
|
|
121
|
+
}
|
|
122
|
+
models = {}
|
|
123
|
+
for key, m in data.get("models", {}).items():
|
|
124
|
+
try:
|
|
125
|
+
roles = {role: files[ref] for role, ref in m["files"].items()}
|
|
126
|
+
except KeyError as e:
|
|
127
|
+
raise CatalogError(f"media.toml: {key} uses the file {e}, which isn't listed") from e
|
|
128
|
+
models[key] = MediaModel(
|
|
129
|
+
key,
|
|
130
|
+
m["name"],
|
|
131
|
+
m["mode"],
|
|
132
|
+
m.get("publisher", ""),
|
|
133
|
+
m.get("license", ""),
|
|
134
|
+
tuple(m.get("tasks", ())),
|
|
135
|
+
m["workflow"],
|
|
136
|
+
roles,
|
|
137
|
+
float(m.get("gpu_gb", 0.0)),
|
|
138
|
+
m.get("measured", ""),
|
|
139
|
+
m.get("notes", ""),
|
|
140
|
+
)
|
|
141
|
+
return Catalog(files, models)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ----------------------------------------------------------------------------- what's installed
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
Installed = Callable[[str], set[str]] # ComfyUI folder -> the file names in it
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def chosen_files(model: MediaModel, installed: Installed) -> tuple[dict[str, str], list[File]]:
|
|
151
|
+
"""For each role, the installed file name to use; and the files that aren't installed."""
|
|
152
|
+
chosen, missing = {}, []
|
|
153
|
+
for role, f in model.files.items():
|
|
154
|
+
found = next((n for n in f.names if n in installed(f.folder)), None)
|
|
155
|
+
if found:
|
|
156
|
+
chosen[role] = found
|
|
157
|
+
elif f not in missing:
|
|
158
|
+
missing.append(f)
|
|
159
|
+
return chosen, missing
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def lister(server=None, install=None) -> Installed:
|
|
163
|
+
"""What's in each model folder: asked from a running ComfyUI, or read from the install's folders."""
|
|
164
|
+
from lcode import comfy
|
|
165
|
+
|
|
166
|
+
seen: dict[str, set[str]] = {}
|
|
167
|
+
|
|
168
|
+
def installed(folder: str) -> set[str]:
|
|
169
|
+
if folder not in seen:
|
|
170
|
+
if server is not None:
|
|
171
|
+
seen[folder] = set(server.models(folder))
|
|
172
|
+
elif install is not None:
|
|
173
|
+
seen[folder] = set(comfy.local_models(install, folder))
|
|
174
|
+
else:
|
|
175
|
+
seen[folder] = set()
|
|
176
|
+
return seen[folder]
|
|
177
|
+
|
|
178
|
+
return installed
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def model_folder(folder: str, server=None, install=None) -> Path | None:
|
|
182
|
+
"""Where ComfyUI keeps one kind of model: from the running server, or the install's models/ folder."""
|
|
183
|
+
if server is not None:
|
|
184
|
+
paths = server.folder_paths().get(folder)
|
|
185
|
+
if paths:
|
|
186
|
+
return Path(paths[0])
|
|
187
|
+
if install is not None:
|
|
188
|
+
return install.path / "models" / folder
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ----------------------------------------------------------------------------- downloading
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def disk_problem(folder: Path, need_gb: float) -> str:
|
|
196
|
+
"""Why `need_gb` won't fit on the disk of `folder` (with a margin), or empty."""
|
|
197
|
+
probe = folder
|
|
198
|
+
while not probe.exists() and probe != probe.parent:
|
|
199
|
+
probe = probe.parent
|
|
200
|
+
free_gb = shutil.disk_usage(probe).free / 1e9
|
|
201
|
+
if need_gb + DISK_MARGIN_GB > free_gb:
|
|
202
|
+
return f"needs {need_gb:.1f} GB, and {probe} has {free_gb:.1f} GB free (lcode keeps {DISK_MARGIN_GB:.0f} GB)"
|
|
203
|
+
return ""
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def remaining_gb(f: File, folder: Path) -> float:
|
|
207
|
+
part = folder / (f.name + ".part")
|
|
208
|
+
have = part.stat().st_size / 1e9 if part.exists() else 0.0
|
|
209
|
+
return max(f.gb - have, 0.0)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def auth_headers(url: str) -> dict[str, str]:
|
|
213
|
+
token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
|
|
214
|
+
if token and urlsplit(url).hostname == "huggingface.co":
|
|
215
|
+
return {"Authorization": f"Bearer {token}"} # requests drops it when redirected to another host
|
|
216
|
+
return {}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def download(
|
|
220
|
+
url: str,
|
|
221
|
+
dest: Path,
|
|
222
|
+
on_progress: Callable[[int, int], None] = lambda done, total: None,
|
|
223
|
+
get=None,
|
|
224
|
+
) -> None:
|
|
225
|
+
"""Download `url` to `dest`, resuming a `.part` file left by an earlier try, and check its SHA-256."""
|
|
226
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
227
|
+
part = dest.with_name(dest.name + ".part")
|
|
228
|
+
have = part.stat().st_size if part.exists() else 0
|
|
229
|
+
headers = {**auth_headers(url), **({"Range": f"bytes={have}-"} if have else {})}
|
|
230
|
+
try:
|
|
231
|
+
r = (get or http_get)(url, headers=headers, stream=True, timeout=(15, 120), allow_redirects=True)
|
|
232
|
+
except requests.RequestException as e:
|
|
233
|
+
raise CatalogError(f"can't download {dest.name}: {e}") from e
|
|
234
|
+
with r:
|
|
235
|
+
if r.status_code == 416 and have: # nothing left to fetch: the .part is complete
|
|
236
|
+
pass
|
|
237
|
+
elif r.status_code in (401, 403):
|
|
238
|
+
raise CatalogError(
|
|
239
|
+
f"{dest.name} needs a Hugging Face login: accept its license on huggingface.co, then set HF_TOKEN"
|
|
240
|
+
)
|
|
241
|
+
elif r.status_code not in (200, 206):
|
|
242
|
+
raise CatalogError(f"can't download {dest.name}: HTTP {r.status_code}")
|
|
243
|
+
else:
|
|
244
|
+
if r.status_code == 200 and have: # the server ignored the range: start over
|
|
245
|
+
have = 0
|
|
246
|
+
total = have + int(r.headers.get("Content-Length") or 0)
|
|
247
|
+
with part.open("ab" if have else "wb") as out:
|
|
248
|
+
done = have
|
|
249
|
+
for chunk in r.iter_content(CHUNK):
|
|
250
|
+
out.write(chunk)
|
|
251
|
+
done += len(chunk)
|
|
252
|
+
on_progress(done, total)
|
|
253
|
+
expected = sha256_of(r)
|
|
254
|
+
if expected:
|
|
255
|
+
actual = file_sha256(part)
|
|
256
|
+
if actual != expected:
|
|
257
|
+
part.unlink()
|
|
258
|
+
raise CatalogError(f"{dest.name} arrived damaged (its SHA-256 doesn't match): it will download again")
|
|
259
|
+
part.replace(dest)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def sha256_of(response) -> str:
|
|
263
|
+
"""The SHA-256 Hugging Face gives for a large file (its X-Linked-Etag, on the redirect), or empty."""
|
|
264
|
+
for r in [*getattr(response, "history", []), response]:
|
|
265
|
+
tag = (r.headers.get("X-Linked-Etag") or "").strip('"')
|
|
266
|
+
if len(tag) == 64 and all(c in "0123456789abcdef" for c in tag):
|
|
267
|
+
return tag
|
|
268
|
+
return ""
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def file_sha256(path: Path) -> str:
|
|
272
|
+
digest = hashlib.sha256()
|
|
273
|
+
with path.open("rb") as f:
|
|
274
|
+
for chunk in iter(lambda: f.read(CHUNK * 8), b""):
|
|
275
|
+
digest.update(chunk)
|
|
276
|
+
return digest.hexdigest()
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def install(model: MediaModel, server=None, install=None, console=None, ask=None, get=None) -> list[Path]:
|
|
280
|
+
"""Download the model's missing files into ComfyUI's model folders. Raises CatalogError with what to do.
|
|
281
|
+
|
|
282
|
+
`ask(question) -> bool` confirms the download (None: go ahead, for `--yes`).
|
|
283
|
+
"""
|
|
284
|
+
from rich.console import Console
|
|
285
|
+
from rich.progress import BarColumn, DownloadColumn, Progress, TextColumn, TimeRemainingColumn, TransferSpeedColumn
|
|
286
|
+
|
|
287
|
+
console = console or Console()
|
|
288
|
+
_, missing = chosen_files(model, lister(server, install))
|
|
289
|
+
if not missing:
|
|
290
|
+
return []
|
|
291
|
+
folders = {}
|
|
292
|
+
for f in missing:
|
|
293
|
+
folder = model_folder(f.folder, server, install)
|
|
294
|
+
if folder is None:
|
|
295
|
+
raise CatalogError("lcode doesn't know where ComfyUI keeps its models: set comfyui_path")
|
|
296
|
+
folders[f.key] = folder
|
|
297
|
+
need = sum(remaining_gb(f, folders[f.key]) for f in missing)
|
|
298
|
+
problem = disk_problem(next(iter(folders.values())), need)
|
|
299
|
+
if problem:
|
|
300
|
+
raise CatalogError(f"not enough disk space for {model.name}: {problem}")
|
|
301
|
+
listing = ", ".join(f"{f.name} ({f.gb:.1f} GB)" for f in missing)
|
|
302
|
+
if ask is not None and not ask(
|
|
303
|
+
f"Download {model.name}: {listing}, {need:.1f} GB into {folders[missing[0].key].parent}?"
|
|
304
|
+
):
|
|
305
|
+
raise CatalogError(f"{model.name} isn't installed (download declined)")
|
|
306
|
+
saved = []
|
|
307
|
+
columns = (TextColumn("{task.description}"), BarColumn(), DownloadColumn(), TransferSpeedColumn())
|
|
308
|
+
with Progress(*columns, TimeRemainingColumn(), console=console, transient=False) as progress:
|
|
309
|
+
for f in missing:
|
|
310
|
+
task = progress.add_task(f.name, total=int(f.gb * 1e9))
|
|
311
|
+
dest = folders[f.key] / f.name
|
|
312
|
+
try:
|
|
313
|
+
download(f.url, dest, lambda done, total, t=task: progress.update(t, completed=done, total=total), get)
|
|
314
|
+
except KeyboardInterrupt:
|
|
315
|
+
raise CatalogError(f"Paused {f.name}: install {model.key} again to resume where it stopped.") from None
|
|
316
|
+
saved.append(dest)
|
|
317
|
+
return saved
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def describe(model: MediaModel, hw: Hardware, installed: Installed | None) -> str:
|
|
321
|
+
"""One line for a list: tasks, size, fit, installed."""
|
|
322
|
+
status, note = model.fit(hw)
|
|
323
|
+
parts = [", ".join(model.tasks), f"{model.size_gb:.1f} GB"]
|
|
324
|
+
if installed is not None:
|
|
325
|
+
_, missing = chosen_files(model, installed)
|
|
326
|
+
if missing:
|
|
327
|
+
parts.append(f"{sum(f.gb for f in missing):.1f} GB to download")
|
|
328
|
+
else:
|
|
329
|
+
parts.append("installed")
|
|
330
|
+
parts.append({"fits": "fits", "slower": "slower", "too big": "too big", "cpu": "CPU only"}[status] + f" ({note})")
|
|
331
|
+
return " · ".join(parts)
|
lcode/modes.py
CHANGED
|
@@ -41,7 +41,7 @@ def ready(agent: Agent) -> str:
|
|
|
41
41
|
from lcode import media
|
|
42
42
|
|
|
43
43
|
return (
|
|
44
|
-
f"Describe an image and press Enter: {media.
|
|
44
|
+
f"Describe an image and press Enter: {media.current(agent).name} makes it on your GPU, into "
|
|
45
45
|
f"{agent.settings.media_dir}. /mode code switches back."
|
|
46
46
|
)
|
|
47
47
|
return COMING.get(agent.settings.mode, "")
|
lcode/repl.py
CHANGED
|
@@ -218,9 +218,9 @@ def model_lines(agent: Agent) -> str:
|
|
|
218
218
|
"""The banner's model lines: the coding model and its context, or the mode's own model."""
|
|
219
219
|
s = agent.settings
|
|
220
220
|
if s.mode == "image":
|
|
221
|
-
return f"[dim]model[/] {escape(media.
|
|
221
|
+
return f"[dim]model[/] {escape(media.current(agent).name)} [dim]· ComfyUI · {escape(s.media_dir)}[/]\n"
|
|
222
222
|
if s.mode == "video":
|
|
223
|
-
return "[dim]model[/] [dim]video models come with
|
|
223
|
+
return "[dim]model[/] [dim]video models come with video generation (#153)[/]\n"
|
|
224
224
|
server = f" [dim]· {escape(agent.ollama.describe())}[/]" if backends.is_openai(agent.ollama) else ""
|
|
225
225
|
return f"[dim]model[/] {escape(s.model)}{server}\n[dim]context[/] {format_tokens(s.context)} tokens\n"
|
|
226
226
|
|
|
@@ -891,6 +891,45 @@ def model_command(agent: Agent, arg: str, hardware: Hardware) -> None:
|
|
|
891
891
|
switch_model(agent, arg, hardware)
|
|
892
892
|
|
|
893
893
|
|
|
894
|
+
def media_model_command(agent: Agent, arg: str, hardware: Hardware) -> None:
|
|
895
|
+
"""/models and /model in image or video mode: the catalog's models for the mode, numbered; pick one."""
|
|
896
|
+
from lcode import media_catalog as catalog
|
|
897
|
+
|
|
898
|
+
c, s = agent.console, agent.settings
|
|
899
|
+
choices = catalog.load().for_mode(s.mode)
|
|
900
|
+
if not choices:
|
|
901
|
+
c.print(f"[yellow]{escape(media.SOON.get(s.mode, modes.ready(agent)))}[/]")
|
|
902
|
+
return
|
|
903
|
+
current = media.current(agent)
|
|
904
|
+
installed = media.installed_lister(agent)
|
|
905
|
+
if not arg:
|
|
906
|
+
for i, m in enumerate(choices, 1):
|
|
907
|
+
mark = "[green]●[/]" if m.key == current.key else " "
|
|
908
|
+
c.print(
|
|
909
|
+
f" {i}. {mark} [bold]{escape(m.name)}[/] [dim]({m.key})[/] "
|
|
910
|
+
f"{escape(catalog.describe(m, hardware, installed))}"
|
|
911
|
+
)
|
|
912
|
+
if m.measured:
|
|
913
|
+
c.print(f" [dim]{escape(m.measured)}[/]")
|
|
914
|
+
try:
|
|
915
|
+
arg = input(f" Switch to which model? (number or name, Enter to keep {current.name}): ").strip()
|
|
916
|
+
except EOFError:
|
|
917
|
+
return
|
|
918
|
+
if not arg:
|
|
919
|
+
return
|
|
920
|
+
model = catalog.load().find(arg, s.mode)
|
|
921
|
+
if model is None:
|
|
922
|
+
c.print(f"[red]There's no {s.mode} model {escape(arg)!r}: choose 1 to {len(choices)}, or a name.[/]")
|
|
923
|
+
return
|
|
924
|
+
s.image_model = model.key
|
|
925
|
+
c.print(f"[green]Image model: {escape(model.name)}.[/]")
|
|
926
|
+
if installed is not None:
|
|
927
|
+
_, missing = catalog.chosen_files(model, installed)
|
|
928
|
+
if missing:
|
|
929
|
+
size = sum(f.gb for f in missing)
|
|
930
|
+
c.print(f"[dim]Not installed yet: {size:.1f} GB download with your next image (you're asked first).[/]")
|
|
931
|
+
|
|
932
|
+
|
|
894
933
|
def switch_model(agent: Agent, wanted: str, hardware: Hardware) -> None:
|
|
895
934
|
"""Switch to a catalog key or an installed model, and size the context for it."""
|
|
896
935
|
from lcode.cli import NotInstalled, resolve_model
|
|
@@ -1116,13 +1155,8 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
|
|
|
1116
1155
|
elif cmd in ("/context", "/ctx"):
|
|
1117
1156
|
context_command(agent, arg, hardware)
|
|
1118
1157
|
elif cmd in ("/models", "/model"):
|
|
1119
|
-
if s.mode
|
|
1120
|
-
|
|
1121
|
-
f"Image model: [bold]{media.image_model()}[/], through ComfyUI. "
|
|
1122
|
-
"[dim]More image models, and installing them from lcode, come with #150.[/]"
|
|
1123
|
-
)
|
|
1124
|
-
elif s.mode != "code":
|
|
1125
|
-
c.print(f"[yellow]{escape(modes.ready(agent))}[/]")
|
|
1158
|
+
if s.mode != "code":
|
|
1159
|
+
media_model_command(agent, arg, hardware)
|
|
1126
1160
|
else:
|
|
1127
1161
|
model_command(agent, arg, hardware)
|
|
1128
1162
|
elif cmd == "/think":
|
|
@@ -1,12 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"name": "FLUX.2 klein 4B",
|
|
3
|
-
"task": "text-to-image",
|
|
4
2
|
"source": "ComfyUI's template image_flux2_klein_text_to_image (the distilled 4B subgraph), in API format",
|
|
5
|
-
"models": {
|
|
6
|
-
"diffusion_models": ["flux-2-klein-4b-fp8.safetensors", "flux-2-klein-4b.safetensors"],
|
|
7
|
-
"text_encoders": ["qwen_3_4b.safetensors"],
|
|
8
|
-
"vae": ["flux2-vae.safetensors"]
|
|
9
|
-
},
|
|
10
3
|
"defaults": {"width": 1024, "height": 1024, "steps": 4, "batch": 1},
|
|
11
4
|
"labels": {
|
|
12
5
|
"1": "loading the model",
|
|
@@ -18,8 +11,8 @@
|
|
|
18
11
|
"13": "saving"
|
|
19
12
|
},
|
|
20
13
|
"graph": {
|
|
21
|
-
"1": {"class_type": "UNETLoader", "inputs": {"unet_name": "$
|
|
22
|
-
"2": {"class_type": "CLIPLoader", "inputs": {"clip_name": "$
|
|
14
|
+
"1": {"class_type": "UNETLoader", "inputs": {"unet_name": "$model", "weight_dtype": "default"}},
|
|
15
|
+
"2": {"class_type": "CLIPLoader", "inputs": {"clip_name": "$text_encoder", "type": "flux2", "device": "default"}},
|
|
23
16
|
"3": {"class_type": "VAELoader", "inputs": {"vae_name": "$vae"}},
|
|
24
17
|
"4": {"class_type": "CLIPTextEncode", "inputs": {"text": "$prompt", "clip": ["2", 0]}},
|
|
25
18
|
"5": {"class_type": "ConditioningZeroOut", "inputs": {"conditioning": ["4", 0]}},
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"source": "ComfyUI's template image_z_image_turbo_int8 (its Text to Image subgraph), in API format",
|
|
3
|
+
"defaults": {"width": 1024, "height": 1024, "steps": 8, "batch": 1},
|
|
4
|
+
"labels": {
|
|
5
|
+
"1": "loading the model",
|
|
6
|
+
"2": "loading the text encoder",
|
|
7
|
+
"3": "loading the VAE",
|
|
8
|
+
"4": "reading the prompt",
|
|
9
|
+
"8": "drawing",
|
|
10
|
+
"9": "decoding the image",
|
|
11
|
+
"10": "saving"
|
|
12
|
+
},
|
|
13
|
+
"graph": {
|
|
14
|
+
"1": {"class_type": "UNETLoader", "inputs": {"unet_name": "$model", "weight_dtype": "default"}},
|
|
15
|
+
"2": {"class_type": "CLIPLoader", "inputs": {"clip_name": "$text_encoder", "type": "lumina2", "device": "default"}},
|
|
16
|
+
"3": {"class_type": "VAELoader", "inputs": {"vae_name": "$vae"}},
|
|
17
|
+
"4": {"class_type": "CLIPTextEncode", "inputs": {"text": "$prompt", "clip": ["2", 0]}},
|
|
18
|
+
"5": {"class_type": "ConditioningZeroOut", "inputs": {"conditioning": ["4", 0]}},
|
|
19
|
+
"6": {"class_type": "ModelSamplingAuraFlow", "inputs": {"model": ["1", 0], "shift": 3}},
|
|
20
|
+
"7": {"class_type": "EmptySD3LatentImage", "inputs": {"width": "$width", "height": "$height", "batch_size": "$batch"}},
|
|
21
|
+
"8": {
|
|
22
|
+
"class_type": "KSampler",
|
|
23
|
+
"inputs": {
|
|
24
|
+
"model": ["6", 0], "seed": "$seed", "steps": "$steps", "cfg": 1, "sampler_name": "res_multistep",
|
|
25
|
+
"scheduler": "simple", "positive": ["4", 0], "negative": ["5", 0], "latent_image": ["7", 0], "denoise": 1
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"9": {"class_type": "VAEDecode", "inputs": {"samples": ["8", 0], "vae": ["3", 0]}},
|
|
29
|
+
"10": {"class_type": "SaveImage", "inputs": {"images": ["9", 0], "filename_prefix": "lcode/image"}}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lcode-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.41.0
|
|
4
4
|
Summary: A local-first terminal coding agent powered by open-weight models on your own GPU or Mac, via Ollama.
|
|
5
5
|
Project-URL: Homepage, https://nasser1941.github.io/lcode/
|
|
6
6
|
Project-URL: Documentation, https://nasser1941.github.io/lcode/
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
lcode/__init__.py,sha256=
|
|
1
|
+
lcode/__init__.py,sha256=cP-2ftJ2hz9SKhg5hKS4FosPfEa3qxeHu6hAb4URJQ8,473
|
|
2
2
|
lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
|
|
3
3
|
lcode/acp.py,sha256=d97VV3sbqXdWKhPQQNbYRaDTm8WrUk0mGbDLiOyX7jE,29058
|
|
4
4
|
lcode/action.py,sha256=wuT5F9EJCdEligPeytzmEi8FpahWBhXer528tpuTj_k,16711
|
|
5
|
-
lcode/agent.py,sha256
|
|
6
|
-
lcode/api.py,sha256=
|
|
5
|
+
lcode/agent.py,sha256=-RZXqheMdbwjRkwMPGBnj_kkRfrAUYkqOLMc888gXgo,87528
|
|
6
|
+
lcode/api.py,sha256=jQmx-ddJC6MoNcRcaWoERs2Ci6KYzBto9GxdVBE7JkU,17632
|
|
7
7
|
lcode/backends.py,sha256=343alwyDZin-bEhb5-Es4f8ek1oBQUzEvV6RFWR1VZo,21160
|
|
8
8
|
lcode/bench.py,sha256=HxeUfy9Kv_0Rug1HAlDLyHYfYd9dw2bMgbXsvUy9nd4,43096
|
|
9
9
|
lcode/bench_backend.py,sha256=X2fo-zRX92Xp3KMxSsFlWioc2vRupBTtowj5kLME8mw,14287
|
|
@@ -11,12 +11,12 @@ lcode/bench_runtime.py,sha256=EjzNgIvUkdbT8cbSy1TpmlyeIk6e-ipjlDFpM7Dnqx0,6835
|
|
|
11
11
|
lcode/catalog.py,sha256=pZ1azX9OH2r4JE2c6E5OfwywpgS39-W2oXzoCHJhmtM,4141
|
|
12
12
|
lcode/checkpoints.py,sha256=eqPtmPe44-aweWqc2H21ROogwzIt3NpvcUobsaCHoGY,18354
|
|
13
13
|
lcode/checks.py,sha256=haYYIPhadKavGYEhh8YWAaWrPMcfzrpT2i6638f4xjQ,12933
|
|
14
|
-
lcode/cli.py,sha256=
|
|
14
|
+
lcode/cli.py,sha256=WuQW4Ao_TbJ0HKFOaFtJnyjvv-lwGYRg_tb0vI-zIg0,51296
|
|
15
15
|
lcode/clipboard.py,sha256=nTSdJyCXPdtjH947RjvB3QvRqnL5HZsS9KB8EkC42cc,5138
|
|
16
16
|
lcode/cloud.py,sha256=q5Dl5GuqWvKzBCIso-eh6yomvEOsnQ0LW8ZgaAGwBxc,2131
|
|
17
17
|
lcode/codesearch.py,sha256=Io8leN1nb_Y-Dvlm9xaGtwUWYXX-ooswuznJV6dGU1s,10766
|
|
18
|
-
lcode/comfy.py,sha256=
|
|
19
|
-
lcode/config.py,sha256=
|
|
18
|
+
lcode/comfy.py,sha256=tCXSWH9Lk0MsU6031-a6LSOTNSZ1h9G3ZvFwh3d_LPo,26357
|
|
19
|
+
lcode/config.py,sha256=EwMp3h0kHoLqHJc1sGCsXYjzj79gpg2HgMhGz1Ol1q0,13569
|
|
20
20
|
lcode/context.py,sha256=wgK16ZcNNnq4ngHx0EmtyKHDFTImciGEmtC5NP8tKJQ,20435
|
|
21
21
|
lcode/escalate.py,sha256=f-_6_moPaHi2kLuJw2JtkzvTkCl6DoBQgYBOCYLhunM,15120
|
|
22
22
|
lcode/extensions.py,sha256=W0HFDl0BvROzr7i_qtabHdIbacBgP5o5qIJLzoM2oNE,16806
|
|
@@ -28,17 +28,19 @@ lcode/jobs.py,sha256=P1P3FqI6EqL4LgdMX9YGZwCW0Bh5BRXFA_FnQxpHNjE,5618
|
|
|
28
28
|
lcode/limits.py,sha256=oJzVQoMyTZ0_Fkpu5t3qxrf9v7Hv9S8U8BRQR5M1aKA,1237
|
|
29
29
|
lcode/lsp.py,sha256=NtSW7xapv7fg2HAQLuYK1nwIqT7nG0LK1hiTpOmla_4,26086
|
|
30
30
|
lcode/matching.py,sha256=2N3_qwKJ5gd8el2m_y_egKkFM9rsF1eUhYJcRCVIp98,14015
|
|
31
|
-
lcode/media.py,sha256=
|
|
31
|
+
lcode/media.py,sha256=laRxRuBPGoOK1e2W6cjNHySps8OO9N6UiauuAtGmlyk,11870
|
|
32
|
+
lcode/media.toml,sha256=8vqFwqE8ECizDkUlnWGLfhcx94vEG1WfRCpJPHTi3UQ,3225
|
|
33
|
+
lcode/media_catalog.py,sha256=rtJPS3S7ZgJjaY3HIRerhf4ciqzXt4BsOikoQavzPAI,13076
|
|
32
34
|
lcode/memory.py,sha256=WZEHTp-lVxAwS-yyLQR2xB3TMZ9sejmX2Ivg7T-tl3w,21949
|
|
33
35
|
lcode/models.toml,sha256=lt2G64zzo_Ryxv2ISRSNZoVD_JqCqYqsM01n8WoXGs8,4098
|
|
34
|
-
lcode/modes.py,sha256=
|
|
36
|
+
lcode/modes.py,sha256=Ke_mEeYF_SskbVUXMpJMeV5d5fFEQ-Vb-MKp4SIvT0Y,4990
|
|
35
37
|
lcode/notify.py,sha256=uieDeXe7XwuVHjOk4tqVyZEW_85XWkoWQmFOWEf77oc,1538
|
|
36
38
|
lcode/ollama.py,sha256=xFHU2XW47ANJ34-1aeSMQVj28GhqcWiFi6zASH9FiYY,8053
|
|
37
39
|
lcode/permissions.py,sha256=q7CjDsc_Xrblw7t3_BbxA5LUjxFSswGs1RiJmtMi0po,12288
|
|
38
40
|
lcode/planning.py,sha256=Q2upaoZPnXDMQT2F-ETxX1lk_wDntTzhSKNVYIQSoQA,6540
|
|
39
41
|
lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
|
|
40
42
|
lcode/repeats.py,sha256=OTrPdb6BKArDLQEgHaXmJZ-tIFsY_1bk4P0wzzANKLY,5964
|
|
41
|
-
lcode/repl.py,sha256=
|
|
43
|
+
lcode/repl.py,sha256=Jjs45rvONRX2F3hx9Q-o3WkcepC_rKIqOE9okdlhNe8,62649
|
|
42
44
|
lcode/repomap.py,sha256=1GRJgEUjBl9XHSXsF7tSGXBCEgsY-3CzwMohaTudZ_M,13969
|
|
43
45
|
lcode/residency.py,sha256=B9pwqG6yx5aQD2M5xyQ-bMnYmhD7fv3wCVw_X6VIeAo,7012
|
|
44
46
|
lcode/review.py,sha256=0QBIS-R1B2b3zqx5TcC1qZptpKBbrDhYvMKSNCaDAUo,4686
|
|
@@ -70,9 +72,10 @@ lcode/servers/encord.py,sha256=WSauawL1UKYZCUAG1oalcKL_An3DVof-JM6xIgFyQSE,12769
|
|
|
70
72
|
lcode/servers/valohai.py,sha256=e5xZGIoa4cja4f5i85q1GpL9c7cglUjV8jN2co1CFMY,14430
|
|
71
73
|
lcode/servers/xai.py,sha256=h2emAvHSnkuB9WmJInlynsDhvxJwNVD2AHFX7vqMXNc,13627
|
|
72
74
|
lcode/workflows/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
73
|
-
lcode/workflows/flux2-klein-4b-t2i.json,sha256
|
|
74
|
-
|
|
75
|
-
lcode_cli-0.
|
|
76
|
-
lcode_cli-0.
|
|
77
|
-
lcode_cli-0.
|
|
78
|
-
lcode_cli-0.
|
|
75
|
+
lcode/workflows/flux2-klein-4b-t2i.json,sha256=yGrYDT8pG2bns9DS1JavhlX5fwhDgIKfAwrHrRCyE8k,1788
|
|
76
|
+
lcode/workflows/z-image-turbo-t2i.json,sha256=sHsL7xnnuULOuWGYlQrDl4jOTySdAmb2T82c_cChv0s,1585
|
|
77
|
+
lcode_cli-0.41.0.dist-info/METADATA,sha256=MitknSeSrdiXm2qjDMKu5eT_xBdeAcFIqxT9oEfvwhM,13386
|
|
78
|
+
lcode_cli-0.41.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
79
|
+
lcode_cli-0.41.0.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
|
|
80
|
+
lcode_cli-0.41.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
|
|
81
|
+
lcode_cli-0.41.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|