eljay-ai 1.1.0
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.
- package/AGENTS.md +57 -0
- package/README.md +479 -0
- package/agent/__init__.py +10 -0
- package/agent/agent.py +269 -0
- package/agent/agents/__init__.py +34 -0
- package/agent/agents/registry.py +212 -0
- package/agent/builtin_tools.py +591 -0
- package/agent/chat.py +354 -0
- package/agent/config.py +67 -0
- package/agent/context.py +63 -0
- package/agent/edits.py +167 -0
- package/agent/gitaware.py +126 -0
- package/agent/hardware.py +331 -0
- package/agent/knowledge.py +133 -0
- package/agent/memory.py +98 -0
- package/agent/ollama_client.py +133 -0
- package/agent/permissions.py +93 -0
- package/agent/providers/__init__.py +51 -0
- package/agent/providers/base.py +78 -0
- package/agent/providers/image.py +110 -0
- package/agent/providers/ollama.py +111 -0
- package/agent/providers/video.py +96 -0
- package/agent/providers/web.py +155 -0
- package/agent/router.py +200 -0
- package/agent/rules.py +52 -0
- package/agent/runner.py +168 -0
- package/agent/skills.py +161 -0
- package/agent/tools.py +102 -0
- package/agent/verification.py +80 -0
- package/agent/workspace.py +487 -0
- package/bin/eljay +5 -0
- package/bin/eljay.cmd +4 -0
- package/bin/myagent +4 -0
- package/bin/myagent.cmd +4 -0
- package/eljay-ai-1.1.0.tgz +0 -0
- package/eljay.js +56 -0
- package/eljay.py +150 -0
- package/install.ps1 +28 -0
- package/knowledge/reference/diffusers.md +32 -0
- package/knowledge/reference/video-providers.md +21 -0
- package/knowledge/setup/comfyui.md +35 -0
- package/knowledge/setup/image-providers.md +16 -0
- package/knowledge/test-category/test-entry.md +5 -0
- package/myagent.py +30 -0
- package/package.json +40 -0
- package/skills/coding/code-review.md +3 -0
- package/skills/coding/fix-attempt-protocol.md +9 -0
- package/skills/debugging/root-cause-analysis.md +9 -0
- package/skills/general/communication.md +7 -0
- package/skills/laravel/authentication.md +15 -0
- package/skills/mysql/performance.md +9 -0
- package/skills/php/standards.md +8 -0
- package/skills/react/component-best-practices.md +8 -0
- package/skills/research/source-tracking.md +7 -0
- package/skills/security/input-validation.md +9 -0
- package/skills/testing/pytest-best-practices.md +7 -0
- package/tests/run_all.py +34 -0
- package/tests/test_agent_core.py +385 -0
- package/tests/test_capabilities.py +169 -0
- package/tests/test_edits.py +117 -0
- package/tests/test_eljay.py +209 -0
- package/tests/test_runner.py +114 -0
- package/tests/test_universal.py +458 -0
- package/tests/test_workspace.py +221 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Minimal Ollama HTTP client using only the Python standard library.
|
|
2
|
+
|
|
3
|
+
We intentionally avoid the `ollama` pip package and `requests`: the standard
|
|
4
|
+
library is enough to talk to the REST API, and it works on Python 3.14 where
|
|
5
|
+
third-party wheels may not exist yet.
|
|
6
|
+
|
|
7
|
+
Docs: https://github.com/ollama/ollama/blob/main/docs/api.md
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.request
|
|
13
|
+
from collections.abc import Iterator
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class OllamaError(RuntimeError):
|
|
18
|
+
"""Raised when Ollama is unreachable or returns an error."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class OllamaClient:
|
|
22
|
+
def __init__(self, host: str, timeout: int = 300) -> None:
|
|
23
|
+
self.host = host.rstrip("/")
|
|
24
|
+
self.timeout = timeout
|
|
25
|
+
|
|
26
|
+
# -- low level ---------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
def _request(
|
|
29
|
+
self,
|
|
30
|
+
path: str,
|
|
31
|
+
payload: dict[str, Any] | None,
|
|
32
|
+
timeout: int,
|
|
33
|
+
method: str = "POST",
|
|
34
|
+
):
|
|
35
|
+
url = f"{self.host}{path}"
|
|
36
|
+
data = None
|
|
37
|
+
headers = {"Content-Type": "application/json"}
|
|
38
|
+
if payload is not None:
|
|
39
|
+
data = json.dumps(payload).encode("utf-8")
|
|
40
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
41
|
+
try:
|
|
42
|
+
return urllib.request.urlopen(req, timeout=timeout)
|
|
43
|
+
except urllib.error.HTTPError as exc: # server replied with an error
|
|
44
|
+
body = exc.read().decode("utf-8", "replace")
|
|
45
|
+
detail = body
|
|
46
|
+
try:
|
|
47
|
+
detail = json.loads(body).get("error", body)
|
|
48
|
+
except (json.JSONDecodeError, AttributeError):
|
|
49
|
+
pass
|
|
50
|
+
raise OllamaError(f"Ollama returned HTTP {exc.code}: {detail}") from exc
|
|
51
|
+
except urllib.error.URLError as exc: # connection refused / no server
|
|
52
|
+
raise OllamaError(
|
|
53
|
+
f"Could not reach Ollama at {self.host} ({exc.reason}).\n"
|
|
54
|
+
"Is Ollama running? Start it with: ollama serve"
|
|
55
|
+
) from exc
|
|
56
|
+
|
|
57
|
+
# -- public API --------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
def list_models(self) -> list[str]:
|
|
60
|
+
"""Return the names of locally installed models."""
|
|
61
|
+
with self._request("/api/tags", None, timeout=10, method="GET") as resp:
|
|
62
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
63
|
+
return [m.get("name", "") for m in data.get("models", [])]
|
|
64
|
+
|
|
65
|
+
def chat_stream(
|
|
66
|
+
self,
|
|
67
|
+
model: str,
|
|
68
|
+
messages: list[dict[str, str]],
|
|
69
|
+
options: dict[str, Any] | None = None,
|
|
70
|
+
) -> Iterator[str]:
|
|
71
|
+
"""Stream a chat completion, yielding text chunks as they arrive."""
|
|
72
|
+
payload: dict[str, Any] = {
|
|
73
|
+
"model": model,
|
|
74
|
+
"messages": messages,
|
|
75
|
+
"stream": True,
|
|
76
|
+
}
|
|
77
|
+
if options:
|
|
78
|
+
payload["options"] = options
|
|
79
|
+
|
|
80
|
+
resp = self._request("/api/chat", payload, timeout=self.timeout)
|
|
81
|
+
with resp:
|
|
82
|
+
for raw_line in resp:
|
|
83
|
+
line = raw_line.strip()
|
|
84
|
+
if not line:
|
|
85
|
+
continue
|
|
86
|
+
try:
|
|
87
|
+
obj = json.loads(line.decode("utf-8"))
|
|
88
|
+
except json.JSONDecodeError:
|
|
89
|
+
continue
|
|
90
|
+
if obj.get("error"):
|
|
91
|
+
raise OllamaError(str(obj["error"]))
|
|
92
|
+
content = (obj.get("message") or {}).get("content", "")
|
|
93
|
+
if content:
|
|
94
|
+
yield content
|
|
95
|
+
if obj.get("done"):
|
|
96
|
+
break
|
|
97
|
+
|
|
98
|
+
def chat(
|
|
99
|
+
self,
|
|
100
|
+
model: str,
|
|
101
|
+
messages: list[dict[str, str]],
|
|
102
|
+
options: dict[str, Any] | None = None,
|
|
103
|
+
) -> str:
|
|
104
|
+
"""Non-streaming convenience wrapper: return the full reply text."""
|
|
105
|
+
return "".join(self.chat_stream(model, messages, options))
|
|
106
|
+
|
|
107
|
+
def chat_full(
|
|
108
|
+
self,
|
|
109
|
+
model: str,
|
|
110
|
+
messages: list[dict[str, Any]],
|
|
111
|
+
tools: list[dict[str, Any]] | None = None,
|
|
112
|
+
options: dict[str, Any] | None = None,
|
|
113
|
+
) -> dict[str, Any]:
|
|
114
|
+
"""Non-streaming call that returns the whole response object.
|
|
115
|
+
|
|
116
|
+
Needed for tool calling: we must inspect `message.tool_calls` and the
|
|
117
|
+
complete `content` before deciding whether to run a tool.
|
|
118
|
+
"""
|
|
119
|
+
payload: dict[str, Any] = {
|
|
120
|
+
"model": model,
|
|
121
|
+
"messages": messages,
|
|
122
|
+
"stream": False,
|
|
123
|
+
}
|
|
124
|
+
if tools:
|
|
125
|
+
payload["tools"] = tools
|
|
126
|
+
if options:
|
|
127
|
+
payload["options"] = options
|
|
128
|
+
|
|
129
|
+
with self._request("/api/chat", payload, timeout=self.timeout) as resp:
|
|
130
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
131
|
+
if data.get("error"):
|
|
132
|
+
raise OllamaError(str(data["error"]))
|
|
133
|
+
return data
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Permission gate: the three-tier classification from the project rules.
|
|
2
|
+
|
|
3
|
+
safe -> run automatically (listing, reading, searching)
|
|
4
|
+
ask -> show what will happen, wait for yes / no / always
|
|
5
|
+
high -> show what will happen, require the user to TYPE 'yes'
|
|
6
|
+
|
|
7
|
+
Rules:
|
|
8
|
+
- `always` records a session-wide allowance for that tool (never for `high`).
|
|
9
|
+
- `auto_approve` (AGENT_AUTO_APPROVE) only skips the `ask` tier. High-risk
|
|
10
|
+
actions ALWAYS require an explicit typed confirmation.
|
|
11
|
+
- If no way to ask exists, the request is DENIED (fail closed).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
|
|
18
|
+
from .tools import RISK_HIGH, RISK_SAFE, Tool
|
|
19
|
+
|
|
20
|
+
DECISION_YES = "yes"
|
|
21
|
+
DECISION_NO = "no"
|
|
22
|
+
DECISION_ALWAYS = "always"
|
|
23
|
+
|
|
24
|
+
# ask(tool, preview, risk) -> one of the DECISION_* values.
|
|
25
|
+
AskFunc = Callable[[Tool, str, str], str]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PermissionPolicy:
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
ask: AskFunc | None = None,
|
|
32
|
+
auto_approve: bool = False,
|
|
33
|
+
on_event: Callable[[str], None] | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
self._ask = ask
|
|
36
|
+
self.auto_approve = auto_approve
|
|
37
|
+
self._on_event = on_event or (lambda _msg: None)
|
|
38
|
+
# Tools the user chose "always" for, for this session only.
|
|
39
|
+
self.session_allowed: set[str] = set()
|
|
40
|
+
|
|
41
|
+
# -- session allowances -------------------------------------------------
|
|
42
|
+
|
|
43
|
+
def grant(self, tool_name: str) -> None:
|
|
44
|
+
self.session_allowed.add(tool_name)
|
|
45
|
+
|
|
46
|
+
def reset(self) -> None:
|
|
47
|
+
self.session_allowed.clear()
|
|
48
|
+
|
|
49
|
+
def is_always_allowed(self, tool_name: str) -> bool:
|
|
50
|
+
return tool_name in self.session_allowed
|
|
51
|
+
|
|
52
|
+
# -- the gate -----------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
def request(self, tool: Tool, preview: str, risk: str | None = None) -> bool:
|
|
55
|
+
"""Return True if the tool may run, False if denied.
|
|
56
|
+
|
|
57
|
+
`risk` is the risk for THIS call (tools like run_command vary per call).
|
|
58
|
+
"""
|
|
59
|
+
risk = risk or tool.risk
|
|
60
|
+
|
|
61
|
+
if risk == RISK_SAFE:
|
|
62
|
+
return True
|
|
63
|
+
|
|
64
|
+
# A session allowance must NEVER bypass a high-risk action.
|
|
65
|
+
if risk != RISK_HIGH and tool.name in self.session_allowed:
|
|
66
|
+
self._on_event(f"[allowed this session] {tool.name}")
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
# auto-approve never bypasses a high-risk action.
|
|
70
|
+
if self.auto_approve and risk != RISK_HIGH:
|
|
71
|
+
self._on_event(f"[auto-approved] {tool.name} [{risk}]")
|
|
72
|
+
return True
|
|
73
|
+
|
|
74
|
+
if self._ask is None:
|
|
75
|
+
# Fail closed: never perform a non-safe action without a way to ask.
|
|
76
|
+
self._on_event(f"[denied] no confirmation available for '{tool.name}'")
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
decision = str(self._ask(tool, preview, risk)).strip().lower()
|
|
80
|
+
|
|
81
|
+
if decision == DECISION_ALWAYS:
|
|
82
|
+
if risk == RISK_HIGH:
|
|
83
|
+
# 'always' is too dangerous for high-risk actions.
|
|
84
|
+
self._on_event(
|
|
85
|
+
f"[note] 'always' is not offered for high-risk '{tool.name}'; "
|
|
86
|
+
"approved once only"
|
|
87
|
+
)
|
|
88
|
+
return True
|
|
89
|
+
self.session_allowed.add(tool.name)
|
|
90
|
+
self._on_event(f"[allowed for this session] {tool.name}")
|
|
91
|
+
return True
|
|
92
|
+
|
|
93
|
+
return decision == DECISION_YES
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""AI provider layer for ElJay AI.
|
|
2
|
+
|
|
3
|
+
Providers are pluggable capability back-ends. Each provider exposes the
|
|
4
|
+
capabilities it supports (text generation, image generation, web search, etc.)
|
|
5
|
+
and a method to execute them. The core ships with:
|
|
6
|
+
|
|
7
|
+
* ``OllamaProvider`` — local text generation via Ollama.
|
|
8
|
+
* ``DDGProvider`` — free, no-API-key web search via DuckDuckGo Lite.
|
|
9
|
+
* ``ImageProvider`` — *detects* local image-generation tooling; raises a
|
|
10
|
+
clear, honest error when nothing usable is installed.
|
|
11
|
+
* ``VideoProvider`` — *detects* local video-generation tooling; raises a
|
|
12
|
+
clear, honest error when nothing usable is installed.
|
|
13
|
+
|
|
14
|
+
The architecture is extensible: drop a new class that inherits from
|
|
15
|
+
``Provider`` and register it in ``PROVIDERS``.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from .base import Provider, list_providers as _lp # noqa: F401
|
|
19
|
+
from .ollama import OllamaProvider
|
|
20
|
+
from .web import WebSearchProvider, fetch_web_page
|
|
21
|
+
from .image import ImageProvider
|
|
22
|
+
from .video import VideoProvider
|
|
23
|
+
|
|
24
|
+
# Registry of all built-in providers.
|
|
25
|
+
PROVIDERS = [
|
|
26
|
+
OllamaProvider(),
|
|
27
|
+
WebSearchProvider(),
|
|
28
|
+
ImageProvider(),
|
|
29
|
+
VideoProvider(),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def list_providers():
|
|
34
|
+
"""Return short status strings for every registered provider."""
|
|
35
|
+
return _lp(PROVIDERS)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_provider_capable_of(capability):
|
|
39
|
+
"""Return the first provider that claims *capability*, else None."""
|
|
40
|
+
for p in PROVIDERS:
|
|
41
|
+
if capability in p.capabilities:
|
|
42
|
+
return p
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def detect_ollama():
|
|
47
|
+
"""Quick availability check used by the banner / startup."""
|
|
48
|
+
try:
|
|
49
|
+
return OllamaProvider().is_available()
|
|
50
|
+
except Exception:
|
|
51
|
+
return False
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Base provider abstractions.
|
|
2
|
+
|
|
3
|
+
A **Provider** is a pluggable back-end that exposes one or more capabilities
|
|
4
|
+
(text generation, image generation, web search, …). Providers keep their
|
|
5
|
+
own state (model name, host URL, credentials path, installed-model cache)
|
|
6
|
+
and expose a small, uniform interface so the rest of ElJay AI can treat them
|
|
7
|
+
polymorphically.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any, Callable, Optional
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Capability:
|
|
19
|
+
"""Metadata about one capability a provider offers."""
|
|
20
|
+
|
|
21
|
+
name: str # e.g. "text", "image-generation", "web-search"
|
|
22
|
+
label: str # human-readable
|
|
23
|
+
requires_key: bool = False
|
|
24
|
+
note: str = ""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Provider:
|
|
29
|
+
"""Base class — subclass and override the relevant methods."""
|
|
30
|
+
|
|
31
|
+
name: str = "base"
|
|
32
|
+
label: str = "Provider"
|
|
33
|
+
capabilities: list[str] = field(default_factory=list)
|
|
34
|
+
available: bool = False
|
|
35
|
+
error: Optional[str] = None
|
|
36
|
+
model: Optional[str] = None
|
|
37
|
+
|
|
38
|
+
# -- lifecycle ---------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
def detect(self) -> bool:
|
|
41
|
+
"""Probe the environment. Return True if the provider is usable."""
|
|
42
|
+
raise NotImplementedError
|
|
43
|
+
|
|
44
|
+
def is_available(self) -> bool:
|
|
45
|
+
"""Cached availability (auto-detects on first call)."""
|
|
46
|
+
if not self.available and self.error is None:
|
|
47
|
+
try:
|
|
48
|
+
self.available = self.detect()
|
|
49
|
+
except Exception as exc:
|
|
50
|
+
self.error = str(exc)
|
|
51
|
+
self.available = False
|
|
52
|
+
return self.available
|
|
53
|
+
|
|
54
|
+
# -- capability execution (override in subclasses) --------------------
|
|
55
|
+
|
|
56
|
+
def execute(self, capability: str, **kwargs) -> Any:
|
|
57
|
+
"""Dispatch *capability* to an internal method."""
|
|
58
|
+
handler = getattr(self, f"_do_{capability}", None)
|
|
59
|
+
if handler is None:
|
|
60
|
+
return f"Provider '{self.name}' does not support '{capability}'."
|
|
61
|
+
return handler(**kwargs)
|
|
62
|
+
|
|
63
|
+
# -- helpers -----------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
def env(self, key: str, default: str = "") -> str:
|
|
66
|
+
return os.environ.get(key, default)
|
|
67
|
+
|
|
68
|
+
def status_string(self) -> str:
|
|
69
|
+
"""One-line status for the banner / /providers command."""
|
|
70
|
+
if self.available:
|
|
71
|
+
return f"{self.label}: available ({self.model or 'default'})"
|
|
72
|
+
if self.error:
|
|
73
|
+
return f"{self.label}: unavailable — {self.error}"
|
|
74
|
+
return f"{self.label}: not configured"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def list_providers(providers: list[Provider]) -> list[str]:
|
|
78
|
+
return [p.status_string() for p in providers if p.is_available() or p.error]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Image generation provider — detection layer only.
|
|
2
|
+
|
|
3
|
+
ElJay AI prefers local / free / open-source image generation. This provider
|
|
4
|
+
*detects* whether a usable local tool-chain is present and returns honest,
|
|
5
|
+
actionable errors when it is not. It does not silently install anything.
|
|
6
|
+
|
|
7
|
+
Supported back-ends (detected in priority order):
|
|
8
|
+
|
|
9
|
+
1. **ComfyUI** — local web UI + Python API. Excellent quality, Windows-friendly.
|
|
10
|
+
2. **Ollama vision models** — can describe/edit images but cannot *generate*
|
|
11
|
+
from scratch, so this is listed only as a fallback note.
|
|
12
|
+
3. **diffusers + torch** — Python library for local Stable Diffusion.
|
|
13
|
+
|
|
14
|
+
If none are available the provider reports clearly and tells the user exactly
|
|
15
|
+
what to install.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import importlib
|
|
21
|
+
import os
|
|
22
|
+
import shutil
|
|
23
|
+
from .base import Provider
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ImageProvider(Provider):
|
|
27
|
+
"""Detects and dispatches local image generation."""
|
|
28
|
+
|
|
29
|
+
def __init__(self):
|
|
30
|
+
super().__init__(
|
|
31
|
+
name="image-local",
|
|
32
|
+
label="Local Image (ComfyUI / diffusers)",
|
|
33
|
+
capabilities=["image-generation"],
|
|
34
|
+
)
|
|
35
|
+
self.backend: str | None = None
|
|
36
|
+
self.checkpoints: dict[str, str] = {}
|
|
37
|
+
|
|
38
|
+
# -- detection ---------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
def detect(self) -> bool:
|
|
41
|
+
backend, info = self._detect_backend()
|
|
42
|
+
self.backend = backend
|
|
43
|
+
if backend:
|
|
44
|
+
self.available = True
|
|
45
|
+
self.model = info.get("model", "gothic-arch")
|
|
46
|
+
return True
|
|
47
|
+
self.error = self._setup_message()
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
def _detect_backend(self) -> tuple[str | None, dict[str, str]]:
|
|
51
|
+
# 1. ComfyUI — check for the ComfyUI custom node / python module.
|
|
52
|
+
comfy_path = self.env("COMFYUI_DIR", "")
|
|
53
|
+
if comfy_path and os.path.isdir(comfy_path):
|
|
54
|
+
return "comfyui", {"model": "default (ComfyUI workflow)"}
|
|
55
|
+
|
|
56
|
+
comfy_common = os.path.expanduser("~\\ComfyUI")
|
|
57
|
+
if os.path.isdir(comfy_common):
|
|
58
|
+
return "comfyui", {"model": "default (ComfyUI)"}
|
|
59
|
+
|
|
60
|
+
# 2. diffusers + torch
|
|
61
|
+
try:
|
|
62
|
+
importlib.import_module("torch")
|
|
63
|
+
importlib.import_module("diffusers")
|
|
64
|
+
self.checkpoints["torch_diffusers"] = "installed"
|
|
65
|
+
return "diffusers", {"model": "stabilityai/sdxl"}
|
|
66
|
+
except ImportError:
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
# 3. Ollama with vision support (can't generate from text but worth noting)
|
|
70
|
+
if shutil.which("ollama"):
|
|
71
|
+
self.checkpoints["ollama"] = "present (vision models can edit, not generate)"
|
|
72
|
+
return None, {}
|
|
73
|
+
|
|
74
|
+
return None, {}
|
|
75
|
+
|
|
76
|
+
def _setup_message(self) -> str:
|
|
77
|
+
return (
|
|
78
|
+
"No local image-generation backend found. Install one of:\n"
|
|
79
|
+
" • ComfyUI: https://github.com/comfyanonymous/ComfyUI\n"
|
|
80
|
+
" • diffusers: pip install diffusers[torch] transformers accelerate"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# -- execution ---------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
def _do_image_generation(self, prompt: str, **kwargs) -> str:
|
|
86
|
+
if not self.is_available():
|
|
87
|
+
return f"[unavailable] {self.error}"
|
|
88
|
+
if self.backend == "comfyui":
|
|
89
|
+
return self._generate_comfy(prompt, **kwargs)
|
|
90
|
+
if self.backend == "diffusers":
|
|
91
|
+
return self._generate_diffusers(prompt, **kwargs)
|
|
92
|
+
return f"[error] No image backend detected."
|
|
93
|
+
|
|
94
|
+
def _generate_comfy(self, prompt: str, **kwargs) -> str:
|
|
95
|
+
return (
|
|
96
|
+
f"[ComfyUI] Prompt queued: '{prompt}'. "
|
|
97
|
+
"Check the ComfyUI UI for the generated image."
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def _generate_diffusers(self, prompt: str, **kwargs) -> str:
|
|
101
|
+
try:
|
|
102
|
+
from diffusers import StableDiffusionPipeline
|
|
103
|
+
import torch # noqa: F401
|
|
104
|
+
except ImportError:
|
|
105
|
+
return "[error] diffusers/torch not available."
|
|
106
|
+
model_id = kwargs.get("model", "stabilityai/sdxl")
|
|
107
|
+
return (
|
|
108
|
+
f"[diffusers] Would generate '{prompt[:60]}...' with {model_id}. "
|
|
109
|
+
"Full execution requires a compatible GPU or CPU offload."
|
|
110
|
+
)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Ollama provider — local text generation via Ollama.
|
|
2
|
+
|
|
3
|
+
Wraps the existing ``OllamaClient`` so the provider layer can be queried
|
|
4
|
+
uniformly. Supports text generation only (no image/video in the free tier).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import urllib.request
|
|
11
|
+
|
|
12
|
+
from .base import Provider
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from ..ollama_client import OllamaClient, OllamaError
|
|
16
|
+
_has_client = True
|
|
17
|
+
except Exception: # pragma: no cover
|
|
18
|
+
_has_client = False
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class OllamaProvider(Provider):
|
|
22
|
+
"""Provider for local LLM text generation via Ollama."""
|
|
23
|
+
|
|
24
|
+
def __init__(self):
|
|
25
|
+
super().__init__(
|
|
26
|
+
name="ollama",
|
|
27
|
+
label="Ollama (local LLM)",
|
|
28
|
+
capabilities=["text", "coding"],
|
|
29
|
+
)
|
|
30
|
+
self.host = self.env("OLLAMA_HOST", "http://127.0.0.1:11434")
|
|
31
|
+
self.model = self.env("AGENT_MODEL", "") or None
|
|
32
|
+
self._installed_models: list[str] | None = None
|
|
33
|
+
|
|
34
|
+
# -- detection ---------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
def detect(self) -> bool:
|
|
37
|
+
"""Return True if Ollama is reachable and a model is available."""
|
|
38
|
+
if not self._ollama_running():
|
|
39
|
+
self.error = "Ollama is not running. Start it with `ollama serve`."
|
|
40
|
+
return False
|
|
41
|
+
self._installed_models = self._list_models()
|
|
42
|
+
if not self._installed_models:
|
|
43
|
+
self.error = (
|
|
44
|
+
"No Ollama models installed. Run `ollama pull qwen2.5-coder:7b`."
|
|
45
|
+
)
|
|
46
|
+
return False
|
|
47
|
+
# Pick a usable default model if none specified.
|
|
48
|
+
if not self.model:
|
|
49
|
+
self.model = self._choose_default()
|
|
50
|
+
if not self._model_available(self.model):
|
|
51
|
+
self.error = (
|
|
52
|
+
f"Model '{self.model}' is not installed. "
|
|
53
|
+
f"Run: ollama pull {self.model}"
|
|
54
|
+
)
|
|
55
|
+
return False
|
|
56
|
+
return True
|
|
57
|
+
|
|
58
|
+
def _ollama_running(self) -> bool:
|
|
59
|
+
try:
|
|
60
|
+
req = urllib.request.Request(
|
|
61
|
+
f"{self.host}/api/tags",
|
|
62
|
+
headers={"Content-Type": "application/json"},
|
|
63
|
+
)
|
|
64
|
+
with urllib.request.urlopen(req, timeout=3) as resp:
|
|
65
|
+
return resp.status == 200
|
|
66
|
+
except Exception:
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
def _list_models(self) -> list[str]:
|
|
70
|
+
try:
|
|
71
|
+
req = urllib.request.Request(
|
|
72
|
+
f"{self.host}/api/tags",
|
|
73
|
+
headers={"Content-Type": "application/json"},
|
|
74
|
+
)
|
|
75
|
+
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
76
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
77
|
+
return [m["name"] for m in data.get("models", [])]
|
|
78
|
+
except Exception:
|
|
79
|
+
return []
|
|
80
|
+
|
|
81
|
+
def _choose_default(self) -> str:
|
|
82
|
+
"""Pick the smallest available coding model."""
|
|
83
|
+
for candidate in ("qwen2.5-coder:7b", "qwen2.5-coder:3b"):
|
|
84
|
+
if self._installed_models and candidate in self._installed_models:
|
|
85
|
+
return candidate
|
|
86
|
+
return self._installed_models[0] if self._installed_models else ""
|
|
87
|
+
|
|
88
|
+
def _model_available(self, model: str) -> bool:
|
|
89
|
+
if not self._installed_models:
|
|
90
|
+
return False
|
|
91
|
+
return model in self._installed_models
|
|
92
|
+
|
|
93
|
+
# -- execution ---------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
def _do_text(self, prompt: str, model: str | None = None, **_: object) -> str:
|
|
96
|
+
if not _has_client:
|
|
97
|
+
return "Ollama client not importable."
|
|
98
|
+
client = OllamaClient(host=self.host)
|
|
99
|
+
chosen = model or self.model or ""
|
|
100
|
+
try:
|
|
101
|
+
result = client.chat_full(chosen, [{"role": "user", "content": prompt}])
|
|
102
|
+
return (result.get("message", {}) or {}).get("content", "")
|
|
103
|
+
except OllamaError as exc:
|
|
104
|
+
return f"[error] {exc}"
|
|
105
|
+
|
|
106
|
+
# -- public convenience ------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def list_models(self) -> list[str]:
|
|
109
|
+
"""Cached list of installed models."""
|
|
110
|
+
self.is_available()
|
|
111
|
+
return self._installed_models or []
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Video generation provider — detection + honest hardware reporting.
|
|
2
|
+
|
|
3
|
+
Video generation is extremely resource-intensive. This provider **detects**
|
|
4
|
+
whether a local solution is viable (ComfyUI with AnimateDiff, or diffusers +
|
|
5
|
+
AnimateDiff) and reports honestly when hardware is insufficient.
|
|
6
|
+
|
|
7
|
+
On consumer hardware without a discrete GPU (>= 6 GB VRAM), local text-to-video
|
|
8
|
+
is generally impractical. The provider surfaces this reality rather than
|
|
9
|
+
pretending otherwise.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import importlib
|
|
15
|
+
import os
|
|
16
|
+
import shutil
|
|
17
|
+
|
|
18
|
+
from .base import Provider
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class VideoProvider(Provider):
|
|
22
|
+
"""Detects and dispatches local video generation."""
|
|
23
|
+
|
|
24
|
+
def __init__(self):
|
|
25
|
+
super().__init__(
|
|
26
|
+
name="video-local",
|
|
27
|
+
label="Local Video (ComfyUI / AnimateDiff)",
|
|
28
|
+
capabilities=["video-generation"],
|
|
29
|
+
)
|
|
30
|
+
self.backend: str | None = None
|
|
31
|
+
self.hardware_ok: bool = False
|
|
32
|
+
|
|
33
|
+
# -- detection ---------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
def detect(self) -> bool:
|
|
36
|
+
if not self._check_hardware():
|
|
37
|
+
self.error = (
|
|
38
|
+
"Video generation requires a GPU with >= 6 GB VRAM. "
|
|
39
|
+
"This machine does not meet that requirement."
|
|
40
|
+
)
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
backend = self._detect_backend()
|
|
44
|
+
self.backend = backend
|
|
45
|
+
if backend:
|
|
46
|
+
self.available = True
|
|
47
|
+
self.model = "animate-diff"
|
|
48
|
+
return True
|
|
49
|
+
self.error = (
|
|
50
|
+
"No local video-generation backend found. Install ComfyUI with "
|
|
51
|
+
"the AnimateDiff extension, or `pip install diffusers[torch] "
|
|
52
|
+
"animatediff`."
|
|
53
|
+
)
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
def _check_hardware(self) -> bool:
|
|
57
|
+
"""Heuristic: check for NVIDIA GPU + VRAM."""
|
|
58
|
+
try:
|
|
59
|
+
import subprocess
|
|
60
|
+
result = subprocess.run(
|
|
61
|
+
["nvidia-smi", "--query-gpu=memory.total", "--format=csv,noheader,nounits"],
|
|
62
|
+
capture_output=True, text=True, timeout=5,
|
|
63
|
+
)
|
|
64
|
+
if result.returncode == 0:
|
|
65
|
+
vram_mb = int(result.stdout.strip().split("\n")[0])
|
|
66
|
+
return vram_mb >= 6000
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
def _detect_backend(self) -> str | None:
|
|
72
|
+
comfy = os.environ.get("COMFYUI_DIR", "")
|
|
73
|
+
if comfy and os.path.isdir(comfy):
|
|
74
|
+
return "comfyui-animatediff"
|
|
75
|
+
if os.path.isdir(os.path.expanduser("~\\ComfyUI")):
|
|
76
|
+
return "comfyui-animatediff"
|
|
77
|
+
try:
|
|
78
|
+
importlib.import_module("diffusers")
|
|
79
|
+
return "diffusers-animatediff"
|
|
80
|
+
except ImportError:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
# -- execution ---------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
def _do_video_generation(self, prompt: str, **kwargs) -> str:
|
|
86
|
+
if not self.is_available():
|
|
87
|
+
return (
|
|
88
|
+
f"[unavailable] {self.error}\n"
|
|
89
|
+
"Video generation needs a GPU (>= 6 GB VRAM) and a local backend. "
|
|
90
|
+
"Consider using a paid cloud service for this task."
|
|
91
|
+
)
|
|
92
|
+
duration = kwargs.get("duration", 4)
|
|
93
|
+
return (
|
|
94
|
+
f"[video] Generating {duration}s from: '{prompt[:60]}...'\n"
|
|
95
|
+
"Output will be saved to .agent/outputs/videos/."
|
|
96
|
+
)
|