mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-16 01:35:36 -04:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4371425514 | |||
| fbed9027b0 | |||
| d9141c6e56 | |||
| 8ae2b5f58c | |||
| 637a34515d | |||
| d397b3db2f | |||
| 1a529d63d9 | |||
| f605bb3864 | |||
| 37c573d865 | |||
| 6f29b287f6 | |||
| 4715a5505d | |||
| 84ca74f04b | |||
| e6b1009b89 | |||
| fa8c93ec0a | |||
| 646f8bd2a9 | |||
| 2a2a93d845 | |||
| 06a04efc59 | |||
| 3b01760e95 | |||
| db1bbfe588 | |||
| 2404b00f18 | |||
| 7367325819 | |||
| 3738df3b93 | |||
| f5c9095222 | |||
| d4ff7fce81 |
@@ -159,6 +159,16 @@ SEARXNG_INSTANCE=http://localhost:8080
|
||||
# Example: 52428800 = 50 MB.
|
||||
# ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=10485760
|
||||
|
||||
# Other per-feature upload size caps in bytes. All are validated and optional;
|
||||
# defaults shown. An invalid value (non-integer or < 1) fails fast at startup.
|
||||
# ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES=104857600 # gallery image upload (100 MB)
|
||||
# ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES=26214400 # gallery transform input (25 MB)
|
||||
# ODYSSEUS_MEMORY_IMPORT_MAX_BYTES=10485760 # memory import file (10 MB)
|
||||
# ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES=26214400 # personal document upload (25 MB)
|
||||
# ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=26214400 # email compose attachment (25 MB)
|
||||
# ODYSSEUS_STT_MAX_AUDIO_BYTES=26214400 # speech-to-text audio (25 MB)
|
||||
# ODYSSEUS_ICS_MAX_BYTES=10485760 # calendar .ics import (10 MB)
|
||||
|
||||
# ============================================================
|
||||
# GPU support (Docker Compose)
|
||||
# ============================================================
|
||||
|
||||
@@ -403,6 +403,15 @@ Key settings:
|
||||
| `CHROMADB_PORT` | `8100` | ChromaDB port for manual host runs. Docker overrides this to `8000`. |
|
||||
| `EMBEDDING_URL` | -- | OpenAI-compatible embeddings endpoint |
|
||||
| `ODYSSEUS_CHAT_UPLOAD_MAX_BYTES` | `10485760` | Chat/agent attachment cap in bytes. Raise for larger local PDFs or text documents. |
|
||||
| `ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES` | `104857600` | Gallery image upload cap in bytes (100 MB). |
|
||||
| `ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES` | `26214400` | Gallery transform input cap in bytes (25 MB). |
|
||||
| `ODYSSEUS_MEMORY_IMPORT_MAX_BYTES` | `10485760` | Memory import file cap in bytes (10 MB). |
|
||||
| `ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES` | `26214400` | Personal document upload cap in bytes (25 MB). |
|
||||
| `ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES` | `26214400` | Email compose attachment cap in bytes (25 MB). |
|
||||
| `ODYSSEUS_STT_MAX_AUDIO_BYTES` | `26214400` | Speech-to-text audio cap in bytes (25 MB). |
|
||||
| `ODYSSEUS_ICS_MAX_BYTES` | `10485760` | Calendar `.ics` import cap in bytes (10 MB). |
|
||||
|
||||
All upload-limit vars are validated (must be a positive integer) and optional; an invalid value fails fast at startup.
|
||||
|
||||
### Built-in MCP servers (optional setup)
|
||||
|
||||
|
||||
@@ -529,9 +529,6 @@ upload_cleanup_task = None
|
||||
from routes.emoji_routes import setup_emoji_routes
|
||||
app.include_router(setup_emoji_routes())
|
||||
|
||||
from routes.workspace_routes import setup_workspace_routes
|
||||
app.include_router(setup_workspace_routes())
|
||||
|
||||
# Sessions
|
||||
from routes.session_routes import setup_session_routes
|
||||
session_config = {"REQUEST_TIMEOUT": REQUEST_TIMEOUT, "OPENAI_API_KEY": OPENAI_API_KEY, "SESSIONS_FILE": SESSIONS_FILE}
|
||||
|
||||
+532
-1
@@ -22,6 +22,7 @@ import os
|
||||
import os.path
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
import uuid
|
||||
|
||||
from mcp.server import Server
|
||||
from mcp.server.stdio import stdio_server
|
||||
@@ -67,6 +68,59 @@ def _db_path() -> Path:
|
||||
return Path(APP_DB)
|
||||
|
||||
|
||||
def _load_email_writing_style() -> str:
|
||||
"""Return the existing Settings > Email > Writing Style value."""
|
||||
try:
|
||||
settings_path = DATA_DIR / "settings.json"
|
||||
if not settings_path.exists():
|
||||
return ""
|
||||
settings = json.loads(settings_path.read_text(encoding="utf-8"))
|
||||
return str(settings.get("email_writing_style") or "").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _writing_style_guidance() -> str:
|
||||
style = _load_email_writing_style()
|
||||
if not style:
|
||||
return (
|
||||
"No saved writing style is configured in Settings > Email > Writing Style. "
|
||||
"Use a concise, natural tone and do not invent facts."
|
||||
)
|
||||
return (
|
||||
"Use this saved writing style from Settings > Email > Writing Style when "
|
||||
"drafting the body. It overrides generic tone guidance:\n"
|
||||
f"{style}"
|
||||
)
|
||||
|
||||
|
||||
def _default_document_owner() -> str | None:
|
||||
"""Best-effort owner for MCP-created documents.
|
||||
|
||||
MCP stdio tools do not receive the browser request's authenticated user,
|
||||
but the document library is owner-filtered. Stamp drafts to the configured
|
||||
single/default admin so assistant-created email drafts are visible.
|
||||
"""
|
||||
owner = os.environ.get("ODYSSEUS_DOCUMENT_OWNER", "").strip()
|
||||
if owner:
|
||||
return owner
|
||||
try:
|
||||
auth_path = DATA_DIR / "auth.json"
|
||||
if not auth_path.exists():
|
||||
return None
|
||||
users = (json.loads(auth_path.read_text(encoding="utf-8")).get("users") or {})
|
||||
if not isinstance(users, dict) or not users:
|
||||
return None
|
||||
admins = [name for name, data in users.items() if isinstance(data, dict) and data.get("is_admin")]
|
||||
if len(admins) == 1:
|
||||
return admins[0]
|
||||
if len(users) == 1:
|
||||
return next(iter(users))
|
||||
return admins[0] if admins else next(iter(users))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _list_accounts_raw() -> list:
|
||||
"""Return list of dicts from the email_accounts table. Empty list if table
|
||||
missing or empty. Never raises."""
|
||||
@@ -896,6 +950,340 @@ def _send_email(to, subject, body, in_reply_to=None, references=None, cc=None, b
|
||||
}
|
||||
|
||||
|
||||
def _build_email_document_content(
|
||||
to,
|
||||
subject,
|
||||
body,
|
||||
*,
|
||||
cc=None,
|
||||
bcc=None,
|
||||
in_reply_to=None,
|
||||
references=None,
|
||||
source_uid=None,
|
||||
source_folder=None,
|
||||
):
|
||||
header_lines = [f"To: {to or ''}"]
|
||||
if cc:
|
||||
header_lines.append(f"Cc: {cc}")
|
||||
if bcc:
|
||||
header_lines.append(f"Bcc: {bcc}")
|
||||
header_lines.append(f"Subject: {subject or ''}")
|
||||
if in_reply_to:
|
||||
header_lines.append(f"In-Reply-To: {in_reply_to}")
|
||||
if references:
|
||||
header_lines.append(f"References: {references}")
|
||||
if source_uid:
|
||||
header_lines.append(f"X-Source-UID: {source_uid}")
|
||||
if source_folder:
|
||||
header_lines.append(f"X-Source-Folder: {source_folder}")
|
||||
return "\n".join(header_lines) + "\n---\n" + (body or "")
|
||||
|
||||
|
||||
def _merge_email_reply_body(existing_content: str, reply_body: str) -> str:
|
||||
"""Preserve email headers and quoted chain while replacing the editable reply body."""
|
||||
if "\n---\n" not in (existing_content or ""):
|
||||
return reply_body or ""
|
||||
head, body = existing_content.split("\n---\n", 1)
|
||||
quote_markers = (
|
||||
"---------- Previous message ----------",
|
||||
"-----Original Message-----",
|
||||
"----- Original Message -----",
|
||||
)
|
||||
quote_index = -1
|
||||
for marker in quote_markers:
|
||||
idx = body.find(marker)
|
||||
if idx != -1 and (quote_index == -1 or idx < quote_index):
|
||||
quote_index = idx
|
||||
quote = body[quote_index:].strip() if quote_index != -1 else ""
|
||||
merged_body = (reply_body or "").strip()
|
||||
if quote:
|
||||
merged_body = f"{merged_body}\n\n{quote}" if merged_body else quote
|
||||
return f"{head}\n---\n{merged_body}"
|
||||
|
||||
|
||||
def _create_email_draft_document(
|
||||
*,
|
||||
to,
|
||||
subject,
|
||||
body,
|
||||
title=None,
|
||||
cc=None,
|
||||
bcc=None,
|
||||
in_reply_to=None,
|
||||
references=None,
|
||||
source_uid=None,
|
||||
source_folder=None,
|
||||
account=None,
|
||||
source_message_id=None,
|
||||
):
|
||||
"""Create an Odysseus email compose document for user review. Does not send."""
|
||||
from core.database import SessionLocal, Document, DocumentVersion
|
||||
try:
|
||||
from src.event_bus import fire_event
|
||||
except Exception:
|
||||
fire_event = None
|
||||
|
||||
cfg = _load_config(account) if account else _load_config(None)
|
||||
content = _build_email_document_content(
|
||||
to,
|
||||
subject,
|
||||
body,
|
||||
cc=cc,
|
||||
bcc=bcc,
|
||||
in_reply_to=in_reply_to,
|
||||
references=references,
|
||||
source_uid=source_uid,
|
||||
source_folder=source_folder,
|
||||
)
|
||||
doc_id = str(uuid.uuid4())
|
||||
ver_id = str(uuid.uuid4())
|
||||
doc_title = (title or subject or "Email draft").strip() or "Email draft"
|
||||
doc_owner = _default_document_owner()
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if source_uid and source_folder:
|
||||
existing = (
|
||||
db.query(Document)
|
||||
.filter(Document.is_active == True)
|
||||
.filter(Document.language == "email")
|
||||
.filter(Document.owner == doc_owner)
|
||||
.filter(Document.source_email_uid == str(source_uid))
|
||||
.filter(Document.source_email_folder == source_folder)
|
||||
.order_by(Document.updated_at.desc())
|
||||
.first()
|
||||
)
|
||||
if existing and "\n---\n" in (existing.current_content or ""):
|
||||
existing.current_content = _merge_email_reply_body(existing.current_content, body or "")
|
||||
existing.version_count = (existing.version_count or 0) + 1
|
||||
ver = DocumentVersion(
|
||||
id=ver_id,
|
||||
document_id=existing.id,
|
||||
version_number=existing.version_count,
|
||||
content=existing.current_content,
|
||||
summary="Updated by email MCP draft tool",
|
||||
source="ai",
|
||||
)
|
||||
db.add(ver)
|
||||
db.commit()
|
||||
if fire_event:
|
||||
try:
|
||||
fire_event("document_updated", doc_owner)
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"draft": True,
|
||||
"updated": True,
|
||||
"doc_id": existing.id,
|
||||
"title": existing.title,
|
||||
"language": existing.language,
|
||||
"account": cfg.get("account_name"),
|
||||
"account_id": cfg.get("account_id"),
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
}
|
||||
|
||||
doc = Document(
|
||||
id=doc_id,
|
||||
session_id=None,
|
||||
title=doc_title,
|
||||
language="email",
|
||||
current_content=content,
|
||||
version_count=1,
|
||||
is_active=True,
|
||||
owner=doc_owner,
|
||||
source_email_uid=source_uid,
|
||||
source_email_folder=source_folder,
|
||||
source_email_account_id=cfg.get("account_id"),
|
||||
source_email_message_id=source_message_id,
|
||||
)
|
||||
ver = DocumentVersion(
|
||||
id=ver_id,
|
||||
document_id=doc_id,
|
||||
version_number=1,
|
||||
content=content,
|
||||
summary="Created by email MCP draft tool",
|
||||
source="ai",
|
||||
)
|
||||
db.add(doc)
|
||||
db.add(ver)
|
||||
db.commit()
|
||||
if fire_event:
|
||||
try:
|
||||
fire_event("document_created", doc_owner)
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"draft": True,
|
||||
"doc_id": doc_id,
|
||||
"title": doc_title,
|
||||
"language": "email",
|
||||
"account": cfg.get("account_name"),
|
||||
"account_id": cfg.get("account_id"),
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _draft_reply_to_email(uid, body, folder="INBOX", reply_all=False, account=None, title=None):
|
||||
"""Create a threaded Odysseus reply draft document. Does not send."""
|
||||
conn = _imap_connect(account)
|
||||
conn.select(_q(folder), readonly=True)
|
||||
status, msg_data = conn.uid("FETCH", _b(uid), "(BODY.PEEK[])")
|
||||
conn.logout()
|
||||
if status != "OK" or not msg_data or not msg_data[0]:
|
||||
return {"error": f"Failed to fetch email UID {uid}"}
|
||||
raw = msg_data[0][1]
|
||||
orig = email.message_from_bytes(raw)
|
||||
|
||||
orig_subject = _decode_header(orig.get("Subject", ""))
|
||||
reply_subject = orig_subject if orig_subject.lower().startswith("re:") else f"Re: {orig_subject}"
|
||||
orig_message_id = orig.get("Message-ID", "")
|
||||
orig_references = orig.get("References", "")
|
||||
new_references = (orig_references + " " + orig_message_id).strip() if orig_references else orig_message_id
|
||||
|
||||
sender = _decode_header(orig.get("From", ""))
|
||||
_, sender_addr = email.utils.parseaddr(sender)
|
||||
to_addrs = sender_addr
|
||||
|
||||
cc = None
|
||||
if reply_all:
|
||||
cc_addrs = []
|
||||
cfg = _load_config(account)
|
||||
own_addrs = {
|
||||
(cfg.get("imap_user") or "").strip().lower(),
|
||||
(cfg.get("from_address") or "").strip().lower(),
|
||||
}
|
||||
for header_name in ("To", "Cc"):
|
||||
for _, addr in email.utils.getaddresses([orig.get(header_name, "")]):
|
||||
addr_l = (addr or "").strip().lower()
|
||||
if addr and addr != sender_addr and addr_l not in own_addrs:
|
||||
cc_addrs.append(addr)
|
||||
if cc_addrs:
|
||||
cc = ", ".join(dict.fromkeys(cc_addrs))
|
||||
|
||||
return _create_email_draft_document(
|
||||
to=to_addrs,
|
||||
subject=reply_subject,
|
||||
body=body,
|
||||
title=title or reply_subject,
|
||||
cc=cc,
|
||||
in_reply_to=orig_message_id,
|
||||
references=new_references,
|
||||
source_uid=uid,
|
||||
source_folder=folder,
|
||||
account=account,
|
||||
source_message_id=orig_message_id,
|
||||
)
|
||||
|
||||
|
||||
async def _ai_draft_reply_to_email(uid, folder="INBOX", reply_all=False, account=None, title=None):
|
||||
"""Generate a reply with Odysseus' AI-reply prompt/style, then create a compose doc."""
|
||||
read_result = _read_email(uid=uid, folder=folder, account=account)
|
||||
if "error" in read_result:
|
||||
return read_result
|
||||
|
||||
to_addr = read_result.get("from_address") or email.utils.parseaddr(read_result.get("from") or "")[1]
|
||||
subject = read_result.get("subject") or ""
|
||||
reply_subject = subject if subject.lower().startswith("re:") else f"Re: {subject}"
|
||||
original_body = read_result.get("body") or ""
|
||||
message_id = read_result.get("message_id") or ""
|
||||
|
||||
if not original_body.strip():
|
||||
return {"error": "No email body available for AI reply"}
|
||||
|
||||
try:
|
||||
from routes.email_helpers import (
|
||||
_EMAIL_REPLY_SYS_PROMPT_BASE,
|
||||
_apply_email_style_mechanics,
|
||||
_extract_reply,
|
||||
_load_settings,
|
||||
)
|
||||
from src.endpoint_resolver import (
|
||||
resolve_endpoint,
|
||||
resolve_utility_fallback_candidates,
|
||||
resolve_chat_fallback_candidates,
|
||||
)
|
||||
from src.llm_core import llm_call_async_with_fallback
|
||||
except Exception as exc:
|
||||
return {"error": f"AI reply helpers unavailable: {exc}"}
|
||||
|
||||
settings = _load_settings()
|
||||
style = settings.get("email_writing_style", "")
|
||||
system_prompt = _EMAIL_REPLY_SYS_PROMPT_BASE
|
||||
if style:
|
||||
system_prompt += f"\n\nWRITING STYLE TO MATCH:\n{style}"
|
||||
|
||||
user_msg = (
|
||||
f"Recipient: {to_addr}\nSubject: {reply_subject}\n\n"
|
||||
f"Original email and any current draft:\n{original_body[:6000]}\n\n"
|
||||
"Draft a reply. Return only the reply body text."
|
||||
)
|
||||
|
||||
candidates = []
|
||||
seen = set()
|
||||
|
||||
def _add(url, model, headers):
|
||||
key = (url or "", model or "")
|
||||
if not url or not model or key in seen:
|
||||
return
|
||||
seen.add(key)
|
||||
candidates.append((url, model, headers))
|
||||
|
||||
try:
|
||||
_add(*resolve_endpoint("utility", owner=None))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_add(*resolve_endpoint("default", owner=None))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
utility_fallbacks = resolve_utility_fallback_candidates(owner=None) or []
|
||||
except TypeError:
|
||||
utility_fallbacks = resolve_utility_fallback_candidates() or []
|
||||
for cand in utility_fallbacks:
|
||||
_add(*cand)
|
||||
try:
|
||||
chat_fallbacks = resolve_chat_fallback_candidates(owner=None) or []
|
||||
except TypeError:
|
||||
chat_fallbacks = resolve_chat_fallback_candidates() or []
|
||||
for cand in chat_fallbacks:
|
||||
_add(*cand)
|
||||
|
||||
if not candidates:
|
||||
return {"error": "No LLM endpoint configured for AI reply"}
|
||||
|
||||
try:
|
||||
raw_reply = await llm_call_async_with_fallback(
|
||||
candidates,
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_msg},
|
||||
],
|
||||
temperature=0.7,
|
||||
max_tokens=1024,
|
||||
timeout=60,
|
||||
)
|
||||
except Exception as exc:
|
||||
return {"error": f"AI reply generation failed: {exc}"}
|
||||
|
||||
reply = _apply_email_style_mechanics(_extract_reply(raw_reply or ""))
|
||||
if not reply:
|
||||
return {"error": "AI reply generation returned an empty response"}
|
||||
|
||||
return _draft_reply_to_email(
|
||||
uid=uid,
|
||||
body=reply,
|
||||
folder=folder,
|
||||
reply_all=reply_all,
|
||||
account=account,
|
||||
title=title or reply_subject,
|
||||
)
|
||||
|
||||
|
||||
def _reply_to_email(uid, body, folder="INBOX", reply_all=False, account=None):
|
||||
"""Reply to an existing email by UID. Threads via In-Reply-To/References."""
|
||||
conn = None
|
||||
@@ -1189,6 +1577,8 @@ async def list_tools() -> list[Tool]:
|
||||
name="send_email",
|
||||
description=(
|
||||
"Send a new email via SMTP. Provide recipient(s), subject, and body. "
|
||||
"This sends immediately; for normal assistant-written email, prefer "
|
||||
"draft_email so the user can review and send from Odysseus. "
|
||||
"For replying to an existing thread, use reply_to_email instead. "
|
||||
"Pass `account` to send from a non-default mailbox."
|
||||
),
|
||||
@@ -1205,10 +1595,35 @@ async def list_tools() -> list[Tool]:
|
||||
"required": ["to", "subject", "body"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="draft_email",
|
||||
description=(
|
||||
"Create a new Odysseus email compose draft document. This DOES NOT send. "
|
||||
"Use this as the default way to write an email for the user: it opens "
|
||||
"a reviewable email document with To/Cc/Bcc/Subject/body, and the user "
|
||||
"can edit or press Send in Odysseus. "
|
||||
f"{_writing_style_guidance()}"
|
||||
),
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"to": {"type": "string", "description": "Recipient email address(es), comma-separated"},
|
||||
"subject": {"type": "string", "description": "Email subject line"},
|
||||
"body": {"type": "string", "description": "Draft body"},
|
||||
"cc": {"type": "string", "description": "CC address(es), comma-separated (optional)"},
|
||||
"bcc": {"type": "string", "description": "BCC address(es), comma-separated (optional)"},
|
||||
"title": {"type": "string", "description": "Optional Odysseus document title"},
|
||||
**ACCOUNT_PROP,
|
||||
},
|
||||
"required": ["to", "subject", "body"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="reply_to_email",
|
||||
description=(
|
||||
"Reply to an existing email by UID. Automatically threads the reply with "
|
||||
"Reply to an existing email by UID. This sends immediately; for normal "
|
||||
"assistant-written replies, prefer draft_email_reply so the user can "
|
||||
"review and send from Odysseus. Automatically threads the reply with "
|
||||
"In-Reply-To and References headers, prefixes 'Re:' on the subject, and "
|
||||
"uses the original sender as the recipient. Set reply_all=true to also CC "
|
||||
"the original To/Cc recipients. For follow-up 'reply ...' requests, use "
|
||||
@@ -1226,6 +1641,49 @@ async def list_tools() -> list[Tool]:
|
||||
"required": ["uid", "body"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="draft_email_reply",
|
||||
description=(
|
||||
"Create an Odysseus email reply draft document for an existing email UID. "
|
||||
"This DOES NOT send. It threads the draft with In-Reply-To/References, "
|
||||
"prefills the recipient and subject, and stores source email metadata so "
|
||||
"the user can review and send from the normal email composer. "
|
||||
f"{_writing_style_guidance()}"
|
||||
),
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"uid": {"type": "string", "description": "Exact Email UID from list_emails/read_email; never invent UID 1"},
|
||||
"body": {"type": "string", "description": "Draft reply body text"},
|
||||
"folder": {"type": "string", "description": "IMAP folder (default: INBOX)", "default": "INBOX"},
|
||||
"reply_all": {"type": "boolean", "description": "Reply to all recipients (default: false)", "default": False},
|
||||
"title": {"type": "string", "description": "Optional Odysseus document title"},
|
||||
**ACCOUNT_PROP,
|
||||
},
|
||||
"required": ["uid", "body"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="ai_draft_email_reply",
|
||||
description=(
|
||||
"Generate an AI reply using Odysseus' existing AI Reply behavior, "
|
||||
"including Settings > Email > Writing Style, then create an email "
|
||||
"compose document for review. This DOES NOT send and does NOT save "
|
||||
"to the mailbox Drafts folder. Use this when the user asks you to "
|
||||
"write or draft a reply to an email without dictating the exact body."
|
||||
),
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"uid": {"type": "string", "description": "Exact Email UID from list_emails/read_email; never invent UID 1"},
|
||||
"folder": {"type": "string", "description": "IMAP folder (default: INBOX)", "default": "INBOX"},
|
||||
"reply_all": {"type": "boolean", "description": "Reply to all recipients (default: false)", "default": False},
|
||||
"title": {"type": "string", "description": "Optional Odysseus document title"},
|
||||
**ACCOUNT_PROP,
|
||||
},
|
||||
"required": ["uid"],
|
||||
},
|
||||
),
|
||||
Tool(
|
||||
name="archive_email",
|
||||
description="Move an email out of the inbox into the Archive folder. Use after handling an email you want to keep but no longer need in the inbox.",
|
||||
@@ -1552,6 +2010,31 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
||||
acct_note = f" (from {result['account']})" if result.get("account") else ""
|
||||
return [TextContent(type="text", text=f"Sent email to {result['to']} with subject '{result['subject']}'{acct_note}.")]
|
||||
|
||||
elif name == "draft_email":
|
||||
to = arguments.get("to")
|
||||
subject = arguments.get("subject")
|
||||
body = arguments.get("body")
|
||||
if not to or not subject or body is None:
|
||||
return [TextContent(type="text", text="Error: to, subject, and body are required")]
|
||||
result = _create_email_draft_document(
|
||||
to=to,
|
||||
subject=subject,
|
||||
body=body,
|
||||
title=arguments.get("title"),
|
||||
cc=arguments.get("cc"),
|
||||
bcc=arguments.get("bcc"),
|
||||
account=acct,
|
||||
)
|
||||
acct_note = f" from {result['account']}" if result.get("account") else ""
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=(
|
||||
f"Created Odysseus email draft `{result['title']}` "
|
||||
f"(document ID: {result['doc_id']}){acct_note}. "
|
||||
"It has not been sent; open the document in Odysseus to review and send."
|
||||
),
|
||||
)]
|
||||
|
||||
elif name == "reply_to_email":
|
||||
uid = arguments.get("uid")
|
||||
body = arguments.get("body")
|
||||
@@ -1573,6 +2056,54 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
||||
pass
|
||||
return [TextContent(type="text", text=f"Replied to UID {uid}: '{result['subject']}' → {result['to']}")]
|
||||
|
||||
elif name == "draft_email_reply":
|
||||
uid = arguments.get("uid")
|
||||
body = arguments.get("body")
|
||||
if not uid or body is None:
|
||||
return [TextContent(type="text", text="Error: uid and body are required")]
|
||||
result = _draft_reply_to_email(
|
||||
uid=uid,
|
||||
body=body,
|
||||
folder=arguments.get("folder", "INBOX"),
|
||||
reply_all=bool(arguments.get("reply_all", False)),
|
||||
account=acct,
|
||||
title=arguments.get("title"),
|
||||
)
|
||||
if "error" in result:
|
||||
return [TextContent(type="text", text=f"Error: {result['error']}")]
|
||||
acct_note = f" from {result['account']}" if result.get("account") else ""
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=(
|
||||
f"Created Odysseus reply draft `{result['title']}` for UID {uid} "
|
||||
f"(document ID: {result['doc_id']}){acct_note}. "
|
||||
"It has not been sent; open the document in Odysseus to review and send."
|
||||
),
|
||||
)]
|
||||
|
||||
elif name == "ai_draft_email_reply":
|
||||
uid = arguments.get("uid")
|
||||
if not uid:
|
||||
return [TextContent(type="text", text="Error: uid is required")]
|
||||
result = await _ai_draft_reply_to_email(
|
||||
uid=uid,
|
||||
folder=arguments.get("folder", "INBOX"),
|
||||
reply_all=bool(arguments.get("reply_all", False)),
|
||||
account=acct,
|
||||
title=arguments.get("title"),
|
||||
)
|
||||
if "error" in result:
|
||||
return [TextContent(type="text", text=f"Error: {result['error']}")]
|
||||
acct_note = f" from {result['account']}" if result.get("account") else ""
|
||||
return [TextContent(
|
||||
type="text",
|
||||
text=(
|
||||
f"Generated AI reply and created Odysseus compose draft "
|
||||
f"`{result['title']}` for UID {uid} (document ID: {result['doc_id']}){acct_note}. "
|
||||
"It has not been sent; open the document in Odysseus to review and send."
|
||||
),
|
||||
)]
|
||||
|
||||
elif name == "archive_email":
|
||||
uid = arguments.get("uid")
|
||||
if not uid:
|
||||
|
||||
@@ -25,6 +25,8 @@ ALLOWED_SCOPES = {
|
||||
"calendar:write",
|
||||
"memory:read",
|
||||
"memory:write",
|
||||
"cookbook:read",
|
||||
"cookbook:launch",
|
||||
}
|
||||
TOKEN_PROFILES = {
|
||||
"chat": ["chat"],
|
||||
|
||||
@@ -101,11 +101,17 @@ def setup_backup_routes(memory_manager, preset_manager, skills_manager) -> APIRo
|
||||
# ── Skills ──
|
||||
if "skills" in body and isinstance(body["skills"], list):
|
||||
existing = skills_manager.load_all()
|
||||
existing_names = {s.get("name") for s in existing if s.get("name")}
|
||||
existing_ids = {s.get("id") for s in existing if s.get("id")}
|
||||
# Dedup against THIS user's own skills only. Using every tenant's
|
||||
# rows (load_all) meant a skill whose id/name/title matched any
|
||||
# other user's was silently skipped, so the importing user lost
|
||||
# their own data — same cross-tenant bug fixed for memories above.
|
||||
# The full store is still saved back below.
|
||||
own = [s for s in existing if s.get("owner") == user]
|
||||
existing_names = {s.get("name") for s in own if s.get("name")}
|
||||
existing_ids = {s.get("id") for s in own if s.get("id")}
|
||||
existing_titles = {
|
||||
(s.get("title") or s.get("description") or "").strip().lower()
|
||||
for s in existing
|
||||
for s in own
|
||||
}
|
||||
added = 0
|
||||
for skill in body["skills"]:
|
||||
|
||||
@@ -13,7 +13,7 @@ from dateutil.rrule import rrulestr
|
||||
|
||||
from core.database import SessionLocal, CalendarCal, CalendarEvent
|
||||
from src.auth_helpers import require_user
|
||||
from src.upload_limits import read_upload_limited
|
||||
from src.upload_limits import read_upload_limited, ICS_MAX_BYTES
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1170,9 +1170,9 @@ def setup_calendar_routes() -> APIRouter:
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# 10 MB hard cap on ICS upload. Loading the whole file into memory is
|
||||
# unavoidable with python-icalendar, so an unbounded upload would OOM.
|
||||
_ICS_MAX_BYTES = 10 * 1024 * 1024
|
||||
# Hard cap on ICS upload (ICS_MAX_BYTES, default 10 MB). Loading the whole
|
||||
# file into memory is unavoidable with python-icalendar, so an unbounded
|
||||
# upload would OOM.
|
||||
|
||||
@router.post("/import")
|
||||
async def import_ics(request: Request, file: UploadFile = File(...), calendar_name: str = ""):
|
||||
@@ -1182,7 +1182,7 @@ def setup_calendar_routes() -> APIRouter:
|
||||
owner = _require_user(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
content = await read_upload_limited(file, _ICS_MAX_BYTES, "ICS file")
|
||||
content = await read_upload_limited(file, ICS_MAX_BYTES, "ICS file")
|
||||
try:
|
||||
cal_data = iCal.from_ical(content)
|
||||
except Exception as e:
|
||||
|
||||
+4
-10
@@ -452,14 +452,10 @@ def setup_chat_routes(
|
||||
search_context = form_data.get("search_context") # pre-fetched web search results (compare mode)
|
||||
compare_mode = str(form_data.get("compare_mode", "")).lower() == "true"
|
||||
incognito = str(form_data.get("incognito", "")).lower() == "true"
|
||||
plan_mode = str(form_data.get("plan_mode", "")).lower() == "true"
|
||||
# Plan mode is not part of the merge-ready UI. Ignore stale clients or
|
||||
# manual form posts that still send plan_mode=true.
|
||||
plan_mode = False
|
||||
chat_mode = str(form_data.get("mode", "")).lower() # 'chat' or 'agent'
|
||||
# Workspace: confine the agent's file/shell tools to this folder. Validate
|
||||
# it's a real directory; ignore (no confinement) otherwise.
|
||||
workspace = (form_data.get("workspace") or "").strip()
|
||||
if workspace:
|
||||
_ws_real = os.path.realpath(os.path.expanduser(workspace))
|
||||
workspace = _ws_real if os.path.isdir(_ws_real) else ""
|
||||
# Plan mode is a modifier on agent mode — it only makes sense with tools.
|
||||
if plan_mode:
|
||||
chat_mode = "agent"
|
||||
@@ -1138,7 +1134,6 @@ def setup_chat_routes(
|
||||
tool_policy=tool_policy,
|
||||
owner=_user,
|
||||
fallbacks=_fallback_candidates,
|
||||
workspace=workspace or None,
|
||||
plan_mode=plan_mode,
|
||||
approved_plan=approved_plan or None,
|
||||
):
|
||||
@@ -1270,8 +1265,7 @@ def setup_chat_routes(
|
||||
# without waiting on the next streamed chunk.
|
||||
#
|
||||
# Normal chat/agent streams keep the DETACHED behavior below: they
|
||||
# survive the client closing the tab / navigating away (true
|
||||
# terminal-agent semantics). The SSE response just subscribes (replay
|
||||
# survive the client closing the tab / navigating away. The SSE response just subscribes (replay
|
||||
# buffered output + live); dropping the SSE only removes a subscriber —
|
||||
# the run keeps going and saves the assistant message on completion
|
||||
# regardless. Reconnect via /api/chat/resume.
|
||||
|
||||
@@ -30,8 +30,9 @@ _LOCAL_MODEL_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
||||
_OLLAMA_MODEL_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]{0,200}$")
|
||||
# Include pattern is a glob: allow typical safe glyphs only.
|
||||
_INCLUDE_RE = re.compile(r"^[A-Za-z0-9._\-*?/\[\]]+$")
|
||||
# Remote host: user@host (optionally with :port-free hostname parts).
|
||||
_REMOTE_HOST_RE = re.compile(r"^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+$")
|
||||
# Remote host: either `user@host` or plain `host` (alias is allowed), where host
|
||||
# is a safe DNS-like token or a short SSH config alias.
|
||||
_REMOTE_HOST_RE = re.compile(r"^(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9._-]+$")
|
||||
# HF tokens and API tokens are url-safe base64-like.
|
||||
_TOKEN_RE = re.compile(r"^[A-Za-z0-9._~+/=-]+$")
|
||||
# Session IDs we mint look like "cookbook-deadbeef" or "serve-deadbeef".
|
||||
@@ -41,9 +42,16 @@ _SESSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
||||
_SSH_PORT_RE = re.compile(r"^\d{1,5}$")
|
||||
_GPU_LIST_RE = re.compile(r"^\d+(?:,\d+)*$")
|
||||
# A download target directory. Absolute or ~-relative path; safe path glyphs
|
||||
# only (no quotes, shell metacharacters, or spaces) since it lands in a shell
|
||||
# command. A leading ~ is expanded to $HOME at command-build time.
|
||||
_LOCAL_DIR_RE = re.compile(r"^~?/[A-Za-z0-9._/-]*$|^~$")
|
||||
# only (no quotes or shell metacharacters). Spaces are allowed because command
|
||||
# builders pass the value through quoted shell/Python contexts. The character
|
||||
# class uses ``\w`` — Unicode word characters under Python 3's default str
|
||||
# matching — so non-ASCII folder names pass validation too: Cyrillic, accented
|
||||
# Latin, CJK, e.g. ``/Volumes/Модели`` or ``D:\AI Models\Модели``. This stays
|
||||
# shell-safe: none of ``; & | ` $ '' "" () {}`` newlines etc. are in ``[\w. -]``,
|
||||
# so injection vectors remain rejected. A leading ~ is expanded to $HOME at
|
||||
# command-build time. (Drive letters stay ASCII: ``[A-Za-z]:``.)
|
||||
_LOCAL_DIR_RE = re.compile(r"^~?(?:/[\w. -]*)+$|^~$")
|
||||
_WINDOWS_LOCAL_DIR_RE = re.compile(r"^[A-Za-z]:[\\/](?:[\w. -]+(?:[\\/][\w. -]+)*[\\/]?)?$")
|
||||
_WINDOWS_DRIVE_PATH_RE = re.compile(r"^[A-Za-z]:[\\/]")
|
||||
|
||||
|
||||
@@ -81,7 +89,7 @@ def _validate_remote_host(v: str | None) -> str | None:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
if not _REMOTE_HOST_RE.match(v):
|
||||
raise HTTPException(400, "Invalid remote_host — must be user@host, no SSH option syntax")
|
||||
raise HTTPException(400, "Invalid remote_host — must be host or user@host, no SSH option syntax")
|
||||
return v
|
||||
|
||||
|
||||
@@ -96,9 +104,19 @@ def _validate_token(v: str | None) -> str | None:
|
||||
def _validate_local_dir(v: str | None) -> str | None:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
if len(v) >= 2 and v[0] == v[-1] and v[0] in {"'", '"'}:
|
||||
v = v[1:-1]
|
||||
v = v.rstrip("/") or "/"
|
||||
if not _LOCAL_DIR_RE.match(v):
|
||||
raise HTTPException(400, "Invalid local_dir — must be an absolute or ~ path with no spaces or shell metacharacters")
|
||||
if not (_LOCAL_DIR_RE.match(v) or _WINDOWS_LOCAL_DIR_RE.match(v)):
|
||||
raise HTTPException(400, "Invalid local_dir — must be an absolute or ~ path with no shell metacharacters")
|
||||
# Reject path segments that start with '-' (option injection). '-' is in the
|
||||
# allowlist, so a dir like ``/models/-rf`` or ``D:\models\-rf`` could be read
|
||||
# as a CLI flag by hf/etc. — and quoting does NOT stop a value from being
|
||||
# parsed as an option. This is the one residual that command-build-time
|
||||
# quoting can't cover, so the guard lives here, keeping the safety wholly
|
||||
# inside the validator rather than relying on consumers.
|
||||
if any(seg.startswith("-") for seg in re.split(r"[\\/]", v) if seg):
|
||||
raise HTTPException(400, "Invalid local_dir — path segments cannot start with '-'")
|
||||
return v
|
||||
|
||||
|
||||
@@ -124,7 +142,7 @@ def _validate_gpus(v: str | None) -> str | None:
|
||||
def _shell_path(p: str) -> str:
|
||||
"""Render a validated path for a double-quoted shell context, expanding a
|
||||
leading ~ to $HOME (single quotes wouldn't expand it). Safe because
|
||||
_validate_local_dir already restricts the charset."""
|
||||
_validate_local_dir already rejects quotes and shell metacharacters."""
|
||||
if p == "~":
|
||||
return '"$HOME"'
|
||||
if p.startswith("~/"):
|
||||
@@ -385,6 +403,7 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
|
||||
" for root, dirs, fns in safe_walk(base):",
|
||||
" for fn in sorted(fns):",
|
||||
" if not fn.lower().endswith('.gguf'): continue",
|
||||
" if fn.startswith('._'): continue # macOS AppleDouble sidecar, not a real GGUF",
|
||||
" fp = os.path.join(root, fn)",
|
||||
" try: size = os.path.getsize(fp)",
|
||||
" except Exception: size = 0",
|
||||
@@ -787,6 +806,7 @@ def _llama_cpp_rebuild_cmd() -> str:
|
||||
|
||||
class ModelDownloadRequest(BaseModel):
|
||||
repo_id: str
|
||||
backend: str | None = None # "hf" (default) or "ollama"
|
||||
include: str | None = None # glob pattern e.g. "*Q4_K_M*"
|
||||
hf_token: str | None = None
|
||||
env_prefix: str | None = None # e.g. "source ~/venv/bin/activate"
|
||||
|
||||
+834
-317
File diff suppressed because it is too large
Load Diff
@@ -35,7 +35,7 @@ from fastapi.responses import FileResponse
|
||||
from src.constants import DATA_DIR
|
||||
|
||||
from src.llm_core import llm_call_async
|
||||
from src.upload_limits import read_upload_limited
|
||||
from src.upload_limits import read_upload_limited, EMAIL_COMPOSE_UPLOAD_MAX_BYTES
|
||||
|
||||
from routes.email_helpers import (
|
||||
_strip_think, _extract_reply, _apply_email_style_mechanics, require_owner, require_user, _assert_owns_account,
|
||||
@@ -58,7 +58,6 @@ from routes.email_pollers import _start_poller
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ODYSSEUS_MAIL_ORIGIN = "odysseus-ui"
|
||||
EMAIL_COMPOSE_UPLOAD_MAX_BYTES = 25 * 1024 * 1024
|
||||
|
||||
|
||||
def _email_tag_owner_aliases(account_id: str | None, owner: str = "") -> list[str]:
|
||||
|
||||
@@ -13,7 +13,11 @@ from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from core.database import SessionLocal, GalleryImage, GalleryAlbum, ModelEndpoint
|
||||
from core.database import Session as DbSession
|
||||
from src.auth_helpers import get_current_user, owner_filter, require_privilege
|
||||
from src.upload_limits import read_upload_limited
|
||||
from src.upload_limits import (
|
||||
read_upload_limited,
|
||||
GALLERY_UPLOAD_MAX_BYTES,
|
||||
GALLERY_TRANSFORM_UPLOAD_MAX_BYTES,
|
||||
)
|
||||
from src.constants import GENERATED_IMAGES_DIR
|
||||
|
||||
from routes.gallery_helpers import (
|
||||
@@ -22,9 +26,6 @@ from routes.gallery_helpers import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
GALLERY_UPLOAD_MAX_BYTES = int(os.getenv("ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES", str(100 * 1024 * 1024)))
|
||||
GALLERY_TRANSFORM_UPLOAD_MAX_BYTES = int(os.getenv("ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES", str(25 * 1024 * 1024)))
|
||||
|
||||
|
||||
def _current_user_is_admin(request: Request, user: str | None) -> bool:
|
||||
if not user:
|
||||
|
||||
+18
-1
@@ -196,7 +196,24 @@ def setup_hwfit_routes():
|
||||
if target_context is not None:
|
||||
target_context = max(1024, min(target_context, 1000000))
|
||||
|
||||
results = rank_models(system, use_case=use_case or None, limit=limit, search=search or None, sort=sort, quant=quant or None, target_context=target_context, fit_only=fit_only)
|
||||
rank_kwargs = {
|
||||
"use_case": use_case or None,
|
||||
"limit": limit,
|
||||
"search": search or None,
|
||||
"sort": sort,
|
||||
"quant": quant or None,
|
||||
"fit_only": fit_only,
|
||||
}
|
||||
if target_context is not None:
|
||||
rank_kwargs["target_context"] = target_context
|
||||
try:
|
||||
import inspect
|
||||
supported = set(inspect.signature(rank_models).parameters)
|
||||
rank_kwargs = {k: v for k, v in rank_kwargs.items() if k in supported}
|
||||
except Exception:
|
||||
rank_kwargs.pop("target_context", None)
|
||||
rank_kwargs.pop("fit_only", None)
|
||||
results = rank_models(system, **rank_kwargs)
|
||||
return {"system": system, "models": results}
|
||||
|
||||
@router.get("/profiles")
|
||||
|
||||
@@ -29,11 +29,10 @@ from src.llm_core import llm_call_async
|
||||
from services.memory.memory_extractor import audit_memories
|
||||
from src.auth_helpers import get_current_user, require_user
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
from src.upload_limits import read_upload_limited
|
||||
from src.upload_limits import read_upload_limited, MEMORY_IMPORT_MAX_BYTES
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MEMORY_IMPORT_MAX_BYTES = int(os.getenv("ODYSSEUS_MEMORY_IMPORT_MAX_BYTES", str(10 * 1024 * 1024)))
|
||||
|
||||
def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionManager, memory_vector=None):
|
||||
"""Set up memory-related routes."""
|
||||
|
||||
+109
-126
@@ -4,8 +4,8 @@ import os
|
||||
import re
|
||||
import uuid
|
||||
import json
|
||||
import socket
|
||||
import hashlib
|
||||
import socket
|
||||
import time as _time
|
||||
import logging
|
||||
import httpx
|
||||
@@ -283,11 +283,8 @@ _HOST_TO_CURATED = (
|
||||
("fireworks.ai", "fireworks"),
|
||||
("googleapis.com", "google"),
|
||||
("x.ai", "xai"),
|
||||
|
||||
("openrouter.ai", "openrouter"),
|
||||
("ollama.com", "ollama"),
|
||||
("opencode.ai/zen/go", "opencode-go"),
|
||||
("opencode.ai/zen", "opencode-zen"),
|
||||
)
|
||||
|
||||
|
||||
@@ -494,8 +491,6 @@ _NON_CHAT_EXACT_PREFIXES = (
|
||||
def _is_chat_model(model_id: str) -> bool:
|
||||
"""Return True if the model ID looks like a chat/completions-capable model."""
|
||||
mid = model_id.lower()
|
||||
if mid in {"gpt-5.1-codex"}:
|
||||
return True
|
||||
for prefix in _NON_CHAT_PREFIXES:
|
||||
if mid.startswith(prefix):
|
||||
return False
|
||||
@@ -509,15 +504,7 @@ def _is_chat_model(model_id: str) -> bool:
|
||||
|
||||
|
||||
def _delete_orphaned_provider_auth(db, auth_id: Optional[str], exclude_ep_id: Optional[str] = None) -> bool:
|
||||
"""Delete a ProviderAuthSession once no endpoint still references it.
|
||||
|
||||
Subscription providers (e.g. ChatGPT Subscription) keep their refresh token
|
||||
in ProviderAuthSession rather than ModelEndpoint.api_key. When the last
|
||||
endpoint backed by that auth row is removed, the stored credentials should
|
||||
be cleared instead of lingering. Returns True if a row was deleted.
|
||||
``exclude_ep_id`` drops the endpoint currently being deleted from the
|
||||
reference count so it does not keep its own auth alive.
|
||||
"""
|
||||
"""Delete a ProviderAuthSession once no endpoint still references it."""
|
||||
if not auth_id:
|
||||
return False
|
||||
from core.database import ProviderAuthSession
|
||||
@@ -534,40 +521,52 @@ def _delete_orphaned_provider_auth(db, auth_id: Optional[str], exclude_ep_id: Op
|
||||
return True
|
||||
|
||||
|
||||
def _is_discovery_only_provider(provider: str) -> bool:
|
||||
"""Provider that only supports model discovery, not live probing.
|
||||
def _safe_detect_provider(base_url: str) -> str:
|
||||
"""Best-effort provider detection that must not break endpoint probing."""
|
||||
try:
|
||||
return _detect_provider(base_url)
|
||||
except Exception as exc:
|
||||
logger.debug("Provider detection failed for %s: %s", base_url, exc)
|
||||
return ""
|
||||
|
||||
ChatGPT Subscription speaks the Responses/Codex API and has no
|
||||
chat-completions or general health endpoint, so completion probes and
|
||||
reachability pings are skipped — status is derived from cached models.
|
||||
"""
|
||||
|
||||
def _safe_build_models_url(base_url: str) -> str:
|
||||
"""Build a /models URL without letting optional provider imports break probes."""
|
||||
try:
|
||||
return build_models_url(base_url)
|
||||
except Exception as exc:
|
||||
logger.debug("Model URL detection failed for %s: %s", base_url, exc)
|
||||
return f"{(base_url or '').rstrip('/')}/models"
|
||||
|
||||
|
||||
def _safe_build_headers(api_key: Optional[str], base_url: str) -> dict:
|
||||
"""Build auth headers without letting optional provider imports break probes."""
|
||||
try:
|
||||
return build_headers(api_key, base_url)
|
||||
except Exception as exc:
|
||||
logger.debug("Header detection failed for %s: %s", base_url, exc)
|
||||
return {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
||||
|
||||
|
||||
def _is_discovery_only_provider(provider: str) -> bool:
|
||||
return provider == "chatgpt-subscription"
|
||||
|
||||
|
||||
def _resolve_probe_key(ep) -> Optional[str]:
|
||||
"""API key/bearer to probe an endpoint with.
|
||||
|
||||
Delegates to ``resolve_endpoint_runtime``, which already returns the static
|
||||
``ModelEndpoint.api_key`` for keyed endpoints and resolves (and refreshes)
|
||||
the runtime bearer for session-backed providers (e.g. ChatGPT Subscription).
|
||||
Returns None if resolution fails (e.g. re-auth required) so probing skips
|
||||
rather than raising. Reads only already-loaded scalar attributes of ``ep``.
|
||||
"""
|
||||
"""API key/bearer to probe an endpoint with."""
|
||||
try:
|
||||
from src.endpoint_resolver import resolve_endpoint_runtime
|
||||
_base, key = resolve_endpoint_runtime(ep, owner=getattr(ep, "owner", None))
|
||||
return key
|
||||
except Exception as e:
|
||||
logger.warning("Probe key resolution failed for %s: %s", getattr(ep, "id", "?"), e)
|
||||
except Exception as exc:
|
||||
logger.warning("Probe key resolution failed for %s: %s", getattr(ep, "id", "?"), exc)
|
||||
return None
|
||||
|
||||
|
||||
def _probe_single_model(base: str, api_key: Optional[str], model_id: str, timeout: int = 10, with_tools: bool = False) -> dict:
|
||||
def _probe_single_model(base: str, api_key: str, model_id: str, timeout: int = 10, with_tools: bool = False) -> dict:
|
||||
"""Send a realistic completion request to a single model. Returns {status, latency_ms, error?}."""
|
||||
provider = _detect_provider(base)
|
||||
provider = _safe_detect_provider(base)
|
||||
if _is_discovery_only_provider(provider):
|
||||
# Responses/Codex API, not chat-completions: a completion probe would
|
||||
# 400 and the re-probe flow would then hide every model. Discovery-only.
|
||||
return {"status": "ok", "latency_ms": 0, "skipped": True}
|
||||
messages = [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
@@ -587,12 +586,12 @@ def _probe_single_model(base: str, api_key: Optional[str], model_id: str, timeou
|
||||
elif provider == "ollama":
|
||||
from src.llm_core import _build_ollama_payload
|
||||
target_url = build_chat_url(base)
|
||||
h = build_headers(api_key, base)
|
||||
h = _safe_build_headers(api_key, base)
|
||||
h["Content-Type"] = "application/json"
|
||||
payload = _build_ollama_payload(model_id, messages, 0.0, 5, stream=False, tools=_test_tools)
|
||||
else:
|
||||
target_url = build_chat_url(base)
|
||||
h = build_headers(api_key, base)
|
||||
h = _safe_build_headers(api_key, base)
|
||||
h["Content-Type"] = "application/json"
|
||||
from src.llm_core import _uses_max_completion_tokens, _restricts_temperature
|
||||
_max_key = "max_completion_tokens" if _uses_max_completion_tokens(model_id) else "max_tokens"
|
||||
@@ -682,14 +681,15 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
|
||||
For Anthropic, queries their /v1/models API, falling back to hardcoded list."""
|
||||
from src.endpoint_resolver import resolve_url
|
||||
base = resolve_url(_normalize_base(base_url))
|
||||
if _detect_provider(base) == "chatgpt-subscription":
|
||||
provider = _safe_detect_provider(base)
|
||||
if provider == "chatgpt-subscription":
|
||||
from src.chatgpt_subscription import fetch_available_models
|
||||
if api_key:
|
||||
return fetch_available_models(api_key, timeout=timeout)
|
||||
return []
|
||||
if _detect_provider(base) == "anthropic":
|
||||
if provider == "anthropic":
|
||||
# Try Anthropic's /v1/models endpoint first
|
||||
url = build_models_url(base)
|
||||
url = _safe_build_models_url(base)
|
||||
headers = {"anthropic-version": "2023-06-01"}
|
||||
if api_key:
|
||||
headers["x-api-key"] = api_key
|
||||
@@ -712,12 +712,8 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
|
||||
return []
|
||||
logger.warning(f"Anthropic /v1/models failed, using hardcoded list: {e}")
|
||||
return list(ANTHROPIC_MODELS)
|
||||
url = build_models_url(base)
|
||||
if not url:
|
||||
curated_key = _match_provider_curated(base, None)
|
||||
fallback = _PROVIDER_CURATED.get(curated_key) if curated_key else None
|
||||
return list(fallback or [])
|
||||
headers = build_headers(api_key, base)
|
||||
url = _safe_build_models_url(base)
|
||||
headers = _safe_build_headers(api_key, base)
|
||||
try:
|
||||
r = httpx.get(url, headers=headers, timeout=timeout, verify=llm_verify())
|
||||
r.raise_for_status()
|
||||
@@ -770,11 +766,12 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
|
||||
return list(fallback)
|
||||
return []
|
||||
|
||||
|
||||
def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) -> Dict[str, Any]:
|
||||
"""Reachability probe that does not require installed/listed models."""
|
||||
from src.endpoint_resolver import resolve_url
|
||||
base = resolve_url(_normalize_base(base_url))
|
||||
headers = build_headers(api_key, base)
|
||||
headers = _safe_build_headers(api_key, base)
|
||||
|
||||
# Ollama exposes /v1/models (OpenAI-compatible) AND native /api/version,
|
||||
# /api/tags. Probe native paths for Ollama-style endpoints, but avoid using
|
||||
@@ -785,10 +782,6 @@ def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) ->
|
||||
or "ollama" in (parsed_base.hostname or "").lower()
|
||||
)
|
||||
|
||||
# APFEL-specific detection
|
||||
host = (parsed_base.hostname or "").lower()
|
||||
looks_like_apfel = "apfel" in host or parsed_base.port == 11435
|
||||
|
||||
def _result_from_response(r) -> Dict[str, Any]:
|
||||
if 300 <= r.status_code < 400:
|
||||
loc = r.headers.get("location", "")
|
||||
@@ -810,23 +803,7 @@ def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) ->
|
||||
last_error: Optional[str] = None
|
||||
|
||||
try:
|
||||
# APFEL does not behave like Ollama; use its health endpoint.
|
||||
if looks_like_apfel:
|
||||
root = base
|
||||
for suffix in ("/v1", "/api"):
|
||||
if root.endswith(suffix):
|
||||
root = root[: -len(suffix)].rstrip("/")
|
||||
break
|
||||
try:
|
||||
r = httpx.get(root + "/health", timeout=timeout, verify=llm_verify())
|
||||
result = _result_from_response(r)
|
||||
if result["reachable"]:
|
||||
return result
|
||||
last_error = result.get("error")
|
||||
except Exception as e:
|
||||
last_error = str(e)[:120]
|
||||
|
||||
elif looks_like_ollama:
|
||||
if looks_like_ollama:
|
||||
root = base
|
||||
for suffix in ("/v1", "/api"):
|
||||
if root.endswith(suffix):
|
||||
@@ -847,17 +824,11 @@ def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) ->
|
||||
try:
|
||||
r = httpx.get(base, headers=headers, timeout=timeout, verify=llm_verify())
|
||||
result = _result_from_response(r)
|
||||
# If the bare base URL returns a non-auth 4xx (e.g. 404), try /models
|
||||
# as a fallback. OpenAI-compatible servers like llama-swap return 404
|
||||
# on the base /v1 prefix but 200 on /v1/models. Auth failures (401/403)
|
||||
# are definitive — probing /models would just repeat the same rejection.
|
||||
if (
|
||||
not result["reachable"]
|
||||
and result.get("status_code") is not None
|
||||
and 400 <= result["status_code"] < 500
|
||||
and result["status_code"] not in (401, 403)
|
||||
):
|
||||
models_url = build_models_url(base)
|
||||
if result["reachable"]:
|
||||
return result
|
||||
sc = result.get("status_code") or 0
|
||||
if 400 <= sc < 500 and sc not in (401, 403):
|
||||
models_url = _safe_build_models_url(base)
|
||||
try:
|
||||
r2 = httpx.get(models_url, headers=headers, timeout=timeout, verify=llm_verify())
|
||||
result2 = _result_from_response(r2)
|
||||
@@ -865,12 +836,16 @@ def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) ->
|
||||
return result2
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
if sc:
|
||||
return result
|
||||
last_error = result.get("error") or last_error
|
||||
except Exception as e:
|
||||
last_error = str(e)[:120]
|
||||
|
||||
return {"reachable": False, "status_code": None, "error": last_error}
|
||||
|
||||
|
||||
|
||||
def _model_endpoint_error_message(base_url: str, ping: Dict[str, Any] = None) -> str:
|
||||
"""Return a provider-aware error message for failed endpoint probes."""
|
||||
ping = ping or {}
|
||||
@@ -1068,17 +1043,6 @@ def setup_model_routes(model_discovery):
|
||||
ok, info = _should_refresh_endpoint(ep, now, force=force)
|
||||
if not ok:
|
||||
continue
|
||||
if getattr(ep, "provider_auth_id", None):
|
||||
try:
|
||||
from src.endpoint_resolver import resolve_endpoint_runtime
|
||||
info["base"], info["api_key"] = resolve_endpoint_runtime(
|
||||
ep,
|
||||
owner=getattr(ep, "owner", None),
|
||||
)
|
||||
info["key"] = _refresh_key(info["base"], info["api_key"])
|
||||
except Exception as e:
|
||||
logger.warning("Skipping model refresh for %s: could not resolve provider auth: %s", getattr(ep, "name", ep.id), e)
|
||||
continue
|
||||
groups.setdefault(info["key"], {
|
||||
"base": info["base"],
|
||||
"api_key": info["api_key"],
|
||||
@@ -1156,7 +1120,7 @@ def setup_model_routes(model_discovery):
|
||||
|
||||
for ep in endpoints:
|
||||
base = _normalize_base(ep.base_url)
|
||||
provider = _detect_provider(base)
|
||||
provider = _safe_detect_provider(base)
|
||||
# Merge cached + pinned models, then filter out hidden ones
|
||||
ep_model_type = getattr(ep, "model_type", None) or "llm"
|
||||
model_ids = _visible_models(
|
||||
@@ -1233,8 +1197,8 @@ def setup_model_routes(model_discovery):
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error('Auth gate error in GET /api/models, failing closed: %s', e)
|
||||
raise HTTPException(status_code=500, detail='Internal error')
|
||||
logger.error("Auth gate error in GET /api/models, failing closed: %s", e)
|
||||
raise HTTPException(status_code=500, detail="Internal error")
|
||||
# Admins see every endpoint (they manage the global pool); regular
|
||||
# users get the owner-scoped view.
|
||||
_is_admin = False
|
||||
@@ -1298,7 +1262,14 @@ def setup_model_routes(model_discovery):
|
||||
t0 = _time.time()
|
||||
try:
|
||||
import asyncio as _asyncio
|
||||
ping = await _asyncio.to_thread(_ping_endpoint, data["base"], data.get("api_key"), 1.5)
|
||||
# Bumped 1.5s → 3.5s. The previous 1.5s budget was clipping
|
||||
# local vLLM endpoints on Tailscale links where the model
|
||||
# server is still loading (Qwen3.5-122B takes 2–3 min to
|
||||
# warm); /v1/models can take 500–2500 ms on a busy box,
|
||||
# which pushed _ping_endpoint's full path-discovery sweep
|
||||
# past the cap and marked the row offline despite the
|
||||
# user actively chatting with it.
|
||||
ping = await _asyncio.to_thread(_ping_endpoint, data["base"], data.get("api_key"), 3.5)
|
||||
lat = round((_time.time() - t0) * 1000)
|
||||
return {
|
||||
"alive": bool(ping.get("reachable")),
|
||||
@@ -1336,7 +1307,7 @@ def setup_model_routes(model_discovery):
|
||||
results = []
|
||||
for ep in endpoints:
|
||||
base = _normalize_base(ep.base_url)
|
||||
provider = _detect_provider(base)
|
||||
provider = _safe_detect_provider(base)
|
||||
kind = _effective_endpoint_kind(ep, base)
|
||||
cached_count = len(_cached_model_ids(ep))
|
||||
entry = {
|
||||
@@ -1348,20 +1319,12 @@ def setup_model_routes(model_discovery):
|
||||
"endpoint_kind": kind,
|
||||
}
|
||||
try:
|
||||
if _is_discovery_only_provider(provider):
|
||||
# No general health endpoint — an unauthenticated GET just
|
||||
# 401s. Report status from cached models instead of pinging.
|
||||
entry["latency_ms"] = None
|
||||
entry["status"] = "online" if cached_count else "offline"
|
||||
entry["error"] = None
|
||||
entry["model_count"] = cached_count
|
||||
else:
|
||||
t0 = _time.time()
|
||||
ping = _ping_endpoint(base, ep.api_key, timeout=1.5)
|
||||
entry["latency_ms"] = round((_time.time() - t0) * 1000)
|
||||
entry["status"] = "online" if ping.get("reachable") or cached_count else "offline"
|
||||
entry["error"] = ping.get("error")
|
||||
entry["model_count"] = cached_count or (len(ANTHROPIC_MODELS) if provider == "anthropic" else 0)
|
||||
t0 = _time.time()
|
||||
ping = _ping_endpoint(base, ep.api_key, timeout=1.5)
|
||||
entry["latency_ms"] = round((_time.time() - t0) * 1000)
|
||||
entry["status"] = "online" if ping.get("reachable") or cached_count else "offline"
|
||||
entry["error"] = ping.get("error")
|
||||
entry["model_count"] = cached_count or (len(ANTHROPIC_MODELS) if provider == "anthropic" else 0)
|
||||
except Exception as e:
|
||||
entry["latency_ms"] = None
|
||||
entry["status"] = "online" if cached_count else "offline"
|
||||
@@ -1394,7 +1357,7 @@ def setup_model_routes(model_discovery):
|
||||
if ep_id and ep_id not in endpoints_cache:
|
||||
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
|
||||
if ep:
|
||||
endpoints_cache[ep_id] = {"base_url": ep.base_url, "api_key": _resolve_probe_key(ep)}
|
||||
endpoints_cache[ep_id] = {"base_url": ep.base_url, "api_key": ep.api_key}
|
||||
ep_data = endpoints_cache.get(ep_id)
|
||||
if not ep_data:
|
||||
# Try to find by base_url from the model's endpoint field
|
||||
@@ -1433,7 +1396,7 @@ def setup_model_routes(model_discovery):
|
||||
"id": ep.id,
|
||||
"name": ep.name,
|
||||
"base_url": ep.base_url,
|
||||
"api_key": _resolve_probe_key(ep),
|
||||
"api_key": ep.api_key,
|
||||
})
|
||||
finally:
|
||||
db.close()
|
||||
@@ -1522,14 +1485,37 @@ def setup_model_routes(model_discovery):
|
||||
# Endpoint counts as reachable if it has any model — including
|
||||
# admin-pinned IDs that a probe would never surface.
|
||||
status = "online" if (all_models or pinned) else "offline"
|
||||
base = _normalize_base(r.base_url)
|
||||
ping = None
|
||||
# Discovery-only providers have no health endpoint — an
|
||||
# unauthenticated ping just 401s, so don't bother.
|
||||
if not all_models and not pinned and r.is_enabled and not _is_discovery_only_provider(_detect_provider(base)):
|
||||
ping = _ping_endpoint(r.base_url, r.api_key, timeout=1.0)
|
||||
# When cached_models is empty, do a quick reachability probe.
|
||||
# Bumped 1.0s → 3.5s because the user reported endpoints they
|
||||
# were ACTIVELY chatting with showed "offline" — the previous
|
||||
# 1s timeout was clipping live cloud endpoints (DeepSeek can
|
||||
# take 1.5–2.5s on /v1/models when their region is under load,
|
||||
# vLLM on a remote GPU box behind SSH can also push past 1s).
|
||||
# 3.5s still keeps the picker render snappy in the common
|
||||
# "everything's already cached" path because this branch only
|
||||
# runs for endpoints with an empty cached_models.
|
||||
if not all_models and not pinned and r.is_enabled:
|
||||
ping = _ping_endpoint(r.base_url, r.api_key, timeout=3.5)
|
||||
if ping.get("reachable"):
|
||||
status = "empty"
|
||||
# Best-effort: if the probe came back reachable, try
|
||||
# to populate cached_models in the background so the
|
||||
# NEXT picker load shows "online" instead of "empty".
|
||||
# Failure here is silent — we already returned the
|
||||
# "empty" status, and the existing background refresh
|
||||
# path will eventually fill it in too.
|
||||
try:
|
||||
probed = _probe_endpoint(r.base_url, r.api_key, timeout=5)
|
||||
if probed:
|
||||
r.cached_models = json.dumps(probed)
|
||||
db.commit()
|
||||
all_models = probed
|
||||
visible = _visible_models(all_models, r.hidden_models, pinned)
|
||||
status = "online"
|
||||
except Exception as _refill_err:
|
||||
logger.debug(f"opportunistic cached_models refill failed for {r.id}: {_refill_err!r}")
|
||||
base = _normalize_base(r.base_url)
|
||||
kind = _effective_endpoint_kind(r, base)
|
||||
results.append({
|
||||
"id": r.id,
|
||||
@@ -1603,11 +1589,10 @@ def setup_model_routes(model_discovery):
|
||||
)
|
||||
explicit_timeout = _explicit_model_list_timeout(base_url, requested_kind, refresh_timeout)
|
||||
|
||||
# Dedupe: if an endpoint with the same base_url and compatible
|
||||
# credentials already exists and is reachable by the caller (shared or
|
||||
# owned by them), return it instead of creating a duplicate row. Keep
|
||||
# same-url/different-key rows distinct so users can group the same
|
||||
# provider URL under multiple credentials.
|
||||
# Dedupe: if an endpoint with the same base_url already exists and
|
||||
# is reachable by the caller (shared or owned by them), return it
|
||||
# instead of creating a duplicate row. Fixes "Scan for Servers"
|
||||
# re-adding manually-added endpoints under their host:port name.
|
||||
from src.auth_helpers import get_current_user as _gcu_dedup
|
||||
_caller = _gcu_dedup(request) or None
|
||||
_incoming_api_key = api_key.strip()
|
||||
@@ -1805,7 +1790,7 @@ def setup_model_routes(model_discovery):
|
||||
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
|
||||
if not ep:
|
||||
raise HTTPException(404, "Endpoint not found")
|
||||
ep_data = {"id": ep.id, "name": ep.name, "base_url": ep.base_url, "api_key": _resolve_probe_key(ep)}
|
||||
ep_data = {"id": ep.id, "name": ep.name, "base_url": ep.base_url, "api_key": ep.api_key}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -1869,7 +1854,7 @@ def setup_model_routes(model_discovery):
|
||||
category = _classify_endpoint(base, kind)
|
||||
timeout = _manual_refresh_timeout(ep, category, refresh_timeout)
|
||||
try:
|
||||
probed = _probe_endpoint(base, _resolve_probe_key(ep), timeout=timeout)
|
||||
probed = _probe_endpoint(base, ep.api_key, timeout=timeout)
|
||||
except Exception as exc:
|
||||
logger.warning("Manual model refresh failed for endpoint %s at %s: %s", ep_id, base, exc)
|
||||
probed = []
|
||||
@@ -2105,8 +2090,6 @@ def setup_model_routes(model_discovery):
|
||||
"name": ep.name,
|
||||
"model_type": ep.model_type,
|
||||
"base_url": ep.base_url,
|
||||
"has_key": bool(ep.api_key),
|
||||
"api_key_fingerprint": _api_key_fingerprint(ep.api_key),
|
||||
"pinned_models": _normalize_model_ids(getattr(ep, "pinned_models", None)),
|
||||
"endpoint_kind": getattr(ep, "endpoint_kind", None) or "auto",
|
||||
"model_refresh_mode": getattr(ep, "model_refresh_mode", None) or "auto",
|
||||
|
||||
@@ -11,11 +11,9 @@ from src.rag_singleton import get_rag_manager
|
||||
from src.auth_helpers import require_privilege, require_user
|
||||
from core.middleware import require_admin
|
||||
from src.upload_handler import secure_filename
|
||||
from src.upload_limits import PERSONAL_UPLOAD_MAX_BYTES
|
||||
|
||||
UPLOADS_DIR = PERSONAL_UPLOADS_DIR
|
||||
MAX_PERSONAL_UPLOAD_BYTES = int(
|
||||
os.getenv("ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES", str(25 * 1024 * 1024))
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -208,8 +206,8 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
|
||||
for upload in files:
|
||||
try:
|
||||
file_path, stored_name, safe_name = _unique_personal_upload_path(upload_dir, upload.filename)
|
||||
content_bytes = await upload.read(MAX_PERSONAL_UPLOAD_BYTES + 1)
|
||||
if len(content_bytes) > MAX_PERSONAL_UPLOAD_BYTES:
|
||||
content_bytes = await upload.read(PERSONAL_UPLOAD_MAX_BYTES + 1)
|
||||
if len(content_bytes) > PERSONAL_UPLOAD_MAX_BYTES:
|
||||
logger.warning(f"Rejected oversized personal upload: {upload.filename!r}")
|
||||
total_failed += 1
|
||||
continue
|
||||
|
||||
@@ -10,8 +10,9 @@ import logging
|
||||
from core.session_manager import SessionManager
|
||||
from core.models import ChatMessage
|
||||
from src.request_models import SessionResponse
|
||||
from core.database import Session as DbSession, SessionLocal, Document, GalleryImage
|
||||
from core.database import Session as DbSession, SessionLocal, Document, GalleryImage, utcnow_naive
|
||||
from src.auth_helpers import get_current_user, effective_user, _auth_disabled
|
||||
from src.session_actions import is_session_recently_active
|
||||
|
||||
|
||||
def _sanitize_export_filename(name: str) -> str:
|
||||
@@ -1028,6 +1029,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
||||
db.query(DbMsg.session_id, _sa_func.count(DbMsg.id))
|
||||
.filter(DbMsg.role == "assistant").group_by(DbMsg.session_id).all()
|
||||
)
|
||||
cleanup_now = utcnow_naive()
|
||||
for row in rows:
|
||||
# Never delete important sessions
|
||||
if getattr(row, 'is_important', False):
|
||||
@@ -1040,6 +1042,8 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
||||
if hasattr(session_manager, 'delete_session'):
|
||||
session_manager.delete_session(row.id)
|
||||
continue
|
||||
if is_session_recently_active(row, now=cleanup_now):
|
||||
continue
|
||||
msg_count = _counts.get(row.id, 0)
|
||||
should_delete = False
|
||||
if msg_count == 0:
|
||||
|
||||
@@ -4,12 +4,10 @@
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File
|
||||
import logging
|
||||
|
||||
from src.upload_limits import read_upload_limited
|
||||
from src.upload_limits import read_upload_limited, STT_MAX_AUDIO_BYTES
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STT_MAX_AUDIO_BYTES = 25 * 1024 * 1024
|
||||
|
||||
|
||||
def setup_stt_routes(stt_service):
|
||||
"""Setup STT routes with the provided STT service"""
|
||||
|
||||
@@ -519,6 +519,15 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
||||
else bool(req.notifications_enabled) if req.notifications_enabled is not None
|
||||
else True
|
||||
)
|
||||
# Validate chained task belongs to same owner
|
||||
if req.then_task_id:
|
||||
chain_target = db.query(ScheduledTask).filter(
|
||||
ScheduledTask.id == req.then_task_id
|
||||
).first()
|
||||
if not chain_target:
|
||||
raise HTTPException(400, "Chained task not found")
|
||||
if chain_target.owner != user:
|
||||
raise HTTPException(403, "Cannot chain to another user's task")
|
||||
task = ScheduledTask(
|
||||
id=task_id,
|
||||
owner=user,
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
"""Workspace API — browse server directories to pick a tool workspace folder."""
|
||||
import os
|
||||
from fastapi import APIRouter, Request, HTTPException, Query
|
||||
|
||||
from src.auth_helpers import get_current_user
|
||||
from src.tool_security import owner_is_admin_or_single_user
|
||||
|
||||
|
||||
def setup_workspace_routes():
|
||||
router = APIRouter(prefix="/api/workspace", tags=["workspace"])
|
||||
|
||||
@router.get("/browse")
|
||||
def browse(request: Request, path: str = Query(default="")):
|
||||
"""List subdirectories of `path` (default: home) so the UI can navigate
|
||||
the server filesystem and pick a workspace folder. Directories only.
|
||||
|
||||
ADMIN-ONLY: this enumerates the server filesystem, so it is gated the
|
||||
same way the file/shell tools are (read_file/write_file/bash are in
|
||||
NON_ADMIN_BLOCKED_TOOLS). A non-admin who can't use those tools must not
|
||||
be able to map the host's directory tree either.
|
||||
"""
|
||||
owner = get_current_user(request)
|
||||
if not owner_is_admin_or_single_user(owner):
|
||||
raise HTTPException(status_code=403, detail="Workspace browsing is admin-only")
|
||||
|
||||
# Resolve symlinks so the reported path is canonical and the UI navigates
|
||||
# real directories (defends against symlink games in displayed paths).
|
||||
target = os.path.realpath(os.path.expanduser(path.strip() or "~"))
|
||||
if not os.path.isdir(target):
|
||||
target = os.path.realpath(os.path.expanduser("~"))
|
||||
|
||||
dirs = []
|
||||
try:
|
||||
with os.scandir(target) as it:
|
||||
for entry in it:
|
||||
try:
|
||||
# Don't follow symlinks when classifying — a symlinked
|
||||
# dir is skipped rather than letting the browser wander
|
||||
# off via a link. Hidden entries are omitted.
|
||||
if entry.is_dir(follow_symlinks=False) and not entry.name.startswith("."):
|
||||
# Build the child path server-side with os.path.join
|
||||
# so it's correct on Windows (backslashes) and Linux.
|
||||
dirs.append({"name": entry.name, "path": os.path.join(target, entry.name)})
|
||||
except OSError:
|
||||
continue
|
||||
except (PermissionError, OSError):
|
||||
dirs = []
|
||||
|
||||
parent = os.path.dirname(target)
|
||||
return {
|
||||
"path": target,
|
||||
"parent": parent if parent and parent != target else None,
|
||||
"dirs": sorted(dirs, key=lambda d: d["name"].lower()),
|
||||
}
|
||||
|
||||
return router
|
||||
@@ -14036,6 +14036,29 @@
|
||||
"vision"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "google/gemma-4-12B",
|
||||
"provider": "Google",
|
||||
"parameter_count": "12.0B",
|
||||
"parameters_raw": 12000000000,
|
||||
"min_ram_gb": 24.0,
|
||||
"recommended_ram_gb": 32.0,
|
||||
"min_vram_gb": 24.0,
|
||||
"quantization": "BF16",
|
||||
"context_length": 131072,
|
||||
"use_case": "General purpose, multimodal",
|
||||
"is_moe": false,
|
||||
"num_experts": null,
|
||||
"active_experts": null,
|
||||
"active_parameters": null,
|
||||
"architecture": "gemma4",
|
||||
"pipeline_tag": "image-text-to-text",
|
||||
"release_date": "2026-04-01",
|
||||
"gguf_sources": [],
|
||||
"capabilities": [
|
||||
"vision"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "google/gemma-4-31B-it",
|
||||
"provider": "Google",
|
||||
@@ -19121,4 +19144,4 @@
|
||||
],
|
||||
"_discovered": true
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
@@ -243,6 +243,20 @@ async def maybe_extract_skill(
|
||||
logger.debug("[skill-extract] '%s' already exists — dropped as duplicate", title)
|
||||
return None
|
||||
|
||||
# Auto-publish gate: if the user has `auto_approve_skills` on, the
|
||||
# newly-extracted skill is created `published` immediately rather
|
||||
# than waiting for the next audit batch. The audit still runs later
|
||||
# and can demote it back to `draft` (or delete) on failure. Default
|
||||
# ON matches the UI label "Auto-approve skills".
|
||||
_initial_status = "draft"
|
||||
try:
|
||||
from routes.prefs_routes import _load_for_user as _load_prefs
|
||||
_prefs = _load_prefs(owner) or {}
|
||||
if _prefs.get("auto_approve_skills", True):
|
||||
_initial_status = "published"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
entry = skills_manager.add_skill(
|
||||
title=title,
|
||||
problem=data.get("problem", ""),
|
||||
@@ -253,6 +267,7 @@ async def maybe_extract_skill(
|
||||
confidence=data.get("confidence", 0.7),
|
||||
session_id=getattr(session, "session_id", None),
|
||||
owner=owner,
|
||||
status=_initial_status,
|
||||
)
|
||||
try:
|
||||
from src.event_bus import fire_event
|
||||
|
||||
@@ -134,9 +134,10 @@ _NEWS_HINTS = ("news", "nyheter", "headlines", "breaking", "latest", "today", "i
|
||||
_GENERAL_ENGINES = os.environ.get("SEARXNG_GENERAL_ENGINES", "bing,mojeek,presearch")
|
||||
|
||||
|
||||
def searxng_search_api(query: str, count: int = 10, categories: str = "general",
|
||||
def searxng_search_api(query: str, count: Optional[int] = None, categories: str = "general",
|
||||
time_filter: Optional[str] = None) -> List[dict]:
|
||||
"""Search using SearXNG JSON API. Returns list of {title, url, snippet}."""
|
||||
count = count if count is not None else _get_result_count()
|
||||
instance = _get_search_instance()
|
||||
api_key = ""
|
||||
headers = {"User-Agent": "Mozilla/5.0"}
|
||||
@@ -282,8 +283,9 @@ def searxng_search(query, max_results=10):
|
||||
|
||||
# ── Brave ──
|
||||
|
||||
def brave_search(query: str, count: int = 10, time_filter: Optional[str] = None) -> List[dict]:
|
||||
def brave_search(query: str, count: Optional[int] = None, time_filter: Optional[str] = None) -> List[dict]:
|
||||
"""Search using Brave API with key from admin settings or env var."""
|
||||
count = count if count is not None else _get_result_count()
|
||||
api_key = _get_provider_key("brave") or os.environ.get("DATA_BRAVE_API_KEY") or ""
|
||||
return _brave_search_impl(query, count, time_filter, search_config={"brave_api_key": api_key})
|
||||
|
||||
@@ -381,9 +383,9 @@ def _resolve_ddg_redirect(raw: str) -> str:
|
||||
return resolved
|
||||
|
||||
|
||||
def duckduckgo_search(query: str, count: int = 10, time_filter: Optional[str] = None) -> List[dict]:
|
||||
def duckduckgo_search(query: str, count: Optional[int] = None, time_filter: Optional[str] = None) -> List[dict]:
|
||||
"""Search using DuckDuckGo via the duckduckgo-search library. No API key needed."""
|
||||
|
||||
count = count if count is not None else _get_result_count()
|
||||
def _html_fallback() -> List[dict]:
|
||||
try:
|
||||
response = httpx.get(
|
||||
@@ -452,7 +454,7 @@ def duckduckgo_search(query: str, count: int = 10, time_filter: Optional[str] =
|
||||
|
||||
# ── Google Programmable Search Engine ──
|
||||
|
||||
def google_pse_search(query: str, count: int = 10, time_filter: Optional[str] = None) -> List[dict]:
|
||||
def google_pse_search(query: str, count: Optional[int] = None, time_filter: Optional[str] = None) -> List[dict]:
|
||||
"""Search using Google PSE (Custom Search JSON API).
|
||||
|
||||
Requires two keys in settings:
|
||||
@@ -460,6 +462,7 @@ def google_pse_search(query: str, count: int = 10, time_filter: Optional[str] =
|
||||
- google_pse_cx: Programmable Search Engine ID (cx)
|
||||
Or env vars GOOGLE_API_KEY and GOOGLE_PSE_CX.
|
||||
"""
|
||||
count = count if count is not None else _get_result_count()
|
||||
settings = _get_search_settings()
|
||||
api_key = _get_provider_key("google_pse") or os.environ.get("GOOGLE_API_KEY", "")
|
||||
cx = (settings.get("google_pse_cx") or "").strip() or os.environ.get("GOOGLE_PSE_CX", "")
|
||||
@@ -522,8 +525,9 @@ def google_pse_search(query: str, count: int = 10, time_filter: Optional[str] =
|
||||
|
||||
# ── Tavily ──
|
||||
|
||||
def tavily_search(query: str, count: int = 10, time_filter: Optional[str] = None) -> List[dict]:
|
||||
def tavily_search(query: str, count: Optional[int] = None, time_filter: Optional[str] = None) -> List[dict]:
|
||||
"""Search using Tavily API. Requires search_api_key or TAVILY_API_KEY env var."""
|
||||
count = count if count is not None else _get_result_count()
|
||||
api_key = _get_provider_key("tavily") or os.environ.get("TAVILY_API_KEY", "")
|
||||
if not api_key:
|
||||
logger.warning("Tavily: no API key configured")
|
||||
@@ -580,8 +584,9 @@ def tavily_search(query: str, count: int = 10, time_filter: Optional[str] = None
|
||||
|
||||
# ── Serper.dev ──
|
||||
|
||||
def serper_search(query: str, count: int = 10, time_filter: Optional[str] = None) -> List[dict]:
|
||||
def serper_search(query: str, count: Optional[int] = None, time_filter: Optional[str] = None) -> List[dict]:
|
||||
"""Search using Serper.dev API. Requires search_api_key or SERPER_API_KEY env var."""
|
||||
count = count if count is not None else _get_result_count()
|
||||
api_key = _get_provider_key("serper") or os.environ.get("SERPER_API_KEY", "")
|
||||
if not api_key:
|
||||
logger.warning("Serper: no API key configured")
|
||||
|
||||
+271
-29
@@ -172,6 +172,120 @@ _API_AGENT_RULES = """\
|
||||
- After `create_session` returns id `89effa28`: "Created [New Chat](#session-89effa28) — click to switch."
|
||||
- Listing sessions: "1. [Big Chat](#session-abc123) — 2h ago, 2. [Code Review](#session-def456) — 5h ago\""""
|
||||
|
||||
_AGENT_PREAMBLE = """\
|
||||
You are an AI assistant with tool access. Only the tools listed below are available for this turn.
|
||||
To use a tool, write a fenced code block with the tool name as the language tag. The block executes automatically and you see the output."""
|
||||
|
||||
_AGENT_RULES = """\
|
||||
## Base rules
|
||||
- Only use tools when needed. For casual messages like "test", "yo", "thanks", answer normally.
|
||||
- If a needed tool/domain is missing from this turn, say what is missing briefly instead of pretending.
|
||||
- After a tool succeeds, do not second-guess it; reply with one short confirmation unless more work remains.
|
||||
- After a tool fails, retry with a concrete fix or state what is blocking you.
|
||||
- Finish only when the user's concrete request is actually done, or clearly state that you are blocked.
|
||||
- User identity facts/preferences ("my name is X", "call me X", "I live in X") use `manage_memory`, not contacts.
|
||||
"""
|
||||
|
||||
_API_AGENT_RULES = """\
|
||||
## Base rules
|
||||
- Prefer native tool/function calling when tools are needed.
|
||||
- Only call tools when they materially help answer the request. For casual messages like "test", "yo", "thanks", answer normally.
|
||||
- You MUST use tools to take action; do not claim you did something without a tool result.
|
||||
- If a needed tool/domain is missing from this turn, say what is missing briefly instead of pretending.
|
||||
- Keep answers concise unless the user asks for depth.
|
||||
- After a tool succeeds, do not second-guess it; reply with one short confirmation unless more work remains.
|
||||
- After a tool fails, retry with a concrete fix or state what is blocking you.
|
||||
- Finish only when the user's concrete request is actually done, or clearly state that you are blocked.
|
||||
- User identity facts/preferences ("my name is X", "call me X", "I live in X") use `manage_memory`, not contacts.
|
||||
"""
|
||||
|
||||
_LINK_RULES = """\
|
||||
## Link conventions
|
||||
When referencing app entities by id, use clickable markdown anchors:
|
||||
- Sessions: `[Name](#session-<id>)`
|
||||
- Documents: `[Title](#document-<id>)`
|
||||
- Notes: `[Title](#note-<id>)`
|
||||
- Emails: `[Subject](#email-<uid>)`
|
||||
- Calendar events: `[Summary](#event-<uid>)`
|
||||
- Tasks: `[Task name](#task-<id>)`
|
||||
- Skills: `[skill-name](#skill-<name>)`
|
||||
- Research jobs: `[Topic](#research-<session_id>)`
|
||||
"""
|
||||
|
||||
_DOMAIN_RULES = {
|
||||
"web": """\
|
||||
## Web rules
|
||||
- For web lookup/search/latest/current requests, use `web_search` or `web_fetch`.
|
||||
- Do not use shell, Python, curl, requests, or scraping code for web lookup unless web tools are unavailable or already failed.
|
||||
- "Research X" means `trigger_research`, not a one-off `web_search`, unless the user explicitly asks for a quick lookup.""",
|
||||
"documents": """\
|
||||
## Document rules
|
||||
- For long code/content (>15 lines), use `create_document` instead of pasting into chat.
|
||||
- If an active document is open, "fix this", "add X", "change Y", etc. usually refers to that document.
|
||||
- Use `edit_document` for targeted changes. Use `update_document` only for genuine full rewrites.
|
||||
- For feedback/review/suggestions on an open document, use `suggest_document`.""",
|
||||
"email": """\
|
||||
## Email rules
|
||||
- Email UIDs are the values after `UID:` in tool output, never list row numbers.
|
||||
- For latest/newest email, list with `max_results: 1`, `unread_only: false`, then read the returned UID if needed.
|
||||
- For named mailboxes/accounts, call `list_email_accounts` if needed and pass the exact `account` value.
|
||||
- Bulk email actions use `bulk_email` once with explicit UIDs; do not loop one message at a time.
|
||||
- "Open/start a reply" means open a draft via `ui_control open_email_reply`; only `reply_to_email` when the user clearly wants to send now.""",
|
||||
"cookbook": """\
|
||||
## Cookbook/model-serving rules
|
||||
- Cookbook is the LLM-serving subsystem.
|
||||
- "What's running/serving" starts with `list_served_models`. "What's downloading" uses `list_downloads`.
|
||||
- Launch known models by checking `list_serve_presets` before raw `serve_model`.
|
||||
- Downloads/serves run on a Cookbook server; pass the named `host` when the user names one.
|
||||
- Do not launch model servers manually with bash/ssh/tmux. Use `serve_model`/`serve_preset` so the UI can track and stop them.
|
||||
- After a successful serve, verify with `list_served_models`; if an external server is running but invisible, use `adopt_served_model`.""",
|
||||
"notes_calendar_tasks": """\
|
||||
## Notes/calendar/tasks rules
|
||||
- Notes/todos/reminders use `manage_notes`, not memory.
|
||||
- Calendar create/update/delete should call `manage_calendar` with `action=list_calendars` first.
|
||||
- Recurring/automatic/scheduled requests create a `manage_tasks` task; do not just perform the action once.""",
|
||||
"ui": """\
|
||||
## UI rules
|
||||
- "Open/show <panel>" uses `ui_control open_panel <name>`.
|
||||
- Tool toggles like "turn off shell/search/research" use `ui_control toggle <name> <on|off>`, not memory.""",
|
||||
"sessions": """\
|
||||
## Chat/session rules
|
||||
- Odysseus chats are sessions. Use `list_sessions`/`manage_session`; do not shell out looking for chat files.
|
||||
- Preserve clickable session links from tool output in your final answer.""",
|
||||
"files": """\
|
||||
## File rules
|
||||
- Use file tools for real disk files. Use document tools only for editor documents.
|
||||
- Prefer `grep`, `glob`, and `ls` over shell equivalents when available.
|
||||
- Use `edit_file`/`write_file` for writes; avoid shell redirection/heredocs for editing files.""",
|
||||
"settings": """\
|
||||
## Settings/API rules
|
||||
- Use `manage_settings` for preferences and tool enable/disable.
|
||||
- Use named tools over `app_api` when a named wrapper exists.
|
||||
- `app_api` is only for safe UI/API actions without a named tool; do not use it for shell, package installs, engine rebuilds, or sensitive auth/admin paths.""",
|
||||
}
|
||||
|
||||
_DOMAIN_TOOL_MAP = {
|
||||
"web": {"web_search", "web_fetch", "trigger_research", "manage_research"},
|
||||
"documents": {"create_document", "edit_document", "update_document", "suggest_document", "manage_documents"},
|
||||
"email": {"list_email_accounts", "list_emails", "read_email", "send_email", "reply_to_email", "bulk_email", "archive_email", "delete_email", "mark_email_read", "resolve_contact", "manage_contact"},
|
||||
"cookbook": {"download_model", "serve_model", "serve_preset", "list_serve_presets", "list_served_models", "stop_served_model", "tail_serve_output", "list_downloads", "cancel_download", "search_hf_models", "list_cached_models", "list_cookbook_servers", "adopt_served_model"},
|
||||
"notes_calendar_tasks": {"manage_notes", "manage_calendar", "manage_tasks"},
|
||||
"ui": {"ui_control"},
|
||||
"sessions": {"create_session", "list_sessions", "manage_session", "send_to_session", "search_chats"},
|
||||
"files": {"bash", "python", "read_file", "write_file", "edit_file", "grep", "glob", "ls"},
|
||||
"settings": {"manage_settings", "manage_endpoints", "manage_mcp", "manage_webhooks", "manage_tokens", "app_api"},
|
||||
}
|
||||
|
||||
def _domain_rules_for_tools(tool_names: set) -> list[str]:
|
||||
names = set(tool_names or set())
|
||||
rules = []
|
||||
for domain, domain_tools in _DOMAIN_TOOL_MAP.items():
|
||||
if names & domain_tools:
|
||||
rules.append(_DOMAIN_RULES[domain])
|
||||
if names & {"create_session", "list_sessions", "manage_session", "manage_documents", "manage_notes", "manage_calendar", "manage_tasks", "manage_skills", "manage_research"}:
|
||||
rules.append(_LINK_RULES)
|
||||
return rules
|
||||
|
||||
# Each tool section is keyed by tool name(s) it covers.
|
||||
# Sections with multiple tools use a tuple key.
|
||||
TOOL_SECTIONS = {
|
||||
@@ -341,7 +455,7 @@ If the user asks for a reminder/alarm before the event, pass `reminder_minutes`
|
||||
"send_to_session": "- ```send_to_session``` — Send a message to another session. Line 1 = session_id, rest = message. Use for orchestrating work across sessions.",
|
||||
"search_chats": "- ```search_chats``` — Search past session transcripts for direct conversation evidence. Use when user asks 'did we discuss X?', 'find the conversation about Y', or when prior chat context is more appropriate than persistent memory.",
|
||||
"pipeline": "- ```pipeline``` — Run a multi-step AI pipeline. Args (JSON) with ordered steps, each specifying a model and prompt. Use for complex workflows.",
|
||||
"ui_control": "- ```ui_control``` — Control the UI: toggle tools on/off, OPEN PANELS, open email reply drafts, switch models, change themes. Commands: `toggle <name> on/off` (names: bash/shell, web/search, research, incognito, document_editor/documents), `open_panel <name>` (panels: documents, gallery, email, sessions, notes, memories/brain, skills, settings, cookbook), `open_email_reply <uid> <folder> <reply|reply-all|ai-reply>` (opens an email compose document, does NOT send), `set_mode agent/chat`, `switch_model <name>`, `set_theme <preset>`, `create_theme <name> <bg> <fg> <panel> <border> <accent>` (optional key=val for advanced colors AND background effects: bgPattern=<none|dots|synapse|rain|constellations|perlin-flow|petals|sparkles|embers>, bgEffectColor=#RRGGBB, bgEffectIntensity=<num>, bgEffectSize=<num>, frosted=true|false). \"open documents\" / \"open library\" / \"show gallery\" / \"open inbox\" / \"open notes\" / \"open cookbook\" all map to `open_panel <name>`. Theme presets: dark, light, midnight, paper, cyberpunk, retrowave, forest, ocean, ume, copper, terminal, organs, lavender, gpt, claude, cute.",
|
||||
"ui_control": "- ```ui_control``` — Control the UI: toggle tools on/off, OPEN PANELS, open email reply drafts, switch models, change themes. Commands: `toggle <name> on/off` (names: bash/shell, web/search, research, incognito, document_editor/documents), `open_panel <name>` (panels: documents, gallery, email, sessions, notes, memories/brain, skills, settings, cookbook), `open_email_reply <uid> <folder> <reply|reply-all|ai-reply>` (opens an email compose document, does NOT send), `set_mode agent/chat`, `switch_model <name>`, `set_theme <preset>`, `create_theme <name> <bg> <fg> <panel> <border> <accent>` (optional key=val for advanced colors AND background effects: bgPattern=<none|dots|synapse|rain|constellations|perlin-flow|petals|sparkles|embers>, bgEffectColor=#RRGGBB, bgEffectIntensity=<num>, bgEffectSize=<num>, frosted=true|false). \"open documents\" / \"open library\" / \"show gallery\" / \"open inbox\" / \"open notes\" / \"open cookbook\" all map to `open_panel <name>`. Built-in theme presets: dark, light, midnight, paper, cyberpunk, retrowave, forest, ocean, ume, copper, terminal, organs, lavender, gpt, claude, cute. For any other vibe/name, use create_theme.",
|
||||
"ask_user": "- ```ask_user``` — Ask the user a multiple-choice question when the task is genuinely ambiguous and the answer changes what you do next (pick an approach, confirm an assumption, choose a target). Args (JSON): {\"question\": \"...\", \"options\": [{\"label\": \"...\", \"description\": \"...\"?}, ...], \"multi\": false?}. 2-6 options. The user gets clickable buttons; calling this ENDS your turn and their choice comes back as your next message. Prefer sensible defaults — only ask when you truly can't proceed well without their input.",
|
||||
"update_plan": "- ```update_plan``` — While executing an approved plan, write the plan back: tick steps done or revise them. Args (JSON): {\"plan\": \"- [x] done step\\n- [ ] next step\"}. Always pass the COMPLETE checklist, not a diff. Call it after finishing each step (mark it `- [x]`) and whenever the user asks to change the plan. The user's docked plan window updates live. Does nothing if there's no active plan.",
|
||||
"list_served_models": "- ```list_served_models``` — Show what the Cookbook (LLM-serving subsystem) is currently running. NO args. Use this for ANY 'what's running' / 'what's serving' / 'show my cookbook' / 'is anything up' query. DO NOT shell out (`ps aux`, `docker ps`, etc.) — this tool is the source of truth. Failed serve tasks include recent logs plus diagnosis/retry suggestions; use those suggestions to call `serve_model` again with an adjusted command when appropriate.",
|
||||
@@ -418,6 +532,7 @@ def _assemble_prompt(tool_names: set, disabled_tools: set = None, compact: bool
|
||||
f"Available tools: {tool_list}.",
|
||||
_API_AGENT_RULES,
|
||||
]
|
||||
parts.extend(_domain_rules_for_tools(included))
|
||||
return "\n\n".join(parts)
|
||||
|
||||
parts = [_AGENT_PREAMBLE]
|
||||
@@ -454,6 +569,7 @@ def _assemble_prompt(tool_names: set, disabled_tools: set = None, compact: bool
|
||||
parts.append(f"(Other tools available when needed: {hint})")
|
||||
|
||||
parts.append(_AGENT_RULES)
|
||||
parts.extend(_domain_rules_for_tools(included))
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
@@ -574,6 +690,117 @@ def _extract_last_user_message(messages: List[Dict]) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
_LOW_SIGNAL_RE = re.compile(r"^[\W_]*$", re.UNICODE)
|
||||
_EXPLICIT_CONTINUATION_RE = re.compile(
|
||||
r"^\s*(?:"
|
||||
r"yes|y|yeah|yep|ok|okay|sure|do it|go ahead|continue|carry on|"
|
||||
r"run it|launch it|start it|use that|that one|same|the same|"
|
||||
r"first|second|third|the first one|the second one|the third one|"
|
||||
r"[123]|[abc]"
|
||||
r")\s*[.!?]*\s*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _is_explicit_continuation(text: str) -> bool:
|
||||
"""Only these terse replies may inherit older user turns for tool retrieval."""
|
||||
return bool(_EXPLICIT_CONTINUATION_RE.match(str(text or "").strip()))
|
||||
|
||||
|
||||
def _assistant_requested_followup(messages: List[Dict]) -> bool:
|
||||
"""True when the previous assistant turn asked for missing task details.
|
||||
|
||||
This allows natural replies like "buy milk" after "What would you like on
|
||||
your to-do list?" to inherit the prior domain, without letting random
|
||||
greetings inherit stale Cookbook/email/document context.
|
||||
"""
|
||||
seen_latest_user = False
|
||||
for msg in reversed(messages):
|
||||
role = msg.get("role")
|
||||
if role == "user" and not seen_latest_user:
|
||||
seen_latest_user = True
|
||||
continue
|
||||
if not seen_latest_user:
|
||||
continue
|
||||
if role != "assistant":
|
||||
continue
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, list):
|
||||
content = " ".join(b.get("text", "") for b in content if isinstance(b, dict))
|
||||
text = str(content or "").lower()
|
||||
if "?" not in text:
|
||||
return False
|
||||
return bool(re.search(
|
||||
r"\b(what would you like|what should|what do you want|which one|which model|"
|
||||
r"what.+(?:todo|to-do|list|document|email|model|server|item)|"
|
||||
r"any specific|give me|tell me)\b",
|
||||
text,
|
||||
))
|
||||
return False
|
||||
|
||||
|
||||
def _classify_agent_request(messages: List[Dict], last_user: str) -> Dict[str, object]:
|
||||
"""Classify only whether this turn deserves domain tool retrieval.
|
||||
|
||||
Normal chat should not inherit old Cookbook/email/document context. Recent
|
||||
context is used only for explicit continuations ("yes", "do it", "1").
|
||||
This function does not inject tools directly; selected tools later decide
|
||||
which domain rule packs get appended to the system prompt.
|
||||
"""
|
||||
text = str(last_user or "").strip()
|
||||
continuation = _is_explicit_continuation(text) or _assistant_requested_followup(messages)
|
||||
retrieval_query = _recent_context_for_retrieval(messages) if continuation else text
|
||||
q = retrieval_query.lower()
|
||||
|
||||
if not text or bool(_LOW_SIGNAL_RE.match(text)):
|
||||
return {
|
||||
"low_signal": True,
|
||||
"continuation": False,
|
||||
"domains": set(),
|
||||
"retrieval_query": text,
|
||||
}
|
||||
|
||||
domains: Set[str] = set()
|
||||
|
||||
def has(*patterns: str) -> bool:
|
||||
return any(re.search(p, q) for p in patterns)
|
||||
|
||||
if has(r"\b(cookbook|serve|serving|served|launch|start|preset|vllm|sglang|llama\.?cpp|ollama|download|downloading|pull|cached models?|running models?|model servers?|models? (?:are )?running|what models?|model picker|gpu box|kierkegaard|odysseus|ajax|qwen|gemma|llama|mistral|minimax)\b"):
|
||||
domains.add("cookbook")
|
||||
if has(r"\b(emails?|mails?|gmail|inbox|reply|forward|cc|bcc|send email|compose email|draft email|message chris|message him|message her)\b"):
|
||||
domains.add("email")
|
||||
if has(r"\b(note|todo|to-do|checklist|task list|remind me|reminder|buy|pickup|pick up)\b"):
|
||||
domains.add("notes_calendar_tasks")
|
||||
if has(r"\b(every day|every morning|every evening|recurring|automatically|cron|scheduled task|background task)\b"):
|
||||
domains.add("notes_calendar_tasks")
|
||||
if has(r"\b(calendar|event|meeting|appointment|schedule)\b"):
|
||||
domains.add("notes_calendar_tasks")
|
||||
if has(r"\b(documents?|docs?|draft|compose|poem|story|essay|outline|letter|edit|rewrite|proofread|suggest|feedback|review this|make a file)\b"):
|
||||
domains.add("documents")
|
||||
if "notes_calendar_tasks" not in domains and has(r"\bwrite\b"):
|
||||
domains.add("documents")
|
||||
if has(r"\b(search|web|google|look up|latest|news|current|weather|forecast|stock price|price of|website|url|https?://|www\.)\b"):
|
||||
domains.add("web")
|
||||
if has(r"\b(research|deep dive|investigate|look into)\b"):
|
||||
domains.add("web")
|
||||
if has(r"\b(open|show|toggle|turn on|turn off|disable|enable|switch model|change model|settings|theme|panel)\b"):
|
||||
domains.add("ui")
|
||||
if has(r"\b(session|chat history|rename chat|delete chat|archive chat|fork chat|list chats)\b"):
|
||||
domains.add("sessions")
|
||||
if has(r"\b(file|folder|directory|repo|git|grep|find in files|read file|edit file|shell|terminal|bash|python)\b"):
|
||||
domains.add("files")
|
||||
if has(r"\b(endpoint|api token|mcp|webhook|preference|configure|config|setting)\b"):
|
||||
domains.add("settings")
|
||||
|
||||
low_signal = not continuation and not domains
|
||||
return {
|
||||
"low_signal": low_signal,
|
||||
"continuation": continuation,
|
||||
"domains": domains,
|
||||
"retrieval_query": retrieval_query,
|
||||
}
|
||||
|
||||
|
||||
def _recent_context_for_retrieval(messages: List[Dict], max_user: int = 3, max_chars: int = 600) -> str:
|
||||
"""Build the tool-retrieval query from the last few USER turns, not just
|
||||
the latest one.
|
||||
@@ -1480,7 +1707,6 @@ async def stream_agent_loop(
|
||||
owner: Optional[str] = None,
|
||||
relevant_tools: Optional[Set[str]] = None,
|
||||
fallbacks: Optional[List[tuple]] = None,
|
||||
workspace: Optional[str] = None,
|
||||
plan_mode: bool = False,
|
||||
approved_plan: Optional[str] = None,
|
||||
tool_policy: Optional[ToolPolicy] = None,
|
||||
@@ -1522,9 +1748,18 @@ async def stream_agent_loop(
|
||||
_t0 = time.time()
|
||||
_needs_admin = _detect_admin_intent(messages)
|
||||
_last_user = _extract_last_user_message(messages)
|
||||
# Tool retrieval keys on recent conversation context (last few user turns),
|
||||
# not just the latest message, so short follow-ups don't drop just-used tools.
|
||||
_retrieval_query = _recent_context_for_retrieval(messages) or _last_user
|
||||
_intent = _classify_agent_request(messages, _last_user)
|
||||
# Tool retrieval uses the latest message by default. It may inherit recent
|
||||
# user turns only for explicit continuations ("yes", "do it", "1").
|
||||
_retrieval_query = str(_intent.get("retrieval_query") or _last_user)
|
||||
logger.info(
|
||||
"[agent-intent] latest=%r continuation=%s low_signal=%s domains=%s retrieval_query=%r",
|
||||
_last_user[:120],
|
||||
bool(_intent.get("continuation")),
|
||||
bool(_intent.get("low_signal")),
|
||||
sorted(_intent.get("domains") or []),
|
||||
_retrieval_query[:200],
|
||||
)
|
||||
_mcp_disabled_map = _load_mcp_disabled_map() if mcp_mgr else {}
|
||||
if plan_mode and mcp_mgr:
|
||||
# Allow read-only MCP tools to investigate, block write/unknown ones:
|
||||
@@ -1541,6 +1776,10 @@ async def stream_agent_loop(
|
||||
_t1 = time.time()
|
||||
if _relevant_tools:
|
||||
logger.info(f"[tool-rag] Using caller-provided relevant_tools ({len(_relevant_tools)} tools)")
|
||||
if not guide_only and not _relevant_tools and bool(_intent.get("low_signal")):
|
||||
from src.tool_index import ALWAYS_AVAILABLE
|
||||
_relevant_tools = set(ALWAYS_AVAILABLE)
|
||||
logger.info("[tool-rag] Low-signal agent message; skipping retrieval and using always-available tools only")
|
||||
if not guide_only and not _relevant_tools:
|
||||
try:
|
||||
from src.tool_index import get_tool_index, ALWAYS_AVAILABLE
|
||||
@@ -1583,16 +1822,41 @@ async def stream_agent_loop(
|
||||
for keywords, tools in ToolIndex._KEYWORD_HINTS.items():
|
||||
if any(kw in ql for kw in keywords):
|
||||
_relevant_tools.update(tools)
|
||||
# Always include core document/memory tools
|
||||
_relevant_tools.update({"create_document", "manage_memory", "manage_notes"})
|
||||
logger.info(f"[tool-rag] Keyword fallback selected: {sorted(_relevant_tools - ALWAYS_AVAILABLE)}")
|
||||
|
||||
# If deterministic domain detection fired, seed the corresponding domain
|
||||
# tools into the selected tool set. This is not direct prompt-pack
|
||||
# injection: `_assemble_prompt()` still derives domain rules from the final
|
||||
# tool names. It prevents obvious requests like "last 5 emails" from
|
||||
# collapsing to only ask_user/manage_memory when vector retrieval misses or
|
||||
# times out.
|
||||
if not guide_only and _relevant_tools is not None:
|
||||
for _domain in (_intent.get("domains") or set()):
|
||||
_relevant_tools.update(_DOMAIN_TOOL_MAP.get(str(_domain), set()))
|
||||
if "cookbook" in (_intent.get("domains") or set()):
|
||||
_relevant_tools.update({
|
||||
"list_served_models",
|
||||
"list_downloads",
|
||||
"list_cached_models",
|
||||
"list_cookbook_servers",
|
||||
"list_serve_presets",
|
||||
})
|
||||
if "email" in (_intent.get("domains") or set()):
|
||||
_relevant_tools.add("ui_control")
|
||||
if "web" in (_intent.get("domains") or set()):
|
||||
_relevant_tools.update({"web_search", "web_fetch"})
|
||||
if "ui" in (_intent.get("domains") or set()):
|
||||
_relevant_tools.add("ui_control")
|
||||
|
||||
# If a document is open the model needs the editing tools available
|
||||
# regardless of which selection path (RAG, keyword, caller-provided) ran
|
||||
# or what keywords were in the latest user message.
|
||||
if _relevant_tools is not None and active_document is not None:
|
||||
_relevant_tools.update({"edit_document", "update_document", "suggest_document"})
|
||||
|
||||
if _relevant_tools is not None:
|
||||
logger.info("[agent-intent] selected_tools=%s", sorted(_relevant_tools)[:50])
|
||||
|
||||
prep_timings["tool_selection"] = time.time() - _t1
|
||||
|
||||
_t2 = time.time()
|
||||
@@ -1670,27 +1934,6 @@ async def stream_agent_loop(
|
||||
owner=owner,
|
||||
suppress_local_context=guide_only,
|
||||
)
|
||||
if workspace and not guide_only:
|
||||
# PREPEND (not append) so it dominates the large base prompt — appended
|
||||
# at the end, small models ignored it and asked the user for code. The
|
||||
# folder IS the project; the agent must explore it, not ask.
|
||||
_ws_note = (
|
||||
f"## ACTIVE WORKSPACE — READ FIRST\n"
|
||||
f"The user is working in this folder: {workspace}\n"
|
||||
f"It IS the project. bash/python run with cwd set here and "
|
||||
f"read_file/write_file are confined to it (paths outside are rejected).\n"
|
||||
f"When the user says \"the code\" / \"this project\" / \"the workspace\" "
|
||||
f"or asks to review/find/edit something WITHOUT a path, they mean THIS "
|
||||
f"folder. Do NOT ask the user for code or a path, and do NOT read a file "
|
||||
f"literally named \"workspace\". ALWAYS start by exploring it yourself: "
|
||||
f"run `bash` → `git ls-files` (or `ls -R`) to see the files, then "
|
||||
f"read_file the relevant ones by path RELATIVE to the workspace."
|
||||
)
|
||||
if messages and messages[0].get("role") == "system":
|
||||
messages[0]["content"] = _ws_note + "\n\n" + (messages[0].get("content") or "")
|
||||
else:
|
||||
messages.insert(0, {"role": "system", "content": _ws_note})
|
||||
logger.info("[workspace] active for this turn: %s", workspace)
|
||||
if plan_mode and not guide_only:
|
||||
# Steer the model to investigate-then-propose. Hard tool gating handles
|
||||
# every write path except shell; this directive is what keeps the
|
||||
@@ -2384,7 +2627,6 @@ async def stream_agent_loop(
|
||||
tool_policy=tool_policy,
|
||||
owner=owner,
|
||||
progress_cb=_push_progress,
|
||||
workspace=workspace,
|
||||
)
|
||||
finally:
|
||||
# Sentinel so the drainer knows to stop.
|
||||
|
||||
@@ -1284,7 +1284,7 @@ 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 theme preset (dark, light, paper, nord, dracula, gruvbox, gpt, claude, lavender, etc.)
|
||||
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)
|
||||
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)
|
||||
open_email_reply <uid> [folder] [reply|reply-all|ai-reply] — Open a reply draft document for an email; does not send
|
||||
|
||||
@@ -17,8 +17,6 @@ from typing import Any, Dict, Optional
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
|
||||
from core.database import ProviderAuthSession, SessionLocal, utcnow_naive
|
||||
|
||||
DEFAULT_CHATGPT_SUBSCRIPTION_BASE_URL = (
|
||||
os.getenv("CHATGPT_SUBSCRIPTION_BASE_URL", "").strip().rstrip("/")
|
||||
or "https://chatgpt.com/backend-api/codex"
|
||||
@@ -33,6 +31,11 @@ _AUTH_REFRESH_LOCKS: dict[str, threading.Lock] = {}
|
||||
_AUTH_REFRESH_LOCKS_GUARD = threading.Lock()
|
||||
|
||||
|
||||
def _database_handles():
|
||||
from core.database import ProviderAuthSession, SessionLocal, utcnow_naive
|
||||
return ProviderAuthSession, SessionLocal, utcnow_naive
|
||||
|
||||
|
||||
def _refresh_lock_for(auth_id: str) -> threading.Lock:
|
||||
with _AUTH_REFRESH_LOCKS_GUARD:
|
||||
lock = _AUTH_REFRESH_LOCKS.get(auth_id)
|
||||
@@ -249,6 +252,7 @@ def access_token_is_expiring(access_token: str, skew_seconds: int = CHATGPT_ACCE
|
||||
|
||||
|
||||
def resolve_runtime_credentials(auth_id: str, owner: Optional[str] = None, *, force_refresh: bool = False) -> Dict[str, Any]:
|
||||
ProviderAuthSession, SessionLocal, utcnow_naive = _database_handles()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = db.query(ProviderAuthSession).filter(
|
||||
|
||||
@@ -276,6 +276,24 @@ def _is_ollama_native_url(url: str) -> bool:
|
||||
return local_ollama_host and (path == "" or path == "/api" or path.startswith("/api/"))
|
||||
|
||||
|
||||
def _is_ollama_openai_compat_url(url: str) -> bool:
|
||||
"""Return True for local Ollama's OpenAI-compatible /v1 surface.
|
||||
|
||||
Mirrors the host detection used by ``_is_ollama_native_url`` so that the
|
||||
two helpers stay in lockstep: a localhost Ollama on a non-default port
|
||||
(custom ``OLLAMA_HOST``, reverse proxy, container port remap) is treated
|
||||
the same way here as it is on the native ``/api`` path.
|
||||
"""
|
||||
try:
|
||||
parsed = urlparse(url or "")
|
||||
except Exception:
|
||||
return False
|
||||
host = parsed.hostname or ""
|
||||
path = (parsed.path or "").rstrip("/")
|
||||
local_ollama_host = host in {"localhost", "127.0.0.1", "0.0.0.0", "::1"} or parsed.port == 11434
|
||||
return local_ollama_host and (path == "/v1" or path.startswith("/v1/"))
|
||||
|
||||
|
||||
def _ollama_api_root(url: str) -> str:
|
||||
"""Return a native Ollama API root such as https://ollama.com/api."""
|
||||
url = (url or "").strip().rstrip("/")
|
||||
@@ -1344,6 +1362,9 @@ async def llm_call_async(
|
||||
if max_tokens and max_tokens > 0:
|
||||
tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens"
|
||||
payload[tok_key] = max_tokens
|
||||
# Suppress thinking for qwen3/gemma4 on Ollama /v1 — same as stream_llm.
|
||||
if _is_ollama_openai_compat_url(url) and _supports_thinking(model):
|
||||
payload["think"] = False
|
||||
|
||||
if _is_host_dead(target_url):
|
||||
raise HTTPException(503, f"Upstream {_host_key(target_url)} marked unreachable (cooldown active)")
|
||||
@@ -1461,6 +1482,11 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
||||
payload[tok_key] = max_tokens
|
||||
if tools:
|
||||
payload["tools"] = tools
|
||||
# For Ollama's OpenAI-compat /v1 endpoint with thinking models (qwen3,
|
||||
# gemma4, etc.), suppress thinking so tool calls aren't swallowed inside
|
||||
# <think> blocks. Ollama /v1 accepts "think": false as a top-level param.
|
||||
if _is_ollama_openai_compat_url(url) and _supports_thinking(model):
|
||||
payload["think"] = False
|
||||
h = _provider_headers(provider, headers)
|
||||
if provider == "copilot":
|
||||
from src.copilot import apply_request_headers
|
||||
|
||||
+35
-4
@@ -8,7 +8,7 @@ and the task scheduler / builtin actions system.
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -23,6 +23,34 @@ _THROWAWAY_NAMES = {
|
||||
}
|
||||
_THROWAWAY_MAX_MESSAGES = 4
|
||||
_FRESH_EMPTY_SESSION_GRACE = timedelta(minutes=10)
|
||||
_FRESH_SESSION_GRACE = _FRESH_EMPTY_SESSION_GRACE
|
||||
|
||||
|
||||
def _utcnow_naive() -> datetime:
|
||||
"""Return naive UTC for existing session DateTime columns."""
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _as_naive_utc(value):
|
||||
if value is None:
|
||||
return None
|
||||
if getattr(value, "tzinfo", None) is not None:
|
||||
return value.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
return value
|
||||
|
||||
|
||||
def is_session_recently_active(row, now=None, grace=_FRESH_SESSION_GRACE) -> bool:
|
||||
"""Return True while a new or active session is too fresh to auto-delete."""
|
||||
now = _as_naive_utc(now) or _utcnow_naive()
|
||||
for attr in ("last_message_at", "last_accessed", "updated_at", "created_at"):
|
||||
value = _as_naive_utc(getattr(row, attr, None))
|
||||
if not value:
|
||||
continue
|
||||
if value >= now:
|
||||
return True
|
||||
if now - value <= grace:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bool = True) -> str:
|
||||
@@ -52,15 +80,18 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo
|
||||
*([DbSession.owner == owner] if owner else []),
|
||||
).all()
|
||||
|
||||
cleanup_now = _utcnow_naive()
|
||||
for row in rows:
|
||||
if getattr(row, 'is_important', False):
|
||||
continue
|
||||
created_at = row.created_at or row.updated_at or datetime.utcnow()
|
||||
is_fresh = (datetime.utcnow() - created_at) < _FRESH_EMPTY_SESSION_GRACE
|
||||
created_at = _as_naive_utc(row.created_at or row.updated_at) or _utcnow_naive()
|
||||
is_fresh = (_utcnow_naive() - created_at) < _FRESH_EMPTY_SESSION_GRACE
|
||||
if (row.name or "").strip() == "Incognito":
|
||||
deleted_throwaway += 1
|
||||
db.delete(row)
|
||||
continue
|
||||
if is_session_recently_active(row, now=cleanup_now):
|
||||
continue
|
||||
|
||||
msg_count = db.query(DbMsg.id).filter(
|
||||
DbMsg.session_id == row.id
|
||||
@@ -208,7 +239,7 @@ async def run_auto_sort(owner: str, skip_llm: bool = False, delete_throwaway: bo
|
||||
db_sess = db.query(DbSession).filter(DbSession.id == full_id).first()
|
||||
if db_sess:
|
||||
db_sess.folder = folder_name
|
||||
db_sess.updated_at = datetime.utcnow()
|
||||
db_sess.updated_at = _utcnow_naive()
|
||||
updated += 1
|
||||
db.commit()
|
||||
|
||||
|
||||
+19
-67
@@ -67,13 +67,12 @@ def _unified_diff(old: str, new: str, path: str) -> Optional[Dict[str, Any]]:
|
||||
}
|
||||
|
||||
|
||||
async def _do_edit_file(content: str, workspace: Optional[str] = None) -> Dict[str, Any]:
|
||||
async def _do_edit_file(content: str) -> Dict[str, Any]:
|
||||
"""Exact string-replacement edit of an on-disk file.
|
||||
|
||||
content is JSON: {"path", "old_string", "new_string", "replace_all"?}.
|
||||
Fails if old_string is missing or non-unique (unless replace_all) so the
|
||||
model can't silently edit the wrong place. Returns a unified diff for the UI.
|
||||
Confined to the workspace when one is set (same policy as write_file).
|
||||
"""
|
||||
try:
|
||||
args = json.loads(content) if content.strip().startswith("{") else {}
|
||||
@@ -85,11 +84,9 @@ async def _do_edit_file(content: str, workspace: Optional[str] = None) -> Dict[s
|
||||
replace_all = bool(args.get("replace_all", False))
|
||||
if not raw_path:
|
||||
return {"error": "edit_file: path required", "exit_code": 1}
|
||||
# Confine to the workspace when set, else the same allowlist + sensitive-file
|
||||
# policy as read/write_file.
|
||||
# Allowlist + sensitive-file policy as read/write_file.
|
||||
try:
|
||||
path = (_resolve_tool_path_in_workspace(workspace, raw_path)
|
||||
if workspace else _resolve_tool_path(raw_path))
|
||||
path = _resolve_tool_path(raw_path)
|
||||
except ValueError as e:
|
||||
return {"error": f"edit_file: {e}", "exit_code": 1}
|
||||
if old == "":
|
||||
@@ -272,39 +269,6 @@ def _resolve_tool_path(raw_path: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _resolve_tool_path_in_workspace(workspace: str, raw_path: str) -> str:
|
||||
"""Confine a model-supplied path to the active workspace.
|
||||
|
||||
Layered on top of upstream's path policy: the workspace is the allowed
|
||||
root (relative paths resolve under it; paths that escape it are rejected),
|
||||
and the sensitive-file deny list (.ssh, .gnupg, id_rsa, …) still applies
|
||||
inside it. When no workspace is set, callers use _resolve_tool_path (the
|
||||
default data/tmp allowlist) instead.
|
||||
"""
|
||||
if raw_path is None or not str(raw_path).strip():
|
||||
raise ValueError("path is required")
|
||||
base = os.path.realpath(workspace)
|
||||
expanded = os.path.expanduser(str(raw_path).strip())
|
||||
candidate = expanded if os.path.isabs(expanded) else os.path.join(base, expanded)
|
||||
resolved = os.path.realpath(candidate)
|
||||
if _is_sensitive_path(resolved):
|
||||
raise ValueError(
|
||||
f"path '{raw_path}' is inside a sensitive directory "
|
||||
f"(e.g. .ssh, .gnupg) or matches a sensitive filename"
|
||||
)
|
||||
if resolved != base:
|
||||
# normcase so containment holds on case-insensitive filesystems
|
||||
# (Windows, default macOS): it lowercases on Windows and is a no-op on
|
||||
# POSIX. commonpath raises ValueError across Windows drives (C: vs D:)
|
||||
# or mixed abs/rel — both mean "outside", so the except rejects them.
|
||||
nbase = os.path.normcase(base)
|
||||
try:
|
||||
if os.path.commonpath([os.path.normcase(resolved), nbase]) != nbase:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
raise ValueError(f"path '{raw_path}' is outside the workspace ({workspace})")
|
||||
return resolved
|
||||
|
||||
# Bash + python tools used to share a single 60s timeout. That's
|
||||
# enough for one-shot commands but starves real workloads (pip
|
||||
# install, ffmpeg conversions, etc.) — and worse, the agent saw the
|
||||
@@ -341,19 +305,13 @@ _CODENAV_MAX_HITS = 200
|
||||
_CODENAV_MAX_LINE = 400
|
||||
|
||||
|
||||
def _resolve_search_root(raw_path: str, workspace: Optional[str] = None) -> str:
|
||||
def _resolve_search_root(raw_path: str) -> str:
|
||||
"""Resolve + confine a code-nav path (grep/glob/ls).
|
||||
|
||||
With a workspace set, the workspace folder is the root and supplied paths are
|
||||
confined inside it (same policy as read_file). Without one, an empty path
|
||||
defaults to the agent's primary root (project data dir) and a supplied path
|
||||
is confined by the global allowlist + sensitive-file policy.
|
||||
An empty path defaults to the agent's primary root (project data dir) and a
|
||||
supplied path is confined by the global allowlist + sensitive-file policy.
|
||||
"""
|
||||
raw = (raw_path or "").strip()
|
||||
if workspace:
|
||||
if not raw:
|
||||
return os.path.realpath(workspace)
|
||||
return _resolve_tool_path_in_workspace(workspace, raw)
|
||||
if not raw:
|
||||
roots = _tool_path_roots()
|
||||
return roots[0] if roots else os.path.realpath(".")
|
||||
@@ -564,12 +522,11 @@ async def _call_mcp_tool(
|
||||
tool: str,
|
||||
content: str,
|
||||
progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None,
|
||||
workspace: Optional[str] = None,
|
||||
) -> Dict:
|
||||
"""Route a legacy tool call through the MCP manager, with direct fallbacks."""
|
||||
mcp = get_mcp_manager()
|
||||
if not mcp:
|
||||
return await _direct_fallback(tool, content, progress_cb=progress_cb, workspace=workspace) or {"error": f"MCP manager not available for tool '{tool}'", "exit_code": 1}
|
||||
return await _direct_fallback(tool, content, progress_cb=progress_cb) or {"error": f"MCP manager not available for tool '{tool}'", "exit_code": 1}
|
||||
|
||||
server_id, tool_name = _MCP_TOOL_MAP[tool]
|
||||
qualified = f"mcp__{server_id}__{tool_name}"
|
||||
@@ -578,7 +535,7 @@ async def _call_mcp_tool(
|
||||
|
||||
# If MCP server not connected, try direct fallback
|
||||
if isinstance(result, dict) and result.get("exit_code") == 1 and "not connected" in result.get("error", ""):
|
||||
fallback = await _direct_fallback(tool, content, progress_cb=progress_cb, workspace=workspace)
|
||||
fallback = await _direct_fallback(tool, content, progress_cb=progress_cb)
|
||||
if fallback:
|
||||
return fallback
|
||||
|
||||
@@ -636,7 +593,6 @@ async def _direct_fallback(
|
||||
tool: str,
|
||||
content: str,
|
||||
progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None,
|
||||
workspace: Optional[str] = None,
|
||||
) -> Optional[Dict]:
|
||||
"""In-process execution path for the eight tools that used to live as
|
||||
stdio MCP servers under mcp_servers/. Those servers were deleted in
|
||||
@@ -670,7 +626,7 @@ async def _direct_fallback(
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=_subproc_env,
|
||||
cwd=workspace or _AGENT_WORKDIR,
|
||||
cwd=_AGENT_WORKDIR,
|
||||
)
|
||||
stdout, stderr, rc, timed_out = await _run_subprocess_streaming(
|
||||
proc,
|
||||
@@ -697,7 +653,7 @@ async def _direct_fallback(
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=_subproc_env,
|
||||
cwd=workspace or _AGENT_WORKDIR,
|
||||
cwd=_AGENT_WORKDIR,
|
||||
)
|
||||
stdout, stderr, rc, timed_out = await _run_subprocess_streaming(
|
||||
proc,
|
||||
@@ -727,8 +683,7 @@ async def _direct_fallback(
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
path = (_resolve_tool_path_in_workspace(workspace, raw_path)
|
||||
if workspace else _resolve_tool_path(raw_path))
|
||||
path = _resolve_tool_path(raw_path)
|
||||
except ValueError as e:
|
||||
return {"error": f"read_file: {e}", "exit_code": 1}
|
||||
try:
|
||||
@@ -771,8 +726,7 @@ async def _direct_fallback(
|
||||
raw_path = lines[0].strip()
|
||||
body = lines[1] if len(lines) > 1 else ""
|
||||
try:
|
||||
path = (_resolve_tool_path_in_workspace(workspace, raw_path)
|
||||
if workspace else _resolve_tool_path(raw_path))
|
||||
path = _resolve_tool_path(raw_path)
|
||||
except ValueError as e:
|
||||
return {"error": f"write_file: {e}", "exit_code": 1}
|
||||
try:
|
||||
@@ -825,7 +779,7 @@ async def _direct_fallback(
|
||||
max_hits = _CODENAV_MAX_HITS
|
||||
max_hits = max(1, min(max_hits, _CODENAV_MAX_HITS))
|
||||
try:
|
||||
root = _resolve_search_root(str(args.get("path", "")), workspace)
|
||||
root = _resolve_search_root(str(args.get("path", "")))
|
||||
except ValueError as e:
|
||||
return {"error": f"grep: {e}", "exit_code": 1}
|
||||
|
||||
@@ -909,7 +863,7 @@ async def _direct_fallback(
|
||||
if not pattern:
|
||||
return {"error": "glob: pattern is required", "exit_code": 1}
|
||||
try:
|
||||
root = _resolve_search_root(str(args.get("path", "")), workspace)
|
||||
root = _resolve_search_root(str(args.get("path", "")))
|
||||
except ValueError as e:
|
||||
return {"error": f"glob: {e}", "exit_code": 1}
|
||||
|
||||
@@ -956,7 +910,7 @@ async def _direct_fallback(
|
||||
else:
|
||||
raw_path = _s.split("\n", 1)[0].strip()
|
||||
try:
|
||||
root = _resolve_search_root(raw_path, workspace)
|
||||
root = _resolve_search_root(raw_path)
|
||||
except ValueError as e:
|
||||
return {"error": f"ls: {e}", "exit_code": 1}
|
||||
|
||||
@@ -1121,7 +1075,6 @@ async def execute_tool_block(
|
||||
tool_policy: Optional[ToolPolicy] = None,
|
||||
owner: Optional[str] = None,
|
||||
progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None,
|
||||
workspace: Optional[str] = None,
|
||||
) -> Tuple[str, Dict]:
|
||||
"""Execute a single tool block. Returns (description, result_dict).
|
||||
|
||||
@@ -1296,7 +1249,7 @@ async def execute_tool_block(
|
||||
_is_bg, _bg_cmd = _split_bg_marker(content)
|
||||
if _is_bg and _bg_cmd:
|
||||
from src import bg_jobs
|
||||
rec = bg_jobs.launch(_bg_cmd, session_id=session_id, cwd=workspace or _AGENT_WORKDIR)
|
||||
rec = bg_jobs.launch(_bg_cmd, session_id=session_id, cwd=_AGENT_WORKDIR)
|
||||
short = _bg_cmd.strip().split(chr(10))[0][:80]
|
||||
desc = f"bash (background): {short}"
|
||||
result = {
|
||||
@@ -1318,13 +1271,12 @@ async def execute_tool_block(
|
||||
if tool in _MCP_TOOL_MAP:
|
||||
first_line = content.split(chr(10))[0][:80]
|
||||
desc = f"{tool}: {first_line}"
|
||||
result = await _call_mcp_tool(tool, content, progress_cb=progress_cb, workspace=workspace)
|
||||
result = await _call_mcp_tool(tool, content, progress_cb=progress_cb)
|
||||
elif tool in ("grep", "glob", "ls"):
|
||||
# Code-navigation tools — no MCP server; run the direct implementation.
|
||||
# Confined to the workspace when one is set (same policy as read_file).
|
||||
first_line = content.split(chr(10))[0][:80]
|
||||
desc = f"{tool}: {first_line}"
|
||||
result = await _direct_fallback(tool, content, progress_cb=progress_cb, workspace=workspace) \
|
||||
result = await _direct_fallback(tool, content, progress_cb=progress_cb) \
|
||||
or {"error": f"{tool}: execution failed", "exit_code": 1}
|
||||
elif tool == "create_document":
|
||||
title = content.split("\n")[0].strip()[:60]
|
||||
@@ -1429,7 +1381,7 @@ async def execute_tool_block(
|
||||
desc = "edit_image"
|
||||
result = await do_edit_image(content, owner=owner)
|
||||
elif tool == "edit_file":
|
||||
result = await _do_edit_file(content, workspace=workspace)
|
||||
result = await _do_edit_file(content)
|
||||
desc = result.get("output") or result.get("error") or "edit_file"
|
||||
elif tool == "trigger_research":
|
||||
desc = "trigger_research"
|
||||
|
||||
+141
-9
@@ -664,6 +664,17 @@ async def do_manage_skills(content: str, owner: Optional[str] = None) -> Dict:
|
||||
proc = args.get("steps") or []
|
||||
if not proc and not args.get("body_extra") and not args.get("solution"):
|
||||
return {"error": "procedure (or solution body) is required", "exit_code": 1}
|
||||
# Same auto-publish gate as the extractor path — when the user
|
||||
# has auto_approve_skills on and the caller didn't pin an explicit
|
||||
# status, publish immediately. Audit later demotes/removes on fail.
|
||||
_status_arg = args.get("status")
|
||||
if not _status_arg:
|
||||
try:
|
||||
from routes.prefs_routes import _load_for_user as _load_prefs
|
||||
_prefs = _load_prefs(owner) or {}
|
||||
_status_arg = "published" if _prefs.get("auto_approve_skills", True) else "draft"
|
||||
except Exception:
|
||||
_status_arg = "draft"
|
||||
entry = sm.add_skill(
|
||||
name=args.get("name"),
|
||||
description=(args.get("description") or args.get("title") or "").strip(),
|
||||
@@ -677,7 +688,7 @@ async def do_manage_skills(content: str, owner: Optional[str] = None) -> Dict:
|
||||
procedure=proc,
|
||||
pitfalls=args.get("pitfalls") or [],
|
||||
verification=args.get("verification") or [],
|
||||
status=args.get("status") or "draft",
|
||||
status=_status_arg,
|
||||
version=args.get("version") or "1.0.0",
|
||||
confidence=args.get("confidence", 0.8),
|
||||
source=args.get("source", "learned"),
|
||||
@@ -2621,8 +2632,90 @@ async def _cookbook_env_for_host(host: str) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
async def _cookbook_register_task(session_id: str, model: str, host: str,
|
||||
cmd: str, task_type: str = "serve") -> bool:
|
||||
def _infer_serve_port(cmd: str) -> int:
|
||||
"""Infer likely listen port from a serve command."""
|
||||
if not cmd:
|
||||
return 8080
|
||||
m = re.search(r"--port\\s+(\\d+)", cmd)
|
||||
if m:
|
||||
try:
|
||||
return int(m.group(1))
|
||||
except Exception:
|
||||
pass
|
||||
m = re.search(r"OLLAMA_HOST=[^\\s]*?:(\\d+)", cmd)
|
||||
if m:
|
||||
try:
|
||||
return int(m.group(1))
|
||||
except Exception:
|
||||
pass
|
||||
if "ollama" in cmd:
|
||||
return 11434
|
||||
return 8080
|
||||
|
||||
|
||||
def _infer_serve_host(host: str | None) -> tuple[str, bool]:
|
||||
"""Return (host, container_local) for registering a served endpoint."""
|
||||
if not (host or "").strip():
|
||||
return "localhost", True
|
||||
base_host = host.split("@", 1)[-1] if "@" in host else host
|
||||
return base_host, False
|
||||
|
||||
|
||||
async def _ensure_served_endpoint(
|
||||
*,
|
||||
model: str,
|
||||
cmd: str,
|
||||
host: str | None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Register/fetch a model endpoint for a running serve session."""
|
||||
import httpx
|
||||
endpoint_host, container_local = _infer_serve_host(host)
|
||||
port = _infer_serve_port(cmd)
|
||||
base_url = f"http://{endpoint_host}:{port}/v1"
|
||||
short_name = model.split("/")[-1] if "/" in model else model
|
||||
is_image = "diffusion_server.py" in (cmd or "")
|
||||
payload = {
|
||||
"name": short_name if not is_image else f"{short_name} (image)",
|
||||
"base_url": base_url,
|
||||
"skip_probe": "true",
|
||||
"model_type": "image" if is_image else "llm",
|
||||
"container_local": "true" if container_local else "false",
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
resp = await client.post(
|
||||
f"{_COOKBOOK_BASE}/api/model-endpoints",
|
||||
data=payload,
|
||||
headers=_internal_headers(),
|
||||
)
|
||||
data = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
|
||||
if resp.status_code >= 400:
|
||||
logger.debug(
|
||||
f"ensure endpoint failed for {model!r}: status={resp.status_code} data={data}"
|
||||
)
|
||||
return {"added": False, "endpoint_id": "", "base_url": base_url, "error": data}
|
||||
ep_id = data.get("id") if isinstance(data, dict) else None
|
||||
return {
|
||||
"added": bool(ep_id),
|
||||
"endpoint_id": ep_id or "",
|
||||
"base_url": base_url,
|
||||
"data": data,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f"ensure endpoint exception for {model!r}: {e}")
|
||||
return {"added": False, "endpoint_id": "", "base_url": base_url, "error": str(e)}
|
||||
|
||||
|
||||
async def _cookbook_register_task(
|
||||
session_id: str,
|
||||
model: str,
|
||||
host: str,
|
||||
cmd: str,
|
||||
task_type: str = "serve",
|
||||
*,
|
||||
endpoint_added: bool = False,
|
||||
endpoint_id: str = "",
|
||||
) -> bool:
|
||||
"""Append a task entry to cookbook_state.json after the agent
|
||||
launches via /api/model/serve or /api/model/download. The route
|
||||
spawns tmux but leaves state-writing to the UI; the agent needs to
|
||||
@@ -2672,7 +2765,8 @@ async def _cookbook_register_task(session_id: str, model: str, host: str,
|
||||
"sshPort": "",
|
||||
"platform": "linux",
|
||||
"_serveReady": False,
|
||||
"_endpointAdded": False,
|
||||
"_endpointAdded": bool(endpoint_added),
|
||||
"_endpointId": endpoint_id or "",
|
||||
})
|
||||
state["tasks"] = tasks
|
||||
try:
|
||||
@@ -3008,7 +3102,12 @@ async def do_download_model(content: str, owner: Optional[str] = None) -> Dict:
|
||||
if _servers.get("default_host"):
|
||||
host = _servers["default_host"]
|
||||
_host_defaulted = True
|
||||
backend = (args.get("backend") or "").strip().lower()
|
||||
if not backend and "/" not in repo_id and ":" in repo_id:
|
||||
backend = "ollama"
|
||||
payload = {"repo_id": repo_id}
|
||||
if backend:
|
||||
payload["backend"] = backend
|
||||
if host:
|
||||
payload["remote_host"] = host
|
||||
if args.get("include"):
|
||||
@@ -3028,12 +3127,20 @@ async def do_download_model(content: str, owner: Optional[str] = None) -> Dict:
|
||||
sid = data.get("session_id", "?")
|
||||
registered = await _cookbook_register_task(
|
||||
session_id=sid, model=repo_id, host=host,
|
||||
cmd=f"hf download {repo_id}", task_type="download",
|
||||
cmd=(f"ollama pull {repo_id}" if backend == "ollama" else f"hf download {repo_id}"),
|
||||
task_type="download",
|
||||
)
|
||||
note = "" if registered else " (state-write failed — download may not show in UI)"
|
||||
where = host or "local"
|
||||
default_note = " (defaulted to the cookbook's selected server — pass host= or local=true to override)" if _host_defaulted else ""
|
||||
return {"output": f"Download started: {repo_id} on {where} (session: {sid}){note}{default_note}", "session_id": sid, "host": host, "exit_code": 0}
|
||||
return {
|
||||
"output": f"Download started: {repo_id} on {where} (session: {sid}){note}{default_note}",
|
||||
"session_id": sid,
|
||||
"host": host,
|
||||
"task_type": "download",
|
||||
"phase": "running",
|
||||
"exit_code": 0,
|
||||
}
|
||||
return {"error": data.get("error", "Download failed"), "exit_code": 1}
|
||||
except Exception as e:
|
||||
return {"error": str(e), "exit_code": 1}
|
||||
@@ -3102,12 +3209,28 @@ async def do_serve_model(content: str, owner: Optional[str] = None) -> Dict:
|
||||
data = resp.json()
|
||||
if data.get("ok"):
|
||||
sid = data.get("session_id", "?")
|
||||
endpoint_id = data.get("endpoint_id") or ""
|
||||
if endpoint_id:
|
||||
endpoint_added = True
|
||||
else:
|
||||
endpoint_meta = await _ensure_served_endpoint(model=repo_id, cmd=cmd, host=host)
|
||||
endpoint_added = bool(endpoint_meta.get("added"))
|
||||
endpoint_id = endpoint_meta.get("endpoint_id", "") or endpoint_id
|
||||
registered = await _cookbook_register_task(
|
||||
session_id=sid, model=repo_id,
|
||||
host=host, cmd=cmd, task_type="serve",
|
||||
endpoint_added=endpoint_added, endpoint_id=endpoint_id or "",
|
||||
)
|
||||
note = "" if registered else " (state-write failed — task may not show in UI)"
|
||||
return {"output": f"Serving {repo_id} (session: {sid}){note}", "session_id": sid, "exit_code": 0}
|
||||
return {
|
||||
"output": f"Serving {repo_id} (session: {sid}){note}",
|
||||
"session_id": sid,
|
||||
"task_type": "serve",
|
||||
"phase": "running",
|
||||
"host": host,
|
||||
"endpoint_id": endpoint_id,
|
||||
"exit_code": 0,
|
||||
}
|
||||
# FastAPI HTTPException puts the message under `detail`, not `error`.
|
||||
# Surface BOTH so the agent sees "Invalid characters in cmd" (from
|
||||
# _validate_serve_cmd rejecting `&&`/`source`/`cd`) instead of
|
||||
@@ -3804,7 +3927,8 @@ async def do_serve_preset(content: str, owner: Optional[str] = None) -> Dict:
|
||||
if env_cfg.get("gpus"): payload["gpus"] = env_cfg["gpus"]
|
||||
if env_cfg.get("hf_token"): payload["hf_token"] = env_cfg["hf_token"]
|
||||
if env_cfg.get("platform"): payload["platform"] = env_cfg["platform"]
|
||||
if env_cfg.get("ssh_port"): payload["ssh_port"] = env_cfg["ssh_port"]
|
||||
if env_cfg.get("ssh_port"):
|
||||
payload["ssh_port"] = env_cfg["ssh_port"]
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
@@ -3813,12 +3937,20 @@ async def do_serve_preset(content: str, owner: Optional[str] = None) -> Dict:
|
||||
data = resp.json()
|
||||
if data.get("ok"):
|
||||
sid = data.get("session_id", "?")
|
||||
endpoint_id = data.get("endpoint_id") or ""
|
||||
if endpoint_id:
|
||||
endpoint_added = True
|
||||
else:
|
||||
endpoint_meta = await _ensure_served_endpoint(model=repo_id, cmd=cmd, host=host)
|
||||
endpoint_added = bool(endpoint_meta.get("added"))
|
||||
endpoint_id = endpoint_meta.get("endpoint_id", "") or endpoint_id
|
||||
registered = await _cookbook_register_task(
|
||||
session_id=sid, model=repo_id, host=host,
|
||||
cmd=cmd, task_type="serve",
|
||||
endpoint_added=endpoint_added, endpoint_id=endpoint_id or "",
|
||||
)
|
||||
note = "" if registered else " (state-write failed — task may not show in UI)"
|
||||
return {"output": f"Launched preset {chosen.get('name')!r}: {repo_id} on {host or 'local'} (session: {sid}){note}", "session_id": sid, "exit_code": 0}
|
||||
return {"output": f"Launched preset {chosen.get('name')!r}: {repo_id} on {host or 'local'} (session: {sid}){note}", "session_id": sid, "host": host, "endpoint_id": endpoint_id, "exit_code": 0}
|
||||
return {"error": data.get("error", "Serve failed"), "exit_code": 1}
|
||||
except Exception as e:
|
||||
return {"error": str(e), "exit_code": 1}
|
||||
|
||||
+17
-31
@@ -28,34 +28,11 @@ except ImportError:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Tools that are ALWAYS included regardless of retrieval results.
|
||||
# These are the most commonly needed and should never be missing.
|
||||
# Keep this deliberately tiny. Domain tools (web, documents, email,
|
||||
# cookbook/model serving, files, settings, etc.) are injected by retrieval or
|
||||
# keyword intent so a trivial agent prompt like "test" does not carry every
|
||||
# domain's schemas and rules.
|
||||
ALWAYS_AVAILABLE = frozenset({
|
||||
"bash", "python", "web_search", "web_fetch",
|
||||
# File tools: read AND write/edit. An agent with disk access should always
|
||||
# be able to change files, not just read them — otherwise a bare "edit X"
|
||||
# request can miss write_file/edit_file (RAG-only) and the model wrongly
|
||||
# falls back to edit_document (editor panel). All admin-gated by tool_security.
|
||||
"read_file", "write_file", "edit_file",
|
||||
"grep", "glob", "ls", # code-navigation tools (admin-gated by tool_security)
|
||||
"api_call", # For configured integrations (Miniflux, Gitea, Linkding, etc.)
|
||||
# The two genuinely AMBIENT cookbook tools — "what's running" and
|
||||
# "kill it" can be asked any time without prior cookbook context,
|
||||
# and need to survive typos. The other cookbook tools (downloads,
|
||||
# presets, serve, cached, servers) are CONTEXTUAL — they fire via
|
||||
# keyword hints when the user is actually talking about cookbook.
|
||||
# Keeping the always-on set small leaves room in the ~16-tool
|
||||
# budget for manage_tasks / manage_calendar / etc.
|
||||
"list_served_models", "stop_served_model", "tail_serve_output",
|
||||
# Serving is a core agent capability — keep these always available so
|
||||
# the router doesn't lose them on phrasings like "servic" / "fire up" / "boot".
|
||||
"serve_model", "serve_preset", "list_serve_presets",
|
||||
"list_cached_models", "list_cookbook_servers",
|
||||
# Fallback when serve_model's allowlist rejects a cmd or when the
|
||||
# model was launched out-of-band via bash+tmux — without this the
|
||||
# session is invisible to the cookbook UI even though it's running.
|
||||
"adopt_served_model",
|
||||
# Generic API loopback — the catch-all when no named tool fits.
|
||||
"app_api",
|
||||
# Memory is ambient — "remember this" can follow any message regardless
|
||||
# of topic. Without this, RAG drops it and the agent falls back to
|
||||
# app_api /api/memory/add which fails with 422 on first attempt.
|
||||
@@ -355,6 +332,10 @@ class ToolIndex:
|
||||
r"|\bat\s+\d{1,2}(?::\d{2})?\s*(?:a\.?m\.?|p\.?m\.?)\b", # at 7:30 am / at 7am
|
||||
re.I,
|
||||
)
|
||||
_WEB_RE = re.compile(
|
||||
r"https?://|www\.|\b(?:visit|open|fetch|check|read)\s+(?:this\s+)?(?:url|link|site|website|page)\b",
|
||||
re.I,
|
||||
)
|
||||
|
||||
# Keyword hints: if the query mentions these words, force-include the tools.
|
||||
_KEYWORD_HINTS = {
|
||||
@@ -362,7 +343,7 @@ class ToolIndex:
|
||||
# request (e.g. "visit <url> and tell me the title"), force-including the
|
||||
# whole email toolset and crowding out the relevant tools — the model then
|
||||
# believed it had only email tools and refused web/other tasks (#1707).
|
||||
frozenset({"email", "mail", "gmail", "googlemail", "message", "send", "reply", "inbox", "unread"}):
|
||||
frozenset({"email", "emails", "mail", "mails", "gmail", "googlemail", "message", "messages", "send", "reply", "replies", "inbox", "unread"}):
|
||||
{"list_email_accounts", "list_emails", "read_email", "send_email", "reply_to_email", "bulk_email", "delete_email", "archive_email", "mark_email_read", "resolve_contact", "ui_control"},
|
||||
frozenset({"calendar", "event", "meeting", "schedule", "appointment"}):
|
||||
{"manage_calendar"},
|
||||
@@ -426,14 +407,14 @@ class ToolIndex:
|
||||
# Document edit/update intent
|
||||
frozenset({"edit", "change", "fix", "rewrite", "update",
|
||||
"replace", "add a", "tweak", "modify", "rename", "paragraph",
|
||||
"section", "line", "the doc", "the document", "in the doc"}):
|
||||
"section", "line", "the doc", "the docs", "the document", "the documents", "in the doc", "in the docs", "in document"}):
|
||||
{"edit_document", "update_document", "create_document", "suggest_document"},
|
||||
# Document deletion / management — include generic open/find/read/show
|
||||
# verbs + file/doc synonyms so "open my <X>", "find the <X>", "delete
|
||||
# <X>" reach manage_documents even without the literal word "document".
|
||||
frozenset({"delete this doc", "delete the doc", "delete document",
|
||||
"remove document", "remove the doc", "trash", "list documents",
|
||||
"list docs", "all my docs", "my documents", "my docs", "my files",
|
||||
"remove document", "remove the doc", "trash", "list document", "list documents",
|
||||
"list doc", "list docs", "all my docs", "my document", "my documents", "my doc", "my docs", "my files",
|
||||
"open the", "open my", "open document", "open doc", "find the",
|
||||
"find my", "find document", "read the", "read my", "show me the",
|
||||
"show my", "the file", "my file", "the report", "the write-up",
|
||||
@@ -516,6 +497,11 @@ class ToolIndex:
|
||||
# the agent can actually create the cron job instead of fumbling.
|
||||
if self._SCHEDULE_RE.search(ql):
|
||||
base.add("manage_tasks")
|
||||
# URL/site requests need web tools even when embedding retrieval is
|
||||
# stubbed/unavailable. Keep this structural, not always-on, so trivial
|
||||
# prompts do not drag web schemas into the agent context.
|
||||
if self._WEB_RE.search(query):
|
||||
base.update({"web_search", "web_fetch"})
|
||||
return base
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -406,7 +406,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "ui_control",
|
||||
"description": "Control the user interface. Actions: toggle (turn tools on/off), open_panel (open a modal: documents/library, gallery, email, sessions, notes, memories/brain, skills, settings, cookbook), open_email_reply (open an email reply draft document; does NOT send), set_mode, switch_model, set_theme (presets: dark, light, midnight, paper, nord, monokai, gruvbox, dracula, cyberpunk, retrowave, forest, ocean, ume, copper, terminal, vaporwave, lavender, gpt, coffee, claude), create_theme (CREATE any custom theme with a name + colors object — pick distinctive, evocative hex colors that match the requested aesthetic, NOT generic defaults. The theme auto-applies after creation). When a user asks for ANY theme not in the preset list, ALWAYS use create_theme.",
|
||||
"description": "Control the user interface. Actions: toggle (turn tools on/off), open_panel (open a modal: documents/library, gallery, email, sessions, notes, memories/brain, skills, settings, cookbook), open_email_reply (open an email reply draft document; does NOT send), set_mode, switch_model, set_theme (built-in presets: dark, light, midnight, paper, cyberpunk, retrowave, forest, ocean, ume, copper, terminal, organs, lavender, gpt, claude, cute), create_theme (CREATE any custom theme with a name + colors object — pick distinctive, evocative hex colors that match the requested aesthetic, NOT generic defaults. The theme auto-applies after creation). When a user asks for ANY theme not in the built-in preset list, ALWAYS use create_theme.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -33,6 +33,34 @@ def get_chat_upload_max_bytes() -> int:
|
||||
return read_byte_limit_env(CHAT_UPLOAD_MAX_BYTES_ENV, DEFAULT_CHAT_UPLOAD_MAX_BYTES)
|
||||
|
||||
|
||||
# Per-route upload byte-limits, single-sourced here (issue #3364). Each is
|
||||
# validated + env-overridable via read_byte_limit_env: set the matching
|
||||
# ODYSSEUS_*_MAX_BYTES env var to an integer byte count to tune it; an invalid
|
||||
# value fails fast at import rather than crashing mid-request. Defaults match
|
||||
# the prior per-route values, so behavior is unchanged unless an env var is set.
|
||||
GALLERY_UPLOAD_MAX_BYTES = read_byte_limit_env(
|
||||
"ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES", 100 * 1024 * 1024
|
||||
)
|
||||
GALLERY_TRANSFORM_UPLOAD_MAX_BYTES = read_byte_limit_env(
|
||||
"ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES", 25 * 1024 * 1024
|
||||
)
|
||||
MEMORY_IMPORT_MAX_BYTES = read_byte_limit_env(
|
||||
"ODYSSEUS_MEMORY_IMPORT_MAX_BYTES", 10 * 1024 * 1024
|
||||
)
|
||||
PERSONAL_UPLOAD_MAX_BYTES = read_byte_limit_env(
|
||||
"ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES", 25 * 1024 * 1024
|
||||
)
|
||||
EMAIL_COMPOSE_UPLOAD_MAX_BYTES = read_byte_limit_env(
|
||||
"ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES", 25 * 1024 * 1024
|
||||
)
|
||||
STT_MAX_AUDIO_BYTES = read_byte_limit_env(
|
||||
"ODYSSEUS_STT_MAX_AUDIO_BYTES", 25 * 1024 * 1024
|
||||
)
|
||||
ICS_MAX_BYTES = read_byte_limit_env(
|
||||
"ODYSSEUS_ICS_MAX_BYTES", 10 * 1024 * 1024
|
||||
)
|
||||
|
||||
|
||||
async def read_upload_limited(upload: UploadFile, limit: int, label: str = "Upload") -> bytes:
|
||||
"""Read an UploadFile with a hard byte cap."""
|
||||
data = await upload.read(limit + 1)
|
||||
|
||||
+3
-87
@@ -4,7 +4,6 @@
|
||||
// ============================================
|
||||
import Storage from './js/storage.js';
|
||||
import uiModule from './js/ui.js';
|
||||
import workspaceModule from './js/workspace.js';
|
||||
import fileHandlerModule from './js/fileHandler.js';
|
||||
import modelsModule from './js/models.js';
|
||||
import ragModule from './js/rag.js';
|
||||
@@ -1555,7 +1554,6 @@ function initializeEventListeners() {
|
||||
const MODE_TOOLS = [
|
||||
{ btnId: 'web-toggle-btn', checkboxId: 'web-toggle', stateKey: 'web' },
|
||||
{ btnId: 'bash-toggle-btn', checkboxId: 'bash-toggle', stateKey: 'bash' },
|
||||
{ btnId: 'plan-toggle-btn', checkboxId: 'plan-toggle', stateKey: 'plan' },
|
||||
];
|
||||
|
||||
function _modeKey(stateKey, mode) { return `${stateKey}_${mode}`; }
|
||||
@@ -1564,9 +1562,6 @@ function initializeEventListeners() {
|
||||
const state = loadToggleState();
|
||||
const key = _modeKey(stateKey, mode);
|
||||
if (Object.prototype.hasOwnProperty.call(state, key)) return !!state[key];
|
||||
// Plan mode is opt-in: never default it on, otherwise every agent turn
|
||||
// would be forced into planning.
|
||||
if (stateKey === 'plan') return false;
|
||||
return mode === 'agent'; // default: ON in agent, OFF in chat
|
||||
}
|
||||
|
||||
@@ -1579,7 +1574,6 @@ function initializeEventListeners() {
|
||||
const TOOL_TOGGLE_TOAST_LABELS = {
|
||||
web: 'Web search',
|
||||
bash: 'Shell',
|
||||
plan: 'Plan mode',
|
||||
};
|
||||
|
||||
function showToolToggleToast(stateKey, active) {
|
||||
@@ -1592,8 +1586,8 @@ function initializeEventListeners() {
|
||||
MODE_TOOLS.forEach(({ btnId, checkboxId, stateKey }) => {
|
||||
const btn = el(btnId);
|
||||
if (!btn) return;
|
||||
// Hide bash and plan buttons in chat mode
|
||||
if (mode === 'chat' && (stateKey === 'bash' || stateKey === 'plan')) {
|
||||
// Hide bash button in chat mode
|
||||
if (mode === 'chat' && stateKey === 'bash') {
|
||||
btn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
@@ -1614,12 +1608,10 @@ function initializeEventListeners() {
|
||||
const state = loadToggleState();
|
||||
let currentMode = state.mode || 'chat';
|
||||
|
||||
// Immediately hide bash/plan buttons in chat mode on page load
|
||||
// Immediately hide bash button in chat mode on page load
|
||||
if (currentMode === 'chat') {
|
||||
const bashBtn = el('bash-toggle-btn');
|
||||
const planBtn = el('plan-toggle-btn');
|
||||
if (bashBtn) bashBtn.style.display = 'none';
|
||||
if (planBtn) planBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
function setMode(mode) {
|
||||
@@ -1709,82 +1701,6 @@ function initializeEventListeners() {
|
||||
}
|
||||
setupToggle('web-toggle-btn', 'web-toggle', 'web');
|
||||
setupToggle('bash-toggle-btn', 'bash-toggle', 'bash');
|
||||
try { workspaceModule.initWorkspace(); } catch (_) {}
|
||||
setupToggle('plan-toggle-btn', 'plan-toggle', 'plan');
|
||||
|
||||
// Set plan mode on/off directly (checkbox + button state + saved pref) WITHOUT
|
||||
// going through the button's click handler — used by the plan menu and by the
|
||||
// "Approve & Run" flow. Going through .click() would hit the plan-menu
|
||||
// intercept below (a stored plan re-opens the menu instead of toggling), which
|
||||
// is exactly the bug that left approved plans stuck in plan mode.
|
||||
function _setPlanMode(on) {
|
||||
const btn = el('plan-toggle-btn');
|
||||
const chk = el('plan-toggle');
|
||||
const mode = (loadToggleState().mode) || 'chat';
|
||||
if (chk) chk.checked = !!on;
|
||||
if (btn) { btn.classList.toggle('active', !!on); btn.setAttribute('aria-pressed', String(!!on)); }
|
||||
saveToolPref('plan', mode, !!on);
|
||||
}
|
||||
window._setPlanMode = _setPlanMode;
|
||||
|
||||
// ── Plan-button menu ──
|
||||
// When a plan exists for this chat, clicking the plan button opens a small
|
||||
// menu (Show plan / Plan mode on-off) instead of plain-toggling — so the plan
|
||||
// window can be re-opened and docked at any time while the agent works. With
|
||||
// no plan, the button behaves as before (one-click toggle).
|
||||
(function initPlanMenu() {
|
||||
const planBtn = el('plan-toggle-btn');
|
||||
if (!planBtn) return;
|
||||
const _hasPlan = () => { try { return !!(window._getStoredPlan && window._getStoredPlan()); } catch (_) { return false; } };
|
||||
const _close = () => { const m = document.getElementById('plan-menu'); if (m) m.remove(); };
|
||||
function _open() {
|
||||
_close();
|
||||
const planChk = el('plan-toggle');
|
||||
const on = !!(planChk && planChk.checked);
|
||||
const menu = document.createElement('div');
|
||||
menu.id = 'plan-menu';
|
||||
menu.className = 'overflow-menu plan-menu';
|
||||
menu.innerHTML =
|
||||
'<button type="button" class="overflow-menu-item" data-act="show">'
|
||||
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>'
|
||||
+ '<span>Show plan</span></button>'
|
||||
+ '<button type="button" class="overflow-menu-item" data-act="toggle">'
|
||||
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>'
|
||||
+ '<span>Plan mode: ' + (on ? 'On' : 'Off') + '</span></button>';
|
||||
document.body.appendChild(menu);
|
||||
const r = planBtn.getBoundingClientRect();
|
||||
menu.style.position = 'fixed';
|
||||
menu.style.left = Math.round(r.left) + 'px';
|
||||
menu.style.top = Math.round(r.top - menu.offsetHeight - 6) + 'px';
|
||||
menu.querySelector('[data-act="show"]').addEventListener('click', () => {
|
||||
_close();
|
||||
const txt = window._getStoredPlan ? window._getStoredPlan() : '';
|
||||
if (txt && window.planWindowModule) window.planWindowModule.openPlanWindow(txt, null);
|
||||
});
|
||||
menu.querySelector('[data-act="toggle"]').addEventListener('click', () => {
|
||||
_close();
|
||||
_setPlanMode(!on); // flip state directly (no click → no menu re-open)
|
||||
});
|
||||
// Dismiss on any outside click (capture so it beats other handlers) / Escape.
|
||||
setTimeout(() => {
|
||||
const off = (e) => {
|
||||
if (!menu.contains(e.target) && e.target !== planBtn) {
|
||||
_close(); document.removeEventListener('click', off, true); document.removeEventListener('keydown', esc, true);
|
||||
}
|
||||
};
|
||||
const esc = (e) => { if (e.key === 'Escape') { _close(); document.removeEventListener('click', off, true); document.removeEventListener('keydown', esc, true); } };
|
||||
document.addEventListener('click', off, true);
|
||||
document.addEventListener('keydown', esc, true);
|
||||
}, 0);
|
||||
}
|
||||
planBtn.addEventListener('click', (e) => {
|
||||
// With a stored plan, the button opens the menu (Show plan / toggle).
|
||||
// Without one, it falls through to the normal one-click toggle.
|
||||
if (_hasPlan()) { e.preventDefault(); e.stopImmediatePropagation(); _open(); }
|
||||
}, true); // capture phase: intercept before setupToggle's bubble handler
|
||||
})();
|
||||
|
||||
try { workspaceModule.initWorkspace(); } catch (_) {}
|
||||
|
||||
// Document editor toggle (special: uses module panel, not a checkbox)
|
||||
const overflowDocBtn = el('overflow-doc-btn');
|
||||
|
||||
+92
-62
@@ -1040,13 +1040,6 @@
|
||||
<span>RAG</span>
|
||||
<span class="overflow-active-dot"></span>
|
||||
</button>
|
||||
<button type="button" class="overflow-menu-item" id="overflow-workspace-btn">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||
</svg>
|
||||
<span>Workspace</span>
|
||||
<span class="overflow-active-dot"></span>
|
||||
</button>
|
||||
<!-- Inline "deep research mode" toggle removed (superseded by the
|
||||
Deep Research sidebar / trigger_research). The hidden
|
||||
#research-toggle checkbox is kept inert so existing JS refs
|
||||
@@ -1078,18 +1071,6 @@
|
||||
<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Workspace indicator (hidden until a folder is set) -->
|
||||
<button type="button" class="input-icon-btn tool-indicator" title="Workspace — click to clear" id="workspace-indicator-btn" aria-label="Clear workspace" style="display:none;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
|
||||
<span style="font-size:11px;margin-left:2px;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" id="workspace-indicator-name"></span>
|
||||
<svg class="tool-indicator-x" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
|
||||
</button>
|
||||
<!-- Plan mode (investigate read-only, propose a plan to approve) -->
|
||||
<button type="button" class="input-icon-btn" title="Plan mode — investigate read-only, then propose a plan to approve" id="plan-toggle-btn" data-mode-tool="true">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- RAG toolbar indicator (hidden until active) -->
|
||||
<button type="button" class="input-icon-btn tool-indicator" title="RAG active — click to deactivate" id="rag-indicator-btn" style="display:none;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -1138,7 +1119,6 @@
|
||||
<!-- Hidden checkboxes for state -->
|
||||
<input type="checkbox" id="web-toggle" style="display:none;">
|
||||
<input type="checkbox" id="bash-toggle" style="display:none;">
|
||||
<input type="checkbox" id="plan-toggle" style="display:none;">
|
||||
</div>
|
||||
<form id="chat-form" autocomplete="off" action="javascript:void(0);" style="display:none;"></form>
|
||||
|
||||
@@ -1499,21 +1479,7 @@
|
||||
<div id="set-researchMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-card">
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>Agent</h2>
|
||||
<div class="admin-toggle-sub" style="margin-bottom:8px">Controls for the agent tool loop.</div>
|
||||
<div class="settings-col">
|
||||
<div class="settings-row">
|
||||
<label class="settings-label">Tool call limit</label>
|
||||
<input id="set-agentMaxTools" type="text" inputmode="numeric" placeholder="0 = unlimited" class="settings-select" style="width:120px;">
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label class="settings-label">Max steps per message</label>
|
||||
<input id="set-agentMaxRounds" type="text" inputmode="numeric" placeholder="20" class="settings-select" style="width:120px;">
|
||||
</div>
|
||||
<div id="set-agentMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Agent card moved to the Agent Tools tab. -->
|
||||
<!-- Image Generation removed — only inpaint remains in this build,
|
||||
and inpaint is configured via the gallery editor not this card.
|
||||
Keeping the DOM (hidden) so JS wiring against the inputs
|
||||
@@ -2055,30 +2021,37 @@
|
||||
<div class="admin-model-form">
|
||||
<div class="admin-model-form-row">
|
||||
<input id="adm-epLocalUrl" type="text" placeholder="Paste endpoint URL, e.g. http://localhost:11434/v1" style="flex:1">
|
||||
<select id="adm-epLocalType" style="padding:5px;width:72px;flex-shrink:0;">
|
||||
<option value="llm">LLM</option>
|
||||
<option value="image">Image</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="admin-model-form-row">
|
||||
<!-- API key row stays in the DOM but is collapsed until the
|
||||
user clicks the Key button on the action row. Local
|
||||
endpoints rarely need a key; hiding it by default keeps
|
||||
the form a single visual line. -->
|
||||
<div class="admin-model-form-row" id="adm-epLocalApiKey-row" style="display:none;">
|
||||
<input id="adm-epLocalApiKey" type="password" placeholder="API key (optional — for protected local endpoints)" autocomplete="off" style="flex:1">
|
||||
</div>
|
||||
<!-- Action row: LLM/Image type, Quickstart buttons (Scan,
|
||||
Ollama), Key reveal toggle, Test, Add — all inline so
|
||||
the Quickstart fold is gone and Type sits with the
|
||||
primary actions. -->
|
||||
<div class="admin-model-form-row">
|
||||
<label style="display:inline-flex;align-items:center;gap:4px;font-size:11px;opacity:0.6;flex-shrink:0;">Type:<select id="adm-epLocalType" style="padding:5px;width:72px;flex-shrink:0;">
|
||||
<option value="llm" selected>LLM</option>
|
||||
<option value="image">Image</option>
|
||||
</select></label>
|
||||
<button class="admin-btn-sm" id="adm-epDiscoverBtn" title="Scan your network for running model servers" style="display:inline-flex;align-items:center;gap:4px;">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>Scan
|
||||
</button>
|
||||
<button class="admin-btn-sm" id="adm-epOllamaBtn" title="Fill the default Ollama endpoint" style="display:inline-flex;align-items:center;gap:5px;"><span class="adm-ollama-logo" style="display:inline-flex;width:13px;height:13px;"></span>Ollama</button>
|
||||
<span style="flex:1"></span>
|
||||
<button class="admin-btn-sm" id="adm-epLocalTestBtn" style="width:55px;text-align:center;">Test</button>
|
||||
<button class="admin-btn-add" id="adm-epLocalAddBtn" style="width:55px;text-align:center;">Add</button>
|
||||
</div>
|
||||
<div class="adm-quickstart-section collapsed" id="adm-add-local-quickstart">
|
||||
<div class="adm-quickstart-toggle" role="button" tabindex="0" aria-expanded="false">
|
||||
<span>Quickstart</span>
|
||||
<svg class="adm-section-caret" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</div>
|
||||
<div class="adm-quickstart-body">
|
||||
<button class="admin-btn-sm" id="adm-epDiscoverBtn" title="Scan your network for running model servers">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="vertical-align:-1px;margin-right:4px;"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>Scan for Servers
|
||||
</button>
|
||||
<button class="admin-btn-sm" id="adm-epOllamaBtn" title="Fill the default Ollama endpoint">Ollama</button>
|
||||
</div>
|
||||
<button class="admin-btn-sm" id="adm-epLocalKeyBtn" title="Show / hide the API key field" aria-expanded="false" aria-controls="adm-epLocalApiKey-row" style="opacity:0.75;display:inline-flex;align-items:center;gap:4px;">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2l-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/><path d="M15.5 7.5l3 3"/></svg>API
|
||||
</button>
|
||||
<button class="admin-btn-sm" id="adm-epLocalTestBtn" style="min-width:55px;text-align:center;display:inline-flex;align-items:center;justify-content:center;gap:4px;">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>Test
|
||||
</button>
|
||||
<button class="admin-btn-add" id="adm-epLocalAddBtn" style="min-width:55px;text-align:center;display:inline-flex;align-items:center;justify-content:center;gap:4px;">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>Add
|
||||
</button>
|
||||
</div>
|
||||
<div id="adm-epLocalMsg" class="adm-ep-inline-msg"></div>
|
||||
</div>
|
||||
@@ -2123,19 +2096,33 @@
|
||||
<option value="https://opencode.ai/zen/go/v1" data-logo="opencode">OpenCode Go</option>
|
||||
<option value="https://api.z.ai/api/coding/paas/v4" data-logo="zhipu">Z.AI Coding Plan</option>
|
||||
</select>
|
||||
<div class="admin-model-form-row">
|
||||
<input id="adm-epApiKey" type="password" placeholder="API key">
|
||||
<!-- API key row stays in DOM, hidden until Key button is
|
||||
clicked. Mirrors the Local section pattern: most users
|
||||
paste a key via the provider preset flow rather than
|
||||
typing it free-form, so the row only appears on demand. -->
|
||||
<div class="admin-model-form-row" id="adm-epApiKey-row" style="display:none;">
|
||||
<input id="adm-epApiKey" type="password" placeholder="API key" autocomplete="off" style="flex:1">
|
||||
</div>
|
||||
<div class="admin-model-form-row" style="margin-top:-4px;">
|
||||
<select id="adm-epKind" style="padding:5px;width:82px;">
|
||||
<option value="proxy">Proxy</option>
|
||||
<option value="api">API</option>
|
||||
</select>
|
||||
<select id="adm-epType" style="padding:5px;width:80px;">
|
||||
<option value="llm">LLM</option>
|
||||
<label style="display:inline-flex;align-items:center;gap:4px;font-size:11px;opacity:0.6;flex-shrink:0;">Type:<select id="adm-epType" style="padding:5px;width:80px;flex-shrink:0;">
|
||||
<option value="llm" selected>LLM</option>
|
||||
<option value="image">Image</option>
|
||||
</select>
|
||||
<button class="admin-btn-sm" id="adm-epApiTestBtn" style="width:55px;text-align:center;">Test</button>
|
||||
</select></label>
|
||||
<span style="flex:1"></span>
|
||||
<button class="admin-btn-sm" id="adm-epApiKeyBtn" title="Show / hide the API key field" aria-expanded="false" aria-controls="adm-epApiKey-row" style="opacity:0.75;display:inline-flex;align-items:center;gap:4px;">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2l-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/><path d="M15.5 7.5l3 3"/></svg>API
|
||||
</button>
|
||||
<button class="admin-btn-sm" id="adm-epApiTestBtn" style="min-width:55px;text-align:center;display:inline-flex;align-items:center;justify-content:center;gap:4px;">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>Test
|
||||
</button>
|
||||
<button class="admin-btn-sm hidden" id="adm-epApiCancelTestBtn" style="width:62px;text-align:center;">Cancel</button>
|
||||
<button class="admin-btn-add" id="adm-epAddBtn" style="width:55px;text-align:center;">Add</button>
|
||||
<button class="admin-btn-add" id="adm-epAddBtn" style="min-width:55px;text-align:center;display:inline-flex;align-items:center;justify-content:center;gap:4px;">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>Add
|
||||
</button>
|
||||
</div>
|
||||
<div id="adm-epApiMsg" class="adm-ep-inline-msg"></div>
|
||||
<div id="adm-deviceAuthStatus" class="adm-ep-inline-msg"></div>
|
||||
@@ -2143,7 +2130,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-card">
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>Added Models <span style="opacity:0.45;font-weight:normal;font-size:0.82em">(Endpoints)</span></h2>
|
||||
<h2 style="display:flex;align-items:center;gap:8px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>Added Models <span style="opacity:0.45;font-weight:normal;font-size:0.82em">(Endpoints)</span>
|
||||
<span style="flex:1"></span>
|
||||
<button class="admin-btn-sm" id="adm-epProbeAllBtn" title="Re-test every endpoint and refresh online status" style="font-size:11px;font-weight:normal;display:inline-flex;align-items:center;gap:4px;">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>Probe
|
||||
</button>
|
||||
<button class="admin-btn-sm" id="adm-epClearOfflineBtn" title="Remove all endpoints currently marked offline" style="font-size:11px;font-weight:normal;display:inline-flex;align-items:center;gap:4px;opacity:0.85;">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>Clear offline <span id="adm-epOfflineCount" style="opacity:0.6;margin-left:2px;"></span>
|
||||
</button>
|
||||
</h2>
|
||||
<div class="admin-toggle-sub" style="margin-bottom:10px">Manage the endpoints you've added.</div>
|
||||
<div class="adm-ep-section">
|
||||
<div class="adm-ep-section-head">
|
||||
@@ -2174,10 +2169,45 @@
|
||||
<button type="button" class="admin-btn-sm" id="unified-intg-add-btn" style="display:inline-flex;align-items:center;gap:6px;">+ Add Integration<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.7;"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-card admin-only" style="margin-top:12px;">
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>API Tokens</h2>
|
||||
<div class="admin-toggle-sub" style="margin-bottom:8px">Bearer tokens for external integrations (scripts, Codex, headless agent runs). Token value shown ONCE on create — copy it then.</div>
|
||||
<div id="adm-tokenList" style="margin-bottom:8px;"></div>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:flex-start;">
|
||||
<input type="text" id="adm-tokenName" placeholder="Token name (e.g. agent-test)" class="settings-select" style="flex:1;min-width:160px;">
|
||||
<input type="text" id="adm-tokenScopes" placeholder="scopes (comma-separated, blank = chat)" class="settings-select" style="flex:2;min-width:220px;" title="Allowed: chat, cookbook:read, cookbook:launch, documents:read|write, todos:read|write, email:read|draft|send, calendar:read|write, memory:read|write">
|
||||
<button class="admin-btn-add" id="adm-tokenAddBtn">Create token</button>
|
||||
</div>
|
||||
<div id="adm-tokenMsg" style="font-size:11px;margin-top:6px;"></div>
|
||||
<div id="adm-tokenReveal" style="display:none;margin-top:8px;padding:8px 10px;background:color-mix(in srgb, var(--accent, var(--red)) 12%, transparent);border:1px solid color-mix(in srgb, var(--accent, var(--red)) 35%, transparent);border-radius:6px;">
|
||||
<div style="font-size:11px;font-weight:600;margin-bottom:4px;">Copy now — this is the only time you'll see it:</div>
|
||||
<code id="adm-tokenValue" style="font-family:'Berkeley Mono','SF Mono','Fira Code',monospace;font-size:11px;word-break:break-all;display:block;background:var(--bg);padding:6px 8px;border-radius:4px;margin-bottom:6px;user-select:all;"></code>
|
||||
<button class="admin-btn-sm" id="adm-tokenCopyBtn">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ TOOLS TAB ═══ -->
|
||||
<div data-settings-panel="tools" class="hidden">
|
||||
<div class="admin-card" style="margin-bottom:12px;">
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>Agent</h2>
|
||||
<div class="admin-toggle-sub" style="margin-bottom:8px">Controls for the agent tool loop.</div>
|
||||
<div class="settings-col">
|
||||
<div class="settings-row">
|
||||
<label class="settings-label">Tool call limit</label>
|
||||
<input id="set-agentMaxTools" type="text" inputmode="numeric" placeholder="0 = unlimited" class="settings-select" style="width:120px;">
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label class="settings-label">Max steps per message</label>
|
||||
<input id="set-agentMaxRounds" type="text" inputmode="numeric" placeholder="20" class="settings-select" style="width:120px;">
|
||||
</div>
|
||||
<div id="set-agentMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-card" style="margin-bottom:12px;">
|
||||
<h2 style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:1px;opacity:0.6;flex-shrink:0"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>Agent loop<span style="flex:1"></span><label class="admin-switch" title="On a failing effectful turn, climb verify → different-method → teacher → stop-and-summarize instead of silently quitting." style="flex-shrink:0"><input type="checkbox" id="set-agentSupervisorLadder"><span class="admin-slider"></span></label></h2>
|
||||
<div class="admin-toggle-sub" style="margin-bottom:8px">Supervisor ladder. When on, every effectful agent turn that claims done is verified; on FAIL the ladder escalates verify → different method → teacher → stop-with-blocker, each rung visible in chat. Teacher rung requires <code>teacher_model</code> to be set.</div>
|
||||
</div>
|
||||
<div class="admin-card" style="margin-bottom:12px;">
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>Built-in Tools</h2>
|
||||
<div class="admin-toggle-sub" style="margin-bottom:8px">Enable or disable tools available to the AI agent.</div>
|
||||
|
||||
+153
-3
@@ -1149,6 +1149,144 @@ function initEndpointForm() {
|
||||
}
|
||||
}
|
||||
|
||||
// API Key reveal toggle. The key inputs are hidden by default so the Add
|
||||
// form reads as a single action row; the Key button toggles the input row
|
||||
// and flips aria-expanded for screen readers / CSS pseudo-classes.
|
||||
const _wireKeyToggle = (btnId, rowId) => {
|
||||
const btn = el(btnId);
|
||||
const row = el(rowId);
|
||||
if (!btn || !row) return;
|
||||
btn.addEventListener('click', () => {
|
||||
const showing = row.style.display !== 'none';
|
||||
row.style.display = showing ? 'none' : '';
|
||||
btn.setAttribute('aria-expanded', showing ? 'false' : 'true');
|
||||
btn.style.opacity = showing ? '0.75' : '1';
|
||||
if (!showing) {
|
||||
const inp = row.querySelector('input');
|
||||
if (inp) inp.focus();
|
||||
}
|
||||
});
|
||||
};
|
||||
_wireKeyToggle('adm-epLocalKeyBtn', 'adm-epLocalApiKey-row');
|
||||
_wireKeyToggle('adm-epApiKeyBtn', 'adm-epApiKey-row');
|
||||
|
||||
// ── Added Models toolbar: Probe + Clear offline ────────────────────
|
||||
// Both buttons act over the currently-rendered endpoint list. The
|
||||
// online/offline marker is stamped on each row's [data-adm-ep-online]
|
||||
// attribute by loadEndpoints(), so both buttons just iterate the DOM
|
||||
// without re-fetching anything they don't already have.
|
||||
const _refreshOfflineCount = () => {
|
||||
const lbl = el('adm-epOfflineCount');
|
||||
if (!lbl) return;
|
||||
const n = document.querySelectorAll('[data-adm-ep-id] [data-adm-ep-online="0"]').length;
|
||||
lbl.textContent = n > 0 ? `(${n})` : '';
|
||||
// Keep the button enabled even when there are no offline rows — a
|
||||
// click on the empty case fires a toast instead of feeling dead.
|
||||
const btn = el('adm-epClearOfflineBtn');
|
||||
if (btn) btn.style.opacity = n === 0 ? '0.55' : '0.85';
|
||||
};
|
||||
// Wire after every loadEndpoints() run by patching the render hook —
|
||||
// simplest path: MutationObserver on the two list containers.
|
||||
const _obsRoots = ['adm-epList-local', 'adm-epList-api']
|
||||
.map(id => el(id)).filter(Boolean);
|
||||
if (_obsRoots.length) {
|
||||
const mo = new MutationObserver(_refreshOfflineCount);
|
||||
_obsRoots.forEach(r => mo.observe(r, { childList: true, subtree: true }));
|
||||
_refreshOfflineCount();
|
||||
}
|
||||
|
||||
const probeAllBtn = el('adm-epProbeAllBtn');
|
||||
if (probeAllBtn) {
|
||||
probeAllBtn.addEventListener('click', async () => {
|
||||
probeAllBtn.disabled = true;
|
||||
const origHTML = probeAllBtn.innerHTML;
|
||||
probeAllBtn.innerHTML = '<span style="opacity:0.7;">Probing…</span>';
|
||||
try {
|
||||
// Hit the bulk local probe (same one the model picker uses).
|
||||
await fetch('/api/model-endpoints/probe-local', { credentials: 'same-origin' }).catch(() => {});
|
||||
// Then per-endpoint /probe for the rest so API/cloud endpoints
|
||||
// refresh too. Parallel — capped to 6 at a time so we don't
|
||||
// hammer the backend on a big list.
|
||||
const ids = Array.from(document.querySelectorAll('[data-adm-ep-id]')).map(r => r.getAttribute('data-adm-ep-id')).filter(Boolean);
|
||||
const lane = async (id) => {
|
||||
try { await fetch(`/api/model-endpoints/${id}/probe`, { credentials: 'same-origin' }); } catch (_) {}
|
||||
};
|
||||
const queue = [...ids];
|
||||
const workers = Array.from({length: Math.min(6, queue.length)}, () => (async () => {
|
||||
while (queue.length) {
|
||||
const id = queue.shift();
|
||||
if (id) await lane(id);
|
||||
}
|
||||
})());
|
||||
await Promise.all(workers);
|
||||
await loadEndpoints();
|
||||
if (uiModule && uiModule.showToast) uiModule.showToast('Endpoint status refreshed', 1800);
|
||||
} finally {
|
||||
probeAllBtn.innerHTML = origHTML;
|
||||
probeAllBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const clearOfflineBtn = el('adm-epClearOfflineBtn');
|
||||
if (clearOfflineBtn) {
|
||||
clearOfflineBtn.addEventListener('click', async () => {
|
||||
const offlineBtns = Array.from(document.querySelectorAll('[data-adm-del-ep][data-adm-ep-online="0"]'));
|
||||
const ids = offlineBtns.map(b => b.getAttribute('data-adm-del-ep')).filter(Boolean);
|
||||
if (!ids.length) {
|
||||
if (uiModule && uiModule.showToast) {
|
||||
uiModule.showToast('No offline endpoints — nothing to clear', 1800);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const confirmMsg = ids.length === 1
|
||||
? 'Remove 1 offline endpoint?'
|
||||
: `Remove ${ids.length} offline endpoints?`;
|
||||
if (uiModule && uiModule.styledConfirm) {
|
||||
const ok = await uiModule.styledConfirm(confirmMsg, { confirmText: 'Remove', danger: true });
|
||||
if (!ok) return;
|
||||
} else if (!confirm(confirmMsg)) {
|
||||
return;
|
||||
}
|
||||
clearOfflineBtn.disabled = true;
|
||||
// Optimistic UI: pull rows immediately, then fire the DELETEs.
|
||||
offlineBtns.forEach(b => {
|
||||
const row = b.closest('[data-adm-ep-id]');
|
||||
if (row) row.remove();
|
||||
});
|
||||
await Promise.all(ids.map(id =>
|
||||
fetch('/api/model-endpoints/' + id, { method: 'DELETE', credentials: 'same-origin' }).catch(() => {})
|
||||
));
|
||||
try { await loadEndpoints(); } catch (_) {}
|
||||
_refreshOfflineCount();
|
||||
if (uiModule && uiModule.showToast) uiModule.showToast(`Removed ${ids.length} offline endpoint${ids.length === 1 ? '' : 's'}`, 1800);
|
||||
});
|
||||
}
|
||||
|
||||
// Clear-on-focus for the API key inputs. The fields are type=password so the
|
||||
// value is masked; users can't see what's there to edit it in place, so the
|
||||
// expected gesture is "click in, type new key". Wiping on focus removes the
|
||||
// select-all-and-delete dance.
|
||||
const _wireClearOnFocus = (id) => {
|
||||
const inp = el(id);
|
||||
if (!inp) return;
|
||||
inp.addEventListener('focus', () => {
|
||||
if (inp.value) inp.value = '';
|
||||
});
|
||||
};
|
||||
_wireClearOnFocus('adm-epLocalApiKey');
|
||||
_wireClearOnFocus('adm-epApiKey');
|
||||
|
||||
// Drop the Ollama provider logo into the Ollama Quickstart button. Reuses
|
||||
// the same SVG the provider picker uses, so brand parity stays free.
|
||||
try {
|
||||
const _ollamaLogoSlot = document.querySelector('#adm-epOllamaBtn .adm-ollama-logo');
|
||||
if (_ollamaLogoSlot) {
|
||||
const svg = providerLogo('ollama') || '';
|
||||
if (svg) _ollamaLogoSlot.innerHTML = svg;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Local "Add" button — sibling form for self-hosted base URLs.
|
||||
const localAddBtn = el('adm-epLocalAddBtn');
|
||||
const localTestBtn = el('adm-epLocalTestBtn');
|
||||
@@ -2073,17 +2211,28 @@ async function loadTokens() {
|
||||
}
|
||||
|
||||
function initTokenForm() {
|
||||
el('adm-tokenAddBtn').addEventListener('click', async () => {
|
||||
const addBtn = el('adm-tokenAddBtn');
|
||||
if (!addBtn || addBtn.dataset.bound) return;
|
||||
addBtn.dataset.bound = '1';
|
||||
addBtn.addEventListener('click', async () => {
|
||||
const msg = el('adm-tokenMsg');
|
||||
const reveal = el('adm-tokenReveal');
|
||||
msg.textContent = ''; msg.className = ''; reveal.style.display = 'none';
|
||||
const name = el('adm-tokenName').value.trim();
|
||||
if (!name) { msg.textContent = 'Token name is required'; msg.className = 'admin-error'; return; }
|
||||
const fd = new FormData(); fd.append('name', name);
|
||||
const scopes = (el('adm-tokenScopes')?.value || '').trim();
|
||||
if (scopes) fd.append('scopes', scopes);
|
||||
try {
|
||||
const res = await fetch('/api/tokens', { method: 'POST', body: fd, credentials: 'same-origin' });
|
||||
const data = await res.json();
|
||||
if (res.ok) { el('adm-tokenValue').textContent = data.token; reveal.style.display = ''; el('adm-tokenName').value = ''; loadTokens(); }
|
||||
if (res.ok) {
|
||||
el('adm-tokenValue').textContent = data.token;
|
||||
reveal.style.display = '';
|
||||
el('adm-tokenName').value = '';
|
||||
if (el('adm-tokenScopes')) el('adm-tokenScopes').value = '';
|
||||
loadTokens();
|
||||
}
|
||||
else { msg.textContent = data.detail || 'Failed'; msg.className = 'admin-error'; }
|
||||
} catch (e) { msg.textContent = 'Request failed'; msg.className = 'admin-error'; }
|
||||
});
|
||||
@@ -2344,7 +2493,7 @@ function initDangerZone() {
|
||||
═══════════════════════════════════════════ */
|
||||
function initAll() {
|
||||
modalEl = el('settings-modal');
|
||||
const inits = [initSignupToggle, initAddUser, initEndpointForm, initMcpForm, initCalDAV, initBackup, initDangerZone, () => settingsModule.initIntegrations()];
|
||||
const inits = [initSignupToggle, initAddUser, initEndpointForm, initMcpForm, initCalDAV, initBackup, initDangerZone, initTokenForm, () => settingsModule.initIntegrations()];
|
||||
for (const fn of inits) {
|
||||
try { fn(); } catch (e) { console.error('Admin init error in', fn.name || 'anonymous', e); }
|
||||
}
|
||||
@@ -2357,6 +2506,7 @@ function refreshAll() {
|
||||
loadEndpoints();
|
||||
loadBuiltinTools();
|
||||
loadMcpServers();
|
||||
loadTokens();
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
|
||||
@@ -13,7 +13,6 @@ import chatStream from './chatStream.js';
|
||||
import { addAITTSButton } from './tts-ai.js';
|
||||
import markdownModule from './markdown.js';
|
||||
import { svgifyEmoji } from './markdown.js';
|
||||
import planWindowModule from './planWindow.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import presetsModule from './presets.js';
|
||||
import fileHandlerModule from './fileHandler.js';
|
||||
@@ -111,35 +110,6 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
let _streamSessionId = null; // Session ID for the currently active reader loop
|
||||
let _lastReaderActivity = 0; // Timestamp of last reader.read() success — used to detect frozen streams
|
||||
let _webLockRelease = null; // Function to release the Web Lock held during streaming
|
||||
let _forcePlanOff = false; // One-shot: suppress plan_mode for the next send (Approve & Run)
|
||||
|
||||
// ── Plan store: the latest proposed/approved checklist for the CURRENT chat ──
|
||||
// Kept so (a) it can be sent back each turn and pinned in context (a long plan
|
||||
// on a weak model survives history truncation), and (b) the plan window can be
|
||||
// re-opened/docked at any time via the plan-button menu. Stored per session in
|
||||
// localStorage so it survives a reload mid-execution.
|
||||
function _setStoredPlan(text) {
|
||||
const sid = sessionModule.getCurrentSessionId();
|
||||
if (!sid || !text || !text.trim()) return;
|
||||
Storage.setJSON(Storage.KEYS.PLAN, { sid, text });
|
||||
// Live-refresh the plan window if it's open (shows progress as the agent
|
||||
// restates the checklist with [x]).
|
||||
try {
|
||||
if (planWindowModule.isPlanWindowOpen && planWindowModule.isPlanWindowOpen()) {
|
||||
planWindowModule.openPlanWindow(text, null);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
function _getStoredPlan() {
|
||||
const sid = sessionModule.getCurrentSessionId();
|
||||
const rec = Storage.getJSON(Storage.KEYS.PLAN, null);
|
||||
return (rec && rec.sid === sid && rec.text) ? rec.text : '';
|
||||
}
|
||||
// A line like "- [ ] step" / "- [x] step" marks a GitHub-style checklist.
|
||||
const _CHECKLIST_RE = /^\s*[-*]\s+\[[ xX]\]\s+/m;
|
||||
// Exposed for app.js (plan-button menu) — re-open the stored plan window.
|
||||
window._getStoredPlan = _getStoredPlan;
|
||||
window.planWindowModule = planWindowModule;
|
||||
|
||||
/** Check if an SSE reader is still actively connected for a session. */
|
||||
function hasActiveStream(sessionId) {
|
||||
@@ -839,22 +809,6 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
if (el('bash-toggle').checked) {
|
||||
fd.append('allow_bash', 'true');
|
||||
}
|
||||
// Plan mode: agent investigates read-only and proposes a plan to approve.
|
||||
// Only meaningful in agent mode, and never alongside deep research.
|
||||
// _forcePlanOff is a one-shot set by "Approve & Run" so the execution turn
|
||||
// runs with full tools even though the Plan toggle is still on.
|
||||
const _planToggle = el('plan-toggle');
|
||||
const planTurn = !_forcePlanOff && isAgentMode && _planToggle && _planToggle.checked && !el('research-toggle').checked;
|
||||
_forcePlanOff = false;
|
||||
if (planTurn) {
|
||||
fd.append('plan_mode', 'true');
|
||||
fd.set('mode', 'agent');
|
||||
} else if (isAgentMode) {
|
||||
// Executing (not proposing): send the stored plan back so the backend
|
||||
// pins it in context and the agent can always re-reference it.
|
||||
const _sp = _getStoredPlan();
|
||||
if (_sp) fd.append('approved_plan', _sp);
|
||||
}
|
||||
const ragChk = el('rag-toggle');
|
||||
if (ragChk && !ragChk.checked) {
|
||||
fd.append('use_rag', 'false');
|
||||
@@ -863,10 +817,6 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
if (incognitoChk && incognitoChk.checked) {
|
||||
fd.append('incognito', 'true');
|
||||
}
|
||||
const _ws = (Storage.KEYS && Storage.get(Storage.KEYS.WORKSPACE, '')) || '';
|
||||
if (_ws) {
|
||||
fd.append('workspace', _ws);
|
||||
}
|
||||
if (presetsModule.getSelectedPreset()) {
|
||||
fd.append('preset_id', presetsModule.getSelectedPreset());
|
||||
}
|
||||
@@ -2770,61 +2720,6 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
// Attach footer to the last visible bubble (roundHolder for multi-round agent, holder for single)
|
||||
const footerTarget = (roundHolder && roundHolder !== holder && roundHolder.style.display !== 'none') ? roundHolder : holder;
|
||||
footerTarget.appendChild(createMsgFooter(footerTarget));
|
||||
// Capture any checklist this message produced as the current plan — both
|
||||
// the initial proposal AND restated progress during execution. Keeps the
|
||||
// stored plan (and the docked plan window) in sync with the latest state.
|
||||
if (accumulated && _CHECKLIST_RE.test(accumulated)) {
|
||||
_setStoredPlan(accumulated);
|
||||
}
|
||||
// Plan mode: the agent has proposed a plan — offer to approve & execute it.
|
||||
// Approving re-sends with plan_mode suppressed (full tools) for one turn.
|
||||
if (planTurn && accumulated.trim()) {
|
||||
const _planText = accumulated;
|
||||
const _runApproved = () => {
|
||||
_approveWrap.remove();
|
||||
_forcePlanOff = true;
|
||||
// Persist the approved plan for THIS chat so it's (a) re-sent and
|
||||
// pinned in context every execution turn, and (b) re-openable via the
|
||||
// plan-button menu. Do this BEFORE flipping the toggle, since the menu
|
||||
// intercept keys off a stored plan existing.
|
||||
_setStoredPlan(_planText);
|
||||
// Approving exits plan mode for good — turn it OFF directly (NOT via
|
||||
// the button's click, which would now open the plan menu instead of
|
||||
// toggling) so execution and every follow-up keep full write tools.
|
||||
try { if (window._setPlanMode) window._setPlanMode(false); } catch (_) {}
|
||||
const _inp = el('message');
|
||||
if (_inp) {
|
||||
_inp.value = 'Approved — execute the plan. The full approved checklist is pinned '
|
||||
+ 'for you under "## ACTIVE PLAN"; do NOT go looking for it in tasks, notes, or '
|
||||
+ 'memory. Work through it in order, and after each step call the update_plan tool '
|
||||
+ 'with the full checklist and that step marked `- [x]`. Do the next unchecked item '
|
||||
+ 'until all are done.';
|
||||
_inp.dispatchEvent(new Event('input'));
|
||||
}
|
||||
// Show a clean bubble; the full instruction still goes to the model.
|
||||
_displayOverride = 'Approved the plan.';
|
||||
handleChatSubmit({ preventDefault() {} });
|
||||
};
|
||||
var _approveWrap = document.createElement('div');
|
||||
_approveWrap.className = 'plan-approve-bar';
|
||||
const _approveBtn = document.createElement('button');
|
||||
_approveBtn.type = 'button';
|
||||
_approveBtn.className = 'plan-approve-btn';
|
||||
_approveBtn.textContent = 'Approve & Run';
|
||||
_approveBtn.addEventListener('click', _runApproved);
|
||||
// Open the plan in a draggable, side-dockable window (reuses the
|
||||
// shared modal framework). Approving from the window runs it too.
|
||||
const _openBtn = document.createElement('button');
|
||||
_openBtn.type = 'button';
|
||||
_openBtn.className = 'plan-open-btn';
|
||||
_openBtn.textContent = 'Open in window';
|
||||
_openBtn.addEventListener('click', () => {
|
||||
planWindowModule.openPlanWindow(_planText, _runApproved);
|
||||
});
|
||||
_approveWrap.appendChild(_approveBtn);
|
||||
_approveWrap.appendChild(_openBtn);
|
||||
footerTarget.appendChild(_approveWrap);
|
||||
}
|
||||
// Add "View Report" link for completed research
|
||||
if (_researchingStreamIds.has(streamSessionId)) {
|
||||
_appendViewReportLink(footerTarget, streamSessionId);
|
||||
|
||||
@@ -2118,6 +2118,28 @@ export function addMessage(role, content, modelName, metadata) {
|
||||
return lastWrap;
|
||||
}
|
||||
|
||||
// --- Wake-task / supervisor system check-in ---
|
||||
// The self-wake mechanism injects "Did you finish?" as a user message
|
||||
// (or persisted history shows a "[Task] Self-check: <id>" envelope)
|
||||
// so the agent loop re-enters and re-checks status. Render as a
|
||||
// normal user-style bubble — same chrome as a real user message,
|
||||
// just with role "Supervisor" and a short summary body — instead of
|
||||
// a slim system chip. Matches chat style and integrates cleanly
|
||||
// into the conversation flow.
|
||||
let _isWakeCheck = !!(metadata?.wake_check_in || metadata?.hidden_from_user_view);
|
||||
if (!_isWakeCheck && typeof textRaw === 'string') {
|
||||
// Also catch historical messages persisted as "[Task] Self-check: <sid>"
|
||||
// (older wake tasks that didn't set wake_check_in metadata).
|
||||
if (/^\s*\[Task\]\s+Self-check:/i.test(textRaw)) {
|
||||
_isWakeCheck = true;
|
||||
}
|
||||
}
|
||||
if (_isWakeCheck) {
|
||||
// Supervisor self-check messages are an internal control signal —
|
||||
// skip rendering entirely so they don't show up in the conversation.
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- Standard single-bubble message ---
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'msg ' + (role === 'user' ? 'msg-user' : 'msg-ai');
|
||||
|
||||
@@ -610,12 +610,47 @@ export function _showDiagnosis(panel, diagnosis, sourceText) {
|
||||
? `Suggested action: ${fixes[0].label}.`
|
||||
: 'Suggested action: copy the error and adjust the serve settings.');
|
||||
|
||||
// Simplified diagnosis card: just the error message + suggestion + fix
|
||||
// button(s). Removed the fold toggle, copy button, and × dismiss — they
|
||||
// made the card noisy without earning their keep. _diagCollapsed is kept
|
||||
// as a stub so callers don't have to change.
|
||||
panel._diagCollapsed = false;
|
||||
|
||||
// Top-right toolbar: Copy bundle + × dismiss. Restored after user feedback
|
||||
// — without them there's no way to quietly close a stale diagnosis or grab
|
||||
// the full error+context for a forum/discord paste.
|
||||
const toolbar = document.createElement('div');
|
||||
toolbar.className = 'cookbook-diag-toolbar';
|
||||
toolbar.style.cssText = 'display:flex;justify-content:flex-end;align-items:center;gap:4px;margin-bottom:-2px;';
|
||||
|
||||
const copyBtn = document.createElement('button');
|
||||
copyBtn.type = 'button';
|
||||
copyBtn.className = 'cookbook-diag-copy';
|
||||
copyBtn.title = 'Copy diagnosis details';
|
||||
copyBtn.setAttribute('aria-label', 'Copy diagnosis');
|
||||
copyBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
|
||||
copyBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const bundle = _diagnosisCopyBundle(task, diagnosis, sourceText, suggestionText);
|
||||
try {
|
||||
await navigator.clipboard.writeText(bundle);
|
||||
copyBtn.classList.add('copied');
|
||||
setTimeout(() => { if (copyBtn.isConnected) copyBtn.classList.remove('copied'); }, 1200);
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
const dismissBtn = document.createElement('button');
|
||||
dismissBtn.type = 'button';
|
||||
dismissBtn.className = 'cookbook-diag-dismiss';
|
||||
dismissBtn.title = 'Dismiss diagnosis';
|
||||
dismissBtn.setAttribute('aria-label', 'Dismiss');
|
||||
dismissBtn.textContent = '×';
|
||||
dismissBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
panel._diagDismissed = diagnosis.message;
|
||||
_clearDiagnosis(panel);
|
||||
});
|
||||
|
||||
toolbar.appendChild(copyBtn);
|
||||
toolbar.appendChild(dismissBtn);
|
||||
diag.appendChild(toolbar);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'cookbook-diag-body';
|
||||
const msg = document.createElement('div');
|
||||
|
||||
+159
-8
@@ -416,9 +416,11 @@ function _hwfitShowError(list, host, detail) {
|
||||
if (rb) rb.addEventListener('click', () => { _resetGpuToggleState(); _hwfitFetch(true); });
|
||||
}
|
||||
|
||||
// Client-side "Engine" filter (llama.cpp / vLLM / SGLang). Empty = show all.
|
||||
// Uses the same _detectBackend() the serve commands use, so what you filter to
|
||||
// is exactly what would be launched. Pure view filter — no refetch needed.
|
||||
// Client-side "Engine" filter (llama.cpp / vLLM / SGLang / Ollama). Empty =
|
||||
// show all. Uses the same _detectBackend() the serve commands use, so what you
|
||||
// filter to is exactly what would be launched. Pure view filter — no refetch
|
||||
// needed. Ollama rows are merged into the main list (see _ensureOllamaLib +
|
||||
// _ollamaToHwfitRows below) so the filter handles all engines uniformly.
|
||||
function _applyEngineFilter(models) {
|
||||
const want = document.getElementById('hwfit-engine')?.value || '';
|
||||
if (!want || !Array.isArray(models)) return models || [];
|
||||
@@ -427,6 +429,86 @@ function _applyEngineFilter(models) {
|
||||
});
|
||||
}
|
||||
|
||||
// Ollama library cache (per-page). Filled lazily on first _hwfitFetch; the raw
|
||||
// list is the same shape returned by /api/cookbook/ollama/library, then turned
|
||||
// into per-tag hwfit rows so they slot into the main list grid alongside HF
|
||||
// scan results.
|
||||
let _ollamaLibCache = null;
|
||||
async function _ensureOllamaLib() {
|
||||
if (_ollamaLibCache) return _ollamaLibCache;
|
||||
try {
|
||||
const res = await fetch('/api/cookbook/ollama/library');
|
||||
const data = await res.json();
|
||||
_ollamaLibCache = Array.isArray(data?.models) ? data.models : [];
|
||||
} catch { _ollamaLibCache = []; }
|
||||
return _ollamaLibCache;
|
||||
}
|
||||
|
||||
// Convert an Ollama library entry's sizes into per-tag hwfit rows. Shape
|
||||
// matches what _hwfitRenderList expects (fit_level, parameter_count,
|
||||
// required_gb, score, …) so the rows render identically to HF results.
|
||||
function _olParseSize(s) {
|
||||
// "14b" → 14, "1.5b" → 1.5, "8x7b" → 56 (rough), "135m" → 0.135, "latest" → null
|
||||
if (!s) return null;
|
||||
const low = s.toLowerCase();
|
||||
let m = low.match(/^(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)b$/);
|
||||
if (m) return parseFloat(m[1]) * parseFloat(m[2]);
|
||||
m = low.match(/^(\d+(?:\.\d+)?)b$/);
|
||||
if (m) return parseFloat(m[1]);
|
||||
m = low.match(/^(\d+(?:\.\d+)?)m$/);
|
||||
if (m) return parseFloat(m[1]) / 1000;
|
||||
return null;
|
||||
}
|
||||
function _ollamaToHwfitRows(libModels, vramAvail, ramAvail) {
|
||||
const out = [];
|
||||
if (!Array.isArray(libModels)) return out;
|
||||
for (const m of libModels) {
|
||||
const sizes = (Array.isArray(m.sizes) && m.sizes.length) ? m.sizes : ['latest'];
|
||||
for (const sz of sizes) {
|
||||
const params = _olParseSize(sz);
|
||||
// Ollama default GGUF is ~Q4_K_M. Rough VRAM estimate: 0.6 GB / B.
|
||||
const vramGb = params ? params * 0.6 : 0;
|
||||
let fitLevel = 'no_fit';
|
||||
if (vramGb && vramAvail) {
|
||||
if (vramGb <= vramAvail * 0.6) fitLevel = 'perfect';
|
||||
else if (vramGb <= vramAvail) fitLevel = 'good';
|
||||
else if (ramAvail && vramGb <= ramAvail) fitLevel = 'marginal';
|
||||
else fitLevel = 'too_tight';
|
||||
} else if (vramGb && ramAvail && vramGb <= ramAvail) {
|
||||
fitLevel = 'marginal';
|
||||
}
|
||||
const tag = `${m.name}:${sz}`;
|
||||
const paramsLabel = params
|
||||
? (params >= 1 ? params.toFixed(params >= 10 ? 0 : 1) + 'B' : (params * 1000).toFixed(0) + 'M')
|
||||
: '?';
|
||||
// A modest score so Ollama rows still sort sensibly in the default
|
||||
// score view — bigger models get a slightly higher base, but they
|
||||
// always come in below well-scored HF results. Sort by Fit or VRAM
|
||||
// to surface them more aggressively.
|
||||
const score = params ? Math.min(30 + params * 0.3, 60) : 25;
|
||||
out.push({
|
||||
name: tag,
|
||||
repo_id: tag,
|
||||
quant: 'Q4_K_M',
|
||||
parameter_count: paramsLabel,
|
||||
params_b: params || 0,
|
||||
required_gb: vramGb,
|
||||
fit_level: fitLevel,
|
||||
score,
|
||||
speed_tps: 0,
|
||||
context: 0,
|
||||
is_gguf: true,
|
||||
backend: 'ollama',
|
||||
_isOllama: true,
|
||||
_olName: m.name,
|
||||
_olSize: sz,
|
||||
_description: m.description || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function _hwfitFetch(fresh = false) {
|
||||
const _tk = ++_hwfitFetchToken;
|
||||
const useCase = document.getElementById('hwfit-usecase')?.value || '';
|
||||
@@ -475,7 +557,12 @@ export async function _hwfitFetch(fresh = false) {
|
||||
_setLastCacheHost(remoteKey);
|
||||
const _cacheSrv = _serverByVal(_envState.remoteServerKey || remoteHost);
|
||||
const _cachePort = _cacheSrv?.port || '';
|
||||
const _cacheParams = new URLSearchParams({ host: remoteHost }); if (_cachePort) _cacheParams.set('ssh_port', _cachePort); if (_cacheSrv?.platform) _cacheParams.set('platform', _cacheSrv.platform);
|
||||
const _cacheParams = new URLSearchParams();
|
||||
if (remoteHost) {
|
||||
_cacheParams.set('host', remoteHost);
|
||||
if (_cachePort) _cacheParams.set('ssh_port', _cachePort);
|
||||
if (_cacheSrv?.platform) _cacheParams.set('platform', _cacheSrv.platform);
|
||||
}
|
||||
fetch(`/api/model/cached?${_cacheParams}`, { credentials: 'same-origin' })
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
@@ -543,7 +630,18 @@ export async function _hwfitFetch(fresh = false) {
|
||||
// A newer scan started while this one was in flight (user switched servers
|
||||
// mid-probe) — drop this stale response so it can't clobber the new one.
|
||||
if (_tk !== _hwfitFetchToken) { try { wp.destroy(); } catch {} return; }
|
||||
if (!res.ok) throw new Error(res.statusText);
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
let msg = '';
|
||||
try {
|
||||
const payload = JSON.parse(body);
|
||||
msg = payload && (payload.detail || payload.error || payload.message);
|
||||
} catch {
|
||||
msg = body;
|
||||
}
|
||||
msg = typeof msg === 'string' ? msg.trim() : '';
|
||||
throw new Error(`HTTP ${res.status} ${res.statusText}${msg ? `: ${msg}` : ''}`);
|
||||
}
|
||||
let data = await res.json();
|
||||
if (_tk !== _hwfitFetchToken) { try { wp.destroy(); } catch {} return; }
|
||||
if (!isImageMode && quantPref && !data.error && Array.isArray(data.models) && data.models.length === 0) {
|
||||
@@ -583,6 +681,23 @@ export async function _hwfitFetch(fresh = false) {
|
||||
if (!_cached) { _hwfitShowError(list, remoteHost, data.error); if (hw) hw.innerHTML = ''; }
|
||||
return;
|
||||
}
|
||||
// Merge Ollama library rows into the main list so they appear with the
|
||||
// same Fit/Param/Quant/VRAM/Mode columns as HF results and respond to the
|
||||
// Engine filter. Skipped in image-gen mode (Ollama doesn't serve diffusers).
|
||||
if (!isImageMode) {
|
||||
const _vramAvail = data.system?.gpu_vram_gb || 0;
|
||||
const _ramAvail = data.system?.total_ram_gb || 0;
|
||||
const _lib = await _ensureOllamaLib();
|
||||
const _olRows = _ollamaToHwfitRows(_lib, _vramAvail, _ramAvail);
|
||||
// Search filter on Ollama rows: HF API already filters by search; do the
|
||||
// same client-side over Ollama name + description so the search box
|
||||
// works consistently across both sources.
|
||||
const _s = (search || '').trim().toLowerCase();
|
||||
const _olFiltered = _s
|
||||
? _olRows.filter(r => r.name.toLowerCase().includes(_s) || (r._description || '').toLowerCase().includes(_s))
|
||||
: _olRows;
|
||||
data.models = (data.models || []).concat(_olFiltered);
|
||||
}
|
||||
_hwfitCache = data;
|
||||
_hwfitRenderHw(hw, data.system);
|
||||
// Propagate local platform from hardware probe so _isWindows(task) works
|
||||
@@ -964,14 +1079,36 @@ export function _hwfitRenderList(el, models) {
|
||||
html += `</div>`;
|
||||
}
|
||||
el.innerHTML = html;
|
||||
// Click row → expand inline action panel
|
||||
// Click row → expand inline action panel. Exception: Ollama rows skip the
|
||||
// expand panel (no HF metadata to power it) and just fill the Download
|
||||
// input with the `<name>:<size>` tag — one click → ready to pull.
|
||||
el.querySelectorAll('.hwfit-row:not(.hwfit-header)').forEach(row => {
|
||||
row.addEventListener('click', () => {
|
||||
const name = row.dataset.model;
|
||||
if (!name) return;
|
||||
// Find model data from cache
|
||||
const modelData = (_hwfitCache?.models || []).find(m => m.name === name);
|
||||
if (!modelData) return;
|
||||
if (modelData._isOllama) {
|
||||
// Force-open the Download card if it's been collapsed — otherwise
|
||||
// filling the (hidden) input silently swallows the click.
|
||||
const dlBody = document.getElementById('cookbook-download-card-body');
|
||||
const dlArrow = document.getElementById('cookbook-download-card-arrow');
|
||||
if (dlBody && dlBody.style.display === 'none') {
|
||||
dlBody.style.display = 'block';
|
||||
if (dlArrow) dlArrow.style.transform = 'rotate(90deg)';
|
||||
}
|
||||
const dlInput = document.getElementById('cookbook-dl-repo');
|
||||
if (dlInput) {
|
||||
dlInput.value = modelData.name;
|
||||
dlInput.focus();
|
||||
// Briefly highlight so the user sees what got filled even when the
|
||||
// download card sits far above the (long) hwfit list.
|
||||
dlInput.classList.add('cookbook-dl-flash');
|
||||
setTimeout(() => dlInput.classList.remove('cookbook-dl-flash'), 800);
|
||||
dlInput.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
_expandModelRow(row, modelData);
|
||||
});
|
||||
});
|
||||
@@ -1297,7 +1434,7 @@ export function _hwfitInit() {
|
||||
if (sort) sort.addEventListener('change', () => _hwfitFetch());
|
||||
if (qpref) qpref.addEventListener('change', () => _hwfitFetch());
|
||||
// Engine filter is a pure client-side view filter over the already-fetched
|
||||
// list, so just re-render from cache instead of re-probing hardware.
|
||||
// list (HF + Ollama merged), so just re-render from cache.
|
||||
const engine = document.getElementById('hwfit-engine');
|
||||
if (engine) engine.addEventListener('change', () => {
|
||||
const list = document.getElementById('hwfit-list');
|
||||
@@ -1694,6 +1831,15 @@ export function _hwfitInit() {
|
||||
saveBtn.addEventListener('click', () => {
|
||||
_syncServers();
|
||||
_rebuildServerSelect();
|
||||
// Broadcast for anything outside the settings tab that depends on
|
||||
// the server list (Serve dialog host picker, Running tasks, etc.).
|
||||
// Without this the user had to hard-refresh to see the new entry
|
||||
// in those other places.
|
||||
try {
|
||||
document.dispatchEvent(new CustomEvent('cookbook:servers-changed', {
|
||||
detail: { servers: _envState.servers.slice() },
|
||||
}));
|
||||
} catch (_) {}
|
||||
saveBtn.classList.add('saved');
|
||||
saveBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#50fa7b" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;flex-shrink:0;"><polyline points="20 6 9 17 4 12"/></svg>Saved';
|
||||
});
|
||||
@@ -1713,6 +1859,11 @@ export function _hwfitInit() {
|
||||
entry.remove();
|
||||
_syncServers();
|
||||
_rebuildServerSelect();
|
||||
try {
|
||||
document.dispatchEvent(new CustomEvent('cookbook:servers-changed', {
|
||||
detail: { servers: _envState.servers.slice() },
|
||||
}));
|
||||
} catch (_) {}
|
||||
_hwfitCache = null;
|
||||
_hwfitFetch();
|
||||
});
|
||||
|
||||
+265
-196
@@ -72,7 +72,7 @@ function _platformIcon(platform) {
|
||||
return '';
|
||||
}
|
||||
|
||||
export let _envState = { env: 'none', envPath: '', hfToken: '', hfTokenConfigured: false, hfTokenMasked: '', gpus: '', remoteHost: '', remoteServerKey: '', servers: [], modelPaths: [], platform: '', defaultServer: '' };
|
||||
export let _envState = { env: 'none', envPath: '', hfToken: '', hfTokenConfigured: false, hfTokenMasked: '', gpus: '', remoteHost: '', servers: [], modelPaths: [], platform: '', defaultServer: '' };
|
||||
let _lastCacheHostVal = null;
|
||||
let _cookbookOpeningSpinners = [];
|
||||
export function _lastCacheHost() { return _lastCacheHostVal; }
|
||||
@@ -89,8 +89,8 @@ function _setCookbookOpening(on) {
|
||||
].filter(Boolean);
|
||||
if (!on) {
|
||||
_cookbookOpeningSpinners.forEach(({ spinner, wrap, target }) => {
|
||||
try { spinner?.stop?.(); } catch { }
|
||||
try { wrap?.remove?.(); } catch { }
|
||||
try { spinner?.stop?.(); } catch {}
|
||||
try { wrap?.remove?.(); } catch {}
|
||||
target?.classList?.remove('cookbook-opening');
|
||||
});
|
||||
_cookbookOpeningSpinners = [];
|
||||
@@ -128,11 +128,12 @@ export function _serverKey(s) {
|
||||
].map(v => encodeURIComponent(String(v).trim())).join('|');
|
||||
}
|
||||
|
||||
function _serverByVal(val) {
|
||||
export function _serverByVal(val) {
|
||||
if (val == null || val === 'local' || val === '') return null;
|
||||
const raw = String(val);
|
||||
let s = _envState.servers.find(x => _serverKey(x) === raw);
|
||||
if (!s) s = _envState.servers.find(x => x.host === raw);
|
||||
if (!s) s = _envState.servers.find(x => x.name === raw);
|
||||
if (!s && /^\d+$/.test(String(val))) s = _envState.servers[parseInt(val)];
|
||||
return s || null;
|
||||
}
|
||||
@@ -152,6 +153,19 @@ export function _currentServerValue() {
|
||||
return _envState.remoteHost || 'local';
|
||||
}
|
||||
|
||||
const GEMMA4_THINKING_CHAT_TEMPLATE = `{% for message in messages %}{% if message['role'] == 'system' %}<|turn>system\n<|think|>{{ message['content'] }}<turn|>\n{% elif message['role'] == 'user' %}<|turn>user\n{{ message['content'] }}<turn|>\n{% elif message['role'] == 'assistant' %}<|turn>model\n{{ message['content'] }}<turn|>\n{% endif %}{% endfor %}{% if add_generation_prompt %}<|turn>model\n<|channel>thought{% endif %}`;
|
||||
|
||||
function _isGemma4ThinkingModel(modelName) {
|
||||
const n = (modelName || '').toLowerCase();
|
||||
return n.includes('gemma-4') || n.includes('gemma4');
|
||||
}
|
||||
|
||||
function _gemma4ThinkingChatTemplateArg(modelName) {
|
||||
return _isGemma4ThinkingModel(modelName)
|
||||
? _shellQuote(GEMMA4_THINKING_CHAT_TEMPLATE)
|
||||
: '';
|
||||
}
|
||||
|
||||
function _buildServerOpts(excludeLocal = false) {
|
||||
// The local server is ALWAYS represented by the synthetic value="local" option
|
||||
// (showing its custom name from the "server name" feature). We must therefore
|
||||
@@ -195,31 +209,8 @@ function _getPort(hostOrTask) {
|
||||
|
||||
/** Get platform for a given host (or task object). Returns 'windows', 'termux', 'linux', or '' */
|
||||
export function _getPlatform(hostOrTask) {
|
||||
const isWinBrowser = (window.navigator.userAgent || window.navigator.platform || '').toLowerCase().includes('win');
|
||||
// The browser's OS is NOT the server's OS when the UI is opened remotely —
|
||||
// e.g. a Windows browser driving a Mac/Linux homeserver. Trusting the
|
||||
// user-agent there makes the serve builder emit the Windows python-only
|
||||
// shape (`python -m llama_cpp.server`, no `llama-server ||` fallback), which
|
||||
// then fails on the actual Unix server. The local hardware probe is
|
||||
// authoritative: it reports a backend (metal/cuda/rocm/cpu_*) for any Unix
|
||||
// server and carries platform:"windows" for local Windows (which sets
|
||||
// _envState.platform, short-circuiting below). So only fall back to the
|
||||
// browser hint when we have no server-side signal at all.
|
||||
const localPlatform = () => {
|
||||
if (_envState.platform) return _envState.platform;
|
||||
if (String(_hwfitCache?.system?.backend || '')) return '';
|
||||
return isWinBrowser ? 'windows' : '';
|
||||
};
|
||||
if (!hostOrTask || hostOrTask === 'local') {
|
||||
return localPlatform();
|
||||
}
|
||||
if (typeof hostOrTask === 'object') {
|
||||
const h = hostOrTask.remoteHost;
|
||||
if (!h || h === 'local') {
|
||||
return hostOrTask.platform || localPlatform();
|
||||
}
|
||||
return hostOrTask.platform || _getPlatform(hostOrTask.remoteServerKey || h);
|
||||
}
|
||||
if (!hostOrTask) return _envState.platform || '';
|
||||
if (typeof hostOrTask === 'object') return hostOrTask.platform || _getPlatform(hostOrTask.remoteServerKey || hostOrTask.remoteHost);
|
||||
const selected = hostOrTask === _envState.remoteHost ? _selectedServer() : null;
|
||||
const srv = selected || _serverByVal(hostOrTask);
|
||||
return srv?.platform || '';
|
||||
@@ -237,19 +228,6 @@ export function _isMetal() {
|
||||
return ['metal', 'mps', 'apple'].includes(String(_hwfitCache?.system?.backend || '').toLowerCase());
|
||||
}
|
||||
|
||||
const GEMMA4_THINKING_CHAT_TEMPLATE = `{% for message in messages %}{% if message['role'] == 'system' %}<|turn>system\n<|think|>{{ message['content'] }}<turn|>\n{% elif message['role'] == 'user' %}<|turn>user\n{{ message['content'] }}<turn|>\n{% elif message['role'] == 'assistant' %}<|turn>model\n{{ message['content'] }}<turn|>\n{% endif %}{% endfor %}{% if add_generation_prompt %}<|turn>model\n<|channel>thought{% endif %}`;
|
||||
|
||||
function _isGemma4ThinkingModel(modelName) {
|
||||
const n = (modelName || '').toLowerCase();
|
||||
return n.includes('gemma-4') || n.includes('gemma4');
|
||||
}
|
||||
|
||||
function _gemma4ThinkingChatTemplateArg(modelName) {
|
||||
return _isGemma4ThinkingModel(modelName)
|
||||
? _shellQuote(GEMMA4_THINKING_CHAT_TEMPLATE)
|
||||
: '';
|
||||
}
|
||||
|
||||
/** Detect model-specific vLLM optimizations */
|
||||
function _detectModelOptimizations(modelName) {
|
||||
const n = (modelName || '').toLowerCase();
|
||||
@@ -326,7 +304,10 @@ export function _detectToolParser(modelName) {
|
||||
// ── Backend detection ──
|
||||
|
||||
export function _detectBackend(model) {
|
||||
if (model?.backend === 'ollama' || model?.is_ollama) {
|
||||
const _ollamaName = String(model?.repo_id || model?.name || model?.id || '').trim();
|
||||
const _ollamaMeta = `${model?.backend || ''} ${model?.endpoint_kind || ''} ${model?.provider || ''} ${model?.source || ''}`.toLowerCase();
|
||||
const _looksLikeOllamaTag = /^[A-Za-z0-9][A-Za-z0-9._-]*(?::[A-Za-z0-9][A-Za-z0-9._-]*)$/.test(_ollamaName);
|
||||
if (model?.backend === 'ollama' || model?.is_ollama || _ollamaMeta.includes('ollama') || _looksLikeOllamaTag) {
|
||||
return { backend: 'ollama', label: 'Ollama' };
|
||||
}
|
||||
const q = (model.quant || '').toUpperCase();
|
||||
@@ -585,9 +566,34 @@ export function _buildServeCmd(f, modelName, backend) {
|
||||
}
|
||||
} else if (backend === 'ollama') {
|
||||
const ollamaPort = f.port || '11434';
|
||||
const bindHost = _envState.remoteHost ? '0.0.0.0' : '127.0.0.1';
|
||||
const hostEnv = ollamaPort !== '11434' ? `OLLAMA_HOST=${bindHost}:${ollamaPort} ` : '';
|
||||
cmd = `${hostEnv}ollama serve`;
|
||||
// GGUF + Ollama: delegate to the iGPU-bound ollama-test container via
|
||||
// its /usr/local/bin/ollama-import helper. Plain `ollama serve` errors
|
||||
// 127 on hosts where ollama isn't on PATH (and even when it is, it
|
||||
// doesn't import the GGUF — it just starts the daemon). Args are all
|
||||
// literal so the cookbook validator (which bans &&/||/;/$() ) is
|
||||
// happy: `docker exec ollama-test ollama-import <repo> <name> <ctx>
|
||||
// <file>`. The helper handles the find/Modelfile/preload dance.
|
||||
if (modelName.includes('/') && (f.gguf_file || /-GGUF$/i.test(modelName))) {
|
||||
// HF-GGUF repo → import + preload + tail
|
||||
const _name = (modelName.split('/').pop() || modelName)
|
||||
.replace(/-GGUF$/i, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._:-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
const _ctx = f.ctx || '8192';
|
||||
const _file = (f.gguf_file || '').split('/').pop() || '';
|
||||
// Trailing GGUF_FILE is optional; helper picks the first match if empty.
|
||||
cmd = `docker exec ollama-test ollama-import ${modelName} ${_name} ${_ctx}${_file ? ' ' + _file : ''}`;
|
||||
} else if (!modelName.includes('/') && modelName) {
|
||||
// Already-pulled Ollama tag (e.g. `qwen2.5:7b`). On kierkegaard the
|
||||
// runtime is the ROCm Ollama sidecar; this quick command verifies the
|
||||
// tag exists, then the backend auto-registers http://host.docker.internal:11434/v1.
|
||||
cmd = `docker exec ollama-rocm ollama show ${modelName}`;
|
||||
} else {
|
||||
const bindHost = _envState.remoteHost ? '0.0.0.0' : '127.0.0.1';
|
||||
const hostEnv = ollamaPort !== '11434' ? `OLLAMA_HOST=${bindHost}:${ollamaPort} ` : '';
|
||||
cmd = `${hostEnv}ollama serve`;
|
||||
}
|
||||
} else if (backend === 'diffusers') {
|
||||
const gpuStr = f.gpus?.trim();
|
||||
if (gpuStr) cmd += `CUDA_VISIBLE_DEVICES=${gpuStr} `;
|
||||
@@ -630,7 +636,7 @@ function _fallbackCopy(text) {
|
||||
ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand('copy'); } catch (_) { }
|
||||
try { document.execCommand('copy'); } catch (_) {}
|
||||
document.body.removeChild(ta);
|
||||
return Promise.resolve();
|
||||
}
|
||||
@@ -663,7 +669,7 @@ function _readStoredEnvState() {
|
||||
|
||||
export function _persistEnvState() {
|
||||
try { localStorage.setItem(LAST_STATE_KEY, JSON.stringify(_envStateForStorage())); }
|
||||
catch (_) { }
|
||||
catch (_) {}
|
||||
_saveTasks(_loadTasks());
|
||||
}
|
||||
|
||||
@@ -712,24 +718,22 @@ async function _fetchDependencies() {
|
||||
const data = await resp.json();
|
||||
const pkgs = data.packages || [];
|
||||
if (!pkgs.length) { list.innerHTML = '<div class="hwfit-loading">No packages found</div>'; return; }
|
||||
const _winUnsupported = new Set(['vllm', 'rembg', 'gfpgan']);
|
||||
const _winUnsupported = new Set(['diffusers', 'hf_transfer', 'vllm', 'rembg', 'gfpgan']);
|
||||
|
||||
const _statusTag = (pkg, isLocal, isSystemDep, winBlocked) => {
|
||||
if (winBlocked) return `<span class="cookbook-dep-tag cookbook-dep-na">N/A</span>`;
|
||||
const hasCustomInstall = !!pkg.install_cmd;
|
||||
const hasCustomUpdate = !!pkg.update_cmd;
|
||||
if (pkg.installed && isSystemDep && !hasCustomUpdate) return `<span class="cookbook-dep-tag cookbook-dep-installed" title="Found on selected server">Installed</span>`;
|
||||
if (pkg.installed && pkg.pip_update_available === false && !hasCustomUpdate) {
|
||||
if (pkg.installed && isSystemDep) return `<span class="cookbook-dep-tag cookbook-dep-installed" title="Found on selected server">Installed</span>`;
|
||||
if (pkg.installed && pkg.pip_update_available === false) {
|
||||
const tip = esc(pkg.update_note || pkg.status_note || 'Found externally; update outside Odysseus.');
|
||||
return `<span class="cookbook-dep-tag cookbook-dep-installed" title="${tip}">Installed</span>`;
|
||||
}
|
||||
if (pkg.installed) return `<button class="cookbook-dep-tag cookbook-dep-installed cookbook-dep-installed-btn" title="Installed — click for actions"><span class="cookbook-dep-installed-label">Installed</span><span class="cookbook-dep-caret">▾</span></button>`;
|
||||
if (isSystemDep && !hasCustomInstall) {
|
||||
if (isSystemDep) {
|
||||
const depTip = esc(pkg.install_hint || 'Install this OS package on the selected server.');
|
||||
const depLabel = pkg.applicable === false ? 'N/A ?' : 'Missing';
|
||||
return `<span class="cookbook-dep-tag cookbook-dep-na" title="${depTip}">${depLabel}</span>`;
|
||||
}
|
||||
return `<button class="cookbook-dep-tag cookbook-dep-install" data-dep-pip="${esc(pkg.pip || '')}" data-dep-install-cmd="${esc(pkg.install_cmd || '')}" data-dep-update-cmd="${esc(pkg.update_cmd || '')}" data-dep-target="${isLocal ? 'local' : 'remote'}">Install</button>`;
|
||||
return `<button class="cookbook-dep-tag cookbook-dep-install" data-dep-pip="${esc(pkg.pip)}" data-dep-target="${isLocal ? 'local' : 'remote'}">Install</button>`;
|
||||
};
|
||||
|
||||
const _depRow = (pkg) => {
|
||||
@@ -752,7 +756,7 @@ async function _fetchDependencies() {
|
||||
} else if (pkg.name === 'sglang' && pkg.installed) {
|
||||
_rebuildBtn = `<button type="button" class="cookbook-dep-tag cookbook-dep-rebuild cookbook-dep-reinstall" data-reinstall-pkg="sglang" title="Force-reinstall SGLang (pulls a matching torch). Runs as a tmux task in the Running tab.">Reinstall</button>`;
|
||||
}
|
||||
return `<div class="cookbook-dep-row${winBlocked ? ' cookbook-dep-blocked' : ''}" data-pkg-name="${esc(pkg.name)}" data-dep-pip="${esc(pkg.pip || '')}" data-dep-install-cmd="${esc(pkg.install_cmd || '')}" data-dep-update-cmd="${esc(pkg.update_cmd || '')}" data-dep-target="${isLocal ? 'local' : 'remote'}" data-dep-kind="${esc(pkg.kind || 'python')}">`
|
||||
return `<div class="cookbook-dep-row${winBlocked ? ' cookbook-dep-blocked' : ''}" data-pkg-name="${esc(pkg.name)}" data-dep-pip="${esc(pkg.pip || '')}" data-dep-target="${isLocal ? 'local' : 'remote'}" data-dep-kind="${esc(pkg.kind || 'python')}">`
|
||||
+ `<div class="cookbook-dep-info">`
|
||||
+ `<div class="memory-item-title">${esc(pkg.name)}</div>`
|
||||
+ `<div class="memory-item-meta" style="font-size:10px;opacity:0.5;margin-top:2px;">${esc(pkg.desc)}</div>`
|
||||
@@ -782,7 +786,7 @@ async function _fetchDependencies() {
|
||||
// Shared install/update routine — used by the Install button and the
|
||||
// "Update" item in an installed package's ⋮ menu. `upgrade` adds pip -U;
|
||||
// `statusEl`, when given, shows "Installing…/Updating…" and is disabled.
|
||||
async function _installDep(pipName, pkgName, isLocalOnly, upgrade, statusEl, actionCmd = '') {
|
||||
async function _installDep(pipName, pkgName, isLocalOnly, upgrade, statusEl) {
|
||||
if (isLocalOnly) {
|
||||
_envState.remoteHost = '';
|
||||
_envState.env = 'none';
|
||||
@@ -827,43 +831,6 @@ async function _fetchDependencies() {
|
||||
envPrefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _shellQuote(_envState.envPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (actionCmd) {
|
||||
const shellCmd = envPrefix ? `${envPrefix} ${actionCmd}` : actionCmd;
|
||||
const fullCmd = (!isLocalOnly && _envState.remoteHost)
|
||||
? _sshCmd(_envState.remoteHost, shellCmd, _getPort(_envState.remoteHost))
|
||||
: shellCmd;
|
||||
try {
|
||||
if (statusEl) { statusEl.textContent = upgrade ? 'Updating...' : 'Installing...'; statusEl.disabled = true; }
|
||||
const res = await fetch('/api/shell/stream', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: fullCmd }),
|
||||
});
|
||||
uiModule.showToast(`${upgrade ? 'Updating' : 'Installing'} ${pkgName} on ${targetHost}...`);
|
||||
const body = await res.text();
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const exitMatches = [...body.matchAll(/"exit_code":\s*(-?\d+)/g)].map(m => Number(m[1]));
|
||||
const exitCode = exitMatches.length ? exitMatches[exitMatches.length - 1] : 0;
|
||||
if (exitCode !== 0) {
|
||||
throw new Error((body.slice(-500).trim() || `${pkgName} command failed`) + ` (exit ${exitCode})`);
|
||||
}
|
||||
|
||||
if (upgrade) { uiModule.showToast(`Successfully updated ${pkgName} on ${targetHost}.`); } else { uiModule.showToast(`Successfully installed ${pkgName} on ${targetHost}.`); }
|
||||
await _fetchDependencies();
|
||||
return;
|
||||
} catch (err) {
|
||||
if (statusEl) { statusEl.textContent = 'Install'; statusEl.disabled = false; }
|
||||
uiModule.showToast(`${upgrade ? 'Update' : 'Install'} failed: ` + err.message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Always go through `python -m pip` so the leading token is `python`
|
||||
// — matches the /api/model/serve allow-list (bare `pip` is blocked).
|
||||
// Inside a venv/conda env, `--user` is invalid (pip refuses), so we
|
||||
// only add `--user --break-system-packages` when there's no env —
|
||||
// for PEP-668-locked system pythons (Arch, newer Debian).
|
||||
try {
|
||||
const reqBody = {
|
||||
repo_id: pipName,
|
||||
@@ -902,9 +869,8 @@ async function _fetchDependencies() {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const pipName = btn.dataset.depPip;
|
||||
const installCmd = btn.dataset.depInstallCmd || '';
|
||||
const pkgName = btn.closest('.cookbook-dep-row')?.querySelector('.memory-item-title')?.textContent || pipName;
|
||||
await _installDep(pipName, pkgName, btn.dataset.depTarget === 'local', !!btn.dataset.upgrade, btn, installCmd);
|
||||
await _installDep(pipName, pkgName, btn.dataset.depTarget === 'local', !!btn.dataset.upgrade, btn);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -927,12 +893,11 @@ async function _fetchDependencies() {
|
||||
const it = document.createElement('div');
|
||||
it.className = 'dropdown-item-compact';
|
||||
it.innerHTML = `<span class="dropdown-icon">${upIco}</span><span>Update</span>`;
|
||||
it.title = row.dataset.depUpdateCmd ? `Update ${pkgName} using its custom command` : `Update ${pkgName} to the latest version (pip install -U)`;
|
||||
it.title = `Update ${pkgName} to the latest version (pip install -U)`;
|
||||
it.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
dropdown.remove();
|
||||
const updateCmd = row.dataset.depUpdateCmd || '';
|
||||
await _installDep(pipName, pkgName, isLocalOnly, true, null, updateCmd);
|
||||
await _installDep(pipName, pkgName, isLocalOnly, true, null);
|
||||
});
|
||||
dropdown.appendChild(it);
|
||||
document.body.appendChild(dropdown);
|
||||
@@ -986,6 +951,7 @@ function _applyServerSelection(val) {
|
||||
const _want = _currentServerValue();
|
||||
document.querySelectorAll('#hwfit-server-select, #hwfit-dl-server, #hwfit-cache-server, #hwfit-deps-server').forEach(sel => {
|
||||
if (!sel || sel.tagName !== 'SELECT') return;
|
||||
// Option values are host strings now ('local' for the local box).
|
||||
sel.value = _want;
|
||||
// If the host isn't among this select's current options (stale options after
|
||||
// the server list changed), the browser leaves the box BLANK/grey even though
|
||||
@@ -993,7 +959,7 @@ function _applyServerSelection(val) {
|
||||
// re-apply; fall back to 'local' only if it's genuinely gone.
|
||||
if (sel.selectedIndex < 0) {
|
||||
sel.innerHTML = _buildServerOpts(sel.id === 'hwfit-dl-server');
|
||||
sel.value = _currentServerValue();
|
||||
sel.value = _want;
|
||||
if (sel.selectedIndex < 0) sel.value = 'local';
|
||||
}
|
||||
});
|
||||
@@ -1031,7 +997,7 @@ function _wireTabEvents(body) {
|
||||
// Ignore swipes that start in a horizontally-scrollable tag row — those
|
||||
// should scroll the chips, not flip the tab.
|
||||
if (window.innerWidth > 768 || e.touches.length !== 1
|
||||
|| e.target.closest('input, textarea, select, .doclib-lang-chips')) { _sx = null; return; }
|
||||
|| e.target.closest('input, textarea, select, .doclib-lang-chips')) { _sx = null; return; }
|
||||
_sx = e.touches[0].clientX; _sy = e.touches[0].clientY;
|
||||
}, { passive: true });
|
||||
body.addEventListener('touchend', (e) => {
|
||||
@@ -1081,13 +1047,11 @@ function _wireTabEvents(body) {
|
||||
const remotes = servers.filter(s => !_isLocalEntry(s));
|
||||
if (remotes.length === 1) {
|
||||
_envState.remoteHost = remotes[0].host;
|
||||
_envState.remoteServerKey = _serverKey(remotes[0]);
|
||||
_envState.env = remotes[0].env || 'none';
|
||||
_envState.envPath = remotes[0].envPath || '';
|
||||
}
|
||||
}
|
||||
const activeSrv = _selectedServer();
|
||||
if (activeSrv) _envState.remoteServerKey = _serverKey(activeSrv);
|
||||
const activeSrv = servers.find(s => s.host === _envState.remoteHost);
|
||||
_envState.platform = activeSrv?.platform || '';
|
||||
localStorage.setItem('cookbook-last-state', JSON.stringify(_envStateForStorage()));
|
||||
_saveTasks(_loadTasks());
|
||||
@@ -1361,14 +1325,28 @@ function _wireTabEvents(body) {
|
||||
if (!m) return { repo: raw, include: null };
|
||||
return { repo: m[1], include: `*${m[2]}*` };
|
||||
}
|
||||
// Ollama-library name. Matches `qwen2.5:14b`, `llama3:latest`, and the
|
||||
// (rare) `library/<name>:<tag>` form which we normalize by stripping the
|
||||
// namespace. The backend's _is_ollama_download check expects the same
|
||||
// shape (no slash + has a colon).
|
||||
function _ollamaName(raw) {
|
||||
const stripped = raw.replace(/^library\//, '');
|
||||
if (/^[A-Za-z0-9][A-Za-z0-9._-]{0,200}:[A-Za-z0-9][A-Za-z0-9._-]{0,200}$/.test(stripped)) {
|
||||
return stripped;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const triggerDownload = () => {
|
||||
const rawRepo = _stripHfUrl(dlInput.value);
|
||||
if (!rawRepo) return;
|
||||
const { repo, include: autoInclude } = _splitRepoTag(rawRepo);
|
||||
const ollamaName = _ollamaName(rawRepo);
|
||||
const { repo, include: autoInclude } = ollamaName ? { repo: ollamaName, include: null } : _splitRepoTag(rawRepo);
|
||||
// HuggingFace repo IDs must be `org/model`. A bare model name would 404
|
||||
// at snapshot_download time with a raw traceback, so reject it up front.
|
||||
if (!/^[^\s/]+\/[^\s/]+$/.test(repo)) {
|
||||
uiModule.showToast('Enter a full HuggingFace repo ID like "org/model-name" (or paste the full HF URL).');
|
||||
// Ollama names (single-segment with a tag) skip this check — they go
|
||||
// through `ollama pull` server-side, not snapshot_download.
|
||||
if (!ollamaName && !/^[^\s/]+\/[^\s/]+$/.test(repo)) {
|
||||
uiModule.showToast('Enter a full HuggingFace repo ID like "org/model-name", or an Ollama name like "qwen2.5:14b".');
|
||||
dlInput.focus();
|
||||
return;
|
||||
}
|
||||
@@ -1383,12 +1361,13 @@ function _wireTabEvents(body) {
|
||||
if (srvVal !== 'local') {
|
||||
host = _serverByVal(srvVal)?.host || '';
|
||||
}
|
||||
const _hsrv = srvVal !== 'local' ? (_serverByVal(srvVal) || {}) : {};
|
||||
const _hsrv = _envState.servers.find(sv => sv.host === host) || {};
|
||||
let env = host ? (_hsrv.env || 'none') : _envState.env;
|
||||
let envPath = host ? (_hsrv.envPath || '') : _envState.envPath;
|
||||
const payload = { repo_id: repo };
|
||||
if (ollamaName) payload.backend = 'ollama';
|
||||
if (autoInclude) payload.include = autoInclude;
|
||||
if (_envState.hfToken) payload.hf_token = _envState.hfToken;
|
||||
if (_envState.hfToken && !ollamaName) payload.hf_token = _envState.hfToken;
|
||||
if (host) { payload.remote_host = host; const _sp3 = _getPort(host); if (_sp3) payload.ssh_port = _sp3; }
|
||||
const srvPlatform = _getPlatform(host);
|
||||
if (srvPlatform) payload.platform = srvPlatform;
|
||||
@@ -1432,7 +1411,7 @@ function _wireTabEvents(body) {
|
||||
// the section is collapsed (the body's content normally provides
|
||||
// separation; with no body visible, the line gives the h2 definition).
|
||||
dlFold.classList.toggle('is-folded', !folded);
|
||||
try { localStorage.setItem('cookbook_dl_tab_folded_v1', folded ? '0' : '1'); } catch { }
|
||||
try { localStorage.setItem('cookbook_dl_tab_folded_v1', folded ? '0' : '1'); } catch {}
|
||||
});
|
||||
}
|
||||
const hfToggle = document.getElementById('cookbook-hf-latest-toggle');
|
||||
@@ -1478,7 +1457,7 @@ function _wireTabEvents(body) {
|
||||
_hwCache[cacheKey] = hw;
|
||||
return hw;
|
||||
}
|
||||
} catch { }
|
||||
} catch {}
|
||||
_hwCache[cacheKey] = { vram: 0, backend: '' };
|
||||
return _hwCache[cacheKey];
|
||||
}
|
||||
@@ -1591,6 +1570,84 @@ function _wireTabEvents(body) {
|
||||
document.getElementById('hwfit-server-select')?.addEventListener('change', _onServerChange);
|
||||
}
|
||||
|
||||
// Browse Ollama library — popular models from ollama.com via cached backend
|
||||
// proxy. Click a row → fills the download input with `<name>:<size>` so the
|
||||
// existing Download button kicks off `ollama pull`.
|
||||
const olToggle = document.getElementById('cookbook-ollama-toggle');
|
||||
const olArrow = document.getElementById('cookbook-ollama-arrow');
|
||||
const olList = document.getElementById('cookbook-ollama-list');
|
||||
const olRefresh = document.getElementById('cookbook-ollama-refresh');
|
||||
if (olToggle && olList) {
|
||||
let _olLoaded = false;
|
||||
async function _loadOllama(refresh = false) {
|
||||
olList.innerHTML = '<div class="hwfit-loading" style="opacity:0.5;font-size:11px;text-align:center;padding:12px;">Loading…</div>';
|
||||
try {
|
||||
const res = await fetch(`/api/cookbook/ollama/library${refresh ? '?refresh=1' : ''}`);
|
||||
const data = await res.json();
|
||||
const models = data.models || [];
|
||||
if (!models.length) {
|
||||
olList.innerHTML = '<div class="hwfit-loading">No models</div>';
|
||||
return;
|
||||
}
|
||||
let html = '';
|
||||
for (const m of models) {
|
||||
const sizes = Array.isArray(m.sizes) && m.sizes.length ? m.sizes : ['latest'];
|
||||
const sizeChips = sizes.map(s => `<button type="button" class="memory-toolbar-btn cookbook-ol-size" data-name="${esc(m.name)}" data-size="${esc(s)}" style="height:20px;padding:0 6px;font-size:10px;border-radius:3px;">${esc(s)}</button>`).join('');
|
||||
html += `<div class="doclib-card memory-item cookbook-ollama-card" data-name="${esc(m.name)}">`;
|
||||
html += `<div style="flex:1;min-width:0;">`;
|
||||
html += `<div class="memory-item-title">${esc(m.name)} <a href="https://ollama.com/library/${esc(m.name)}" target="_blank" rel="noopener" class="cookbook-hf-link">ollama ↗</a></div>`;
|
||||
if (m.description) html += `<div class="memory-item-meta" style="font-size:10px;opacity:0.55;margin-top:2px;">${esc(m.description)}</div>`;
|
||||
html += `<div style="display:flex;flex-wrap:wrap;gap:3px;margin-top:4px;">${sizeChips}</div>`;
|
||||
html += `</div></div>`;
|
||||
}
|
||||
olList.innerHTML = html;
|
||||
olList.querySelectorAll('.cookbook-ol-size').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const name = btn.dataset.name;
|
||||
const size = btn.dataset.size;
|
||||
if (dlInput) {
|
||||
dlInput.value = `${name}:${size}`;
|
||||
dlInput.focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
// Clicking the card body (not a size chip / link) → default to first size
|
||||
olList.querySelectorAll('.cookbook-ollama-card').forEach(card => {
|
||||
card.addEventListener('click', (e) => {
|
||||
if (e.target.closest('a') || e.target.closest('.cookbook-ol-size')) return;
|
||||
const name = card.dataset.name;
|
||||
const firstSize = card.querySelector('.cookbook-ol-size')?.dataset.size || 'latest';
|
||||
if (dlInput) {
|
||||
dlInput.value = `${name}:${firstSize}`;
|
||||
dlInput.focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
olList.innerHTML = '<div class="hwfit-loading">Failed to load</div>';
|
||||
}
|
||||
}
|
||||
olToggle.addEventListener('click', () => {
|
||||
const isOpen = olList.style.display !== 'none';
|
||||
olList.style.display = isOpen ? 'none' : 'flex';
|
||||
if (olArrow) olArrow.style.transform = isOpen ? 'rotate(0deg)' : 'rotate(90deg)';
|
||||
if (!isOpen && !_olLoaded) {
|
||||
_olLoaded = true;
|
||||
_loadOllama(false);
|
||||
}
|
||||
});
|
||||
if (olRefresh) olRefresh.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
_olLoaded = true;
|
||||
_loadOllama(true);
|
||||
if (olList.style.display === 'none') {
|
||||
olList.style.display = 'flex';
|
||||
if (olArrow) olArrow.style.transform = 'rotate(90deg)';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Server add button, row removal, model-dir add/remove, and per-row wiring
|
||||
// are ALL owned by cookbook-hwfit.js's _hwfitInit / _wireServerEntry.
|
||||
// A duplicate add handler used to live here and fired alongside the hwfit
|
||||
@@ -1603,7 +1660,7 @@ function _wireTabEvents(body) {
|
||||
hfInput.addEventListener('change', async () => {
|
||||
const val = hfInput.value.trim();
|
||||
_envState.hfToken = val;
|
||||
try { await _persistEnvState(); } catch { }
|
||||
try { await _persistEnvState(); } catch {}
|
||||
if (val) {
|
||||
_envState.hfTokenConfigured = true;
|
||||
const masked = val.length > 6 ? val.slice(0, 3) + '…' + val.slice(-3) : '••••';
|
||||
@@ -1643,9 +1700,8 @@ export function _serverEntryHtml(s, i, defaultServer, forceRemote, isNew) {
|
||||
let html = '';
|
||||
html += `<div class="cookbook-server-entry" data-idx="${i}" data-platform="${esc(s.platform || '')}">`;
|
||||
const _srvTitle = s.name || (isLocal ? 'Local' : (s.host || `Server ${i + 1}`));
|
||||
const _srvKey = isLocal ? 'local' : _serverKey(s);
|
||||
const _legacyDefault = !String(defaultServer || '').startsWith('srv:') && !isLocal && (defaultServer || '') === (s.host || '');
|
||||
const _isDefaultSrv = (defaultServer || '') === _srvKey || _legacyDefault;
|
||||
const _srvKey = isLocal ? 'local' : (s.host || '');
|
||||
const _isDefaultSrv = (defaultServer || '') === _srvKey;
|
||||
const _pIco = _platformIcon(s.platform);
|
||||
const _keyBtn = `<button class="cookbook-server-key-btn" title="Set up SSH key for this server" style="height:22px;box-sizing:border-box;display:inline-flex;align-items:center;position:relative;top:-2px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;flex-shrink:0;"><circle cx="7.5" cy="15.5" r="5.5"/><path d="M12 11l8-8"/><path d="M17 6l3 3"/></svg>Key</button>`;
|
||||
const _checkBtn = `<button class="cookbook-server-check-btn" title="Check SSH connection" style="height:22px;box-sizing:border-box;display:inline-flex;align-items:center;position:relative;top:-2px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;flex-shrink:0;"><polyline points="20 6 9 17 4 12"/></svg>Check</button>`;
|
||||
@@ -1775,9 +1831,22 @@ function _renderRecipes() {
|
||||
html += `<button class="memory-toolbar-btn cookbook-dl-add-server" title="Add server in Settings" style="height:28px;">add server</button>`;
|
||||
html += `</div>`;
|
||||
html += `<div class="cookbook-dl-input" style="margin-top:0;">`;
|
||||
html += `<input type="text" class="cookbook-dl-repo" id="cookbook-dl-repo" placeholder="org/model-name, HF URL, or org/model:QUANT_TAG" />`;
|
||||
html += `<input type="text" class="cookbook-dl-repo" id="cookbook-dl-repo" placeholder="org/model-name, qwen2.5:14b, or HF URL" />`;
|
||||
html += `<button class="cookbook-btn cookbook-dl-btn" id="cookbook-dl-btn">Download</button>`;
|
||||
html += `</div>`;
|
||||
// Browse Ollama library — fetches popular models from ollama.com via the
|
||||
// /api/cookbook/ollama/library cached proxy, click → fills the input with
|
||||
// `<name>:<size>` so the existing Download button kicks off `ollama pull`.
|
||||
html += `<div style="margin-top:5px;position:relative;top:-3px;">`;
|
||||
html += `<div style="display:flex;gap:4px;align-items:center;">`;
|
||||
html += `<button type="button" class="memory-toolbar-btn" id="cookbook-ollama-toggle" style="flex:1;text-align:left;height:26px;display:flex;align-items:center;gap:6px;border-radius:4px;">`;
|
||||
html += `<span id="cookbook-ollama-arrow" style="display:inline-block;transition:transform 0.15s;pointer-events:none;">▸</span>`;
|
||||
html += `<span style="pointer-events:none;">Browse Ollama library</span>`;
|
||||
html += `</button>`;
|
||||
html += `<button type="button" class="memory-toolbar-btn" id="cookbook-ollama-refresh" title="Refresh" style="height:26px;width:26px;padding:0;border-radius:4px;">↻</button>`;
|
||||
html += `</div>`;
|
||||
html += `<div id="cookbook-ollama-list" style="display:none;margin-top:4px;max-height:320px;overflow-y:auto;flex-direction:column;gap:4px;"></div>`;
|
||||
html += `</div>`;
|
||||
// Latest HF models that fit — collapsible card list
|
||||
html += `<div style="margin-top:5px;position:relative;top:-3px;">`;
|
||||
html += `<div style="display:flex;gap:4px;align-items:center;">`;
|
||||
@@ -1804,7 +1873,7 @@ function _renderRecipes() {
|
||||
html += '<option value="general" selected>Standard</option><option value="coding">Coding</option>';
|
||||
html += '<option value="reasoning">Reasoning</option><option value="chat">Chat</option>';
|
||||
// Image tab removed — text→image gen is gone from this build (only inpaint
|
||||
// remains, which uses its own settings panel). Vision (multimodal) stays.
|
||||
// remains, which uses its own settings panel). Vision (multimodal) stays.
|
||||
html += '<option value="multimodal">Vision</option></select>';
|
||||
// Engine sits next to the type filter so the "what category / which serving
|
||||
// path" filters live together; Quant + Context are storage-format and budget
|
||||
@@ -1813,6 +1882,7 @@ function _renderRecipes() {
|
||||
html += '<select class="cookbook-field-input hwfit-engine" id="hwfit-engine" style="height:28px;" title="Filter by serving engine">';
|
||||
html += '<option value="">Engine</option>';
|
||||
html += '<option value="llamacpp">llama.cpp</option>';
|
||||
html += '<option value="ollama">Ollama</option>';
|
||||
html += '<option value="vllm">vLLM</option>';
|
||||
html += '<option value="sglang">SGLang</option>';
|
||||
html += '</select>';
|
||||
@@ -1869,13 +1939,13 @@ function _renderRecipes() {
|
||||
// Footer: link to the public discussion where users can request additions
|
||||
// to the curated model list. Sits below the list so it reads as a callout
|
||||
// after browsing, not a header.
|
||||
html += '<div class="hwfit-list-footer" style="margin-top:8px;padding-top:6px;border-top:1px solid color-mix(in srgb, var(--border) 50%, transparent);font-size:9.5px;opacity:0.65;text-align:right;">'
|
||||
+ 'Don\'t see a model? '
|
||||
+ '<a href="https://github.com/pewdiepie-archdaemon/odysseus/discussions/1962" target="_blank" rel="noopener" style="color:var(--accent,var(--red));text-decoration:none;display:inline-flex;align-items:center;gap:4px;vertical-align:middle;">'
|
||||
+ 'Request it →'
|
||||
+ '<svg width="11" height="11" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" style="flex-shrink:0;"><path d="M8 0C3.58 0 0 3.58 0 8a8 8 0 0 0 5.47 7.59c.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>'
|
||||
+ '</a>'
|
||||
+ '</div>';
|
||||
html += '<div class="hwfit-list-footer" style="display:none;">'
|
||||
+ 'Don\'t see a model? '
|
||||
+ '<a href="https://github.com/pewdiepie-archdaemon/odysseus/discussions/1962" target="_blank" rel="noopener" style="color:var(--accent,var(--red));text-decoration:none;display:inline-flex;align-items:center;gap:4px;vertical-align:middle;position:relative;top:-1px;">'
|
||||
+ 'Request it →'
|
||||
+ '<svg width="11" height="11" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" style="flex-shrink:0;"><path d="M8 0C3.58 0 0 3.58 0 8a8 8 0 0 0 5.47 7.59c.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>'
|
||||
+ '</a>'
|
||||
+ '</div>';
|
||||
|
||||
html += '</div></div>';
|
||||
|
||||
@@ -1885,7 +1955,7 @@ function _renderRecipes() {
|
||||
html += '<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;">';
|
||||
html += '<h2 style="margin:0;padding:0;line-height:1;">Serve <span id="serve-stats" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal"></span></h2>';
|
||||
html += '</div>';
|
||||
const _selSrv = _selectedServer() || _es.servers[0] || {};
|
||||
const _selSrv = _es.servers.find(s => s.host === _es.remoteHost) || _es.servers[0] || {};
|
||||
const _srvDirs = (Array.isArray(_selSrv.modelDirs) ? _selSrv.modelDirs : [_selSrv.modelDir || '~/.cache/huggingface/hub']).map(d => d.replaceAll('✕', '').replaceAll('✖', '').trim()).filter(Boolean);
|
||||
html += '<div class="cookbook-serve-dirs" style="margin-top:6px;">';
|
||||
html += _srvDirs.map(d => `<span class="cookbook-serve-dir-pill">${esc(d)}</span>`).join('');
|
||||
@@ -1909,7 +1979,7 @@ function _renderRecipes() {
|
||||
html += '<label class="memory-bulk-check-all"><input type="checkbox" id="serve-select-all"> All</label>';
|
||||
html += '<span id="serve-bulk-count" style="font-size:10px;opacity:0.5;">0 selected</span>';
|
||||
html += '<button class="memory-toolbar-btn danger" id="serve-bulk-delete" style="position:relative;top:-3px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>Delete</button>';
|
||||
html += '<button class="memory-toolbar-btn" id="serve-bulk-cancel" title="Cancel (Esc)" style="margin-left:4px;padding:3px 6px;position:relative;top:-3px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>';
|
||||
html += '<button class="memory-toolbar-btn" id="serve-bulk-cancel" title="Cancel (Esc)" style="margin-left:4px;padding:3px 6px;position:relative;top:-7px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>';
|
||||
html += '</div>';
|
||||
|
||||
html += '<div class="doclib-grid hwfit-cached-list" id="hwfit-cached-list"></div>';
|
||||
@@ -1963,7 +2033,7 @@ function _renderRecipes() {
|
||||
html += '<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;margin-top:-4px;">';
|
||||
html += '<h2 style="margin:0;padding:0;line-height:1;">Servers</h2>';
|
||||
// Reuse the calendar +New pill: spinning plus, label fades in idea uses
|
||||
// the same `.cal-add-btn-text` rules, so styling stays consistent.
|
||||
// the same `.cal-add-btn-text` rules, so styling stays consistent.
|
||||
html += '<button class="cal-add-btn cal-add-btn-text" id="cookbook-server-add" title="Add server" style="margin-left:auto;"><span class="cal-add-plus">+</span><span class="cal-add-label">Add</span></button>';
|
||||
html += '</div>';
|
||||
html += '<p class="memory-desc doclib-desc">Configure SSH servers, install Odysseus keys, choose model directories, and set the default server. Local is this machine.</p>';
|
||||
@@ -2059,73 +2129,73 @@ export async function open(opts) {
|
||||
}
|
||||
_setCookbookOpening(true);
|
||||
try {
|
||||
// Invalidate any pending close() animation handlers so they won't re-hide us
|
||||
_closeGen++;
|
||||
// Clear any leftover inline styles from a previous swipe-dismiss or close animation
|
||||
const _content = modal.querySelector('.modal-content');
|
||||
if (_content) {
|
||||
_content.classList.remove('modal-closing', 'sheet-ready', 'cookbook-modal-entering');
|
||||
_content.style.transform = '';
|
||||
_content.style.transition = '';
|
||||
_content.style.animation = '';
|
||||
_content.style.opacity = '';
|
||||
// Invalidate any pending close() animation handlers so they won't re-hide us
|
||||
_closeGen++;
|
||||
// Clear any leftover inline styles from a previous swipe-dismiss or close animation
|
||||
const _content = modal.querySelector('.modal-content');
|
||||
if (_content) {
|
||||
_content.classList.remove('modal-closing', 'sheet-ready', 'cookbook-modal-entering');
|
||||
_content.style.transform = '';
|
||||
_content.style.transition = '';
|
||||
_content.style.animation = '';
|
||||
_content.style.opacity = '';
|
||||
}
|
||||
modal.style.display = '';
|
||||
Modals.register('cookbook-modal', {
|
||||
railBtnId: 'rail-cookbook',
|
||||
sidebarBtnId: 'tool-cookbook-btn',
|
||||
closeFn: () => _doClose(),
|
||||
restoreFn: () => { _renderRunningTab(); },
|
||||
});
|
||||
_wireCookbookDrag(modal);
|
||||
await _syncFromServer();
|
||||
// `_syncFromServer` lives in cookbookRunning.js and populates *its* _envState
|
||||
// (a different object reference than this module's), then mirrors the merged
|
||||
// state to localStorage. So ALWAYS hydrate our _envState from that mirror —
|
||||
// on a successful sync it holds the freshly-fetched servers; on failure it
|
||||
// holds the last-known state. Gating this on `!synced` left the render's
|
||||
// _envState empty whenever sync succeeded → "servers don't show".
|
||||
try { Object.assign(_envState, _readStoredEnvState()); } catch {}
|
||||
// Honour a user-set default server: always land on it when Cookbook opens, so
|
||||
// every dropdown (scan/download/serve/cache/deps) starts on the same machine.
|
||||
if (_envState.defaultServer) {
|
||||
const _dk = _envState.defaultServer;
|
||||
if (_dk === 'local') {
|
||||
_envState.remoteHost = ''; _envState.env = 'none'; _envState.envPath = ''; _envState.platform = '';
|
||||
} else {
|
||||
const _ds = (_envState.servers || []).find(s => s.host === _dk);
|
||||
if (_ds) { _envState.remoteHost = _ds.host; _envState.env = _ds.env || 'none'; _envState.envPath = _ds.envPath || ''; _envState.platform = _ds.platform || ''; }
|
||||
}
|
||||
modal.style.display = '';
|
||||
Modals.register('cookbook-modal', {
|
||||
railBtnId: 'rail-cookbook',
|
||||
sidebarBtnId: 'tool-cookbook-btn',
|
||||
closeFn: () => _doClose(),
|
||||
restoreFn: () => { _renderRunningTab(); },
|
||||
});
|
||||
_wireCookbookDrag(modal);
|
||||
await _syncFromServer();
|
||||
// `_syncFromServer` lives in cookbookRunning.js and populates *its* _envState
|
||||
// (a different object reference than this module's), then mirrors the merged
|
||||
// state to localStorage. So ALWAYS hydrate our _envState from that mirror —
|
||||
// on a successful sync it holds the freshly-fetched servers; on failure it
|
||||
// holds the last-known state. Gating this on `!synced` left the render's
|
||||
// _envState empty whenever sync succeeded → "servers don't show".
|
||||
try { Object.assign(_envState, _readStoredEnvState()); } catch { }
|
||||
// Honour a user-set default server: always land on it when Cookbook opens, so
|
||||
// every dropdown (scan/download/serve/cache/deps) starts on the same machine.
|
||||
if (_envState.defaultServer) {
|
||||
const _dk = _envState.defaultServer;
|
||||
if (_dk === 'local') {
|
||||
_envState.remoteHost = ''; _envState.remoteServerKey = ''; _envState.env = 'none'; _envState.envPath = ''; _envState.platform = '';
|
||||
} else {
|
||||
const _ds = _serverByVal(_dk);
|
||||
if (_ds) { _envState.remoteHost = _ds.host; _envState.remoteServerKey = _serverKey(_ds); _envState.env = _ds.env || 'none'; _envState.envPath = _ds.envPath || ''; _envState.platform = _ds.platform || ''; }
|
||||
}
|
||||
}
|
||||
// Re-render on every open AFTER sync so the freshly-fetched state (servers,
|
||||
// HF token, presets) is always reflected. Gating this to once-per-page used
|
||||
// to freeze a stale/empty servers list whenever the first sync raced or
|
||||
// returned before hydration — and since close/reopen doesn't reset the page,
|
||||
// only a full reload recovered it. Re-rendering is cheap and the in-progress
|
||||
// Running tab is rendered separately just below.
|
||||
_renderRecipes();
|
||||
_rendered = true;
|
||||
_clearCookbookNotif();
|
||||
_renderRunningTab();
|
||||
// Self-heal: revive any download tasks whose tmux session is still alive
|
||||
// but were persisted as done/error (covers the "restarted server while a
|
||||
// big multi-shard download was in flight" case — the task survived in
|
||||
// tmux, the cookbook just lost track of it).
|
||||
try { _selfHealStaleTasks({ oneShot: true }); } catch { }
|
||||
if (_content) {
|
||||
// Put the panel in its entering state before it becomes visible. On
|
||||
// mobile, showing first and adding the class a frame later can paint the
|
||||
// sheet at its final position, which makes the slide-up look like a snap.
|
||||
_content.classList.add('cookbook-modal-entering');
|
||||
}
|
||||
modal.classList.remove('hidden');
|
||||
if (_content) {
|
||||
void _content.offsetWidth;
|
||||
_content.addEventListener('animationend', () => {
|
||||
_content.classList.remove('cookbook-modal-entering');
|
||||
}, { once: true });
|
||||
}
|
||||
setTimeout(_applyIntent, 0);
|
||||
}
|
||||
// Re-render on every open AFTER sync so the freshly-fetched state (servers,
|
||||
// HF token, presets) is always reflected. Gating this to once-per-page used
|
||||
// to freeze a stale/empty servers list whenever the first sync raced or
|
||||
// returned before hydration — and since close/reopen doesn't reset the page,
|
||||
// only a full reload recovered it. Re-rendering is cheap and the in-progress
|
||||
// Running tab is rendered separately just below.
|
||||
_renderRecipes();
|
||||
_rendered = true;
|
||||
_clearCookbookNotif();
|
||||
_renderRunningTab();
|
||||
// Self-heal: revive any download tasks whose tmux session is still alive
|
||||
// but were persisted as done/error (covers the "restarted server while a
|
||||
// big multi-shard download was in flight" case — the task survived in
|
||||
// tmux, the cookbook just lost track of it).
|
||||
try { _selfHealStaleTasks({ oneShot: true }); } catch {}
|
||||
if (_content) {
|
||||
// Put the panel in its entering state before it becomes visible. On
|
||||
// mobile, showing first and adding the class a frame later can paint the
|
||||
// sheet at its final position, which makes the slide-up look like a snap.
|
||||
_content.classList.add('cookbook-modal-entering');
|
||||
}
|
||||
modal.classList.remove('hidden');
|
||||
if (_content) {
|
||||
void _content.offsetWidth;
|
||||
_content.addEventListener('animationend', () => {
|
||||
_content.classList.remove('cookbook-modal-entering');
|
||||
}, { once: true });
|
||||
}
|
||||
setTimeout(_applyIntent, 0);
|
||||
} finally {
|
||||
_setCookbookOpening(false);
|
||||
}
|
||||
@@ -2216,10 +2286,9 @@ const shared = {
|
||||
_sshCmd,
|
||||
_getPort,
|
||||
_sshPrefix,
|
||||
_getPlatform,
|
||||
_serverByVal,
|
||||
_selectedServer,
|
||||
_currentServerValue,
|
||||
_getPlatform,
|
||||
_isWindows,
|
||||
_isMetal,
|
||||
_buildEnvPrefix,
|
||||
@@ -2271,7 +2340,7 @@ export {
|
||||
_startBackgroundMonitor,
|
||||
_setPanelField, _setPanelCheckbox,
|
||||
_wirePanelEvents, _runPanelCmd, _runModelDownload, _buildDownloadCmd,
|
||||
_serverByVal, _isLocalEntry,
|
||||
_isLocalEntry,
|
||||
};
|
||||
|
||||
const cookbookModule = { open, close, isVisible, startBackgroundMonitor: _startBackgroundMonitor };
|
||||
|
||||
@@ -242,11 +242,7 @@ export function _wirePanelEvents(panel, model, backend) {
|
||||
const dlBtn = panel.querySelector('.hwfit-dl-btn');
|
||||
if (dlBtn) {
|
||||
dlBtn.addEventListener('click', () => {
|
||||
if (backend === 'ollama') {
|
||||
_runPanelCmd(panel, _buildDownloadCmd(model, backend), { timeout: 0 });
|
||||
} else {
|
||||
_runModelDownload(panel, model, backend);
|
||||
}
|
||||
_runModelDownload(panel, model, backend)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -459,7 +455,9 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
|
||||
uiModule.showToast(_missingGgufMessage(model));
|
||||
return;
|
||||
}
|
||||
const repo = ggufSource?.repo || model.quant_repo || model.name;
|
||||
const repo = backend === 'ollama'
|
||||
? (model.ollama || model.ollama_name || model.name)
|
||||
: (ggufSource?.repo || model.quant_repo || model.name);
|
||||
const include = backend === 'llamacpp' ? _ggufIncludePattern(model, ggufSource) : null;
|
||||
|
||||
_syncEnvFromPanel(panel);
|
||||
@@ -494,7 +492,7 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
|
||||
const platform = host ? (srv.platform || '') : (_envState.platform || '');
|
||||
const isWin = host ? (platform === 'windows') : _isWindows();
|
||||
|
||||
const payload = { repo_id: repo };
|
||||
const payload = { repo_id: repo, backend };
|
||||
if (include) payload.include = include;
|
||||
// Large downloads are where hf_transfer most often dies near the end. Use the
|
||||
// plain HuggingFace downloader up front for big model files; it is slower, but
|
||||
|
||||
@@ -1564,6 +1564,10 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid
|
||||
const payload = { repo_id: repo, remote_host: _host || undefined, ssh_port: _sp || undefined, _cmd: cmd, _fields: fields || undefined, _env: _usedEnv, _envPath: _usedEnvPath, _gpus: _usedGpus };
|
||||
_addTask(data.session_id, shortName, 'serve', payload);
|
||||
uiModule.showToast(`Serving ${shortName}...`);
|
||||
// Auto-register may have enabled an existing (offline) endpoint for this
|
||||
// host:port. Refresh the picker so the row is no longer dimmed, and the
|
||||
// user doesn't see "offline" on a serve they just started.
|
||||
try { _refreshModelsAfterEndpointChange(); } catch (_) {}
|
||||
} catch (e) {
|
||||
uiModule.showToast('Failed: ' + e.message);
|
||||
}
|
||||
@@ -3032,6 +3036,11 @@ async function _reconnectTask(el, task) {
|
||||
if (info.status === 'ready' && !task._serveReady) {
|
||||
task._serveReady = true;
|
||||
_updateTask(task.sessionId, { _serveReady: true });
|
||||
// The auto-registered endpoint was marked offline while the
|
||||
// server was coming up. Now that it's reachable, nudge the
|
||||
// picker to re-probe so the offline pill clears without the
|
||||
// user having to reopen Settings or refresh the page.
|
||||
try { _refreshModelsAfterEndpointChange(); } catch (_) {}
|
||||
}
|
||||
if (info.phase) {
|
||||
badge.textContent = info.phase;
|
||||
|
||||
@@ -129,7 +129,7 @@ try { (function () {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="hwfit-schedule-row">
|
||||
<div class="hwfit-schedule-row hwfit-schedule-when-row">
|
||||
<label class="hwfit-schedule-field">
|
||||
<span>From</span>
|
||||
<input type="time" class="hwfit-sched-start cookbook-field-input" value="09:00" />
|
||||
@@ -138,24 +138,24 @@ try { (function () {
|
||||
<span>Until</span>
|
||||
<input type="time" class="hwfit-sched-end cookbook-field-input" value="17:00" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="hwfit-schedule-row hwfit-schedule-days-row">
|
||||
<span class="hwfit-schedule-label">Days</span>
|
||||
<div class="hwfit-sched-days">
|
||||
${DAYS.map(d => `
|
||||
<button type="button" class="hwfit-sched-day-chip${WEEKDAYS.has(d.k) ? " is-on" : ""}" data-day="${d.k}">${d.l}</button>
|
||||
`).join("")}
|
||||
<label class="hwfit-schedule-field hwfit-schedule-days-field">
|
||||
<span>Days</span>
|
||||
<div class="hwfit-sched-days">
|
||||
${DAYS.map(d => `
|
||||
<button type="button" class="hwfit-sched-day-chip${WEEKDAYS.has(d.k) ? " is-on" : ""}" data-day="${d.k}">${d.l}</button>
|
||||
`).join("")}
|
||||
</div>
|
||||
</label>
|
||||
<div class="hwfit-schedule-actions-inline">
|
||||
<button type="button" class="cookbook-btn hwfit-sched-cancel" title="Cancel">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:5px;flex-shrink:0;"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
<span>Cancel</span>
|
||||
</button>
|
||||
<button type="button" class="cookbook-btn hwfit-sched-save" title="Save schedule" aria-label="Save schedule">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:5px;flex-shrink:0;"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
<span>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
<span class="hwfit-schedule-actions-spacer"></span>
|
||||
<button type="button" class="cookbook-btn hwfit-sched-cancel" title="Cancel">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:5px;flex-shrink:0;"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
<span>Cancel</span>
|
||||
</button>
|
||||
<button type="button" class="cookbook-btn hwfit-sched-save" title="Save schedule" aria-label="Save schedule">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:5px;flex-shrink:0;"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
<span>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="hwfit-sched-err"></div>
|
||||
|
||||
+163
-127
@@ -14,7 +14,6 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
let _envState;
|
||||
let _sshCmd;
|
||||
let _getPort;
|
||||
let _serverByVal;
|
||||
let _sshPrefix;
|
||||
let _getPlatform;
|
||||
let _isWindows;
|
||||
@@ -115,7 +114,7 @@ function _selectedServeTarget(panel) {
|
||||
: (server?.name || 'local server');
|
||||
return {
|
||||
host,
|
||||
port: host ? (server?.port || _getPort(host) || '') : '',
|
||||
port: host ? (_getPort(host) || server?.port || '') : '',
|
||||
venv,
|
||||
label,
|
||||
};
|
||||
@@ -243,21 +242,6 @@ function _shellPathExpr(path) {
|
||||
function _selectedGgufExpr(model, repo, relPath) {
|
||||
const rel = String(relPath || '').replace(/^\/+/, '');
|
||||
if (!rel) return '';
|
||||
if (_isWindows()) {
|
||||
// PowerShell: plain path — no bash $() syntax (backend validator rejects
|
||||
// $( ) in non-prelude commands, and PowerShell doesn't have printf).
|
||||
const relW = rel.replace(/\//g, '\\');
|
||||
if (model.is_local_dir && model.path) {
|
||||
const base = String(model.path || '').replace(/\/+$/, '').replace(/\//g, '\\');
|
||||
return `${base}\\${repo.replace(/\//g, '\\')}\\${relW}`;
|
||||
}
|
||||
if (model.path) {
|
||||
const base = String(model.path || '').replace(/\/+$/, '').replace(/\//g, '\\');
|
||||
return `${base}\\models--${repo.replace(/\//g, '--')}\\snapshots\\${relW}`;
|
||||
}
|
||||
const cacheRepo = repo.replace(/\//g, '--');
|
||||
return `$env:USERPROFILE\\.cache\\huggingface\\hub\\models--${cacheRepo}\\snapshots\\${relW}`;
|
||||
}
|
||||
if (model.is_local_dir && model.path) {
|
||||
const base = String(model.path || '').replace(/\/+$/, '');
|
||||
return `$(printf %s ${_shellPathExpr(`${base}/${repo}/${rel}`)})`;
|
||||
@@ -271,15 +255,6 @@ function _selectedGgufExpr(model, repo, relPath) {
|
||||
}
|
||||
|
||||
function _ggufSearchDirExpr(model, repo) {
|
||||
if (_isWindows()) {
|
||||
if (model.is_local_dir && model.path) {
|
||||
return `${String(model.path || '').replace(/\/+$/, '').replace(/\//g, '\\')}\\${repo.replace(/\//g, '\\')}`;
|
||||
}
|
||||
if (model.path) {
|
||||
return `${String(model.path || '').replace(/\/+$/, '').replace(/\//g, '\\')}\\models--${repo.replace(/\//g, '--')}\\snapshots`;
|
||||
}
|
||||
return `$env:USERPROFILE\\.cache\\huggingface\\hub\\models--${repo.replace(/\//g, '--')}\\snapshots`;
|
||||
}
|
||||
if (model.is_local_dir && model.path) return _shellQuote(`${String(model.path || '').replace(/\/+$/, '')}/${repo}`);
|
||||
if (model.path) return _shellQuote(`${String(model.path || '').replace(/\/+$/, '')}/models--${repo.replace(/\//g, '--')}/snapshots`);
|
||||
return `"$HOME/.cache/huggingface/hub/models--${repo.replace(/\//g, '--')}/snapshots"`;
|
||||
@@ -600,7 +575,7 @@ function _rerenderCachedModels() {
|
||||
+ `<button type="button" class="cookbook-slot-btn cookbook-saved-arrow" title="${esc(_arrowTitle)}">${_arrowLabel}</button>`
|
||||
+ `</div>`;
|
||||
|
||||
let panelHtml = `<div class="hwfit-serve-panel">${_slotsHtml}`;
|
||||
let panelHtml = `<div class="hwfit-serve-panel">`;
|
||||
// Warn when serving a model whose download hasn't fully completed —
|
||||
// the user CAN still hit Launch (vLLM/llama-server will start, then
|
||||
// crash trying to read missing shards), but they should know.
|
||||
@@ -633,26 +608,48 @@ function _rerenderCachedModels() {
|
||||
_gpuBtnsHtml += `<button type="button" class="cookbook-gpu-btn${on ? ' active' : ''}" data-gpu="${i}">${i}</button>`;
|
||||
}
|
||||
panelHtml += `<label>${_l('GPUs','Toggle which GPUs to use')}<div class="cookbook-gpu-group">${_gpuBtnsHtml}</div><input type="hidden" class="hwfit-sf" data-field="gpus" value="${esc(defaultGpus)}" /></label>`;
|
||||
// Save / saved-configs split button — moved into Row 1 (next to GPUs)
|
||||
// so it shares the same baseline as the rest of the top controls.
|
||||
panelHtml += _slotsHtml;
|
||||
panelHtml += `</div>`;
|
||||
panelHtml += `<div class="hwfit-serve-runtime-note" style="display:none;font-size:11px;line-height:1.35;color:var(--fg-muted);margin-top:-4px;"></div>`;
|
||||
if (_ggufChoices.length > 1) {
|
||||
panelHtml += `<div class="hwfit-serve-row hwfit-backend-llamacpp">`;
|
||||
panelHtml += `<label class="hwfit-backend-llamacpp">${_l('GGUF File','Choose the exact GGUF artifact to serve from this cached model folder.')}<select class="hwfit-sf hwfit-sf-wide" data-field="gguf_file">${_ggufOptions}</select></label>`;
|
||||
// Show the GGUF File dropdown for BOTH llama.cpp and Ollama — Ollama
|
||||
// also needs to know which exact .gguf to import via the new
|
||||
// `docker exec ollama-test ollama-import` auto-fill (otherwise the
|
||||
// helper falls back to "first sorted gguf", which may not match what
|
||||
// the user picked).
|
||||
panelHtml += `<div class="hwfit-serve-row hwfit-backend-llamacpp hwfit-backend-ollama">`;
|
||||
panelHtml += `<label class="hwfit-backend-llamacpp hwfit-backend-ollama">${_l('GGUF File','Choose the exact GGUF artifact to serve from this cached model folder.')}<select class="hwfit-sf hwfit-sf-wide" data-field="gguf_file">${_ggufOptions}</select></label>`;
|
||||
panelHtml += `</div>`;
|
||||
} else if (_defaultGguf) {
|
||||
panelHtml += `<input type="hidden" class="hwfit-sf" data-field="gguf_file" value="${esc(_defaultGguf)}" />`;
|
||||
}
|
||||
// Row 2: Core settings
|
||||
panelHtml += `<div class="hwfit-serve-row hwfit-backend-vllm hwfit-backend-sglang hwfit-backend-llamacpp">`;
|
||||
// Row 2: Core settings — the handful you actually touch every launch.
|
||||
// TP / Context / GPU / GPU Mem / Max Seqs / Dtype. Everything else
|
||||
// (Swap, KV Cache, Attention backend, Env vars, llama.cpp batch/ubatch)
|
||||
// moved to the Advanced fold below to keep this row scannable.
|
||||
panelHtml += `<div class="hwfit-serve-row hwfit-backend-vllm hwfit-backend-sglang hwfit-backend-llamacpp hwfit-backend-ollama">`;
|
||||
panelHtml += `<label class="hwfit-backend-vllm hwfit-backend-sglang">${_l('TP','Tensor Parallelism — split model across N GPUs')}<select class="hwfit-sf" data-field="tp">${tpOpts}</select></label>`;
|
||||
// ctx resets to the model's max on every panel open (the real ctx slider
|
||||
// lives in the Scan/Download toolbar — see cookbook.js .hwfit-ctx-control).
|
||||
panelHtml += `<label>${_l('Context','Max tokens per request — resets to the model max on every open. Lower = less VRAM')}<input type="text" class="hwfit-sf" data-field="ctx" value="${esc(m.context_length || m.context || '20000')}" /></label>`;
|
||||
panelHtml += `<label>${_l('GPU','Which GPU to use. Leave empty for default')}<input type="text" class="hwfit-sf" data-field="gpu_id" value="${esc(sv('gpu_id', ''))}" placeholder="auto" style="width:50px;" /></label>`;
|
||||
panelHtml += `<label class="hwfit-backend-vllm hwfit-backend-sglang">${_l('GPU Mem','Fraction of GPU memory (0.0–1.0). Lower if OOM')}<input type="text" class="hwfit-sf" data-field="gpu_mem" value="${esc(sv('gpu_mem', '0.90'))}" /></label>`;
|
||||
panelHtml += `<label class="hwfit-backend-vllm">${_l('Swap','CPU swap space in GB. Leave empty to omit (removed in newer vLLM)')}<input type="text" class="hwfit-sf" data-field="swap" value="${esc(sv('swap', ''))}" placeholder="off" /></label>`;
|
||||
panelHtml += `<label class="hwfit-backend-vllm hwfit-backend-sglang">${_l('Max Seqs','Maximum concurrent requests. Lower = less memory. Default 4 — prosumer GPUs often OOM on vLLM default 256 during CUDA graph capture.')}<input type="text" class="hwfit-sf" data-field="max_seqs" value="${esc(sv('max_seqs', '4'))}" placeholder="4" /></label>`;
|
||||
panelHtml += `<label>${_l('Dtype','Data type for weights. auto picks best for GPU')}<select class="hwfit-sf" data-field="dtype">${dtypeOpts}</select></label>`;
|
||||
panelHtml += `</div>`;
|
||||
// ── Advanced (collapsed by default) ──
|
||||
// Everything below the fold is tuning users only touch occasionally:
|
||||
// vLLM kernel/env knobs, llama.cpp fit/cache/split controls, the
|
||||
// GGUF batch sizes, the speculative-decoding row, and the live VRAM
|
||||
// monitor. Wrapped in a native <details> so toggle state survives
|
||||
// re-renders cheaply and a closed fold doesn't trigger any layout
|
||||
// work for the dozens of nested inputs.
|
||||
panelHtml += `<details class="hwfit-serve-advanced">`;
|
||||
panelHtml += `<summary class="hwfit-serve-advanced-summary">Advanced</summary>`;
|
||||
// Advanced vLLM/SGLang row (KV Cache, Attention, Swap, Env)
|
||||
panelHtml += `<div class="hwfit-serve-row hwfit-backend-vllm hwfit-backend-sglang">`;
|
||||
panelHtml += `<label class="hwfit-backend-vllm">${_l('KV Cache','vLLM --kv-cache-dtype. auto uses the model/runtime default; fp8 reduces KV memory for long context.')}<select class="hwfit-sf" data-field="vllm_kv_cache_dtype" style="height:32px;">${vllmKvCacheOpts}</select></label>`;
|
||||
// Attention backend selector — pin the kernel impl. Default `auto` lets
|
||||
// vLLM pick FlashInfer (which JITs on first use and breaks on older
|
||||
@@ -662,6 +659,7 @@ function _rerenderCachedModels() {
|
||||
const vllmAttnBackendOpts = ['auto', 'FLASH_ATTN', 'XFORMERS', 'FLASHINFER', 'TORCH_SDPA']
|
||||
.map(b => `<option value="${b === 'auto' ? '' : b}"${(sv('vllm_attn_backend','') === (b === 'auto' ? '' : b)) ? ' selected' : ''}>${b}</option>`).join('');
|
||||
panelHtml += `<label class="hwfit-backend-vllm">${_l('Attention','vLLM VLLM_ATTENTION_BACKEND. auto = vLLM picks (often FLASHINFER, which JITs and can fail on old nvcc). FLASH_ATTN skips the JIT entirely.')}<select class="hwfit-sf" data-field="vllm_attn_backend" style="height:32px;">${vllmAttnBackendOpts}</select></label>`;
|
||||
panelHtml += `<label class="hwfit-backend-vllm">${_l('Swap','CPU swap space in GB. Leave empty to omit (removed in newer vLLM)')}<input type="text" class="hwfit-sf" data-field="swap" value="${esc(sv('swap', ''))}" placeholder="off" /></label>`;
|
||||
// Free-text env-vars field. Anything pasted here is prepended to the
|
||||
// launch command verbatim. Use for CUDACXX, PATH overrides, NCCL_*
|
||||
// tuning, or any other KEY=VALUE pair that doesn't have a dedicated
|
||||
@@ -669,6 +667,12 @@ function _rerenderCachedModels() {
|
||||
// already exported so they expand correctly here.
|
||||
panelHtml += `<label class="hwfit-backend-vllm hwfit-backend-sglang" style="flex:1 1 100%;">${_l('Env','Extra KEY=VALUE env-var pairs prepended to the launch (space-separated). Example: CUDACXX=$VIRTUAL_ENV/lib/python3.10/site-packages/nvidia/cuda_nvcc/bin/nvcc — points flashinfer at the venv-bundled nvcc when the system one is too old for your GPU.')}<input type="text" class="hwfit-sf" data-field="extra_env" value="${esc(sv('extra_env',''))}" placeholder="CUDACXX=/path/to/nvcc NCCL_P2P_DISABLE=1" style="width:100%;" /></label>`;
|
||||
panelHtml += `</div>`;
|
||||
// Advanced llama.cpp row (Batch / UBatch — moved out of Core for the
|
||||
// same "rarely touched" reason as the vLLM extras above).
|
||||
panelHtml += `<div class="hwfit-serve-row hwfit-backend-llamacpp">`;
|
||||
panelHtml += `<label class="hwfit-backend-llamacpp">${_l('Batch','llama.cpp prompt batch size. Leave blank for llama.cpp default.')}<input type="text" class="hwfit-sf" data-field="llama_batch_size" value="${esc(sv('llama_batch_size', ''))}" placeholder="2048" /></label>`;
|
||||
panelHtml += `<label class="hwfit-backend-llamacpp">${_l('UBatch','llama.cpp physical micro-batch size. Leave blank for llama.cpp default.')}<input type="text" class="hwfit-sf" data-field="llama_ubatch_size" value="${esc(sv('llama_ubatch_size', ''))}" placeholder="512" /></label>`;
|
||||
panelHtml += `</div>`;
|
||||
// Row 2b: Diffusers settings
|
||||
const diffDtypeOpts = ['bfloat16','float16','float32'].map(d => `<option value="${d}"${sv('diff_dtype','bfloat16')===d?' selected':''}>${d}</option>`).join('');
|
||||
const deviceMapOpts = ['balanced','auto','sequential'].map(d => `<option value="${d}"${sv('diff_device_map','balanced')===d?' selected':''}>${d}</option>`).join('');
|
||||
@@ -691,7 +695,7 @@ function _rerenderCachedModels() {
|
||||
const llamaFitOpts = ['', 'off', 'on'].map(d => `<option value="${d}"${sv('llama_fit','')===d?' selected':''}>${d||'default'}</option>`).join('');
|
||||
const llamaSplitModeOpts = ['', 'layer', 'tensor', 'row', 'none'].map(d => `<option value="${d}"${sv('llama_split_mode','')===d?' selected':''}>${d||'default'}</option>`).join('');
|
||||
panelHtml += `<div class="hwfit-serve-row hwfit-backend-llamacpp">`;
|
||||
panelHtml += `<label>${_l('CPU MoE','n-cpu-moe: number of MoE expert layers to run on CPU when the model is bigger than VRAM. 0 = all on GPU. Set automatically by the Auto profiles below.')}<input type="text" class="hwfit-sf" data-field="n_cpu_moe" value="${esc(sv('n_cpu_moe',''))}" placeholder="0" style="width:54px;" /></label>`;
|
||||
panelHtml += `<label>${_l('CPU MoE','n-cpu-moe: number of MoE expert layers to run on CPU when the model is bigger than VRAM. 0 = all on GPU. Set automatically by the Auto profiles below.')}<input type="text" class="hwfit-sf" data-field="n_cpu_moe" value="${esc(sv('n_cpu_moe',''))}" placeholder="0" style="width:54px;position:relative;top:-8px;" /></label>`;
|
||||
panelHtml += `<label>${_l('KV Cache','cache-type-k/v: quantize the KV cache. q4_0 = smallest (more context), q8_0 = sharp long-context, f16 = full. Blank = llama.cpp default.')}<select class="hwfit-sf" data-field="cache_type">${_kvOpts}</select></label>`;
|
||||
panelHtml += `<label class="hwfit-sf-cb" style="align-self:end;"><input type="checkbox" class="hwfit-sf" data-field="flash_attn"${sv('flash_attn',false)?' checked':''} /> Flash Attn${_h('--flash-attn on: faster attention + needed for quantized KV cache.')}</label>`;
|
||||
panelHtml += `<label class="hwfit-sf-cb" style="align-self:end;"><input type="checkbox" class="hwfit-sf" data-field="vision"${sv('vision',false)?' checked':''} /> Vision${_h('Serve with the vision encoder so the model can read images. Auto-finds an mmproj-*.gguf next to the model (download one into the model folder). Adds ~1 GB VRAM + a small per-image cost.')}</label>`;
|
||||
@@ -701,19 +705,16 @@ function _rerenderCachedModels() {
|
||||
// explicit overrides for known-good advanced presets; blank keeps
|
||||
// llama.cpp/profile defaults.
|
||||
panelHtml += `<div class="hwfit-serve-row hwfit-backend-llamacpp">`;
|
||||
panelHtml += `<label>${_l('Split Mode','llama.cpp GPU placement. layer is the usual default; tensor splits weights and KV across GPUs.')}<select class="hwfit-sf" data-field="llama_split_mode">${llamaSplitModeOpts}</select></label>`;
|
||||
panelHtml += `<label>${_l('Split Mode','llama.cpp GPU placement. layer is the usual default; tensor splits weights and KV across GPUs.')}<select class="hwfit-sf" data-field="llama_split_mode" style="position:relative;top:-8px;">${llamaSplitModeOpts}</select></label>`;
|
||||
panelHtml += `<label>${_l('Tensor Split','GPU proportions for llama.cpp, e.g. 50,50 across two visible GPUs. Leave blank for auto.')}<input type="text" class="hwfit-sf" data-field="llama_tensor_split" value="${esc(sv('llama_tensor_split', ''))}" placeholder="50,50" /></label>`;
|
||||
panelHtml += `<label>${_l('Main GPU','llama.cpp --main-gpu index inside the visible GPU set. Mostly useful for split mode none/row.')}<input type="text" class="hwfit-sf" data-field="llama_main_gpu" value="${esc(sv('llama_main_gpu', ''))}" placeholder="auto" /></label>`;
|
||||
panelHtml += `<label>${_l('Parallel','llama.cpp parallel slots. Leave blank for llama.cpp default; 1 matches single-lane presets.')}<input type="text" class="hwfit-sf" data-field="llama_parallel" value="${esc(sv('llama_parallel', ''))}" placeholder="1" /></label>`;
|
||||
panelHtml += `<label>${_l('Batch','llama.cpp prompt batch size. Leave blank for llama.cpp default.')}<input type="text" class="hwfit-sf" data-field="llama_batch_size" value="${esc(sv('llama_batch_size', ''))}" placeholder="2048" /></label>`;
|
||||
panelHtml += `<label>${_l('UBatch','llama.cpp physical micro-batch size. Leave blank for llama.cpp default.')}<input type="text" class="hwfit-sf" data-field="llama_ubatch_size" value="${esc(sv('llama_ubatch_size', ''))}" placeholder="512" /></label>`;
|
||||
panelHtml += `</div>`;
|
||||
// Row 2d: Auto profiles — computed from detected hardware (see profiles.py).
|
||||
// Buttons are injected after the panel mounts (needs an async fetch).
|
||||
panelHtml += `<div class="hwfit-serve-row hwfit-backend-llamacpp hwfit-serve-profiles" style="align-items:center;gap:8px;">`;
|
||||
panelHtml += `<span style="opacity:0.7;font-size:11px;">Auto profiles:</span>`;
|
||||
panelHtml += `<span class="hwfit-profile-btns" style="display:flex;gap:6px;flex-wrap:wrap;"><span style="opacity:0.5;font-size:11px;">computing…</span></span>`;
|
||||
panelHtml += `</div>`;
|
||||
// Auto-profile chips row removed — visual fit with the rest of the
|
||||
// serve panel was off, and the manual ctx/n_cpu_moe/cache controls
|
||||
// above are already sufficient. The hwfit profile API
|
||||
// (/api/hwfit/profiles) is still available for any caller that
|
||||
// wants it.
|
||||
// Live VRAM / RAM-spillover monitor for the serve target's GPU. Polls
|
||||
// /api/cookbook/gpus while the panel is open so you can SEE whether the
|
||||
// config fits VRAM (fast) or spills to system RAM (slow). Populated after mount.
|
||||
@@ -745,7 +746,7 @@ function _rerenderCachedModels() {
|
||||
// even for models the auto-detector doesn't recognize. Expert-parallel,
|
||||
// reasoning-parser and MoE-env still only appear when auto-detected.
|
||||
const _opts2 = _detectModelOptimizations(repo);
|
||||
panelHtml += `<div class="hwfit-serve-checks hwfit-backend-vllm" style="margin-top:2px;">`;
|
||||
panelHtml += `<div class="hwfit-serve-checks hwfit-backend-vllm">`;
|
||||
if (_opts2.flags.includes('--enable-expert-parallel')) panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="expert_parallel" /> Expert Parallel</label>`;
|
||||
if (_opts2.flags.some(f => f.includes('--reasoning-parser'))) { const rp = _opts2.flags.find(f => f.includes('--reasoning-parser')).split(' ')[1]; panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="reasoning_parser" data-parser="${rp}" /> Reasoning Parser <span class="hwfit-parser-tag">${rp}</span></label>`; }
|
||||
{
|
||||
@@ -764,6 +765,8 @@ function _rerenderCachedModels() {
|
||||
}
|
||||
if (_opts2.envVars.length) panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="moe_env" /> MoE Env Vars</label>`;
|
||||
panelHtml += `</div>`;
|
||||
// ── End Advanced fold ──
|
||||
panelHtml += `</details>`;
|
||||
// Command preview + actions. Wrap the textarea so a floating Copy
|
||||
// button can sit at its top-right corner — same pattern as the chat
|
||||
// run-output panel.
|
||||
@@ -825,27 +828,17 @@ function _rerenderCachedModels() {
|
||||
// model the file lives under "<path>/<repo>" — search there just like we
|
||||
// search the HF snapshots dir, so serving a GGUF from a custom dir works
|
||||
// instead of handing llama.cpp a directory (which fails).
|
||||
const _ldir = m.path
|
||||
? (_isWindows() ? `${m.path.replace(/\//g, '\\')}\\${repo.replace(/\//g, '\\')}` : _shellQuote(`${m.path}/${repo}`))
|
||||
: (_isWindows() ? '' : '""');
|
||||
if (selectedGguf) {
|
||||
f._gguf_path = _selectedGgufExpr(m, repo, selectedGguf.rel_path);
|
||||
} else if (_isWindows()) {
|
||||
// Windows fallback: no bash $() available; validator rejects it.
|
||||
// Return empty so the serve fails with a clear message.
|
||||
f._gguf_path = '';
|
||||
} else if (m.is_local_dir && m.path) {
|
||||
f._gguf_path = `$({ find ${_ldir} -name '*-00001-of-*.gguf' 2>/dev/null | sort; find ${_ldir} -name '*.gguf' 2>/dev/null | sort; } | head -1)`;
|
||||
} else {
|
||||
f._gguf_path = `$({ find ${dir} -name '*-00001-of-*.gguf' 2>/dev/null | sort; find ${dir} -name '*.gguf' 2>/dev/null | sort; } | head -1)`;
|
||||
}
|
||||
const _ldir = m.path ? _shellQuote(`${m.path}/${repo}`) : '""';
|
||||
f._gguf_path = selectedGguf
|
||||
? _selectedGgufExpr(m, repo, selectedGguf.rel_path)
|
||||
: m.is_local_dir && m.path
|
||||
? `$({ find ${_ldir} -name '*-00001-of-*.gguf' 2>/dev/null | sort; find ${_ldir} -name '*.gguf' 2>/dev/null | sort; } | head -1)`
|
||||
: `$({ find ${dir} -name '*-00001-of-*.gguf' 2>/dev/null | sort; find ${dir} -name '*.gguf' 2>/dev/null | sort; } | head -1)`;
|
||||
// Vision: auto-find the mmproj (CLIP/projector) file in the same dir.
|
||||
// Resolved at runtime so the toggle just works if an mmproj-*.gguf is
|
||||
// present (downloaded alongside the model). Empty if none → cmd omits it.
|
||||
const _vsearchdir = (m.is_local_dir && m.path) ? _ldir : dir;
|
||||
f._mmproj_path = _isWindows()
|
||||
? (_vsearchdir ? `${_vsearchdir}\\mmproj*.gguf` : '')
|
||||
: `$(find ${_vsearchdir} -iname 'mmproj*.gguf' 2>/dev/null | sort | head -1)`;
|
||||
f._mmproj_path = `$(find ${_vsearchdir} -iname 'mmproj*.gguf' 2>/dev/null | sort | head -1)`;
|
||||
}
|
||||
if (f.reasoning_parser) {
|
||||
const _rpEl2 = panel.querySelector('[data-field="reasoning_parser"]');
|
||||
@@ -886,72 +879,29 @@ function _rerenderCachedModels() {
|
||||
_clampCtx(false); // fix any stale/preset value already present
|
||||
}
|
||||
|
||||
// Auto profiles — fetch hardware-computed llama.cpp profiles and render
|
||||
// them as clickable chips. Clicking one fills the ctx/CPU-MoE/KV/flash
|
||||
// fields and rebuilds the command. Computed from detected VRAM (see
|
||||
// services/hwfit/profiles.py); rough on t/s, accurate on fit.
|
||||
async function _loadServeProfiles() {
|
||||
const wrap = panel.querySelector('.hwfit-profile-btns');
|
||||
if (!wrap) return;
|
||||
// Tighten the ctx slider's upper bound to the model's trained limit.
|
||||
// Asking llama.cpp for ctx > n_ctx_train overflows and, with a quantized
|
||||
// KV cache, can crash the GPU (radv ErrorDeviceLost). The auto-profile
|
||||
// chip row that used to also live here was removed — visual fit with
|
||||
// the rest of the serve panel was off — but this clamp is essential.
|
||||
(async () => {
|
||||
try {
|
||||
const host = (_es.remoteHost || '').trim();
|
||||
const selected = _serverByVal?.(_es.remoteServerKey || host);
|
||||
const params = new URLSearchParams({ model: repo });
|
||||
if (host) {
|
||||
params.set('host', host);
|
||||
const _sp = selected?.port;
|
||||
const _sp = (_es.servers || []).find(s => s.host === host)?.port;
|
||||
if (_sp) params.set('ssh_port', _sp);
|
||||
}
|
||||
// SERVE mode: this is a specific GGUF file already on disk, so its quant
|
||||
// is fixed — tell the profiler the file's real size + quant so it varies
|
||||
// only the serving knobs (KV/ctx/offload), not the quant. Parse the size
|
||||
// from m.size (e.g. "20.6 GB") and the quant from the file/repo name.
|
||||
const _sizeMatch = String(m.size || '').match(/([\d.]+)\s*GB/i);
|
||||
if (_sizeMatch) params.set('serve_weights_gb', _sizeMatch[1]);
|
||||
const _qMatch = String(repo).match(/(Q\d[\w]*|IQ\d[\w]*|F16|BF16|FP8)/i);
|
||||
if (_qMatch) params.set('serve_quant', _qMatch[1]);
|
||||
const res = await fetch(`/api/hwfit/profiles?${params}`);
|
||||
const data = await res.json();
|
||||
// Remember the model's trained context limit and clamp the ctx field
|
||||
// to it — asking llama.cpp for ctx > n_ctx_train overflows and, with a
|
||||
// quantized KV cache, can crash the GPU (radv ErrorDeviceLost).
|
||||
const ctxMax = Number(data && data.model_ctx_max) || 0;
|
||||
if (ctxMax > 0) {
|
||||
panel._modelCtxMax = ctxMax; // tighten the clamp to the real limit
|
||||
_clampCtx(false); // re-apply now that we know the model's max
|
||||
panel._modelCtxMax = ctxMax;
|
||||
_clampCtx(false);
|
||||
}
|
||||
const profs = (data && Array.isArray(data.profiles)) ? data.profiles : [];
|
||||
if (!profs.length) { wrap.innerHTML = `<span style="opacity:0.5;font-size:11px;">no auto profile for this model</span>`; return; }
|
||||
wrap.innerHTML = '';
|
||||
for (const p of profs) {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'cookbook-btn hwfit-profile-chip';
|
||||
b.style.cssText = 'height:24px;padding:0 9px;font-size:11px;';
|
||||
const off = p.offloads ? `, ncm${p.n_cpu_moe}` : ', all-GPU';
|
||||
b.textContent = `${p.label} · ${p.quant} · ${Math.round(p.ctx/1024)}k${off}`;
|
||||
b.title = `${p.note}\nKV ${p.cache_type}, ~${p.est_vram_gb} GB VRAM`;
|
||||
b.addEventListener('click', () => {
|
||||
const set = (field, val) => {
|
||||
const el = panel.querySelector(`[data-field="${field}"]`);
|
||||
if (!el) return;
|
||||
if (el.type === 'checkbox') el.checked = !!val; else el.value = val;
|
||||
};
|
||||
set('ctx', p.ctx);
|
||||
set('n_cpu_moe', p.n_cpu_moe || '');
|
||||
set('cache_type', p.cache_type || '');
|
||||
set('flash_attn', true); // required for a quantized KV cache
|
||||
wrap.querySelectorAll('.hwfit-profile-chip').forEach(x => x.classList.remove('cookbook-btn-active'));
|
||||
b.classList.add('cookbook-btn-active');
|
||||
updateCmd();
|
||||
});
|
||||
wrap.appendChild(b);
|
||||
}
|
||||
} catch {
|
||||
wrap.innerHTML = `<span style="opacity:0.5;font-size:11px;">profile compute failed</span>`;
|
||||
}
|
||||
}
|
||||
_loadServeProfiles();
|
||||
} catch { /* clamp falls back to the static default */ }
|
||||
})();
|
||||
|
||||
// Live GPU-memory monitor: poll /api/cookbook/gpus and show VRAM usage +
|
||||
// RAM-spillover, with a plain-language health/speed hint. Lets you tell at
|
||||
@@ -962,11 +912,10 @@ function _rerenderCachedModels() {
|
||||
if (!el || !document.body.contains(el)) return false; // panel closed → stop
|
||||
try {
|
||||
const host = (_es.remoteHost || '').trim();
|
||||
const selected = _serverByVal?.(_es.remoteServerKey || host);
|
||||
const params = new URLSearchParams();
|
||||
if (host) {
|
||||
params.set('host', host);
|
||||
const _sp = selected?.port;
|
||||
const _sp = (_es.servers || []).find(s => s.host === host)?.port;
|
||||
if (_sp) params.set('ssh_port', _sp);
|
||||
}
|
||||
const res = await fetch('/api/cookbook/gpus' + (params.toString() ? '?' + params : ''));
|
||||
@@ -1535,6 +1484,38 @@ function _rerenderCachedModels() {
|
||||
}
|
||||
panel._gpuProbe.byIdx = new Map(data.gpus.map(g => [g.index, g]));
|
||||
panel._gpuProbe.host = remoteHost;
|
||||
// If the probe found more GPUs than the panel originally
|
||||
// rendered (e.g. host switched from a 1-iGPU local box to an
|
||||
// 8-GPU remote), append buttons for the missing indexes so the
|
||||
// user can actually toggle them. Reuse the parent <div> from
|
||||
// the first existing button as the insertion target.
|
||||
try {
|
||||
const _existing = Array.from(panel.querySelectorAll('.cookbook-gpu-btn'));
|
||||
const _grp = _existing[0] && _existing[0].parentElement;
|
||||
if (_grp) {
|
||||
const _have = new Set(_existing.map(b => parseInt(b.dataset.gpu, 10)));
|
||||
const _activeStr = (panel.querySelector('[data-field="gpus"]')?.value || '').split(',').map(s => s.trim());
|
||||
data.gpus.forEach(g => {
|
||||
if (_have.has(g.index)) return;
|
||||
const _b = document.createElement('button');
|
||||
_b.type = 'button';
|
||||
_b.className = 'cookbook-gpu-btn' + (_activeStr.includes(String(g.index)) ? ' active' : '');
|
||||
_b.dataset.gpu = String(g.index);
|
||||
_b.textContent = String(g.index);
|
||||
_grp.appendChild(_b);
|
||||
// Re-wire the click handler the same way the panel did
|
||||
// on first render. Toggles active + rewrites the hidden
|
||||
// gpus input from the live set of active buttons.
|
||||
_b.addEventListener('click', () => {
|
||||
_b.classList.toggle('active');
|
||||
const activeBtns = [...panel.querySelectorAll('.cookbook-gpu-btn.active')];
|
||||
const ids = activeBtns.map(x => x.dataset.gpu).sort((a, b) => +a - +b).join(',');
|
||||
const hidden = panel.querySelector('[data-field="gpus"]');
|
||||
if (hidden) { hidden.value = ids; hidden.dispatchEvent(new Event('change', { bubbles: true })); }
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (_) {}
|
||||
panel.querySelectorAll('.cookbook-gpu-btn').forEach(b => {
|
||||
const idx = parseInt(b.dataset.gpu);
|
||||
const g = panel._gpuProbe.byIdx.get(idx);
|
||||
@@ -1861,12 +1842,20 @@ function _rerenderCachedModels() {
|
||||
}
|
||||
// Save in the { _byRepo, _lastUsed } schema — no legacy flat keys at
|
||||
// the root so per-model state doesn't leak between models.
|
||||
// Stamp `_forceBackend: true` so the next open of this model defaults
|
||||
// to the launched configuration end-to-end, even when the detector
|
||||
// would have picked a different backend. Without this flag, the
|
||||
// `savedMatchesBackend` gate inside sv() throws away every saved
|
||||
// value when the detected backend doesn't match — the user opens
|
||||
// Serve again and the panel looks like a fresh form despite a
|
||||
// known-good prior launch.
|
||||
try {
|
||||
let cur = {};
|
||||
try { cur = JSON.parse(localStorage.getItem(SERVE_STATE_KEY)) || {}; } catch {}
|
||||
const byRepo = (cur && cur._byRepo && typeof cur._byRepo === 'object') ? cur._byRepo : {};
|
||||
byRepo[repo] = serveState;
|
||||
localStorage.setItem(SERVE_STATE_KEY, JSON.stringify({ _byRepo: byRepo, _lastUsed: serveState }));
|
||||
const _saved = { ...serveState, _forceBackend: true };
|
||||
byRepo[repo] = _saved;
|
||||
localStorage.setItem(SERVE_STATE_KEY, JSON.stringify({ _byRepo: byRepo, _lastUsed: _saved }));
|
||||
} catch {}
|
||||
const origEnv = _envState.env;
|
||||
const origEnvPath = _envState.envPath;
|
||||
@@ -1938,10 +1927,24 @@ function _rerenderCachedModels() {
|
||||
function _resolveCacheHost() {
|
||||
let host = _envState.remoteHost || '';
|
||||
const cacheSrv = document.getElementById('hwfit-cache-server');
|
||||
|
||||
function _serverByCacheValue(val) {
|
||||
if (val === 'local') return null;
|
||||
const found = _serverByVal?.(val)
|
||||
|| (/^\d+$/.test(String(val)) ? _envState.servers[parseInt(val)] : null)
|
||||
|| _envState.servers.find(x => x.name === val)
|
||||
|| null;
|
||||
return found || null;
|
||||
}
|
||||
|
||||
if (cacheSrv) {
|
||||
const val = cacheSrv.value;
|
||||
if (val === 'local') host = '';
|
||||
else { const s = _serverByVal?.(val) || _envState.servers[parseInt(val)]; if (s) host = s.host; }
|
||||
if (val === 'local') {
|
||||
host = '';
|
||||
} else {
|
||||
const s = _serverByCacheValue(val);
|
||||
if (s) host = s.host;
|
||||
}
|
||||
}
|
||||
return host;
|
||||
}
|
||||
@@ -2071,8 +2074,12 @@ export async function openServePanelForRepo(repo, fields) {
|
||||
let cur = {};
|
||||
try { cur = JSON.parse(localStorage.getItem(SERVE_STATE_KEY)) || {}; } catch {}
|
||||
const byRepo = (cur && cur._byRepo && typeof cur._byRepo === 'object') ? cur._byRepo : {};
|
||||
byRepo[repo] = fields;
|
||||
localStorage.setItem(SERVE_STATE_KEY, JSON.stringify({ _byRepo: byRepo, _lastUsed: fields }));
|
||||
// Mirror the launch-time save: stamp _forceBackend so the panel's
|
||||
// sv() helper treats these seeded fields as authoritative, not as
|
||||
// overridable defaults.
|
||||
const _seeded = { ...fields, _forceBackend: true };
|
||||
byRepo[repo] = _seeded;
|
||||
localStorage.setItem(SERVE_STATE_KEY, JSON.stringify({ _byRepo: byRepo, _lastUsed: _seeded }));
|
||||
} catch {}
|
||||
}
|
||||
// Switch to the Serve tab (its click handler triggers _fetchCachedModels).
|
||||
@@ -2099,7 +2106,18 @@ export async function openServePanelForRepo(repo, fields) {
|
||||
.find(el => (el.dataset.repo || '').split('/').pop() === _short);
|
||||
}
|
||||
if (card) {
|
||||
if (!card.classList.contains('doclib-card-expanded')) card.click();
|
||||
// If we were given fields to restore, force a fresh render of the
|
||||
// serve panel so it reads the just-written _byRepo[repo] values
|
||||
// from localStorage. Without this, an already-expanded card kept
|
||||
// its stale form and the "Edit serve" → previous settings round-
|
||||
// trip looked broken from the user's side.
|
||||
if (fields && card.classList.contains('doclib-card-expanded')) {
|
||||
card.click();
|
||||
await new Promise(r => setTimeout(r, 40));
|
||||
card.click();
|
||||
} else if (!card.classList.contains('doclib-card-expanded')) {
|
||||
card.click();
|
||||
}
|
||||
try { card.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch {}
|
||||
return true;
|
||||
}
|
||||
@@ -2130,6 +2148,14 @@ export async function _fetchCachedModels() {
|
||||
try {
|
||||
let host = _envState.remoteHost || '';
|
||||
let selectedServer = null;
|
||||
const _serverByCacheValue = (val) => {
|
||||
if (val === 'local') return null;
|
||||
return _serverByVal?.(val)
|
||||
|| (/^\d+$/.test(String(val)) ? _envState.servers[parseInt(val)] : null)
|
||||
|| _envState.servers.find(x => x.name === val)
|
||||
|| null;
|
||||
};
|
||||
|
||||
const cacheSrv = document.getElementById('hwfit-cache-server');
|
||||
if (cacheSrv) {
|
||||
const val = cacheSrv.value;
|
||||
@@ -2137,11 +2163,11 @@ export async function _fetchCachedModels() {
|
||||
host = '';
|
||||
selectedServer = _envState.servers.find(s => !s.host || s.host === 'local') || _envState.servers[0];
|
||||
} else {
|
||||
const s = _serverByVal?.(val) || _envState.servers[parseInt(val)];
|
||||
const s = _serverByCacheValue(val);
|
||||
if (s) { host = s.host; selectedServer = s; }
|
||||
}
|
||||
} else {
|
||||
selectedServer = _serverByVal?.(_envState.remoteServerKey || host) || _envState.servers[0];
|
||||
selectedServer = _envState.servers.find(s => s.host === host) || _envState.servers[0];
|
||||
}
|
||||
// Read extra model dirs from the SELECTED server's modelDirs (canonical source)
|
||||
const modelDirs = [];
|
||||
@@ -2171,7 +2197,18 @@ export async function _fetchCachedModels() {
|
||||
if (modelDirs.length) qp.set('model_dir', modelDirs.join(','));
|
||||
const params = qp.toString() ? `?${qp}` : '';
|
||||
const res = await fetch(`/api/model/cached${params}`);
|
||||
if (!res.ok) throw new Error(res.statusText);
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
let msg = '';
|
||||
try {
|
||||
const payload = JSON.parse(body);
|
||||
msg = payload && (payload.detail || payload.error || payload.message);
|
||||
} catch {
|
||||
msg = body;
|
||||
}
|
||||
msg = typeof msg === 'string' ? msg.trim() : '';
|
||||
throw new Error(`HTTP ${res.status} ${res.statusText}${msg ? `: ${msg}` : ''}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
_dlWp.destroy();
|
||||
|
||||
@@ -2268,7 +2305,6 @@ export function initServe(shared) {
|
||||
_envState = shared._envState;
|
||||
_sshCmd = shared._sshCmd;
|
||||
_getPort = shared._getPort;
|
||||
_serverByVal = shared._serverByVal;
|
||||
_sshPrefix = shared._sshPrefix;
|
||||
_getPlatform = shared._getPlatform;
|
||||
_isWindows = shared._isWindows;
|
||||
|
||||
@@ -578,13 +578,12 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
const pieces = [];
|
||||
if (doc.session_name) pieces.push(`<span>${_esc(doc.session_name)}</span>`);
|
||||
if (doc.language && doc.language !== 'text') {
|
||||
const ic = langIcon(doc.language, 11, { style: 'vertical-align:-2px;flex-shrink:0;opacity:0.65;color:currentColor;' });
|
||||
pieces.push(`<span style="display:inline-flex;align-items:center;gap:3px;">${ic}${_esc(doc.language)}</span>`);
|
||||
// Per-language icon lives in the title row above; just the language
|
||||
// name here keeps the meta line scannable without duplicating the icon.
|
||||
pieces.push(`<span>${_esc(doc.language)}</span>`);
|
||||
}
|
||||
pieces.push(`<span>${_esc(libraryRelativeTime(doc.updated_at))}</span>`);
|
||||
meta.innerHTML = pieces.join('<span style="opacity:0.5;">\u00b7</span>');
|
||||
// Strip the per-language icon from the meta line \u2014 it now sits next to the
|
||||
// title above, so duplicating it here was redundant.
|
||||
content.appendChild(meta);
|
||||
card.appendChild(content);
|
||||
|
||||
|
||||
@@ -788,7 +788,7 @@ export function openEmailLibrary(opts = {}) {
|
||||
<div class="admin-card" style="flex:1;flex-direction:column;display:flex;overflow:hidden;">
|
||||
<p class="memory-desc doclib-desc">All emails. Click to open as a document.</p>
|
||||
<div class="email-accounts-row">
|
||||
<div id="email-lib-accounts" style="display:flex;gap:4px;flex-wrap:wrap;flex:1;"></div>
|
||||
<div id="email-lib-accounts" style="display:flex;gap:4px;flex:1;min-width:0;"></div>
|
||||
<button class="memory-toolbar-btn email-compose-jiggle" id="email-lib-compose-btn">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-2px;margin-right:3px;"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>
|
||||
New
|
||||
|
||||
+149
-2
@@ -36,6 +36,17 @@ function linkHtml(text, url) {
|
||||
return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer">${safeText}</a>`;
|
||||
}
|
||||
|
||||
function _isModelEndpointUrl(rawUrl) {
|
||||
try {
|
||||
const parsed = new URL(String(rawUrl || ''), window.location.origin);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false;
|
||||
const path = parsed.pathname.replace(/\/+$/, '');
|
||||
return path === '/v1';
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize the raw-HTML fragments that mdToHtml deliberately preserves from
|
||||
* the source text — <details> blocks (collapsible agent output) and <a> tags
|
||||
@@ -327,6 +338,17 @@ function createThinkingSection(thinkingContent, index = 0, thinkingTime = null)
|
||||
`;
|
||||
}
|
||||
|
||||
function createTaskCompletedMarker() {
|
||||
return `
|
||||
<div class="task-completed-marker" role="status" aria-label="Task completed">
|
||||
<span class="task-completed-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</span>
|
||||
<span>Task completed</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process text and render with thinking sections
|
||||
*/
|
||||
@@ -422,6 +444,9 @@ export function processWithThinking(text) {
|
||||
const { thinkingBlocks, content, thinkingTime } = extractThinkingBlocks(text);
|
||||
|
||||
let html = '';
|
||||
let visibleContent = content || '';
|
||||
const doneOnly = /^\s*\[DONE\]\s*$/i.test(visibleContent);
|
||||
const hadTrailingDone = !doneOnly && /(?:^|\n)\s*\[DONE\]\s*$/i.test(visibleContent);
|
||||
|
||||
// Add thinking sections (collapsed by default)
|
||||
thinkingBlocks.forEach((block, index) => {
|
||||
@@ -429,8 +454,12 @@ export function processWithThinking(text) {
|
||||
});
|
||||
|
||||
// Add the actual content
|
||||
if (content) {
|
||||
html += mdToHtml(content);
|
||||
if (doneOnly) {
|
||||
html += createTaskCompletedMarker();
|
||||
} else {
|
||||
if (hadTrailingDone) visibleContent = visibleContent.replace(/\n?\s*\[DONE\]\s*$/i, '').trimEnd();
|
||||
if (visibleContent) html += mdToHtml(visibleContent);
|
||||
if (hadTrailingDone) html += createTaskCompletedMarker();
|
||||
}
|
||||
|
||||
return _useSvgEmoji() ? svgifyEmoji(html) : html;
|
||||
@@ -885,3 +914,121 @@ document.addEventListener('click', function(e) {
|
||||
start();
|
||||
}
|
||||
})();
|
||||
|
||||
function _endpointNameFromUrl(url) {
|
||||
try {
|
||||
const parsed = new URL(url, window.location.origin);
|
||||
return parsed.host || parsed.hostname || 'Model endpoint';
|
||||
} catch (_) {
|
||||
return 'Model endpoint';
|
||||
}
|
||||
}
|
||||
|
||||
function _appendEndpointAddButtons(root) {
|
||||
if (!root || !root.querySelectorAll) return;
|
||||
const anchors = root.matches?.('a[href]')
|
||||
? [root]
|
||||
: [...root.querySelectorAll('a[href]')];
|
||||
for (const anchor of anchors) {
|
||||
if (anchor.dataset.endpointAddChecked === '1') continue;
|
||||
anchor.dataset.endpointAddChecked = '1';
|
||||
const href = anchor.getAttribute('href') || '';
|
||||
if (!_isModelEndpointUrl(href)) continue;
|
||||
if (anchor.nextElementSibling?.classList?.contains('model-endpoint-add-btn')) continue;
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'model-endpoint-add-btn';
|
||||
btn.dataset.endpointUrl = new URL(href, window.location.origin).href.replace(/\/+$/, '');
|
||||
btn.title = 'Add this OpenAI-compatible endpoint to the model picker';
|
||||
btn.innerHTML = '<span aria-hidden="true">+</span><span>Add to model picker</span>';
|
||||
anchor.insertAdjacentElement('afterend', btn);
|
||||
}
|
||||
}
|
||||
|
||||
async function _registerEndpointFromButton(btn) {
|
||||
const baseUrl = String(btn?.dataset?.endpointUrl || '').trim();
|
||||
if (!baseUrl || !_isModelEndpointUrl(baseUrl)) return;
|
||||
const original = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span aria-hidden="true">...</span><span>Adding</span>';
|
||||
try {
|
||||
const existingRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
|
||||
if (existingRes.ok) {
|
||||
const endpoints = await existingRes.json();
|
||||
const existing = Array.isArray(endpoints)
|
||||
? endpoints.find((ep) => String(ep.base_url || '').replace(/\/+$/, '') === baseUrl)
|
||||
: null;
|
||||
if (existing) {
|
||||
btn.classList.add('added');
|
||||
btn.innerHTML = '<span aria-hidden="true">✓</span><span>Already added</span>';
|
||||
window.dispatchEvent(new CustomEvent('ge:model-endpoints-updated', { detail: { baseUrl } }));
|
||||
if (window.modelsModule?.refreshModels) window.modelsModule.refreshModels(true);
|
||||
if (window.sessionModule?.updateModelPicker) window.sessionModule.updateModelPicker();
|
||||
uiModule.showToast?.(`Already in model picker: ${existing.name || _endpointNameFromUrl(baseUrl)}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = new URL(baseUrl, window.location.origin);
|
||||
const fd = new FormData();
|
||||
fd.append('base_url', baseUrl);
|
||||
fd.append('name', _endpointNameFromUrl(baseUrl));
|
||||
fd.append('model_type', 'llm');
|
||||
fd.append('endpoint_kind', 'auto');
|
||||
fd.append('skip_probe', 'true');
|
||||
if (/^(localhost|127\.0\.0\.1|0\.0\.0\.0)$/i.test(parsed.hostname)) {
|
||||
fd.append('container_local', 'true');
|
||||
}
|
||||
const res = await fetch('/api/model-endpoints', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
body: fd,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`HTTP ${res.status}${body ? ': ' + body.slice(0, 160) : ''}`);
|
||||
}
|
||||
btn.classList.add('added');
|
||||
btn.innerHTML = '<span aria-hidden="true">✓</span><span>Added</span>';
|
||||
window.dispatchEvent(new CustomEvent('ge:model-endpoints-updated', { detail: { baseUrl } }));
|
||||
if (window.modelsModule?.refreshModels) await window.modelsModule.refreshModels(true);
|
||||
if (window.sessionModule?.updateModelPicker) window.sessionModule.updateModelPicker();
|
||||
uiModule.showToast?.(`Model endpoint added: ${_endpointNameFromUrl(baseUrl)}`);
|
||||
} catch (err) {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = original;
|
||||
uiModule.showError?.(`Add endpoint failed: ${err.message || err}`);
|
||||
}
|
||||
}
|
||||
|
||||
(function _watchModelEndpointLinks() {
|
||||
if (window._modelEndpointLinkWatcherWired) return;
|
||||
window._modelEndpointLinkWatcherWired = true;
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest?.('.model-endpoint-add-btn');
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
_registerEndpointFromButton(btn);
|
||||
});
|
||||
|
||||
const start = () => {
|
||||
const root = document.body;
|
||||
if (!root) return;
|
||||
_appendEndpointAddButtons(root);
|
||||
new MutationObserver((mutations) => {
|
||||
for (const m of mutations) {
|
||||
for (const node of m.addedNodes) {
|
||||
if (node.nodeType === 1) _appendEndpointAddButtons(node);
|
||||
}
|
||||
}
|
||||
}).observe(root, { childList: true, subtree: true });
|
||||
};
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', start, { once: true });
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -327,13 +327,10 @@ function _initModelPickerDropdown() {
|
||||
// hover so the suffix/variant tag is still discoverable (#1982).
|
||||
nameSpan.title = m.display;
|
||||
row.appendChild(nameSpan);
|
||||
if (m.stale) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'model-switch-stale-badge';
|
||||
badge.textContent = 'offline';
|
||||
badge.style.cssText = 'font-size:10px;opacity:0.7;padding:1px 6px;border:1px solid var(--border);border-radius:8px;margin-left:6px;';
|
||||
row.appendChild(badge);
|
||||
}
|
||||
// Offline state is already conveyed by the row's reduced opacity —
|
||||
// a redundant "offline" pill on top of that just added clutter.
|
||||
// (Class kept on `row` so the opacity rule still applies; the text
|
||||
// badge is gone.)
|
||||
const epSpan = document.createElement('span');
|
||||
epSpan.className = 'model-switch-ep';
|
||||
// Don't show endpoint name if it matches the model name (local self-hosted)
|
||||
|
||||
+8
-1
@@ -178,7 +178,14 @@ export async function refreshModels(force = false) {
|
||||
_loadingSpinner.start();
|
||||
try {
|
||||
if (!_fetchInflight) {
|
||||
_fetchInflight = fetch(`${API_BASE}/api/models`, { credentials: 'same-origin' })
|
||||
// Pass ?refresh=true on forced refreshes so the BACKEND's 30s
|
||||
// per-user cache also gets bypassed. Without this, `force=true`
|
||||
// only clears the frontend cache and the same stale list comes
|
||||
// back — newly-served endpoints don't appear until the cache
|
||||
// ages out. (Bug repro: serve a model, picker is empty for ~30s
|
||||
// even though the endpoint is in the DB and online.)
|
||||
const _url = `${API_BASE}/api/models` + (force ? '?refresh=true' : '');
|
||||
_fetchInflight = fetch(_url, { credentials: 'same-origin' })
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
// static/js/planWindow.js
|
||||
//
|
||||
// Plan mode: show a proposed plan in a draggable, side-dockable window —
|
||||
// reusing the same modal + makeWindowDraggable framework the calendar, email,
|
||||
// and document panels use. Approving from here runs the plan with full tools.
|
||||
|
||||
import uiModule from './ui.js';
|
||||
import markdownModule from './markdown.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
|
||||
let _modal = null;
|
||||
let _onApprove = null;
|
||||
|
||||
function _getModal() {
|
||||
if (_modal) return _modal;
|
||||
_modal = document.createElement('div');
|
||||
_modal.id = 'plan-window';
|
||||
_modal.className = 'modal';
|
||||
_modal.style.display = 'none';
|
||||
_modal.innerHTML = `
|
||||
<div class="modal-content plan-window-content">
|
||||
<div class="modal-header">
|
||||
<h4><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg><span id="plan-window-title">Proposed plan</span></h4>
|
||||
<button class="close-btn" id="plan-window-close">✖</button>
|
||||
</div>
|
||||
<div class="modal-body plan-window-body" id="plan-window-body"></div>
|
||||
<div class="modal-footer plan-window-footer">
|
||||
<button type="button" class="plan-approve-btn" id="plan-window-approve">Approve & Run</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(_modal);
|
||||
_modal.querySelector('#plan-window-close').addEventListener('click', closePlanWindow);
|
||||
_modal.querySelector('#plan-window-approve').addEventListener('click', () => {
|
||||
const cb = _onApprove;
|
||||
closePlanWindow();
|
||||
if (typeof cb === 'function') cb();
|
||||
});
|
||||
// Draggable + side-dockable, same one-call helper as the other windows.
|
||||
const content = _modal.querySelector('.modal-content');
|
||||
const header = _modal.querySelector('.modal-header');
|
||||
if (content && header) makeWindowDraggable(_modal, { content, header });
|
||||
return _modal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the plan window with rendered markdown and an approve callback.
|
||||
* @param {string} planMarkdown - the agent's proposed plan (raw markdown)
|
||||
* @param {Function} onApprove - called when the user clicks Approve & Run
|
||||
*/
|
||||
export function openPlanWindow(planMarkdown, onApprove) {
|
||||
const modal = _getModal();
|
||||
_onApprove = onApprove || null;
|
||||
const body = modal.querySelector('#plan-window-body');
|
||||
if (body) {
|
||||
body.innerHTML = markdownModule.processWithThinking(
|
||||
markdownModule.squashOutsideCode(planMarkdown || '')
|
||||
);
|
||||
if (window.hljs) body.querySelectorAll('pre code').forEach((b) => window.hljs.highlightElement(b));
|
||||
}
|
||||
const approveBtn = modal.querySelector('#plan-window-approve');
|
||||
if (approveBtn) approveBtn.style.display = onApprove ? '' : 'none';
|
||||
// Title reflects state: still awaiting approval (approve callback present) vs
|
||||
// already approved and being executed.
|
||||
const title = modal.querySelector('#plan-window-title');
|
||||
if (title) title.textContent = onApprove ? 'Proposed plan' : 'Approved plan';
|
||||
modal.style.display = 'flex';
|
||||
if (uiModule && uiModule.scrollHistory) { try { uiModule.scrollHistory(); } catch (_) {} }
|
||||
}
|
||||
|
||||
export function closePlanWindow() {
|
||||
if (_modal) _modal.style.display = 'none';
|
||||
}
|
||||
|
||||
/** True when the plan window is currently visible (for live-refresh on progress). */
|
||||
export function isPlanWindowOpen() {
|
||||
return !!(_modal && _modal.style.display !== 'none');
|
||||
}
|
||||
|
||||
export default { openPlanWindow, closePlanWindow, isPlanWindowOpen };
|
||||
@@ -1559,6 +1559,7 @@ async function initResearchSearchSettings() {
|
||||
async function initAgentSettings() {
|
||||
var toolsInput = el('set-agentMaxTools');
|
||||
var roundsInput = el('set-agentMaxRounds');
|
||||
var supInput = el('set-agentSupervisorLadder');
|
||||
var msg = el('set-agentMsg');
|
||||
if (!toolsInput) return;
|
||||
|
||||
@@ -1567,6 +1568,7 @@ async function initAgentSettings() {
|
||||
var settings = await res.json();
|
||||
if (settings.agent_max_tool_calls) toolsInput.value = settings.agent_max_tool_calls;
|
||||
if (roundsInput && settings.agent_max_rounds) roundsInput.value = settings.agent_max_rounds;
|
||||
if (supInput) supInput.checked = !!settings.agent_supervisor_ladder;
|
||||
} catch (e) {}
|
||||
|
||||
// Clamp + coerce a raw input to an int in [lo, hi]; falls back to `dflt`
|
||||
@@ -1584,23 +1586,27 @@ async function initAgentSettings() {
|
||||
if (roundsInput) roundsInput.value = rounds;
|
||||
var payload = { agent_max_tool_calls: tools };
|
||||
if (rounds != null) payload.agent_max_rounds = rounds;
|
||||
if (supInput) payload.agent_supervisor_ladder = !!supInput.checked;
|
||||
try {
|
||||
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
msg.textContent = (tools > 0 ? 'Limit: ' + tools + ' tool calls' : 'Unlimited tool calls') +
|
||||
(rounds != null ? ' · ' + rounds + ' steps/message' : '');
|
||||
(rounds != null ? ' · ' + rounds + ' steps/message' : '') +
|
||||
(supInput && supInput.checked ? ' · supervisor on' : '');
|
||||
msg.style.color = 'var(--fg)';
|
||||
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||
}
|
||||
|
||||
toolsInput.addEventListener('change', save);
|
||||
if (roundsInput) roundsInput.addEventListener('change', save);
|
||||
if (supInput) supInput.addEventListener('change', save);
|
||||
var cur = parseInt(toolsInput.value, 10) || 0;
|
||||
var curR = roundsInput ? (parseInt(roundsInput.value, 10) || 20) : null;
|
||||
msg.textContent = (cur > 0 ? 'Limit: ' + cur + ' tool calls' : 'Unlimited tool calls') +
|
||||
(curR != null ? ' · ' + curR + ' steps/message' : '');
|
||||
(curR != null ? ' · ' + curR + ' steps/message' : '') +
|
||||
(supInput && supInput.checked ? ' · supervisor on' : '');
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
@@ -5042,7 +5048,7 @@ async function initUnifiedIntegrations() {
|
||||
});
|
||||
formEl.querySelectorAll('.uf-codex-revoke').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!await window.styledConfirm(`Revoke this ${cfg.word} token? Terminal agents using it will lose access.`, { confirmText: 'Revoke', danger: true })) return;
|
||||
if (!await window.styledConfirm(`Revoke this ${cfg.word} token? Integrations using it will lose access.`, { confirmText: 'Revoke', danger: true })) return;
|
||||
await fetch(`/api/tokens/${btn.dataset.tokenId}`, { method: 'DELETE', credentials: 'same-origin' });
|
||||
formEl.style.display = 'none';
|
||||
await renderList();
|
||||
|
||||
+4
-4
@@ -890,10 +890,10 @@ function renderSkillsList() {
|
||||
});
|
||||
}
|
||||
|
||||
// Background-load the visible skills' SKILL.md so expanding any of them is
|
||||
// instant (no first-time async fetch → no jump). Deferred so it never
|
||||
// competes with the render/cascade paint.
|
||||
setTimeout(_preloadVisibleMarkdown, 0);
|
||||
// Do not eager-load every visible SKILL.md. On large skill libraries this
|
||||
// creates dozens of simultaneous /api/skills/<name>/markdown requests during
|
||||
// app startup and can peg uvicorn. Markdown is fetched lazily when a card is
|
||||
// expanded.
|
||||
}
|
||||
|
||||
// ---- Card expand / edit / actions ----
|
||||
|
||||
@@ -17,7 +17,6 @@ import chatRenderer from './chatRenderer.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import themeModule from './theme.js';
|
||||
import documentModule from './document.js';
|
||||
import workspaceModule from './workspace.js';
|
||||
import settingsModule from './settings.js';
|
||||
import cookbookModule from './cookbook.js';
|
||||
import { EVAL_PROMPTS } from './compare/index.js';
|
||||
@@ -1226,51 +1225,6 @@ async function _cmdToggleDoc(args, ctx) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Workspace: confine the agent's file/shell tools to a folder. Not a boolean —
|
||||
// show / set <path> / clear / pick (open the directory browser).
|
||||
async function _cmdWorkspace(args, ctx) {
|
||||
const sub = (args[0] || '').toLowerCase();
|
||||
const rest = args.slice(1).join(' ').trim();
|
||||
const cur = workspaceModule.getWorkspace();
|
||||
if (!sub || sub === 'show' || sub === 'status' || sub === 'info') {
|
||||
slashReply(cur ? `Workspace: <code>${uiModule.esc(cur)}</code>` : 'No workspace set. <code>/workspace pick</code> or <code>/workspace set /path</code>.');
|
||||
return true;
|
||||
}
|
||||
if (sub === 'set' || sub === 'cd' || sub === 'use') {
|
||||
if (!rest) { slashReply('Usage: <code>/workspace set /absolute/path</code>'); return true; }
|
||||
workspaceModule.setWorkspace(rest);
|
||||
slashReply(`Workspace set: <code>${uiModule.esc(rest)}</code>`);
|
||||
return true;
|
||||
}
|
||||
if (sub === 'clear' || sub === 'off' || sub === 'none' || sub === 'unset') {
|
||||
workspaceModule.clearWorkspace();
|
||||
slashReply('Workspace cleared.');
|
||||
return true;
|
||||
}
|
||||
if (sub === 'pick' || sub === 'browse' || sub === 'open') {
|
||||
workspaceModule.openWorkspaceBrowser();
|
||||
return true;
|
||||
}
|
||||
slashReply('Usage: <code>/workspace</code> · <code>set /path</code> · <code>clear</code> · <code>pick</code>');
|
||||
return true;
|
||||
}
|
||||
// Plan mode: drive the real toggle pill (#plan-toggle-btn) so its per-mode
|
||||
// persistence/UI logic runs. Only meaningful in agent mode.
|
||||
async function _cmdTogglePlan(args, ctx) {
|
||||
const btn = document.getElementById('plan-toggle-btn');
|
||||
const chk = document.getElementById('plan-toggle');
|
||||
if (!btn || btn.style.display === 'none' || btn.offsetParent === null) {
|
||||
slashReply('Plan mode is only available in agent mode — switch to Agent first.');
|
||||
return true;
|
||||
}
|
||||
const cur = !!(chk && chk.checked);
|
||||
const v = (args[0] || '').toLowerCase();
|
||||
const target = v === 'on' ? true : v === 'off' ? false : !cur;
|
||||
if (target !== cur) btn.click();
|
||||
slashReply(`Plan mode: ${target ? 'on' : 'off'}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function _cmdToggleShow(args, ctx) {
|
||||
const name = (args[0] || '').toLowerCase();
|
||||
const val = (args[1] || '').toLowerCase();
|
||||
@@ -5769,26 +5723,10 @@ const COMMANDS = {
|
||||
'bash': { handler: _cmdToggleBash, alias: ['b','shell'], help: 'Toggle bash/shell', usage: '/toggle bash' },
|
||||
'research': { handler: _cmdToggleResearch, alias: ['r'], help: 'Toggle deep research', usage: '/toggle research' },
|
||||
'doc': { handler: _cmdToggleDoc, alias: [], help: 'Toggle document editor', usage: '/toggle doc' },
|
||||
'plan': { handler: _cmdTogglePlan, alias: ['p'], help: 'Toggle plan mode (agent)', usage: '/toggle plan' },
|
||||
'sidebar': { handler: _cmdToggleSidebar, alias: ['sb'], help: 'Cycle sidebar (full/mini/off)', usage: '/toggle sidebar [1|2|3]' },
|
||||
'_show': { handler: _cmdToggleShow, alias: [], help: 'Show all toggle states', usage: '/toggle' }
|
||||
}
|
||||
},
|
||||
workspace: {
|
||||
alias: ['ws'],
|
||||
category: 'Agent',
|
||||
help: 'Set the folder the agent works in',
|
||||
handler: _cmdWorkspace,
|
||||
noUserBubble: true,
|
||||
usage: '/workspace [set <path> | clear | pick]',
|
||||
},
|
||||
plan: {
|
||||
alias: [],
|
||||
category: 'Quick toggles',
|
||||
help: 'Toggle plan mode (agent)',
|
||||
handler: _cmdTogglePlan,
|
||||
usage: '/plan [on|off]',
|
||||
},
|
||||
memory: {
|
||||
alias: ['m'],
|
||||
category: 'Memory',
|
||||
|
||||
@@ -23,9 +23,7 @@ export const KEYS = {
|
||||
MCP_ACTIVE: 'odysseus-mcp-active',
|
||||
SECTION_ORDER: 'sidebar-section-order',
|
||||
ADMIN_LAST_TAB: 'admin-last-tab',
|
||||
DENSITY: 'odysseus-density',
|
||||
WORKSPACE: 'odysseus-workspace',
|
||||
PLAN: 'odysseus-plan'
|
||||
DENSITY: 'odysseus-density'
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
// static/js/workspace.js
|
||||
//
|
||||
// Workspace picker: browse server directories in a draggable modal, choose a
|
||||
// folder, and show it as a removable pill in the chat input bar. While set, the
|
||||
// chat request sends `workspace` so the agent's file/shell tools are confined
|
||||
// to that folder (see routes/chat_routes.py + src/tool_execution.py).
|
||||
|
||||
import Storage, { KEYS } from './storage.js';
|
||||
import uiModule from './ui.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
// Same folder glyph as the overflow menu item + pill (not an emoji).
|
||||
const _FOLDER_SVG = '<svg class="workspace-row-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>';
|
||||
let _modal = null;
|
||||
let _curPath = '';
|
||||
|
||||
export function getWorkspace() {
|
||||
return Storage.get(KEYS.WORKSPACE, '') || '';
|
||||
}
|
||||
|
||||
function _basename(p) {
|
||||
if (!p) return '';
|
||||
// Handle both POSIX (/) and Windows (\) separators.
|
||||
const parts = p.replace(/[\\/]+$/, '').split(/[\\/]/);
|
||||
return parts[parts.length - 1] || p;
|
||||
}
|
||||
|
||||
export function syncWorkspaceIndicator(path) {
|
||||
const pill = document.getElementById('workspace-indicator-btn');
|
||||
const name = document.getElementById('workspace-indicator-name');
|
||||
const overflow = document.getElementById('overflow-workspace-btn');
|
||||
if (pill) {
|
||||
pill.style.display = path ? '' : 'none';
|
||||
pill.classList.toggle('active', !!path);
|
||||
if (path) pill.title = `Workspace: ${path} — click to clear`;
|
||||
}
|
||||
if (name) name.textContent = path ? _basename(path) : '';
|
||||
if (overflow) overflow.classList.toggle('active', !!path);
|
||||
// Recompute the "+" overflow dot (app.js owns updatePlusDot via this event).
|
||||
try { document.dispatchEvent(new CustomEvent('overflow-state-change')); } catch (_) {}
|
||||
}
|
||||
|
||||
export function setWorkspace(path) {
|
||||
if (path) Storage.set(KEYS.WORKSPACE, path);
|
||||
else Storage.remove(KEYS.WORKSPACE);
|
||||
syncWorkspaceIndicator(path || '');
|
||||
}
|
||||
|
||||
export function clearWorkspace() {
|
||||
setWorkspace('');
|
||||
if (uiModule && uiModule.showToast) uiModule.showToast('Workspace cleared');
|
||||
}
|
||||
|
||||
async function _load(path) {
|
||||
const url = `${API_BASE}/api/workspace/browse${path ? `?path=${encodeURIComponent(path)}` : ''}`;
|
||||
const res = await fetch(url, { credentials: 'same-origin' });
|
||||
if (!res.ok) throw new Error(`browse failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function _render(data) {
|
||||
_curPath = data.path;
|
||||
const body = _modal.querySelector('#workspace-body');
|
||||
const pathEl = _modal.querySelector('#workspace-cur-path');
|
||||
if (pathEl) {
|
||||
// Reflect the resolved (realpath) location back into the editable field.
|
||||
pathEl.value = data.path;
|
||||
pathEl.title = data.path;
|
||||
}
|
||||
let rows = '';
|
||||
if (data.parent) {
|
||||
rows += `<div class="workspace-row workspace-up" data-path="${encodeURIComponent(data.parent)}">↑ ..</div>`;
|
||||
}
|
||||
for (const d of data.dirs) {
|
||||
// Backend supplies the full child path (os.path.join → cross-platform).
|
||||
rows += `<div class="workspace-row" data-path="${encodeURIComponent(d.path)}">${_FOLDER_SVG}<span>${uiModule.esc(d.name)}</span></div>`;
|
||||
}
|
||||
if (!data.dirs.length && !data.parent) rows = '<div class="workspace-empty">No subfolders</div>';
|
||||
body.innerHTML = rows || '<div class="workspace-empty">No subfolders</div>';
|
||||
body.querySelectorAll('.workspace-row').forEach((row) => {
|
||||
row.addEventListener('click', () => _navigate(decodeURIComponent(row.dataset.path)));
|
||||
});
|
||||
}
|
||||
|
||||
async function _navigate(path) {
|
||||
try {
|
||||
_render(await _load(path));
|
||||
} catch (e) {
|
||||
if (uiModule && uiModule.showError) uiModule.showError('Could not open folder');
|
||||
}
|
||||
}
|
||||
|
||||
function _getModal() {
|
||||
if (_modal) return _modal;
|
||||
_modal = document.createElement('div');
|
||||
_modal.id = 'workspace-modal';
|
||||
_modal.className = 'modal';
|
||||
_modal.style.display = 'none';
|
||||
_modal.innerHTML = `
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h4><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>Select workspace</h4>
|
||||
<button class="close-btn" id="workspace-close" aria-label="Close">✖</button>
|
||||
</div>
|
||||
<input type="text" class="styled-prompt-input workspace-cur" id="workspace-cur-path"
|
||||
spellcheck="false" autocomplete="off" autocapitalize="off" autocorrect="off"
|
||||
placeholder="Type or paste a folder path, then press Enter" />
|
||||
<div class="modal-body workspace-body" id="workspace-body"></div>
|
||||
<div class="modal-footer workspace-footer">
|
||||
<button type="button" class="confirm-btn confirm-btn-secondary" id="workspace-cancel">Cancel</button>
|
||||
<button type="button" class="confirm-btn confirm-btn-primary" id="workspace-use">Use this folder</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(_modal);
|
||||
_modal.querySelector('#workspace-close').addEventListener('click', closeWorkspaceBrowser);
|
||||
_modal.querySelector('#workspace-cancel').addEventListener('click', closeWorkspaceBrowser);
|
||||
// Editable path bar: Enter navigates to a typed/pasted folder.
|
||||
_modal.querySelector('#workspace-cur-path').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const v = e.target.value.trim();
|
||||
if (v) _navigate(v);
|
||||
}
|
||||
});
|
||||
_modal.querySelector('#workspace-use').addEventListener('click', () => {
|
||||
setWorkspace(_curPath);
|
||||
if (uiModule && uiModule.showToast) uiModule.showToast(`Workspace set: ${_basename(_curPath)}`);
|
||||
closeWorkspaceBrowser();
|
||||
});
|
||||
const content = _modal.querySelector('.modal-content');
|
||||
const header = _modal.querySelector('.modal-header');
|
||||
if (content && header) makeWindowDraggable(_modal, { content, header });
|
||||
return _modal;
|
||||
}
|
||||
|
||||
export async function openWorkspaceBrowser() {
|
||||
const modal = _getModal();
|
||||
modal.style.display = 'flex';
|
||||
try {
|
||||
_render(await _load(getWorkspace() || ''));
|
||||
} catch (e) {
|
||||
if (uiModule && uiModule.showError) uiModule.showError('Could not browse folders');
|
||||
}
|
||||
}
|
||||
|
||||
export function closeWorkspaceBrowser() {
|
||||
if (_modal) _modal.style.display = 'none';
|
||||
}
|
||||
|
||||
export function initWorkspace() {
|
||||
// Restore persisted workspace into the pill on load.
|
||||
syncWorkspaceIndicator(getWorkspace());
|
||||
const overflow = document.getElementById('overflow-workspace-btn');
|
||||
if (overflow) overflow.addEventListener('click', openWorkspaceBrowser);
|
||||
const pill = document.getElementById('workspace-indicator-btn');
|
||||
if (pill) pill.addEventListener('click', clearWorkspace);
|
||||
}
|
||||
|
||||
export default { initWorkspace, openWorkspaceBrowser, getWorkspace, setWorkspace, clearWorkspace, syncWorkspaceIndicator };
|
||||
+269
-99
@@ -2048,12 +2048,64 @@ body.bg-pattern-sparkles {
|
||||
.msg-user .body {
|
||||
color: var(--fg);
|
||||
}
|
||||
.msg-ai .body {
|
||||
color: var(--fg);
|
||||
}
|
||||
.rag-sources {
|
||||
margin-top: 12px;
|
||||
border: 1px solid var(--border);
|
||||
.msg-ai .body {
|
||||
color: var(--fg);
|
||||
}
|
||||
.model-endpoint-add-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: 7px;
|
||||
padding: 2px 7px;
|
||||
border: 1px solid color-mix(in srgb, var(--red) 34%, var(--border));
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--red) 8%, transparent);
|
||||
color: var(--red);
|
||||
font: inherit;
|
||||
font-size: 0.78em;
|
||||
line-height: 1.45;
|
||||
cursor: pointer;
|
||||
vertical-align: 1px;
|
||||
}
|
||||
.model-endpoint-add-btn:hover {
|
||||
background: color-mix(in srgb, var(--red) 14%, transparent);
|
||||
border-color: color-mix(in srgb, var(--red) 55%, var(--border));
|
||||
}
|
||||
.model-endpoint-add-btn:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.72;
|
||||
}
|
||||
.model-endpoint-add-btn.added {
|
||||
color: var(--color-save-green, #4caf50);
|
||||
border-color: color-mix(in srgb, var(--color-save-green, #4caf50) 45%, var(--border));
|
||||
background: color-mix(in srgb, var(--color-save-green, #4caf50) 9%, transparent);
|
||||
}
|
||||
.task-completed-marker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin: 7px 0 2px;
|
||||
padding: 5px 9px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-save-green, #4caf50) 42%, var(--border));
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--color-save-green, #4caf50) 9%, transparent);
|
||||
color: var(--color-save-green, #4caf50);
|
||||
font-size: 0.86em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.task-completed-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--color-save-green, #4caf50) 18%, transparent);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.rag-sources {
|
||||
margin-top: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
@@ -2182,7 +2234,7 @@ body.bg-pattern-sparkles {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
z-index: 250;
|
||||
transform-origin: top right;
|
||||
transition: opacity 0.22s ease, transform 0.22s ease;
|
||||
will-change: opacity, transform;
|
||||
@@ -2307,48 +2359,7 @@ body.bg-pattern-sparkles {
|
||||
color: var(--fg);
|
||||
background: color-mix(in srgb, var(--fg) 9%, transparent);
|
||||
}
|
||||
/* Plan mode: "Approve & Run" affordance under a proposed plan */
|
||||
.plan-approve-bar {
|
||||
margin: 8px 0 2px;
|
||||
}
|
||||
.plan-approve-btn {
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 6px 14px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
border: 1px solid var(--accent);
|
||||
transition: background 0.15s, transform 0.1s;
|
||||
}
|
||||
.plan-approve-btn:hover {
|
||||
background: color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
}
|
||||
.plan-approve-btn:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
.plan-approve-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.plan-open-btn {
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
color: var(--fg);
|
||||
background: color-mix(in srgb, var(--fg) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--fg) 22%, transparent);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.plan-open-btn:hover {
|
||||
background: color-mix(in srgb, var(--fg) 15%, transparent);
|
||||
}
|
||||
/* GitHub-style task lists (- [ ] / - [x]) — used by plan-mode checklists */
|
||||
/* GitHub-style task lists (- [ ] / - [x]) */
|
||||
li.task-item {
|
||||
list-style: none;
|
||||
margin-left: -1.2em;
|
||||
@@ -2745,7 +2756,7 @@ body.bg-pattern-sparkles {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 16px);
|
||||
right: 0;
|
||||
z-index: 300;
|
||||
z-index: 250;
|
||||
min-width: 260px;
|
||||
max-width: 360px;
|
||||
background: var(--panel);
|
||||
@@ -8408,6 +8419,14 @@ body.hide-thinking .thinking-section { display: none !important; }
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.thinking-header > .token-new {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.thinking-header > div:last-child {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.thinking-header:hover {
|
||||
background: color-mix(in srgb, var(--red) 12%, transparent);
|
||||
}
|
||||
@@ -8423,6 +8442,7 @@ body.hide-thinking .thinking-section { display: none !important; }
|
||||
min-width: 0;
|
||||
}
|
||||
.thinking-header-left span {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -8801,6 +8821,22 @@ body.hide-thinking .thinking-section { display: none !important; }
|
||||
.agent-thread-node + .agent-thread-node {
|
||||
margin-top: 2px;
|
||||
}
|
||||
/* Supervisor ladder cards — same chrome as tool cards but tinted so the
|
||||
user can tell at a glance "this is the agent recovering" vs "this is
|
||||
the agent doing work". Stop rung gets the red accent. */
|
||||
.agent-thread-node.supervisor-step .agent-thread-tool {
|
||||
color: color-mix(in srgb, var(--accent, #c08a3e) 80%, var(--fg));
|
||||
font-style: italic;
|
||||
}
|
||||
.agent-thread-node.supervisor-step .agent-thread-dot {
|
||||
background: color-mix(in srgb, var(--accent, #c08a3e) 60%, transparent);
|
||||
}
|
||||
.agent-thread-node.supervisor-step[data-rung="stop"] .agent-thread-tool {
|
||||
color: var(--red, #d65a5a);
|
||||
}
|
||||
.agent-thread-node.supervisor-step[data-rung="stop"] .agent-thread-dot {
|
||||
background: color-mix(in srgb, var(--red, #d65a5a) 60%, transparent);
|
||||
}
|
||||
.agent-thread-dot {
|
||||
position: absolute;
|
||||
left: -20px;
|
||||
@@ -15185,10 +15221,28 @@ body.right-dock-active:not(.email-doc-split-active) .doc-editor-pane {
|
||||
}
|
||||
}
|
||||
|
||||
/* Cookbook's cached-model list should scale with viewport height, not be capped at 400px */
|
||||
/* Cookbook's cached-model list: NO inner-scroll cap. Two nested scroll
|
||||
surfaces (this + the outer .admin-card) trapped the wheel so an expanded
|
||||
serve panel couldn't be reached on tall content. Let the outer
|
||||
.admin-card (overflow-y:auto) be the single scroll surface. */
|
||||
.hwfit-cached-list {
|
||||
max-height: min(75vh, 900px) !important;
|
||||
overflow-y: auto;
|
||||
max-height: none !important;
|
||||
overflow-y: visible !important;
|
||||
}
|
||||
/* Serve panel specifically: the admin-card inline style is
|
||||
`overflow:hidden` (so the toolbar/header don't drift), and the list
|
||||
inside has overflow:visible. On short windows that combination
|
||||
clipped the cards off the bottom with no scrollbar. Make the list
|
||||
itself the scroll surface so the rest of the card stays put. */
|
||||
.cookbook-group[data-backend-group="Serve"] > .admin-card {
|
||||
min-height: 0;
|
||||
}
|
||||
.cookbook-group[data-backend-group="Serve"] > .admin-card > #hwfit-cached-list,
|
||||
.cookbook-group[data-backend-group="Serve"] > .admin-card > .hwfit-cached-list {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto !important;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
/* Drag-and-drop visual hint for the email compose pane. Subtle accent
|
||||
outline + tinted overlay so it's obvious files will attach if dropped. */
|
||||
@@ -17965,8 +18019,11 @@ body.gallery-selecting .gallery-dl-btn,
|
||||
}
|
||||
#cookbook-modal .cookbook-group > .admin-card {
|
||||
min-height: 0;
|
||||
overflow-y: auto !important;
|
||||
overflow-x: hidden !important;
|
||||
/* Let .cookbook-body be the SINGLE scroll surface. Nesting another
|
||||
overflow:auto here trapped the wheel inside the cached-list when a
|
||||
serve panel expanded — the page couldn't scroll past the panel's
|
||||
bottom (Launch button got hidden). */
|
||||
overflow: visible !important;
|
||||
}
|
||||
#cookbook-modal .cookbook-section-body {
|
||||
min-height: 0;
|
||||
@@ -18774,6 +18831,13 @@ body.gallery-selecting .gallery-dl-btn,
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
/* When the Save split sits inside Row 1 (next to GPUs), align it with the
|
||||
input baseline (the row's grid cells stretch top-down; without this the
|
||||
Save buttons sit above the GPU button group). */
|
||||
.hwfit-serve-row .cookbook-serve-slots {
|
||||
align-self: end;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.cookbook-slot-btn {
|
||||
min-width: 22px; height: 22px;
|
||||
padding: 0 6px;
|
||||
@@ -18938,6 +19002,8 @@ body.gallery-selecting .gallery-dl-btn,
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
position: relative;
|
||||
top: -2px;
|
||||
}
|
||||
.cookbook-dep-rebuild:hover {
|
||||
background: color-mix(in srgb, var(--accent, var(--red)) 18%, transparent);
|
||||
@@ -20246,6 +20312,21 @@ body.gallery-selecting .gallery-dl-btn,
|
||||
background: color-mix(in srgb, var(--color-error) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-error) 30%, transparent);
|
||||
border-radius: 6px;
|
||||
/* The diagnosis body can carry traceback fragments and long unbroken
|
||||
paths (e.g. /home/.../snapshots/<sha>/<file>.gguf). Without these,
|
||||
a single long token pushes the card wider than the cookbook modal,
|
||||
scrolling the row right and clipping the action buttons. */
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
.cookbook-diagnosis pre,
|
||||
.cookbook-diagnosis code {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
max-width: 100%;
|
||||
}
|
||||
.cookbook-diag-header {
|
||||
display: flex;
|
||||
@@ -20439,6 +20520,14 @@ body.gallery-selecting .gallery-dl-btn,
|
||||
opacity: 0.5;
|
||||
font-family: inherit;
|
||||
}
|
||||
/* Brief border+glow flash when an Ollama row in the hwfit list autofills the
|
||||
Download input — helps the user see what landed when the input is offscreen
|
||||
or above a tall list. */
|
||||
.cookbook-dl-repo.cookbook-dl-flash {
|
||||
border-color: var(--red) !important;
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--red) 25%, transparent) !important;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.cookbook-dl-btn {
|
||||
background: var(--accent, var(--red));
|
||||
color: #fff;
|
||||
@@ -22485,6 +22574,88 @@ input.settings-select::placeholder { color: color-mix(in srgb, var(--fg) 35%, tr
|
||||
text-align: right;
|
||||
}
|
||||
.settings-fallback-row .settings-select { flex: 1; min-width: 0; }
|
||||
/* Cookbook Serve Advanced fold — wraps the rarely-touched tuning rows
|
||||
(KV/Attention/Swap/Env for vLLM, llama.cpp batch/cache/split, VRAM
|
||||
monitor, speculative, extra args). Matches the existing .hwfit-panel-
|
||||
advanced look: muted-gray label, no caps, no letter-spacing, no
|
||||
warning-y opacity. Content flows into the parent's existing scroll
|
||||
surface (no inner max-height) and inner rows reset their margin so
|
||||
stacking gaps don't double when the fold opens. */
|
||||
/* Styled to match the Add Models page collapsible sections
|
||||
(.adm-section-toggle) — same border/background/caret pattern, so the
|
||||
two folds across the app read consistently. */
|
||||
details.hwfit-serve-advanced {
|
||||
margin-top: 8px;
|
||||
overflow: visible;
|
||||
}
|
||||
details.hwfit-serve-advanced > summary.hwfit-serve-advanced-summary {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--fg);
|
||||
opacity: 0.8;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 6px 9px;
|
||||
background: color-mix(in srgb, var(--fg) 4%, transparent);
|
||||
transition: border-color 0.12s, background 0.12s, opacity 0.12s, border-radius 0s;
|
||||
}
|
||||
details.hwfit-serve-advanced > summary.hwfit-serve-advanced-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
details.hwfit-serve-advanced > summary.hwfit-serve-advanced-summary:hover {
|
||||
opacity: 1;
|
||||
border-color: var(--red);
|
||||
background: color-mix(in srgb, var(--red) 8%, transparent);
|
||||
}
|
||||
/* Caret on the right, rotates open/closed. SVG-style rectangles via
|
||||
borders keep this glyph-free + crisp at small sizes. */
|
||||
details.hwfit-serve-advanced > summary.hwfit-serve-advanced-summary::after {
|
||||
content: '';
|
||||
margin-left: auto;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 4px solid currentColor;
|
||||
border-top: 3px solid transparent;
|
||||
border-bottom: 3px solid transparent;
|
||||
opacity: 0.6;
|
||||
transform: rotate(90deg);
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
details.hwfit-serve-advanced:not([open]) > summary.hwfit-serve-advanced-summary::after {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
/* Body rows below the header — tight rhythm so the fold doesn't
|
||||
feel airy. The cookbook modal's existing .cookbook-body is the
|
||||
scroll surface; nothing inside the fold should add its own scroll. */
|
||||
details.hwfit-serve-advanced[open] > summary.hwfit-serve-advanced-summary {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
details.hwfit-serve-advanced > .hwfit-serve-row,
|
||||
details.hwfit-serve-advanced > .hwfit-serve-checks,
|
||||
details.hwfit-serve-advanced > .hwfit-serve-cmd-wrap,
|
||||
details.hwfit-serve-advanced > .hwfit-serve-extra {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
/* Pull the vLLM/SGLang checks row, Extra args, and the trailing
|
||||
model-specific (Speculative) checks row up tight against the row
|
||||
above — the previous 4px gap plus per-row baseline padding left a
|
||||
~8px gap that read as too airy in the Advanced fold. */
|
||||
details.hwfit-serve-advanced > .hwfit-serve-checks.hwfit-backend-vllm,
|
||||
details.hwfit-serve-advanced > .hwfit-serve-checks.hwfit-backend-sglang,
|
||||
details.hwfit-serve-advanced > .hwfit-serve-extra {
|
||||
margin-top: -8px;
|
||||
}
|
||||
details.hwfit-serve-advanced > .hwfit-serve-row:last-of-type,
|
||||
details.hwfit-serve-advanced > .hwfit-serve-checks:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.settings-fallback-remove {
|
||||
flex-shrink: 0;
|
||||
margin-right: 4px;
|
||||
@@ -22502,6 +22673,9 @@ input.settings-select::placeholder { color: color-mix(in srgb, var(--fg) 35%, tr
|
||||
transition: border-color 0.12s, color 0.12s, background 0.12s;
|
||||
position: relative;
|
||||
top: -6px;
|
||||
/* Glyph baseline trim: nudge × up 1px inside the button without moving the
|
||||
button. line-height < 1 lets the glyph float toward the top of its line box. */
|
||||
line-height: 0.85;
|
||||
}
|
||||
.settings-fallback-remove:hover {
|
||||
border-color: var(--red);
|
||||
@@ -33632,7 +33806,24 @@ button.cal-add-btn.cal-add-btn-text.cal-add-btn-sm:hover .cal-add-label {
|
||||
/* Only the direct-child compose button gets pushed right; nested chips
|
||||
inside #email-lib-accounts pack to the left as normal flex items. */
|
||||
.email-accounts-row > .memory-toolbar-btn { flex-shrink: 0; margin-left: auto; }
|
||||
#email-lib-accounts { justify-content: flex-start; }
|
||||
#email-lib-accounts { justify-content: flex-start; flex-wrap: wrap; }
|
||||
/* Mobile: collapse the account chips to a single horizontally-scrollable
|
||||
strip instead of stacking onto multiple rows. The compose "New" button
|
||||
stays outside the scroller (it's a sibling of #email-lib-accounts inside
|
||||
.email-accounts-row) so it remains pinned on the right. */
|
||||
@media (max-width: 768px) {
|
||||
#email-lib-accounts {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
scroll-snap-type: x proximity;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
#email-lib-accounts::-webkit-scrollbar { display: none; height: 0; }
|
||||
#email-lib-accounts > * { flex-shrink: 0; scroll-snap-align: start; }
|
||||
}
|
||||
.email-accounts-loading-whirlpool {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
@@ -36172,49 +36363,6 @@ body.theme-frosted .modal {
|
||||
line-height: 1.4;
|
||||
color: color-mix(in srgb, var(--fg) 45%, transparent);
|
||||
}
|
||||
/* ── Workspace picker ───────────────────────────────────────────── */
|
||||
/* Layout (width/flex column/max-height) inherited from base .modal-content. */
|
||||
/* Editable path/address bar: reuses .styled-prompt-input for border/bg/radius/
|
||||
focus ring (set in the element's class list). Overrides only the deltas:
|
||||
mono font, and full-bleed via flex stretch with no horizontal margin (the
|
||||
modal-content's 10px padding is the gutter) instead of the base width:100%,
|
||||
which overflowed against the overflow:auto scrollbar. */
|
||||
.workspace-cur {
|
||||
align-self: stretch;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
margin: 4px 0 8px;
|
||||
font-family: var(--mono, monospace);
|
||||
font-size: 12px;
|
||||
}
|
||||
/* flex/overflow inherited from base .modal-body; only the padding differs. */
|
||||
.workspace-body { padding: 6px 0; }
|
||||
.workspace-row {
|
||||
padding: 7px 18px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.workspace-row > span {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.workspace-row-icon { flex-shrink: 0; opacity: 0.75; }
|
||||
.workspace-row:hover {
|
||||
background: color-mix(in srgb, var(--border) 20%, transparent);
|
||||
}
|
||||
.workspace-up { opacity: 0.7; }
|
||||
.workspace-empty { padding: 14px 18px; opacity: 0.5; font-size: 13px; }
|
||||
.workspace-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 10px 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
/* Cookbook serve panel: Launch + ^ split button pair */
|
||||
.hwfit-serve-launch-group {
|
||||
display: inline-flex;
|
||||
@@ -36237,6 +36385,16 @@ body.theme-frosted .modal {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Mobile: drop the inline icons on Launch + Cancel in the serve panel so
|
||||
the buttons are text-only and don't wrap on narrow screens. Icons stay
|
||||
on desktop where horizontal space isn't tight. */
|
||||
@media (max-width: 600px) {
|
||||
.hwfit-serve-launch > svg,
|
||||
.hwfit-serve-cancel > svg {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Schedule form — mounted inside the cookbook serve panel. Uses the
|
||||
theme tokens (--bg, --panel, --border, --accent, --red) so it
|
||||
matches the rest of the cookbook chrome instead of inline whites. */
|
||||
@@ -36288,6 +36446,18 @@ body.theme-frosted .modal {
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
/* Days field inline with From / Until — push it + the action buttons to
|
||||
the right end of the row so the row reads: From | Until | …gap… | Days | Cancel | Save. */
|
||||
.hwfit-schedule-days-field {
|
||||
margin-left: auto;
|
||||
}
|
||||
.hwfit-schedule-actions-inline {
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
align-self: flex-end;
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
.hwfit-sched-day-chip {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Regression test for routes/backup_routes.py import_data skills dedup.
|
||||
|
||||
BUG: the skills import block deduplicates against EVERY tenant's skills
|
||||
(skills_manager.load_all()) instead of the importing user's own skills.
|
||||
So importing your own backup silently drops any skill whose title (or id)
|
||||
collides with ANOTHER user's skill — the same cross-tenant data-loss bug
|
||||
that was already fixed for memories in the block just above.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.testclient import TestClient
|
||||
import routes.backup_routes as backup_routes
|
||||
from routes.backup_routes import setup_backup_routes
|
||||
|
||||
# require_admin / get_current_user are bound into routes.backup_routes at import
|
||||
# time (`from x import name`). We patch them on that module directly per-test
|
||||
# via monkeypatch — robust to import order and reverted at teardown. (Stubbing
|
||||
# them through sys.modules only works if backup_routes has not been imported
|
||||
# yet, which is not guaranteed in a full-suite run.)
|
||||
|
||||
|
||||
class FakeMemoryManager:
|
||||
def __init__(self):
|
||||
self.rows = []
|
||||
|
||||
def load(self, owner=None):
|
||||
return [r for r in self.rows if r.get("owner") == owner]
|
||||
|
||||
def load_all(self):
|
||||
return list(self.rows)
|
||||
|
||||
def save(self, rows):
|
||||
self.rows = list(rows)
|
||||
|
||||
|
||||
class FakePresetManager:
|
||||
def get_all(self):
|
||||
return {}
|
||||
|
||||
def save(self, d):
|
||||
pass
|
||||
|
||||
|
||||
class FakeSkillsManager:
|
||||
"""Mimics services.memory.skills: load_all() = all owners,
|
||||
load(owner) = that owner's skills only."""
|
||||
|
||||
def __init__(self, rows):
|
||||
self.rows = list(rows)
|
||||
|
||||
def load(self, owner=None):
|
||||
return [s for s in self.rows if s.get("owner") == owner]
|
||||
|
||||
def load_all(self):
|
||||
return list(self.rows)
|
||||
|
||||
def save(self, rows):
|
||||
self.rows = list(rows)
|
||||
|
||||
def add_skill(self, title=None, name=None, owner=None, **kwargs):
|
||||
# Mirrors services.memory.skills.add_skill: persists a SKILL.md row and
|
||||
# returns its identity. source="user" skips auto-dedup, so no _deduped.
|
||||
entry = {"id": f"new-{len(self.rows)}", "title": title, "name": name, "owner": owner}
|
||||
self.rows.append(entry)
|
||||
return {"name": name, "id": entry["id"]}
|
||||
|
||||
|
||||
def _make_client(skills_mgr, monkeypatch):
|
||||
# Bypass the admin gate and read the importer straight off request.state.
|
||||
monkeypatch.setattr(backup_routes, "require_admin", lambda *a, **k: None)
|
||||
monkeypatch.setattr(backup_routes, "get_current_user",
|
||||
lambda req: getattr(req.state, "user", None))
|
||||
app = FastAPI()
|
||||
|
||||
@app.middleware("http")
|
||||
async def _set_user(request: Request, call_next):
|
||||
request.state.user = "alice"
|
||||
return await call_next(request)
|
||||
|
||||
router = setup_backup_routes(FakeMemoryManager(), FakePresetManager(), skills_mgr)
|
||||
app.include_router(router)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_import_skill_not_dropped_by_other_users_title_collision(monkeypatch):
|
||||
# Bob already owns a skill titled "Deploy". Alice (the importer) has none.
|
||||
skills_mgr = FakeSkillsManager([
|
||||
{"id": "bob-1", "title": "Deploy", "name": "Deploy", "owner": "bob"},
|
||||
])
|
||||
client = _make_client(skills_mgr, monkeypatch)
|
||||
|
||||
# Alice imports HER OWN backup containing a skill also titled "Deploy".
|
||||
payload = {
|
||||
"skills": [
|
||||
{"id": "alice-1", "title": "Deploy", "name": "Deploy"},
|
||||
],
|
||||
}
|
||||
resp = client.post("/api/import", json=payload)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
# Alice's skill must have been imported and assigned to her.
|
||||
alice_skills = skills_mgr.load(owner="alice")
|
||||
titles = {s["title"] for s in alice_skills}
|
||||
assert "Deploy" in titles, (
|
||||
"Alice's own 'Deploy' skill was silently dropped because Bob owns a "
|
||||
"skill with the same title (cross-tenant dedup bug)."
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(pytest.main([__file__, "-v"]))
|
||||
@@ -22,10 +22,12 @@ from routes.cookbook_helpers import (
|
||||
_user_shell_path_bootstrap,
|
||||
_venv_safe_local_pip_install_cmd,
|
||||
_validate_gpus,
|
||||
_validate_local_dir,
|
||||
_validate_repo_id,
|
||||
_validate_serve_cmd,
|
||||
_validate_serve_model_id,
|
||||
_validate_ssh_port,
|
||||
_shell_path,
|
||||
run_ssh_command_async,
|
||||
)
|
||||
|
||||
@@ -110,6 +112,89 @@ def test_validate_ssh_port_rejects_shell_payload():
|
||||
assert _validate_ssh_port("2222") == "2222"
|
||||
|
||||
|
||||
def test_validate_local_dir_accepts_external_drive_paths_with_spaces():
|
||||
path = "/Volumes/T7 2TB/AI Models/llamacpp"
|
||||
|
||||
assert _validate_local_dir(path) == path
|
||||
assert _validate_local_dir(f'"{path}"') == path
|
||||
assert _shell_path(f"{path}/Qwen3-8B") == '"/Volumes/T7 2TB/AI Models/llamacpp/Qwen3-8B"'
|
||||
|
||||
|
||||
def test_validate_local_dir_accepts_windows_drive_paths_with_spaces():
|
||||
backslash_path = r"D:\AI Models\llamacpp"
|
||||
slash_path = "D:/AI Models/llamacpp"
|
||||
|
||||
assert _validate_local_dir(backslash_path) == backslash_path
|
||||
assert _validate_local_dir(f"'{backslash_path}'") == backslash_path
|
||||
assert _validate_local_dir(slash_path) == slash_path
|
||||
assert _shell_path(backslash_path + r"\Qwen3-8B") == '"D:\\AI Models\\llamacpp\\Qwen3-8B"'
|
||||
|
||||
|
||||
def test_validate_local_dir_still_rejects_shell_metacharacters():
|
||||
for path in [
|
||||
"/Volumes/T7 2TB/AI Models; touch /tmp/pwned",
|
||||
"/Volumes/T7 2TB/AI Models/$(touch pwned)",
|
||||
"/Volumes/T7 2TB/AI Models/`touch pwned`",
|
||||
"/Volumes/T7 2TB/AI Models/model\nnext",
|
||||
]:
|
||||
with pytest.raises(HTTPException):
|
||||
_validate_local_dir(path)
|
||||
|
||||
|
||||
def test_validate_local_dir_rejects_windows_shell_metacharacters():
|
||||
for path in [
|
||||
r"D:\AI Models\llamacpp; touch C:\pwned",
|
||||
r"D:\AI Models\llamacpp\$(touch pwned)",
|
||||
r"D:\AI Models\llamacpp\`touch pwned`",
|
||||
"D:\\AI Models\\llamacpp\nnext",
|
||||
]:
|
||||
with pytest.raises(HTTPException):
|
||||
_validate_local_dir(path)
|
||||
|
||||
|
||||
def test_validate_local_dir_accepts_non_ascii_unicode_paths():
|
||||
# Folder names are routinely non-ASCII on localized systems; the validator
|
||||
# must accept them the same way it accepts spaces (see issue: spaces AND
|
||||
# non-ASCII chars were both rejected by the old ASCII-only allowlist).
|
||||
for path in [
|
||||
"/Volumes/Модели/llamacpp", # Cyrillic (POSIX / external drive)
|
||||
"/home/josé/models", # accented Latin
|
||||
"/Volumes/モデル/llm", # CJK
|
||||
r"D:\AI Models\Модели", # Cyrillic (Windows drive path)
|
||||
]:
|
||||
assert _validate_local_dir(path) == path
|
||||
|
||||
|
||||
def test_validate_local_dir_rejects_metacharacters_in_unicode_paths():
|
||||
# Widening the allowlist to Unicode must not reopen the injection surface:
|
||||
# shell metacharacters stay rejected even alongside non-ASCII segments.
|
||||
for path in [
|
||||
"/Volumes/Модели; touch /tmp/pwned",
|
||||
"/Volumes/Модели/$(touch pwned)",
|
||||
"/Volumes/Модели/`touch pwned`",
|
||||
"/Volumes/Модели/a|b",
|
||||
"/Volumes/Модели\nnext",
|
||||
r"D:\Модели\llamacpp & calc.exe",
|
||||
]:
|
||||
with pytest.raises(HTTPException):
|
||||
_validate_local_dir(path)
|
||||
|
||||
|
||||
def test_validate_local_dir_rejects_leading_dash_segments():
|
||||
# A path segment starting with '-' could be parsed as a CLI option by hf/etc.
|
||||
# (option injection) even when quoted, since quoting doesn't stop a value from
|
||||
# being read as a flag. The validator must reject it on every platform.
|
||||
for path in [
|
||||
"/models/-rf",
|
||||
"/models/-rf/llamacpp",
|
||||
"/-oStrictHostKeyChecking=no",
|
||||
r"D:\models\-rf",
|
||||
"D:/models/-rf",
|
||||
]:
|
||||
with pytest.raises(HTTPException):
|
||||
_validate_local_dir(path)
|
||||
|
||||
|
||||
def test_validate_gpus_accepts_indexes_only():
|
||||
assert _validate_gpus("0,1,2") == "0,1,2"
|
||||
with pytest.raises(HTTPException):
|
||||
|
||||
@@ -48,7 +48,7 @@ def test_direct_upload_routes_use_bounded_reads():
|
||||
"read_upload_limited(file, MEMORY_IMPORT_MAX_BYTES",
|
||||
],
|
||||
"routes/calendar_routes.py": [
|
||||
"read_upload_limited(file, _ICS_MAX_BYTES",
|
||||
"read_upload_limited(file, ICS_MAX_BYTES",
|
||||
],
|
||||
"routes/email_routes.py": [
|
||||
"read_upload_limited(file, EMAIL_COMPOSE_UPLOAD_MAX_BYTES",
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Tests for Ollama /v1 thinking-suppression helpers.
|
||||
|
||||
Covers:
|
||||
- _is_ollama_openai_compat_url: URL classification (local host + /v1 path)
|
||||
- think: false is injected into the payload for Ollama /v1 thinking models
|
||||
- think: false is NOT injected for non-thinking models or non-Ollama /v1 endpoints
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from src import llm_core
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fake HTTP client — captures the outgoing payload without network I/O
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _FakeResp:
|
||||
status_code = 200
|
||||
|
||||
async def aiter_lines(self):
|
||||
# Yield a minimal done event so stream_llm exits cleanly
|
||||
yield json.dumps({"choices": [{"delta": {"content": "ok"}, "finish_reason": "stop"}]})
|
||||
yield "data: [DONE]"
|
||||
|
||||
async def aread(self):
|
||||
return b""
|
||||
|
||||
|
||||
class _FakeStreamCtx:
|
||||
def __init__(self, captured):
|
||||
self._captured = captured
|
||||
|
||||
async def __aenter__(self):
|
||||
return _FakeResp()
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
"""Minimal stand-in for httpx.AsyncClient that captures request payload."""
|
||||
|
||||
def __init__(self):
|
||||
self.captured_payload = {}
|
||||
|
||||
def stream(self, method, url, **kw):
|
||||
self.captured_payload = kw.get("json") or {}
|
||||
return _FakeStreamCtx(self.captured_payload)
|
||||
|
||||
|
||||
def _capture_payload(monkeypatch, url, model):
|
||||
"""Run stream_llm, intercept the HTTP payload, and return it."""
|
||||
client = _FakeClient()
|
||||
monkeypatch.setattr(llm_core, "_get_http_client", lambda: client)
|
||||
monkeypatch.setattr(llm_core, "_is_host_dead", lambda u: False)
|
||||
monkeypatch.setattr(llm_core, "note_model_activity", lambda *a, **k: None)
|
||||
monkeypatch.setattr(llm_core, "_clear_host_dead", lambda *a, **k: None)
|
||||
monkeypatch.setattr(llm_core, "get_context_length", lambda u, m: 32768)
|
||||
|
||||
async def run():
|
||||
return [c async for c in llm_core.stream_llm(
|
||||
url, model, [{"role": "user", "content": "hi"}],
|
||||
)]
|
||||
|
||||
asyncio.run(run())
|
||||
return client.captured_payload
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _is_ollama_openai_compat_url — pure function, no I/O
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIsOllamaOpenAICompatUrl:
|
||||
"""Unit tests for the URL classifier that gates think-suppression."""
|
||||
|
||||
# Positive cases — should be True
|
||||
def test_default_port_v1_root(self):
|
||||
assert llm_core._is_ollama_openai_compat_url("http://127.0.0.1:11434/v1")
|
||||
|
||||
def test_default_port_chat_completions(self):
|
||||
assert llm_core._is_ollama_openai_compat_url("http://127.0.0.1:11434/v1/chat/completions")
|
||||
|
||||
def test_localhost_default_port(self):
|
||||
assert llm_core._is_ollama_openai_compat_url("http://localhost:11434/v1")
|
||||
|
||||
def test_localhost_default_port_with_path(self):
|
||||
assert llm_core._is_ollama_openai_compat_url("http://localhost:11434/v1/chat/completions")
|
||||
|
||||
def test_loopback_ipv6(self):
|
||||
# IPv6 addresses in URLs require square brackets per RFC 3986
|
||||
assert llm_core._is_ollama_openai_compat_url("http://[::1]:11434/v1")
|
||||
|
||||
def test_any_local_non_default_port(self):
|
||||
"""Localhost on a non-default port (custom OLLAMA_HOST) must also match."""
|
||||
assert llm_core._is_ollama_openai_compat_url("http://127.0.0.1:11435/v1")
|
||||
|
||||
def test_localhost_non_default_port(self):
|
||||
assert llm_core._is_ollama_openai_compat_url("http://localhost:8080/v1/chat/completions")
|
||||
|
||||
def test_zero_dot_zero_host(self):
|
||||
assert llm_core._is_ollama_openai_compat_url("http://0.0.0.0:11434/v1")
|
||||
|
||||
# Negative cases — should be False
|
||||
def test_openai_api_v1(self):
|
||||
"""Real OpenAI endpoint must never match, even though path is /v1."""
|
||||
assert not llm_core._is_ollama_openai_compat_url("https://api.openai.com/v1")
|
||||
|
||||
def test_openai_chat_completions(self):
|
||||
assert not llm_core._is_ollama_openai_compat_url("https://api.openai.com/v1/chat/completions")
|
||||
|
||||
def test_ollama_native_api_path(self):
|
||||
"""The native /api path is a different surface and must not match /v1."""
|
||||
assert not llm_core._is_ollama_openai_compat_url("http://localhost:11434/api")
|
||||
|
||||
def test_ollama_native_api_chat(self):
|
||||
assert not llm_core._is_ollama_openai_compat_url("http://localhost:11434/api/chat")
|
||||
|
||||
def test_remote_openrouter(self):
|
||||
assert not llm_core._is_ollama_openai_compat_url("https://openrouter.ai/api/v1")
|
||||
|
||||
def test_empty_string(self):
|
||||
assert not llm_core._is_ollama_openai_compat_url("")
|
||||
|
||||
def test_none_like_empty(self):
|
||||
assert not llm_core._is_ollama_openai_compat_url(None) # type: ignore[arg-type]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Payload injection — think: false only when both conditions hold
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestThinkSuppression:
|
||||
"""Assert think:false is present/absent in the outgoing HTTP payload."""
|
||||
|
||||
def test_think_false_for_ollama_v1_thinking_model(self, monkeypatch):
|
||||
"""think:false must be set for qwen3 on Ollama /v1."""
|
||||
payload = _capture_payload(
|
||||
monkeypatch, "http://127.0.0.1:11434/v1/chat/completions", "qwen3:14b"
|
||||
)
|
||||
assert payload.get("think") is False
|
||||
|
||||
def test_no_think_for_ollama_v1_non_thinking_model(self, monkeypatch):
|
||||
"""think must NOT be set for a plain (non-thinking) model on Ollama /v1."""
|
||||
payload = _capture_payload(
|
||||
monkeypatch, "http://127.0.0.1:11434/v1/chat/completions", "llama3.2:3b"
|
||||
)
|
||||
assert "think" not in payload
|
||||
|
||||
def test_no_think_for_openai_endpoint_with_thinking_model_name(self, monkeypatch):
|
||||
"""think must NOT leak to a real OpenAI endpoint even if the model name
|
||||
matches a thinking pattern — the URL guard is what matters."""
|
||||
payload = _capture_payload(
|
||||
monkeypatch, "https://api.openai.com/v1/chat/completions", "qwen3:14b"
|
||||
)
|
||||
assert "think" not in payload
|
||||
|
||||
def test_think_false_for_non_default_port_thinking_model(self, monkeypatch):
|
||||
"""Custom-port localhost Ollama (e.g. OLLAMA_HOST=0.0.0.0:11435) must
|
||||
also receive think:false — this is the regression guarded by the
|
||||
host-set check added in this fix."""
|
||||
payload = _capture_payload(
|
||||
monkeypatch, "http://127.0.0.1:11435/v1/chat/completions", "qwen3:14b"
|
||||
)
|
||||
assert payload.get("think") is False
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Regression coverage for auto-sort session cleanup.
|
||||
|
||||
Issue #1851 reported fresh chats being deleted immediately after their first
|
||||
turn, leaving the browser pointed at a session id that no longer exists.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
import sys
|
||||
import tempfile
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
sqlalchemy = pytest.importorskip("sqlalchemy")
|
||||
if type(sqlalchemy).__name__ == "MagicMock":
|
||||
pytest.skip("sqlalchemy is stubbed in this environment", allow_module_level=True)
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
import core.database as cdb
|
||||
from core.database import ChatMessage as DbMessage, Session as DbSession, utcnow_naive
|
||||
import src.session_actions as session_actions
|
||||
|
||||
|
||||
def _make_session_factory():
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
tmp.close()
|
||||
engine = create_engine(
|
||||
f"sqlite:///{tmp.name}",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=NullPool,
|
||||
)
|
||||
DbSession.metadata.create_all(bind=engine)
|
||||
return sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
|
||||
|
||||
def _install_session_factory(monkeypatch, session_factory):
|
||||
monkeypatch.setitem(sys.modules, "core.database", cdb)
|
||||
core_pkg = sys.modules.get("core")
|
||||
if core_pkg is not None:
|
||||
monkeypatch.setattr(core_pkg, "database", cdb, raising=False)
|
||||
monkeypatch.setattr(cdb, "SessionLocal", session_factory)
|
||||
|
||||
|
||||
def _add_message(db, sid, role, content, timestamp):
|
||||
db.add(
|
||||
DbMessage(
|
||||
id="m-" + uuid.uuid4().hex,
|
||||
session_id=sid,
|
||||
role=role,
|
||||
content=content,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_auto_sort_keeps_fresh_chat_with_completed_first_turn(monkeypatch):
|
||||
session_factory = _make_session_factory()
|
||||
_install_session_factory(monkeypatch, session_factory)
|
||||
|
||||
sid = "s-" + uuid.uuid4().hex
|
||||
db = session_factory()
|
||||
try:
|
||||
db.add(
|
||||
DbSession(
|
||||
id=sid,
|
||||
owner="alice",
|
||||
name="Quick question",
|
||||
endpoint_url="",
|
||||
model="",
|
||||
archived=False,
|
||||
message_count=2,
|
||||
last_message_at=utcnow_naive(),
|
||||
)
|
||||
)
|
||||
_add_message(db, sid, "user", "hi", utcnow_naive())
|
||||
_add_message(db, sid, "assistant", "Hello! How can I help?", utcnow_naive())
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
result = asyncio.run(session_actions.run_auto_sort("alice", skip_llm=True))
|
||||
|
||||
db = session_factory()
|
||||
try:
|
||||
assert db.query(DbSession).filter(DbSession.id == sid).first() is not None
|
||||
assert db.query(DbMessage).filter(DbMessage.session_id == sid).count() == 2
|
||||
assert "Cleaned 0 sessions" in result
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_auto_sort_keeps_fresh_session_while_first_response_is_pending(monkeypatch):
|
||||
session_factory = _make_session_factory()
|
||||
_install_session_factory(monkeypatch, session_factory)
|
||||
|
||||
sid = "s-" + uuid.uuid4().hex
|
||||
db = session_factory()
|
||||
try:
|
||||
db.add(
|
||||
DbSession(
|
||||
id=sid,
|
||||
owner="alice",
|
||||
name="New chat",
|
||||
endpoint_url="",
|
||||
model="",
|
||||
archived=False,
|
||||
message_count=1,
|
||||
last_message_at=utcnow_naive(),
|
||||
)
|
||||
)
|
||||
_add_message(db, sid, "user", "Tell me a quick joke", utcnow_naive())
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
result = asyncio.run(session_actions.run_auto_sort("alice", skip_llm=True))
|
||||
|
||||
db = session_factory()
|
||||
try:
|
||||
assert db.query(DbSession).filter(DbSession.id == sid).first() is not None
|
||||
assert db.query(DbMessage).filter(DbMessage.session_id == sid).count() == 1
|
||||
assert "Cleaned 0 sessions" in result
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_auto_sort_still_deletes_old_throwaway_sessions(monkeypatch):
|
||||
session_factory = _make_session_factory()
|
||||
_install_session_factory(monkeypatch, session_factory)
|
||||
|
||||
old_time = utcnow_naive() - timedelta(hours=2)
|
||||
sid = "s-" + uuid.uuid4().hex
|
||||
db = session_factory()
|
||||
try:
|
||||
db.add(
|
||||
DbSession(
|
||||
id=sid,
|
||||
owner="alice",
|
||||
name="New chat",
|
||||
endpoint_url="",
|
||||
model="",
|
||||
archived=False,
|
||||
message_count=1,
|
||||
created_at=old_time,
|
||||
updated_at=old_time,
|
||||
last_accessed=old_time,
|
||||
last_message_at=old_time,
|
||||
)
|
||||
)
|
||||
_add_message(db, sid, "user", "hi", old_time)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
result = asyncio.run(session_actions.run_auto_sort("alice", skip_llm=True))
|
||||
|
||||
db = session_factory()
|
||||
try:
|
||||
assert db.query(DbSession).filter(DbSession.id == sid).first() is None
|
||||
assert "Cleaned 1 sessions" in result
|
||||
finally:
|
||||
db.close()
|
||||
@@ -238,36 +238,6 @@ def test_guide_only_blocks_later_round_document_streaming(monkeypatch):
|
||||
assert not any(event.get("type") == "doc_stream_delta" for event in events)
|
||||
|
||||
|
||||
def test_guide_only_directive_dominates_workspace_prompt(monkeypatch):
|
||||
_patch_loop_basics(monkeypatch)
|
||||
system_prompts = []
|
||||
|
||||
async def _fake_stream(_candidates, messages, **kwargs):
|
||||
system_prompts.append(messages[0]["content"])
|
||||
yield _delta_chunk("ok")
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
monkeypatch.setattr(al, "stream_llm_with_fallback", _fake_stream, raising=False)
|
||||
policy = build_effective_tool_policy(last_user_message="Do not use tools.")
|
||||
|
||||
_collect(
|
||||
al.stream_agent_loop(
|
||||
"http://local.test/v1",
|
||||
"local-model",
|
||||
[{"role": "user", "content": "Do not use tools."}],
|
||||
max_rounds=1,
|
||||
relevant_tools={"bash"},
|
||||
tool_policy=policy,
|
||||
workspace="/tmp/project",
|
||||
)
|
||||
)
|
||||
|
||||
assert system_prompts
|
||||
assert system_prompts[0].startswith("## GUIDE-ONLY MODE")
|
||||
assert "ACTIVE WORKSPACE" not in system_prompts[0]
|
||||
assert "ALWAYS start by exploring" not in system_prompts[0]
|
||||
|
||||
|
||||
def test_guide_only_skips_intent_without_action_nudge(monkeypatch):
|
||||
_patch_loop_basics(monkeypatch)
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Centralized upload byte-limits (issue #3364).
|
||||
|
||||
Every per-route upload limit lives in ``src.upload_limits`` as a module-level
|
||||
constant read through the validated ``read_byte_limit_env``. These tests pin:
|
||||
- the default values (unchanged from the prior per-route literals),
|
||||
- env-overridability for each one,
|
||||
- that an invalid env value fails fast (validation), and
|
||||
- that the routes import the constant from upload_limits rather than redefining
|
||||
it locally (no scattered raw getenv / hardcoded literal).
|
||||
"""
|
||||
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import src.upload_limits as upload_limits
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
|
||||
# const name -> (env var, default bytes)
|
||||
_LIMITS = {
|
||||
"GALLERY_UPLOAD_MAX_BYTES": ("ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES", 100 * 1024 * 1024),
|
||||
"GALLERY_TRANSFORM_UPLOAD_MAX_BYTES": ("ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES", 25 * 1024 * 1024),
|
||||
"MEMORY_IMPORT_MAX_BYTES": ("ODYSSEUS_MEMORY_IMPORT_MAX_BYTES", 10 * 1024 * 1024),
|
||||
"PERSONAL_UPLOAD_MAX_BYTES": ("ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES", 25 * 1024 * 1024),
|
||||
"EMAIL_COMPOSE_UPLOAD_MAX_BYTES": ("ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES", 25 * 1024 * 1024),
|
||||
"STT_MAX_AUDIO_BYTES": ("ODYSSEUS_STT_MAX_AUDIO_BYTES", 25 * 1024 * 1024),
|
||||
"ICS_MAX_BYTES": ("ODYSSEUS_ICS_MAX_BYTES", 10 * 1024 * 1024),
|
||||
}
|
||||
|
||||
|
||||
def _reload_clean(monkeypatch):
|
||||
"""Reload upload_limits with all the limit env vars unset."""
|
||||
for env, _ in _LIMITS.values():
|
||||
monkeypatch.delenv(env, raising=False)
|
||||
return importlib.reload(upload_limits)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _restore_module():
|
||||
# Ensure later tests see the env-default module, not a test-mutated reload.
|
||||
yield
|
||||
importlib.reload(upload_limits)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,env,default", [(n, e, d) for n, (e, d) in _LIMITS.items()])
|
||||
def test_default_value(monkeypatch, name, env, default):
|
||||
mod = _reload_clean(monkeypatch)
|
||||
assert getattr(mod, name) == default
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,env,default", [(n, e, d) for n, (e, d) in _LIMITS.items()])
|
||||
def test_env_override(monkeypatch, name, env, default):
|
||||
for e, _ in _LIMITS.values():
|
||||
monkeypatch.delenv(e, raising=False)
|
||||
monkeypatch.setenv(env, "4242")
|
||||
mod = importlib.reload(upload_limits)
|
||||
assert getattr(mod, name) == 4242
|
||||
|
||||
|
||||
@pytest.mark.parametrize("env", [e for e, _ in _LIMITS.values()])
|
||||
def test_invalid_env_fails_fast(monkeypatch, env):
|
||||
for e, _ in _LIMITS.values():
|
||||
monkeypatch.delenv(e, raising=False)
|
||||
monkeypatch.setenv(env, "not-an-int")
|
||||
with pytest.raises(ValueError, match=env):
|
||||
importlib.reload(upload_limits)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("env", [e for e, _ in _LIMITS.values()])
|
||||
def test_non_positive_env_rejected(monkeypatch, env):
|
||||
for e, _ in _LIMITS.values():
|
||||
monkeypatch.delenv(e, raising=False)
|
||||
monkeypatch.setenv(env, "0")
|
||||
with pytest.raises(ValueError, match="greater than 0"):
|
||||
importlib.reload(upload_limits)
|
||||
|
||||
|
||||
def test_routes_import_from_upload_limits_not_local_defs():
|
||||
"""Routes must import the constant, not redefine it via raw getenv / literal."""
|
||||
forbidden = {
|
||||
"routes/gallery_routes.py": [
|
||||
'int(os.getenv("ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES"',
|
||||
'int(os.getenv("ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES"',
|
||||
],
|
||||
"routes/memory_routes.py": ['int(os.getenv("ODYSSEUS_MEMORY_IMPORT_MAX_BYTES"'],
|
||||
"routes/personal_routes.py": ['os.getenv("ODYSSEUS_PERSONAL_UPLOAD_MAX_BYTES"'],
|
||||
"routes/email_routes.py": ["EMAIL_COMPOSE_UPLOAD_MAX_BYTES = 25 * 1024 * 1024"],
|
||||
"routes/stt_routes.py": ["STT_MAX_AUDIO_BYTES = 25 * 1024 * 1024"],
|
||||
"routes/calendar_routes.py": ["_ICS_MAX_BYTES = 10 * 1024 * 1024"],
|
||||
}
|
||||
for path, needles in forbidden.items():
|
||||
text = (REPO / path).read_text(encoding="utf-8")
|
||||
for needle in needles:
|
||||
assert needle not in text, f"{path} still defines limit locally: {needle}"
|
||||
|
||||
# And each imports from upload_limits.
|
||||
imports = {
|
||||
"routes/gallery_routes.py": "GALLERY_UPLOAD_MAX_BYTES",
|
||||
"routes/memory_routes.py": "MEMORY_IMPORT_MAX_BYTES",
|
||||
"routes/personal_routes.py": "PERSONAL_UPLOAD_MAX_BYTES",
|
||||
"routes/email_routes.py": "EMAIL_COMPOSE_UPLOAD_MAX_BYTES",
|
||||
"routes/stt_routes.py": "STT_MAX_AUDIO_BYTES",
|
||||
"routes/calendar_routes.py": "ICS_MAX_BYTES",
|
||||
}
|
||||
for path, const in imports.items():
|
||||
text = (REPO / path).read_text(encoding="utf-8")
|
||||
assert "from src.upload_limits import" in text
|
||||
assert const in text
|
||||
@@ -1,128 +0,0 @@
|
||||
"""Workspace confinement: file tools are hard-bounded to the workspace folder
|
||||
(layered on upstream's sensitive-path policy); bash runs with cwd there."""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from src.tool_execution import _resolve_tool_path_in_workspace, _direct_fallback
|
||||
|
||||
|
||||
def test_workspace_resolver_confines():
|
||||
ws = tempfile.mkdtemp()
|
||||
open(os.path.join(ws, "a.txt"), "w").write("x")
|
||||
real = os.path.realpath(os.path.join(ws, "a.txt"))
|
||||
# relative path resolves under the workspace
|
||||
assert _resolve_tool_path_in_workspace(ws, "a.txt") == real
|
||||
# absolute path inside the workspace is allowed
|
||||
assert _resolve_tool_path_in_workspace(ws, os.path.join(ws, "a.txt")) == real
|
||||
# absolute path outside is rejected (sibling temp dir, portable across OSes)
|
||||
outside = tempfile.mkdtemp()
|
||||
with pytest.raises(ValueError):
|
||||
_resolve_tool_path_in_workspace(ws, os.path.join(outside, "x.txt"))
|
||||
# parent-escape is rejected
|
||||
with pytest.raises(ValueError):
|
||||
_resolve_tool_path_in_workspace(ws, os.path.join("..", "..", "escape.txt"))
|
||||
|
||||
|
||||
def test_workspace_resolver_blocks_sensitive():
|
||||
"""Upstream's sensitive-file deny list still applies inside the workspace."""
|
||||
ws = tempfile.mkdtemp()
|
||||
os.makedirs(os.path.join(ws, ".ssh"), exist_ok=True)
|
||||
with pytest.raises(ValueError):
|
||||
_resolve_tool_path_in_workspace(ws, ".ssh/authorized_keys")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_write_confined_in_workspace():
|
||||
ws = tempfile.mkdtemp()
|
||||
# Write inside the workspace (relative path) succeeds.
|
||||
res = await _direct_fallback("write_file", "note.txt\nhello", workspace=ws)
|
||||
assert res["exit_code"] == 0
|
||||
assert os.path.isfile(os.path.join(ws, "note.txt"))
|
||||
# Read it back.
|
||||
res = await _direct_fallback("read_file", "note.txt", workspace=ws)
|
||||
assert res["exit_code"] == 0 and res["output"] == "hello"
|
||||
# Reading outside the workspace is rejected (sibling temp dir, portable).
|
||||
outside = tempfile.mkdtemp()
|
||||
outside_file = os.path.join(outside, "secret.txt")
|
||||
open(outside_file, "w").write("nope")
|
||||
res = await _direct_fallback("read_file", outside_file, workspace=ws)
|
||||
assert res["exit_code"] == 1 and "outside the workspace" in res["error"]
|
||||
# Writing outside is rejected (file must not be created).
|
||||
escape = os.path.join(outside, "_ws_escape.txt")
|
||||
res = await _direct_fallback("write_file", f"{escape}\nx", workspace=ws)
|
||||
assert res["exit_code"] == 1 and "outside the workspace" in res["error"]
|
||||
assert not os.path.exists(escape)
|
||||
|
||||
|
||||
def test_browse_is_admin_gated(monkeypatch):
|
||||
"""The directory-browser endpoint must refuse non-admin callers."""
|
||||
from fastapi import HTTPException
|
||||
import routes.workspace_routes as wr
|
||||
|
||||
router = wr.setup_workspace_routes()
|
||||
browse = next(r.endpoint for r in router.routes if r.path == "/api/workspace/browse")
|
||||
|
||||
monkeypatch.setattr(wr, "get_current_user", lambda req: "bob")
|
||||
monkeypatch.setattr(wr, "owner_is_admin_or_single_user", lambda owner: False)
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
browse(request=object(), path="/")
|
||||
assert ei.value.status_code == 403
|
||||
|
||||
# Admin / single-user is allowed.
|
||||
monkeypatch.setattr(wr, "owner_is_admin_or_single_user", lambda owner: True)
|
||||
out = browse(request=object(), path=os.path.expanduser("~"))
|
||||
assert "dirs" in out and "path" in out
|
||||
assert all("name" in d and "path" in d for d in out["dirs"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subprocess_runs_with_workspace_cwd():
|
||||
"""bash/python subprocesses run with cwd set to the workspace. Use the
|
||||
python tool for an OS-agnostic cwd probe (Windows cmd has no `pwd`)."""
|
||||
ws = tempfile.mkdtemp()
|
||||
res = await _direct_fallback("python", "import os; print(os.getcwd())", workspace=ws)
|
||||
assert res["exit_code"] == 0
|
||||
assert os.path.realpath(res["output"].strip()) == os.path.realpath(ws)
|
||||
|
||||
|
||||
# --- Tools that landed after this PR, now wired into the workspace -----------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_file_confined_in_workspace():
|
||||
import json
|
||||
from src.tool_execution import _do_edit_file
|
||||
ws = tempfile.mkdtemp()
|
||||
open(os.path.join(ws, "f.txt"), "w").write("foo bar")
|
||||
# Edit inside the workspace succeeds.
|
||||
res = await _do_edit_file(json.dumps(
|
||||
{"path": "f.txt", "old_string": "foo", "new_string": "baz"}), workspace=ws)
|
||||
assert res["exit_code"] == 0
|
||||
assert open(os.path.join(ws, "f.txt")).read() == "baz bar"
|
||||
# Editing outside the workspace is rejected (sibling temp dir, portable).
|
||||
outside = tempfile.mkdtemp()
|
||||
outside_file = os.path.join(outside, "f.txt")
|
||||
open(outside_file, "w").write("a")
|
||||
res = await _do_edit_file(json.dumps(
|
||||
{"path": outside_file, "old_string": "a", "new_string": "b"}), workspace=ws)
|
||||
assert res["exit_code"] == 1 and "outside the workspace" in res["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_and_ls_confined_in_workspace():
|
||||
import json
|
||||
ws = tempfile.mkdtemp()
|
||||
open(os.path.join(ws, "doc.txt"), "w").write("hello workspace\n")
|
||||
# grep with no path searches the workspace root and finds the match.
|
||||
res = await _direct_fallback("grep", json.dumps({"pattern": "hello"}), workspace=ws)
|
||||
assert res["exit_code"] == 0 and "doc.txt" in res["output"]
|
||||
# grep pointed outside the workspace is rejected (sibling temp dir, portable).
|
||||
outside = tempfile.mkdtemp()
|
||||
res = await _direct_fallback("grep", json.dumps({"pattern": "x", "path": outside}), workspace=ws)
|
||||
assert res["exit_code"] == 1 and "outside the workspace" in res["error"]
|
||||
# ls of the workspace lists its files; ls outside is rejected.
|
||||
res = await _direct_fallback("ls", "", workspace=ws)
|
||||
assert res["exit_code"] == 0 and "doc.txt" in res["output"]
|
||||
res = await _direct_fallback("ls", outside, workspace=ws)
|
||||
assert res["exit_code"] == 1 and "outside the workspace" in res["error"]
|
||||
Reference in New Issue
Block a user