mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-23 23:32:22 +02:00
Merge commit 'refs/phase3/pre-ajax/publication-tip' into integration/pre-ajax-release
# Conflicts: # routes/chat_routes.py # routes/session_routes.py # src/agent_loop.py # src/agent_tools/filesystem_tools.py # src/teacher_escalation.py # src/tool_capabilities.py # src/tool_execution.py # tests/test_mcp_add_server_args_validation.py # tests/test_token_cache_atomic_swap.py
This commit is contained in:
+305
-35
@@ -17,6 +17,7 @@ through the standard agent_tools.py pipeline.
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
import time
|
||||
from typing import Any, Awaitable, Callable, Dict, Optional, Tuple
|
||||
@@ -338,6 +339,44 @@ async def do_pipeline(content: str, session_id: Optional[str] = None, owner: Opt
|
||||
# Memory management tool
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _manage_memory_lines(content: str) -> list[str]:
|
||||
"""Normalize the public JSON contract to the legacy line protocol."""
|
||||
raw_content = content.strip()
|
||||
if not raw_content.startswith("{"):
|
||||
return raw_content.split("\n")
|
||||
try:
|
||||
payload = json.loads(raw_content)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
return raw_content.split("\n")
|
||||
if not isinstance(payload, dict):
|
||||
return raw_content.split("\n")
|
||||
action = str(payload.get("action") or "").strip().lower()
|
||||
command = payload.get("command")
|
||||
if not action and isinstance(command, str) and command.strip():
|
||||
return command.strip().split("\n")
|
||||
command_lines: list[str] = []
|
||||
if isinstance(command, str) and command.strip():
|
||||
command_lines = command.strip().split("\n")
|
||||
if command_lines and command_lines[0].strip().lower() == action:
|
||||
command_lines = command_lines[1:]
|
||||
if action == "list":
|
||||
return [action, str(payload.get("category") or "")]
|
||||
if action == "add":
|
||||
text = payload.get("text") or (command_lines[0] if command_lines else "")
|
||||
category = payload.get("category") or (command_lines[1] if len(command_lines) > 1 else "fact")
|
||||
return [action, str(text), str(category)]
|
||||
if action == "edit":
|
||||
memory_id = payload.get("memory_id") or payload.get("id") or (command_lines[0] if command_lines else "")
|
||||
text = payload.get("text") or ("\n".join(command_lines[1:]) if len(command_lines) > 1 else "")
|
||||
return [action, str(memory_id), str(text)]
|
||||
if action == "delete":
|
||||
memory_id = payload.get("memory_id") or payload.get("id") or (command_lines[0] if command_lines else "")
|
||||
return [action, str(memory_id)]
|
||||
if action == "search":
|
||||
query = payload.get("text") or payload.get("query") or "\n".join(command_lines)
|
||||
return [action, str(query)]
|
||||
return [action] if action else raw_content.split("\n")
|
||||
|
||||
async def do_manage_memory(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
|
||||
"""Manage memories: list, add, edit, delete, search.
|
||||
|
||||
@@ -355,7 +394,12 @@ async def do_manage_memory(content: str, session_id: Optional[str] = None, owner
|
||||
if not _memory_manager:
|
||||
return {"error": "Memory manager not available"}
|
||||
|
||||
lines = content.strip().split("\n")
|
||||
from src.agent_runtime.owned_resources import active_owned_operation
|
||||
bound = active_owned_operation()
|
||||
if bound is not None:
|
||||
bound.validate()
|
||||
|
||||
lines = _manage_memory_lines(content)
|
||||
if not lines:
|
||||
return {"error": "Need at least 1 line: action"}
|
||||
|
||||
@@ -428,7 +472,7 @@ async def do_manage_memory(content: str, session_id: Optional[str] = None, owner
|
||||
memories = _memory_manager.load_all()
|
||||
found = False
|
||||
for m in memories:
|
||||
if m.get("id", "").startswith(memory_id):
|
||||
if (m.get("id", "") == memory_id if bound is not None else m.get("id", "").startswith(memory_id)):
|
||||
# Verify ownership
|
||||
if owner and m.get("owner") != owner:
|
||||
return {"error": f"Memory '{memory_id}' not found"}
|
||||
@@ -463,7 +507,7 @@ async def do_manage_memory(content: str, session_id: Optional[str] = None, owner
|
||||
full_id = None
|
||||
delete_id = None
|
||||
for m in memories:
|
||||
if m.get("id", "").startswith(memory_id):
|
||||
if (m.get("id", "") == memory_id if bound is not None else m.get("id", "").startswith(memory_id)):
|
||||
# Verify ownership
|
||||
if owner and m.get("owner") != owner:
|
||||
return {"error": f"Memory '{memory_id}' not found"}
|
||||
@@ -493,7 +537,16 @@ async def do_manage_memory(content: str, session_id: Optional[str] = None, owner
|
||||
query_lower = query.lower()
|
||||
exact_results = [m for m in memories if query_lower in (m.get("text", "").lower())]
|
||||
|
||||
if hasattr(_memory_manager, 'get_relevant_memories'):
|
||||
# An exact marker is commonly used to identify one record for an
|
||||
# edit/delete workflow. Do not mix fuzzy neighbors into that result:
|
||||
# a semantically related record must never be mistaken for the exact
|
||||
# target of a destructive operation.
|
||||
exact_marker_query = bool(
|
||||
re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{12,}", query)
|
||||
)
|
||||
if exact_results or exact_marker_query:
|
||||
vector_results = []
|
||||
elif hasattr(_memory_manager, 'get_relevant_memories'):
|
||||
vector_results = _memory_manager.get_relevant_memories(query, memories, threshold=0.05, max_items=20)
|
||||
else:
|
||||
vector_results = []
|
||||
@@ -508,6 +561,29 @@ async def do_manage_memory(content: str, session_id: Optional[str] = None, owner
|
||||
if len(results) >= 20:
|
||||
break
|
||||
|
||||
# Keep a lexical safety net for short preference/identity queries. A
|
||||
# vector-only top-k result can bury an exact fact such as "Maya uses
|
||||
# Pacific time" beneath several related Maya fixture facts.
|
||||
if not exact_results and not exact_marker_query and len(results) < 20:
|
||||
query_terms = {
|
||||
token for token in re.findall(r"[a-z0-9]+", query_lower)
|
||||
if len(token) >= 4 and token not in {"what", "does", "with", "saved", "memory", "search", "look"}
|
||||
}
|
||||
lexical = []
|
||||
for memory in memories:
|
||||
text_lower = str(memory.get("text") or "").lower()
|
||||
overlap = sum(1 for token in query_terms if token in text_lower)
|
||||
if overlap:
|
||||
lexical.append((overlap, memory))
|
||||
for _, memory in sorted(lexical, key=lambda item: (-item[0], str(item[1].get("id", "")))):
|
||||
mid = memory.get("id")
|
||||
if mid in seen:
|
||||
continue
|
||||
seen.add(mid)
|
||||
results.append(memory)
|
||||
if len(results) >= 20:
|
||||
break
|
||||
|
||||
if not results:
|
||||
return {"results": f"No memories found matching '{query}'."}
|
||||
result_lines = [f"Found {len(results)} matching memories:\n"]
|
||||
@@ -628,9 +704,10 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
|
||||
toggle <name> <on|off> — Toggle a setting (web, bash, rag, research, incognito, document_editor)
|
||||
set_mode <agent|chat> — Switch between agent and chat mode
|
||||
switch_model <model> — Change the model for the current session
|
||||
set_theme <preset> — Apply a built-in theme preset (dark, light, midnight, paper, cyberpunk, retrowave, forest, ocean, ume, copper, terminal, organs, lavender, gpt, claude, cute)
|
||||
set_theme <preset> — Apply a built-in theme preset (dark, light, midnight, cyberpunk, retrowave, forest, ocean, ume, terminal, organs, gpt, claude, cute, eclipse, porcelain, arcade, blueprint, monolith, yoyo)
|
||||
create_theme <name> <bg> <fg> <panel> <border> <accent> [key=val ...] — Create custom theme. Optional key=val: advanced color overrides AND background effects: bgPattern=<none|dots|synapse|rain|constellations|perlin-flow|petals|sparkles|embers>, bgEffectColor=#RRGGBB, bgEffectIntensity=<num>, bgEffectSize=<num>, frosted=true|false
|
||||
open_panel <name> — Open a panel (documents, gallery, email, sessions, notes, memories, skills, settings, cookbook)
|
||||
get_theme — Return the last server-synchronized theme for this user
|
||||
open_panel <name> [view] — Open a panel; Cookbook views are download/models, launch/serve, active/running, dependencies, settings
|
||||
open_email_reply <uid> [folder] [reply|reply-all|ai-reply] [body text] — Open a reply draft document for an email; does not send. ALWAYS append the body text when the user told you what to say (one-shot draft); only omit body when the user just asked to "open a reply" without content.
|
||||
get_toggles — Return current toggle states (server-side knowledge)
|
||||
"""
|
||||
@@ -638,6 +715,16 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
|
||||
if not lines:
|
||||
return {"error": "No action specified"}
|
||||
|
||||
theme_args = None
|
||||
if content.lstrip().startswith('{'):
|
||||
import json
|
||||
try:
|
||||
theme_args = json.loads(content)
|
||||
except ValueError:
|
||||
return {"error": "Invalid UI action JSON."}
|
||||
if not isinstance(theme_args, dict) or theme_args.get('action') != 'create_theme':
|
||||
return {"error": "Structured UI action must be create_theme."}
|
||||
lines = ['create_theme']
|
||||
parts = lines[0].strip().split(None, 2)
|
||||
action = parts[0].lower()
|
||||
|
||||
@@ -724,26 +811,41 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
|
||||
}
|
||||
|
||||
elif action == "set_theme":
|
||||
theme_name = parts[1].lower() if len(parts) > 1 else ""
|
||||
theme_name = content.strip().partition(' ')[2].strip().lower().replace(' ', '-')
|
||||
# Theme colors are defined in static/js/theme.js on the frontend.
|
||||
# We pass the name; the frontend looks it up from presets + custom themes.
|
||||
# Also check user's custom themes stored in prefs.
|
||||
# Must match the THEMES keys in static/js/theme.js.
|
||||
known_presets = [
|
||||
"dark", "light", "midnight", "paper", "cyberpunk", "retrowave",
|
||||
"forest", "ocean", "ume", "copper", "terminal", "organs",
|
||||
"lavender", "gpt", "claude", "cute",
|
||||
]
|
||||
from src.theme_palette import THEME_PRESETS
|
||||
known_presets = THEME_PRESETS
|
||||
custom_themes = {}
|
||||
try:
|
||||
from routes.prefs_routes import _load as _load_prefs
|
||||
custom_themes = _load_prefs().get("custom-themes", {}) or {}
|
||||
from routes.prefs_routes import _load_for_user
|
||||
custom_themes = _load_for_user(owner).get("custom-themes", {}) or {}
|
||||
except Exception:
|
||||
pass
|
||||
all_known = set(known_presets) | set(custom_themes.keys())
|
||||
if theme_name not in all_known:
|
||||
custom_label = f" | Custom: {', '.join(sorted(custom_themes.keys()))}" if custom_themes else ""
|
||||
return {"error": f"Unknown theme '{theme_name}'. Available: {', '.join(sorted(known_presets))}{custom_label}"}
|
||||
try:
|
||||
from routes.prefs_routes import _load_for_user, _save_for_user
|
||||
prefs = _load_for_user(owner)
|
||||
previous = prefs.get("theme") if isinstance(prefs.get("theme"), dict) else {}
|
||||
stored = {"name": theme_name}
|
||||
if previous.get("name") == theme_name and isinstance(previous.get("colors"), dict):
|
||||
stored["colors"] = previous["colors"]
|
||||
elif isinstance(custom_themes.get(theme_name), dict):
|
||||
stored["colors"] = custom_themes[theme_name]
|
||||
theme_source = custom_themes.get(theme_name)
|
||||
if not theme_source and previous.get('name') == theme_name:
|
||||
theme_source = previous
|
||||
if isinstance(theme_source, dict):
|
||||
stored.update({k: v for k, v in theme_source.items() if k.startswith('bgEffect') or k in ('bgPattern', 'frosted')})
|
||||
prefs["theme"] = stored
|
||||
_save_for_user(owner, prefs)
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"ui_event": "set_theme",
|
||||
"theme_name": theme_name,
|
||||
@@ -751,8 +853,24 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
|
||||
}
|
||||
|
||||
elif action == "create_theme":
|
||||
# Re-split without limit to get all parts
|
||||
parts = lines[0].strip().split()
|
||||
import shlex
|
||||
try:
|
||||
if theme_args is not None:
|
||||
from src.theme_palette import normalize_theme_colors
|
||||
palette = normalize_theme_colors(theme_args.get('colors'))
|
||||
theme_name = theme_args.get('name')
|
||||
if not isinstance(theme_name, str) or not theme_name.strip():
|
||||
return {"error": "name must be a nonempty theme name."}
|
||||
base = ('bg', 'fg', 'panel', 'border', 'accent')
|
||||
parts = ['create_theme', theme_name.strip(), *(palette[k] for k in base)]
|
||||
parts.extend(f'{k}={v}' for k, v in palette.items() if k not in base)
|
||||
from src.theme_palette import normalize_theme_background
|
||||
background = normalize_theme_background(theme_args.get('background'), palette['accent'])
|
||||
parts.extend(f'{k}={v}' for k, v in background.items())
|
||||
else:
|
||||
parts = shlex.split(content.strip())
|
||||
except ValueError as exc:
|
||||
return {"error": f"Invalid theme arguments: {exc}"}
|
||||
# create_theme <name> <bg> <fg> <panel> <border> <accent> [key=value ...]
|
||||
if len(parts) < 7:
|
||||
return {"error": "create_theme needs: create_theme <name> <bg> <fg> <panel> <border> <accent> (all hex colors). Optional advanced color key=value pairs (userBubbleBg, aiBubbleBg, bubbleBorder, sidebarBg, sectionAccent, brandColor, inputBg, inputBorder, sendBtnBg, sendBtnHover, codeBg, codeFg, toggleBg, toggleActive, accentPrimary, accentError). Optional background EFFECTS: bgPattern=<none|dots|synapse|rain|constellations|perlin-flow|petals|sparkles|embers>, bgEffectColor=#RRGGBB, bgEffectIntensity=<num e.g. 1>, bgEffectSize=<num e.g. 1>, frosted=true|false"}
|
||||
@@ -774,7 +892,8 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
|
||||
# Background-effect fields (animated pattern + frosted glass). Different
|
||||
# value types than the hex-only advanced keys, so parse separately.
|
||||
_BG_PATTERNS = {"none", "dots", "synapse", "rain", "constellations",
|
||||
"perlin-flow", "petals", "sparkles", "embers"}
|
||||
"perlin-flow", "petals", "sparkles", "embers",
|
||||
"starfield-depth", "ascii-fireflies"}
|
||||
bg = {}
|
||||
for part in parts[7:]:
|
||||
if "=" not in part:
|
||||
@@ -792,15 +911,30 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
|
||||
if not _re.match(r'^#[0-9a-fA-F]{6}$', av):
|
||||
return {"error": f"Invalid hex color for bgEffectColor: '{av}'. Use format #RRGGBB"}
|
||||
bg["effectColor"] = av
|
||||
elif ak in ("bgEffectIntensity", "bgEffectSize"):
|
||||
elif ak in ("bgEffectIntensity", "bgEffectSize", "bgEffectSpeed"):
|
||||
try:
|
||||
bg["effectIntensity" if ak == "bgEffectIntensity" else "effectSize"] = float(av)
|
||||
bg[ak[2].lower() + ak[3:]] = float(av)
|
||||
except ValueError:
|
||||
return {"error": f"Invalid number for {ak}: '{av}'"}
|
||||
elif ak == "frosted":
|
||||
bg["frosted"] = av.lower() in ("true", "1", "yes", "on")
|
||||
if advanced:
|
||||
colors["advanced"] = advanced
|
||||
try:
|
||||
from routes.prefs_routes import _load_for_user, _save_for_user
|
||||
prefs = _load_for_user(owner)
|
||||
custom_themes = prefs.get("custom-themes")
|
||||
custom_themes = dict(custom_themes) if isinstance(custom_themes, dict) else {}
|
||||
custom_themes[name] = dict(colors)
|
||||
prefs["custom-themes"] = custom_themes
|
||||
prefs["theme"] = {"name": name, "colors": dict(colors)}
|
||||
for key, value in bg.items():
|
||||
stored_key = 'frosted' if key == 'frosted' else 'bg' + key[0].upper() + key[1:]
|
||||
custom_themes[name][stored_key] = value
|
||||
prefs['theme'][stored_key] = value
|
||||
_save_for_user(owner, prefs)
|
||||
except Exception:
|
||||
return {"error": "Could not save the theme. No theme change was applied; retry when preferences storage is available."}
|
||||
return {
|
||||
"ui_event": "create_theme",
|
||||
"theme_name": name,
|
||||
@@ -831,8 +965,11 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
|
||||
|
||||
elif action == "open_panel":
|
||||
# Open a top-level panel/modal: documents/library, gallery,
|
||||
# email, sessions, notes, memories, skills, settings, cookbook.
|
||||
# calendar, email, sessions, notes, memories, skills, settings, theme, cookbook.
|
||||
panel = parts[1].lower() if len(parts) > 1 else ""
|
||||
view = ""
|
||||
view_label = ""
|
||||
target_date = ""
|
||||
_panel_aliases = {
|
||||
"documents": "documents",
|
||||
"document": "documents",
|
||||
@@ -842,6 +979,9 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
|
||||
"doclib": "documents",
|
||||
"gallery": "gallery",
|
||||
"images": "gallery",
|
||||
"calendar": "calendar",
|
||||
"cal": "calendar",
|
||||
"schedule": "calendar",
|
||||
"email": "email",
|
||||
"emails": "email",
|
||||
"inbox": "email",
|
||||
@@ -859,6 +999,9 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
|
||||
"skills": "skills",
|
||||
"settings": "settings",
|
||||
"preferences": "settings",
|
||||
"theme": "theme",
|
||||
"themes": "theme",
|
||||
"appearance": "theme",
|
||||
"cookbook": "cookbook",
|
||||
"models": "cookbook",
|
||||
"llm": "cookbook",
|
||||
@@ -867,12 +1010,55 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
|
||||
}
|
||||
target = _panel_aliases.get(panel)
|
||||
if not target:
|
||||
return {"error": f"Unknown panel '{panel}'. Valid: documents, gallery, email, sessions, notes, memories, skills, settings, cookbook."}
|
||||
return {
|
||||
return {"error": f"Unknown panel '{panel}'. Valid: documents, gallery, calendar, email, sessions, notes, memories, skills, settings, theme, cookbook."}
|
||||
if target == "cookbook":
|
||||
cookbook_views = {
|
||||
"models": ("Search", "models"), "model": ("Search", "models"),
|
||||
"download": ("Search", "models"), "search": ("Search", "models"),
|
||||
"serve": ("Serve", "launch"), "serving": ("Serve", "launch"),
|
||||
"launch": ("Serve", "launch"),
|
||||
"active": ("Running", "running"), "running": ("Running", "running"),
|
||||
"dependencies": ("Dependencies", "dependencies"),
|
||||
"dependency": ("Dependencies", "dependencies"),
|
||||
"settings": ("Settings", "settings"),
|
||||
}
|
||||
requested_view = parts[2].strip().lower() if len(parts) > 2 else ""
|
||||
# A panel alias can carry the subview intent by itself. Previously
|
||||
# `models` and `serve` were silently collapsed to bare Cookbook.
|
||||
resolved_view = cookbook_views.get(requested_view) or cookbook_views.get(panel)
|
||||
if resolved_view:
|
||||
view, view_label = resolved_view
|
||||
if target == "calendar":
|
||||
view_words = {"day", "week", "month", "year", "agenda"}
|
||||
tail_text = ""
|
||||
if len(parts) > 2:
|
||||
tail_text = parts[2]
|
||||
if len(lines) > 1:
|
||||
tail_text = " ".join(p for p in [tail_text, " ".join(line.strip() for line in lines[1:] if line.strip())] if p)
|
||||
tail = [p.strip().lower() for p in tail_text.split() if p.strip()]
|
||||
for i, token in enumerate(tail):
|
||||
if token in view_words:
|
||||
view = token
|
||||
target_date = " ".join(t for t in tail[i + 1:] if t != "view").strip()
|
||||
break
|
||||
if not view and tail and tail[0] in view_words:
|
||||
view = tail[0]
|
||||
target_date = " ".join(tail[1:]).strip()
|
||||
payload = {
|
||||
"ui_event": "open_panel",
|
||||
"panel": target,
|
||||
"results": f"Opening {target} panel",
|
||||
}
|
||||
if panel != target:
|
||||
payload["requested_panel"] = panel
|
||||
if view:
|
||||
payload["view"] = view
|
||||
if view_label:
|
||||
payload["view_label"] = view_label
|
||||
payload["results"] = f"Opening {target} panel in {view_label or view} view"
|
||||
if target_date:
|
||||
payload["target_date"] = target_date
|
||||
return payload
|
||||
|
||||
elif action == "open_email_reply":
|
||||
# Two forms supported:
|
||||
@@ -924,6 +1110,34 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
|
||||
result["body"] = body
|
||||
return result
|
||||
|
||||
elif action == "get_theme":
|
||||
from src.theme_palette import BACKGROUND_PATTERNS, THEME_PRESETS
|
||||
prefs = {}
|
||||
try:
|
||||
from routes.prefs_routes import _load_for_user
|
||||
prefs = _load_for_user(owner)
|
||||
saved = prefs.get("theme")
|
||||
except Exception:
|
||||
saved = None
|
||||
available = {'presets': list(THEME_PRESETS),
|
||||
'custom_themes': sorted((prefs.get('custom-themes') or {}).keys()),
|
||||
'background_patterns': list(BACKGROUND_PATTERNS)}
|
||||
name = str(saved.get("name") or "").strip() if isinstance(saved, dict) else ""
|
||||
if not name:
|
||||
return {
|
||||
"results": "The current client theme has not been synchronized to the server.",
|
||||
"theme_known": False,
|
||||
**available,
|
||||
}
|
||||
return {
|
||||
"results": f"Current theme: {name}",
|
||||
"current_theme": name,
|
||||
"theme_known": True,
|
||||
'colors': saved.get('colors'),
|
||||
'background': {k: v for k, v in saved.items() if k.startswith('bg') or k == 'frosted'},
|
||||
**available,
|
||||
}
|
||||
|
||||
elif action == "get_toggles":
|
||||
return {
|
||||
"results": (
|
||||
@@ -934,7 +1148,7 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
|
||||
}
|
||||
|
||||
else:
|
||||
return {"error": f"Unknown action '{action}'. Use: toggle, set_mode, switch_model, set_theme, highlight, clear_highlight, get_toggles"}
|
||||
return {"error": f"Unknown action '{action}'. Use: toggle, set_mode, switch_model, set_theme, create_theme, get_theme, highlight, clear_highlight, get_toggles"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -956,11 +1170,23 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
from pathlib import Path
|
||||
from src.url_safety import check_outbound_url
|
||||
|
||||
lines = content.strip().split("\n")
|
||||
prompt = lines[0].strip() if lines else ""
|
||||
model_spec = lines[1].strip() if len(lines) > 1 and lines[1].strip() else ""
|
||||
size = lines[2].strip() if len(lines) > 2 and lines[2].strip() else "1024x1024"
|
||||
quality = lines[3].strip() if len(lines) > 3 and lines[3].strip() else "medium"
|
||||
if content.lstrip().startswith('{'):
|
||||
try:
|
||||
args = json.loads(content)
|
||||
except (TypeError, ValueError):
|
||||
return {"error": "Image arguments must be a JSON object"}
|
||||
if not isinstance(args, dict):
|
||||
return {"error": "Image arguments must be a JSON object"}
|
||||
prompt = str(args.get('prompt') or '').strip()
|
||||
model_spec = str(args.get('model') or '').strip()
|
||||
size = str(args.get('size') or '1024x1024')
|
||||
quality = str(args.get('quality') or 'medium')
|
||||
else:
|
||||
lines = content.strip().split("\n")
|
||||
prompt = lines[0].strip() if lines else ""
|
||||
model_spec = lines[1].strip() if len(lines) > 1 and lines[1].strip() else ""
|
||||
size = lines[2].strip() if len(lines) > 2 and lines[2].strip() else "1024x1024"
|
||||
quality = lines[3].strip() if len(lines) > 3 and lines[3].strip() else "medium"
|
||||
|
||||
if not prompt:
|
||||
return {"error": "Image prompt is required (line 1)"}
|
||||
@@ -972,6 +1198,9 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
except Exception:
|
||||
_settings = {}
|
||||
|
||||
if not _settings.get("image_gen_enabled", True):
|
||||
return {"error": "Image generation is disabled by the administrator."}
|
||||
|
||||
# Use admin-configured model/quality if not specified by the tool call
|
||||
if not model_spec:
|
||||
model_spec = _settings.get("image_model", "")
|
||||
@@ -1059,6 +1288,9 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
# Build the images endpoint URL from the chat completions URL
|
||||
base_url = url.replace("/chat/completions", "").replace("/v1/messages", "").rstrip("/")
|
||||
images_url = base_url + "/images/generations"
|
||||
from src.model_capability_readers.base import detect_vendor
|
||||
if detect_vendor(url) == "openrouter":
|
||||
images_url = base_url + "/images"
|
||||
|
||||
# Validate size for cloud image models (local diffusion accepts any WxH)
|
||||
valid_gpt_sizes = {"1024x1024", "1024x1536", "1536x1024", "auto"}
|
||||
@@ -1195,7 +1427,7 @@ async def do_edit_image(
|
||||
model_spec: str = "",
|
||||
session_id: Optional[str] = None,
|
||||
owner: Optional[str] = None,
|
||||
size: str = "1024x1024",
|
||||
size: str = "auto",
|
||||
quality: str = "medium",
|
||||
progress_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None,
|
||||
) -> Dict:
|
||||
@@ -1242,8 +1474,23 @@ async def do_edit_image(
|
||||
except ValueError:
|
||||
return {"error": f"No endpoint found with image model '{model_spec}'."}
|
||||
|
||||
if not size or size == "auto":
|
||||
from PIL import Image
|
||||
from src.image_model_ids import image_edit_size
|
||||
try:
|
||||
with Image.open(path) as source:
|
||||
width, height = source.size
|
||||
# EXIF rotation changes the displayed portrait/landscape shape.
|
||||
if source.getexif().get(274) in {5, 6, 7, 8}:
|
||||
width, height = height, width
|
||||
size = image_edit_size(model_id, width, height)
|
||||
except (OSError, ValueError, Image.DecompressionBombError):
|
||||
return {"error": "Could not read the attached image dimensions. Try a PNG, JPEG, or WebP image."}
|
||||
|
||||
base_url = url.replace("/chat/completions", "").replace("/v1/messages", "").rstrip("/")
|
||||
edits_url = base_url + "/images/edits"
|
||||
from src.model_capability_readers.base import detect_vendor
|
||||
is_openrouter = detect_vendor(url) == "openrouter"
|
||||
mime = mimetypes.guess_type(str(path))[0] or "image/png"
|
||||
payload = {
|
||||
"model": model_id,
|
||||
@@ -1281,6 +1528,11 @@ async def do_edit_image(
|
||||
return ""
|
||||
|
||||
def _save_image_bytes(image_bytes: bytes, suffix: str = ".png") -> tuple[str, str]:
|
||||
nonlocal size
|
||||
from io import BytesIO
|
||||
from PIL import Image
|
||||
with Image.open(BytesIO(image_bytes)) as output:
|
||||
size = f"{output.width}x{output.height}"
|
||||
img_dir = Path(GENERATED_IMAGES_DIR)
|
||||
img_dir.mkdir(parents=True, exist_ok=True)
|
||||
filename = f"{uuid.uuid4().hex[:12]}{suffix}"
|
||||
@@ -1351,7 +1603,7 @@ async def do_edit_image(
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(connect=30.0, read=600.0, write=60.0, pool=30.0)) as client:
|
||||
progress_task = None
|
||||
if progress_callback:
|
||||
if progress_callback and not is_openrouter:
|
||||
progress_url = base_url + f"/images/progress/{request_id}"
|
||||
|
||||
async def _poll_progress():
|
||||
@@ -1375,9 +1627,27 @@ async def do_edit_image(
|
||||
|
||||
progress_task = asyncio.create_task(_poll_progress())
|
||||
try:
|
||||
with path.open("rb") as f:
|
||||
files = {"image": (path.name, f, mime)}
|
||||
resp = await client.post(edits_url, data=payload, files=files, headers=headers)
|
||||
if is_openrouter:
|
||||
# OpenRouter's Image API uses JSON reference images for
|
||||
# edits, not OpenAI's multipart /images/edits protocol.
|
||||
image_b64 = base64.b64encode(path.read_bytes()).decode("ascii")
|
||||
edit_payload = {
|
||||
"model": model_id,
|
||||
"prompt": prompt,
|
||||
"n": 1,
|
||||
"size": size,
|
||||
"quality": payload["quality"],
|
||||
"output_format": "png",
|
||||
"input_references": [{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:{mime};base64,{image_b64}"},
|
||||
}],
|
||||
}
|
||||
resp = await client.post(base_url + "/images", json=edit_payload, headers=headers)
|
||||
else:
|
||||
with path.open("rb") as f:
|
||||
files = {"image": (path.name, f, mime)}
|
||||
resp = await client.post(edits_url, data=payload, files=files, headers=headers)
|
||||
finally:
|
||||
if progress_task:
|
||||
progress_task.cancel()
|
||||
@@ -1398,14 +1668,14 @@ async def do_edit_image(
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if resp.status_code in (400, 404, 405, 422):
|
||||
if not is_openrouter and resp.status_code in (400, 404, 405, 422):
|
||||
fallback = await _try_local_img2img_fallback(client)
|
||||
if fallback:
|
||||
return fallback
|
||||
if resp.status_code == 404:
|
||||
return {
|
||||
"error": (
|
||||
f"Image model '{model_id}' is reachable, but this endpoint does not expose image editing. "
|
||||
f"The configured endpoint returned 404 for image editing with '{model_id}'. "
|
||||
"Use it without an attached image for text-to-image generation, or serve an edit/img2img "
|
||||
"model for attached-image prompts."
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user