24 Commits

Author SHA1 Message Date
Kenny Van de Maele 4371425514 refactor(tools): remove dead workspace-confinement plumbing
Commit e6b1009 removed the workspace feature's entry point (deleted
routes/workspace_routes.py + static/js/workspace.js and dropped the
workspace-param parsing in chat_routes), but left the downstream backend
plumbing dangling: chat_routes passed a hardcoded workspace=None into
stream_agent_loop, which forwarded it to execute_tool_block, so the
workspace value was permanently None and every workspace-gated branch
was unreachable.

Remove the now-dead code (no behavior change, since workspace was always
None):
- src/tool_execution.py: drop _resolve_tool_path_in_workspace and the
  workspace params/branches on execute_tool_block, _direct_fallback,
  _call_mcp_tool, _do_edit_file, and _resolve_search_root; restore the
  bash/python/bg cwd to _AGENT_WORKDIR.
- src/agent_loop.py: drop the workspace param on stream_agent_loop, the
  dead 'ACTIVE WORKSPACE' system-prompt block, and the workspace forward.
- routes/chat_routes.py: drop the hardcoded workspace=None arg and var.
- tests: delete test_workspace_confine.py (tested the removed feature) and
  the workspace assertion in test_tool_policy.py.

Full suite: 2903 passed, 1 skipped.
2026-06-09 08:27:07 +02:00
Afonso Coutinho fbed9027b0 fix: backup import dropping a user's skill on cross-tenant title/id collision (#2057)
* Fix backup import dropping a user's skill on cross-tenant title/id collision

The skills block of import_data deduped incoming skills against
skills_manager.load_all(), which returns EVERY tenant's skills. So when
a user imports their own backup, any skill whose id or title collides
with another user's skill was silently skipped — the importing user
lost their own data. This is the same cross-tenant bug already fixed
for the memories block just above (#1743); the skills block was left
with the old pattern. Filter the dedup sets to the importing user's own
skills (owner == user); the full store is still saved back, preserving
other users' skills.

* Restore sys.modules after stubbing so backup test does not break collection of later src.* test modules

* Patch backup_routes auth helpers via monkeypatch instead of sys.modules stubs so the test is import-order robust

* Give FakeSkillsManager an add_skill method matching the disk-backed skills API
2026-06-09 08:04:22 +02:00
Disorder AA d9141c6e56 fix(cookbook): allow spaces and non-ASCII characters in model directory paths (#3473)
* fix(cookbook): allow spaces in model directory paths

Allow POSIX external-drive paths and Windows drive paths with spaces while keeping shell metacharacters rejected.

* fix(cookbook): also allow non-ASCII (Unicode) characters in model dir paths

The ASCII-only allowlist that rejected spaces also rejected Cyrillic,
accented Latin and CJK folder names (e.g. /Volumes/Модели,
D:\AI Models\Модели) with 400 Invalid local_dir. Switch the path
character class from [A-Za-z0-9._ -] to [\w. -] (\w is Unicode-aware on
Python 3 str patterns) so localized folder names validate, while shell
metacharacters (; & | ` $ quotes newlines) stay rejected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cookbook): reject local_dir path segments starting with '-'

The local_dir allowlist includes '-', so a directory like /models/-rf
(or D:\models\-rf) could be parsed as a CLI flag by hf/etc. (option
injection) — and quoting does not stop a value from being read as an
option. Guard against it inside the validator so the safety stays fully
self-contained there rather than depending on consumers' quoting.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 07:58:38 +02:00
onemorethan0 8ae2b5f58c fix(llm): suppress thinking mode for qwen3/gemma4 on Ollama /v1 endpoint (#3228)
* fix(llm): suppress thinking for qwen3/gemma4 on Ollama /v1 compat endpoint

When using qwen3, QwQ, gemma4, or other thinking models via Ollama's
OpenAI-compatible /v1 endpoint, the model routes all output into its
<think>...</think> reasoning block. Since Odysseus strips thinking
content from round_response and only accumulates native tool_calls,
this produces a round with 0 chars, 0 native calls, 0 tool blocks —
the agent appears to silently do nothing.

Root cause: Odysseus classifies the /v1 endpoint as provider="openai"
(not "ollama"), so the payload is built as a standard OpenAI payload
without any Ollama-specific options. Ollama's /v1 endpoint accepts
"think": false as a top-level parameter to suppress extended thinking,
but this was never sent.

Fix:
- Add _is_ollama_openai_compat_url() to detect local Ollama /v1 URLs
- Inject "think": false in both stream_llm and llm_call_async for
  thinking models (qwen3, QwQ, gemma4, DeepSeek-R1, etc.) on this
  endpoint

Verified with qwen3:14b on Ollama 0.24: with think=False the model
correctly emits native tool_calls in a single streaming chunk and
the agent executes bash/file/web tools as expected.

* fix(llm): extend _is_ollama_openai_compat_url to match localhost on any port

Per reviewer feedback on PR #3228:

1. Generalize host detection to mirror _is_ollama_native_url: match any
   localhost/127.0.0.1/0.0.0.0/::1 host (not just port 11434) so that
   custom OLLAMA_HOST ports and container remaps are also covered.

2. Add tests/test_llm_core_ollama_thinking.py covering:
   - _is_ollama_openai_compat_url for all positive/negative URL cases
     including IPv6, non-default port, native /api path, and real OpenAI
   - Payload injection: think:false set for Ollama /v1 thinking model,
     not set for non-thinking model, not set for real OpenAI endpoint,
     and set for localhost on a non-default port (the new case)
2026-06-09 07:35:15 +02:00
pewdiepie-archdaemon 637a34515d Merge remote-tracking branch 'origin/main' into dev 2026-06-09 10:41:48 +09:00
pewdiepie-archdaemon d397b3db2f Restore dropped regression fixes 2026-06-09 10:31:43 +09:00
pewdiepie-archdaemon 1a529d63d9 Fix remaining CI regressions 2026-06-09 10:21:56 +09:00
Boody f605bb3864 fix: Enforce dynamic custom search result limits in backend (#2359)
* fixed confusing credentials prompt

* fix(setup): return status from create_default_admin function

* fix(setup): initialize admin creation status in main function

* fix(setup): enhance admin creation feedback and status handling

* Enhance admin user login messages with conditional feedback based on creation status

* Refine admin user creation feedback messages for clarity and actionability and formatted code

* Add fallback error message for admin creation failure in setup script

* Add run script for Uvicorn with dotenv integration

* Refactor server runner to use argparse for host and port configuration

* Remove captured output print statement from server runner

* Fix server runner to ensure cross-platform compatibility and improve log handling

* removed run.py to match original repo

* Fixing custom search not working properly

* Refactor search settings event listeners for improved functionality and clarity

* Update search function signatures to use Optional for count parameter

* revert changes

* fixed broken merge issue

* Delete services/chat_data_scraper.py

added by mistake

---------

Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-06-09 02:20:59 +01:00
pewdiepie-archdaemon 37c573d865 Fix model endpoint route test regressions 2026-06-09 10:16:38 +09:00
pewdiepie-archdaemon 6f29b287f6 Remove stale plan slash toggle 2026-06-09 09:54:46 +09:00
pewdiepie-archdaemon 4715a5505d Fix duplicate cookbook server helper export 2026-06-09 09:53:41 +09:00
pewdiepie-archdaemon 84ca74f04b Restore cookbook server key exports 2026-06-09 09:51:53 +09:00
pewdiepie-archdaemon e6b1009b89 Remove non-merge-ready workspace and terminal agent hooks 2026-06-09 09:48:59 +09:00
pewdiepie-archdaemon fa8c93ec0a Cookbook UI: Ollama browser, advanced serve fold, API tokens form, diagnosis toolbar, polish
Surface a lot of accumulated cookbook + UI work as a single non-agent
commit so the agent rework lands cleanly.

Highlights:
- Ollama as a first-class backend in the Cookbook:
  * Download input accepts ollama-style names (name:tag) → backend=ollama
  * /api/cookbook/ollama/library (cached scrape of ollama.com + curated
    fallback so classic models like qwen2.5 stay reachable)
  * "Browse Ollama library" toggle below Download with size chips
  * Engine=Ollama in hwfit toolbar merges the Ollama library into the
    main scan list as per-tag rows with the same Fit/Param/Quant/VRAM
    columns; click → fills Download input
- API Tokens form added to Integrations panel (matching wired
  loadTokens()/initTokenForm() that had no HTML)
- Serve panel polish: Advanced fold tightening (-8px nudges on vLLM
  checks, Extra args, Spec row), n_cpu_moe + Split Mode controls
  pulled up 8px to align with the row's checkboxes, GGUF File dropdown
  exposed for Ollama backend, GPU re-render on Edit serve restore,
  _forceBackend flag so saved serveState wins over backend detection,
  cookbook:servers-changed CustomEvent so panels don't need refresh
- Models page redesign: Add Models row (URL + hidden API key reveal +
  Type select + Scan/Ollama/Key/Test/Add icon buttons), Probe All +
  Clear-offline buttons in Added Models toolbar, offline-pill removed
  (opacity already conveys state), Engine dropdown gains Ollama option
- _ping_endpoint probes /v1/models then base, accepts 4xx as
  reachable (vLLM returns 404 on bare /v1, fully working endpoints
  were showing offline)
- Diagnosis card: × dismiss + Copy bundle buttons restored on the
  serve error feedback card
- Orphan tmux sweep re-enabled behind a 60s rate-limit + background
  Thread (off the main event loop) so dead serves get discovered
- cookbook_routes auto-register watchdog: drops the endpoint if the
  serve session exits non-zero within the first ~3min
- ollama-rocm sidecar awareness in download wrapper (`docker exec
  ollama-rocm ollama pull` when host ollama isn't installed)
- Skill extractor sets initial_status="published" when
  auto_approve_skills pref is on (audit demotes later)
- Skill list / model list / cookbook scan misc polish
2026-06-09 09:46:19 +09:00
pewdiepie-archdaemon 646f8bd2a9 Remove remaining plan mode frontend code 2026-06-09 09:44:22 +09:00
pewdiepie-archdaemon 2a2a93d845 Remove plan mode from merge-ready UI 2026-06-09 09:40:20 +09:00
pewdiepie-archdaemon 06a04efc59 Merge branch 'dev'
# Conflicts:
#	routes/task_routes.py
#	src/caldav_sync.py
2026-06-09 09:36:01 +09:00
pewdiepie-archdaemon 3b01760e95 Prepare tested main sync cleanup 2026-06-09 09:34:42 +09:00
Ocean Bennett db1bbfe588 fix(sessions): keep fresh chats during auto tidy (#1871)
Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-06-09 01:06:20 +01:00
Kenny Van de Maele 2404b00f18 refactor(uploads): centralize upload byte-limits in upload_limits.py (#3364) (#3518)
Move every per-route upload byte-limit into src/upload_limits.py as a
validated, env-overridable constant via read_byte_limit_env:

- Add GALLERY_UPLOAD_MAX_BYTES, GALLERY_TRANSFORM_UPLOAD_MAX_BYTES,
  MEMORY_IMPORT_MAX_BYTES, PERSONAL_UPLOAD_MAX_BYTES,
  EMAIL_COMPOSE_UPLOAD_MAX_BYTES, STT_MAX_AUDIO_BYTES, ICS_MAX_BYTES.
- Routes import their constant instead of defining it locally: replaces 4
  raw int(os.getenv(...)) and removes 3 hardcoded literals.
- The 3 previously-hardcoded limits (email compose, STT audio, calendar
  ICS) are now env-overridable with the same ODYSSEUS_*_MAX_BYTES naming.
- Defaults unchanged, so behavior is unchanged unless an env var is set;
  an invalid value now fails fast with a clear message instead of a bare
  int() ValueError.
- Document all env vars in .env.example and the README.

Fixes #3364
2026-06-09 01:24:30 +02:00
Ernest Hysa 7367325819 fix(caldav): include owner in calendar ID hash to prevent PK collision (#2765)
_stable_cal_id hashed only the remote URL, producing the same calendar
ID for all users syncing the same CalDAV endpoint. The second user would
get an IntegrityError on the primary key. Now includes owner in the
hash so each user gets a distinct calendar row.
2026-06-05 15:12:54 +02:00
Ernest Hysa 3738df3b93 fix(tasks): validate then_task_id belongs to same owner on create/update (#2764)
then_task_id was stored without checking the target task's owner. A user
could chain their task to execute any other user's task on success via the
scheduler's _run_chained path. Now verifies the target task exists and
belongs to the requesting user before storing.
2026-06-05 15:12:47 +02:00
Ernest Hysa f5c9095222 fix(document): add 404 guard to version list/get endpoints (#2762)
list_versions and get_version used a soft 'if doc:' guard that skipped
ownership verification when the Document row was missing (e.g. after
hard delete). Orphaned DocumentVersion rows would be returned to any
caller without auth. Now raises 404 when the parent document is gone,
matching the pattern already used in restore_version.
2026-06-05 15:12:40 +02:00
Ernest Hysa d4ff7fce81 fix(gallery): add auth check to /api/image/sharpen endpoint (#2761)
Every other image-processing endpoint (denoise, upscale, remove-bg,
enhance-face, inpaint, harmonize) calls require_privilege(request,
"can_generate_images"). The sharpen endpoint was missing this check,
allowing unauthenticated users to trigger CPU-intensive image processing.
2026-06-05 15:12:33 +02:00
65 changed files with 4197 additions and 1901 deletions
+10
View File
@@ -159,6 +159,16 @@ SEARXNG_INSTANCE=http://localhost:8080
# Example: 52428800 = 50 MB. # Example: 52428800 = 50 MB.
# ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=10485760 # 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) # GPU support (Docker Compose)
# ============================================================ # ============================================================
+9
View File
@@ -403,6 +403,15 @@ Key settings:
| `CHROMADB_PORT` | `8100` | ChromaDB port for manual host runs. Docker overrides this to `8000`. | | `CHROMADB_PORT` | `8100` | ChromaDB port for manual host runs. Docker overrides this to `8000`. |
| `EMBEDDING_URL` | -- | OpenAI-compatible embeddings endpoint | | `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_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) ### Built-in MCP servers (optional setup)
-3
View File
@@ -529,9 +529,6 @@ upload_cleanup_task = None
from routes.emoji_routes import setup_emoji_routes from routes.emoji_routes import setup_emoji_routes
app.include_router(setup_emoji_routes()) app.include_router(setup_emoji_routes())
from routes.workspace_routes import setup_workspace_routes
app.include_router(setup_workspace_routes())
# Sessions # Sessions
from routes.session_routes import setup_session_routes from routes.session_routes import setup_session_routes
session_config = {"REQUEST_TIMEOUT": REQUEST_TIMEOUT, "OPENAI_API_KEY": OPENAI_API_KEY, "SESSIONS_FILE": SESSIONS_FILE} session_config = {"REQUEST_TIMEOUT": REQUEST_TIMEOUT, "OPENAI_API_KEY": OPENAI_API_KEY, "SESSIONS_FILE": SESSIONS_FILE}
+532 -1
View File
@@ -22,6 +22,7 @@ import os
import os.path import os.path
from pathlib import Path from pathlib import Path
from datetime import datetime, timedelta from datetime import datetime, timedelta
import uuid
from mcp.server import Server from mcp.server import Server
from mcp.server.stdio import stdio_server from mcp.server.stdio import stdio_server
@@ -67,6 +68,59 @@ def _db_path() -> Path:
return Path(APP_DB) 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: def _list_accounts_raw() -> list:
"""Return list of dicts from the email_accounts table. Empty list if table """Return list of dicts from the email_accounts table. Empty list if table
missing or empty. Never raises.""" 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): 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.""" """Reply to an existing email by UID. Threads via In-Reply-To/References."""
conn = None conn = None
@@ -1189,6 +1577,8 @@ async def list_tools() -> list[Tool]:
name="send_email", name="send_email",
description=( description=(
"Send a new email via SMTP. Provide recipient(s), subject, and body. " "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. " "For replying to an existing thread, use reply_to_email instead. "
"Pass `account` to send from a non-default mailbox." "Pass `account` to send from a non-default mailbox."
), ),
@@ -1205,10 +1595,35 @@ async def list_tools() -> list[Tool]:
"required": ["to", "subject", "body"], "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( Tool(
name="reply_to_email", name="reply_to_email",
description=( 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 " "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 " "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 " "the original To/Cc recipients. For follow-up 'reply ...' requests, use "
@@ -1226,6 +1641,49 @@ async def list_tools() -> list[Tool]:
"required": ["uid", "body"], "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( Tool(
name="archive_email", 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.", 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 "" 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}.")] 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": elif name == "reply_to_email":
uid = arguments.get("uid") uid = arguments.get("uid")
body = arguments.get("body") body = arguments.get("body")
@@ -1573,6 +2056,54 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
pass pass
return [TextContent(type="text", text=f"Replied to UID {uid}: '{result['subject']}'{result['to']}")] 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": elif name == "archive_email":
uid = arguments.get("uid") uid = arguments.get("uid")
if not uid: if not uid:
+2
View File
@@ -25,6 +25,8 @@ ALLOWED_SCOPES = {
"calendar:write", "calendar:write",
"memory:read", "memory:read",
"memory:write", "memory:write",
"cookbook:read",
"cookbook:launch",
} }
TOKEN_PROFILES = { TOKEN_PROFILES = {
"chat": ["chat"], "chat": ["chat"],
+9 -3
View File
@@ -101,11 +101,17 @@ def setup_backup_routes(memory_manager, preset_manager, skills_manager) -> APIRo
# ── Skills ── # ── Skills ──
if "skills" in body and isinstance(body["skills"], list): if "skills" in body and isinstance(body["skills"], list):
existing = skills_manager.load_all() existing = skills_manager.load_all()
existing_names = {s.get("name") for s in existing if s.get("name")} # Dedup against THIS user's own skills only. Using every tenant's
existing_ids = {s.get("id") for s in existing if s.get("id")} # 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 = { existing_titles = {
(s.get("title") or s.get("description") or "").strip().lower() (s.get("title") or s.get("description") or "").strip().lower()
for s in existing for s in own
} }
added = 0 added = 0
for skill in body["skills"]: for skill in body["skills"]:
+5 -5
View File
@@ -13,7 +13,7 @@ from dateutil.rrule import rrulestr
from core.database import SessionLocal, CalendarCal, CalendarEvent from core.database import SessionLocal, CalendarCal, CalendarEvent
from src.auth_helpers import require_user 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__) logger = logging.getLogger(__name__)
@@ -1170,9 +1170,9 @@ def setup_calendar_routes() -> APIRouter:
finally: finally:
db.close() db.close()
# 10 MB hard cap on ICS upload. Loading the whole file into memory is # Hard cap on ICS upload (ICS_MAX_BYTES, default 10 MB). Loading the whole
# unavoidable with python-icalendar, so an unbounded upload would OOM. # file into memory is unavoidable with python-icalendar, so an unbounded
_ICS_MAX_BYTES = 10 * 1024 * 1024 # upload would OOM.
@router.post("/import") @router.post("/import")
async def import_ics(request: Request, file: UploadFile = File(...), calendar_name: str = ""): 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) owner = _require_user(request)
db = SessionLocal() db = SessionLocal()
try: try:
content = await read_upload_limited(file, _ICS_MAX_BYTES, "ICS file") content = await read_upload_limited(file, ICS_MAX_BYTES, "ICS file")
try: try:
cal_data = iCal.from_ical(content) cal_data = iCal.from_ical(content)
except Exception as e: except Exception as e:
+4 -10
View File
@@ -452,14 +452,10 @@ def setup_chat_routes(
search_context = form_data.get("search_context") # pre-fetched web search results (compare mode) search_context = form_data.get("search_context") # pre-fetched web search results (compare mode)
compare_mode = str(form_data.get("compare_mode", "")).lower() == "true" compare_mode = str(form_data.get("compare_mode", "")).lower() == "true"
incognito = str(form_data.get("incognito", "")).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' 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. # Plan mode is a modifier on agent mode — it only makes sense with tools.
if plan_mode: if plan_mode:
chat_mode = "agent" chat_mode = "agent"
@@ -1138,7 +1134,6 @@ def setup_chat_routes(
tool_policy=tool_policy, tool_policy=tool_policy,
owner=_user, owner=_user,
fallbacks=_fallback_candidates, fallbacks=_fallback_candidates,
workspace=workspace or None,
plan_mode=plan_mode, plan_mode=plan_mode,
approved_plan=approved_plan or None, approved_plan=approved_plan or None,
): ):
@@ -1270,8 +1265,7 @@ def setup_chat_routes(
# without waiting on the next streamed chunk. # without waiting on the next streamed chunk.
# #
# Normal chat/agent streams keep the DETACHED behavior below: they # Normal chat/agent streams keep the DETACHED behavior below: they
# survive the client closing the tab / navigating away (true # survive the client closing the tab / navigating away. The SSE response just subscribes (replay
# terminal-agent semantics). The SSE response just subscribes (replay
# buffered output + live); dropping the SSE only removes a subscriber — # buffered output + live); dropping the SSE only removes a subscriber —
# the run keeps going and saves the assistant message on completion # the run keeps going and saves the assistant message on completion
# regardless. Reconnect via /api/chat/resume. # regardless. Reconnect via /api/chat/resume.
+29 -9
View File
@@ -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}$") _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 pattern is a glob: allow typical safe glyphs only.
_INCLUDE_RE = re.compile(r"^[A-Za-z0-9._\-*?/\[\]]+$") _INCLUDE_RE = re.compile(r"^[A-Za-z0-9._\-*?/\[\]]+$")
# Remote host: user@host (optionally with :port-free hostname parts). # Remote host: either `user@host` or plain `host` (alias is allowed), where host
_REMOTE_HOST_RE = re.compile(r"^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+$") # 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. # HF tokens and API tokens are url-safe base64-like.
_TOKEN_RE = re.compile(r"^[A-Za-z0-9._~+/=-]+$") _TOKEN_RE = re.compile(r"^[A-Za-z0-9._~+/=-]+$")
# Session IDs we mint look like "cookbook-deadbeef" or "serve-deadbeef". # 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}$") _SSH_PORT_RE = re.compile(r"^\d{1,5}$")
_GPU_LIST_RE = re.compile(r"^\d+(?:,\d+)*$") _GPU_LIST_RE = re.compile(r"^\d+(?:,\d+)*$")
# A download target directory. Absolute or ~-relative path; safe path glyphs # A download target directory. Absolute or ~-relative path; safe path glyphs
# only (no quotes, shell metacharacters, or spaces) since it lands in a shell # only (no quotes or shell metacharacters). Spaces are allowed because command
# command. A leading ~ is expanded to $HOME at command-build time. # builders pass the value through quoted shell/Python contexts. The character
_LOCAL_DIR_RE = re.compile(r"^~?/[A-Za-z0-9._/-]*$|^~$") # 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]:[\\/]") _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 == "": if v is None or v == "":
return None return None
if not _REMOTE_HOST_RE.match(v): 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 return v
@@ -96,9 +104,19 @@ def _validate_token(v: str | None) -> str | None:
def _validate_local_dir(v: str | None) -> str | None: def _validate_local_dir(v: str | None) -> str | None:
if v is None or v == "": if v is None or v == "":
return None return None
if len(v) >= 2 and v[0] == v[-1] and v[0] in {"'", '"'}:
v = v[1:-1]
v = v.rstrip("/") or "/" v = v.rstrip("/") or "/"
if not _LOCAL_DIR_RE.match(v): 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 spaces or shell metacharacters") 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 return v
@@ -124,7 +142,7 @@ def _validate_gpus(v: str | None) -> str | None:
def _shell_path(p: str) -> str: def _shell_path(p: str) -> str:
"""Render a validated path for a double-quoted shell context, expanding a """Render a validated path for a double-quoted shell context, expanding a
leading ~ to $HOME (single quotes wouldn't expand it). Safe because 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 == "~": if p == "~":
return '"$HOME"' return '"$HOME"'
if p.startswith("~/"): 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 root, dirs, fns in safe_walk(base):",
" for fn in sorted(fns):", " for fn in sorted(fns):",
" if not fn.lower().endswith('.gguf'): continue", " if not fn.lower().endswith('.gguf'): continue",
" if fn.startswith('._'): continue # macOS AppleDouble sidecar, not a real GGUF",
" fp = os.path.join(root, fn)", " fp = os.path.join(root, fn)",
" try: size = os.path.getsize(fp)", " try: size = os.path.getsize(fp)",
" except Exception: size = 0", " except Exception: size = 0",
@@ -787,6 +806,7 @@ def _llama_cpp_rebuild_cmd() -> str:
class ModelDownloadRequest(BaseModel): class ModelDownloadRequest(BaseModel):
repo_id: str repo_id: str
backend: str | None = None # "hf" (default) or "ollama"
include: str | None = None # glob pattern e.g. "*Q4_K_M*" include: str | None = None # glob pattern e.g. "*Q4_K_M*"
hf_token: str | None = None hf_token: str | None = None
env_prefix: str | None = None # e.g. "source ~/venv/bin/activate" env_prefix: str | None = None # e.g. "source ~/venv/bin/activate"
+834 -317
View File
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -35,7 +35,7 @@ from fastapi.responses import FileResponse
from src.constants import DATA_DIR from src.constants import DATA_DIR
from src.llm_core import llm_call_async 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 ( from routes.email_helpers import (
_strip_think, _extract_reply, _apply_email_style_mechanics, require_owner, require_user, _assert_owns_account, _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__) logger = logging.getLogger(__name__)
ODYSSEUS_MAIL_ORIGIN = "odysseus-ui" 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]: def _email_tag_owner_aliases(account_id: str | None, owner: str = "") -> list[str]:
+5 -4
View File
@@ -13,7 +13,11 @@ from fastapi import APIRouter, HTTPException, Query, Request
from core.database import SessionLocal, GalleryImage, GalleryAlbum, ModelEndpoint from core.database import SessionLocal, GalleryImage, GalleryAlbum, ModelEndpoint
from core.database import Session as DbSession from core.database import Session as DbSession
from src.auth_helpers import get_current_user, owner_filter, require_privilege 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 src.constants import GENERATED_IMAGES_DIR
from routes.gallery_helpers import ( from routes.gallery_helpers import (
@@ -22,9 +26,6 @@ from routes.gallery_helpers import (
logger = logging.getLogger(__name__) 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: def _current_user_is_admin(request: Request, user: str | None) -> bool:
if not user: if not user:
+18 -1
View File
@@ -196,7 +196,24 @@ def setup_hwfit_routes():
if target_context is not None: if target_context is not None:
target_context = max(1024, min(target_context, 1000000)) 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} return {"system": system, "models": results}
@router.get("/profiles") @router.get("/profiles")
+1 -2
View File
@@ -29,11 +29,10 @@ from src.llm_core import llm_call_async
from services.memory.memory_extractor import audit_memories from services.memory.memory_extractor import audit_memories
from src.auth_helpers import get_current_user, require_user from src.auth_helpers import get_current_user, require_user
from src.endpoint_resolver import resolve_endpoint 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__) 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): def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionManager, memory_vector=None):
"""Set up memory-related routes.""" """Set up memory-related routes."""
+109 -126
View File
@@ -4,8 +4,8 @@ import os
import re import re
import uuid import uuid
import json import json
import socket
import hashlib import hashlib
import socket
import time as _time import time as _time
import logging import logging
import httpx import httpx
@@ -283,11 +283,8 @@ _HOST_TO_CURATED = (
("fireworks.ai", "fireworks"), ("fireworks.ai", "fireworks"),
("googleapis.com", "google"), ("googleapis.com", "google"),
("x.ai", "xai"), ("x.ai", "xai"),
("openrouter.ai", "openrouter"), ("openrouter.ai", "openrouter"),
("ollama.com", "ollama"), ("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: def _is_chat_model(model_id: str) -> bool:
"""Return True if the model ID looks like a chat/completions-capable model.""" """Return True if the model ID looks like a chat/completions-capable model."""
mid = model_id.lower() mid = model_id.lower()
if mid in {"gpt-5.1-codex"}:
return True
for prefix in _NON_CHAT_PREFIXES: for prefix in _NON_CHAT_PREFIXES:
if mid.startswith(prefix): if mid.startswith(prefix):
return False 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: 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. """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.
"""
if not auth_id: if not auth_id:
return False return False
from core.database import ProviderAuthSession 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 return True
def _is_discovery_only_provider(provider: str) -> bool: def _safe_detect_provider(base_url: str) -> str:
"""Provider that only supports model discovery, not live probing. """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 def _safe_build_models_url(base_url: str) -> str:
reachability pings are skipped status is derived from cached models. """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" return provider == "chatgpt-subscription"
def _resolve_probe_key(ep) -> Optional[str]: def _resolve_probe_key(ep) -> Optional[str]:
"""API key/bearer to probe an endpoint with. """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``.
"""
try: try:
from src.endpoint_resolver import resolve_endpoint_runtime from src.endpoint_resolver import resolve_endpoint_runtime
_base, key = resolve_endpoint_runtime(ep, owner=getattr(ep, "owner", None)) _base, key = resolve_endpoint_runtime(ep, owner=getattr(ep, "owner", None))
return key return key
except Exception as e: except Exception as exc:
logger.warning("Probe key resolution failed for %s: %s", getattr(ep, "id", "?"), e) logger.warning("Probe key resolution failed for %s: %s", getattr(ep, "id", "?"), exc)
return None 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?}.""" """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): 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} return {"status": "ok", "latency_ms": 0, "skipped": True}
messages = [ messages = [
{"role": "system", "content": "You are a helpful assistant."}, {"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": elif provider == "ollama":
from src.llm_core import _build_ollama_payload from src.llm_core import _build_ollama_payload
target_url = build_chat_url(base) target_url = build_chat_url(base)
h = build_headers(api_key, base) h = _safe_build_headers(api_key, base)
h["Content-Type"] = "application/json" h["Content-Type"] = "application/json"
payload = _build_ollama_payload(model_id, messages, 0.0, 5, stream=False, tools=_test_tools) payload = _build_ollama_payload(model_id, messages, 0.0, 5, stream=False, tools=_test_tools)
else: else:
target_url = build_chat_url(base) target_url = build_chat_url(base)
h = build_headers(api_key, base) h = _safe_build_headers(api_key, base)
h["Content-Type"] = "application/json" h["Content-Type"] = "application/json"
from src.llm_core import _uses_max_completion_tokens, _restricts_temperature 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" _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.""" For Anthropic, queries their /v1/models API, falling back to hardcoded list."""
from src.endpoint_resolver import resolve_url from src.endpoint_resolver import resolve_url
base = resolve_url(_normalize_base(base_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 from src.chatgpt_subscription import fetch_available_models
if api_key: if api_key:
return fetch_available_models(api_key, timeout=timeout) return fetch_available_models(api_key, timeout=timeout)
return [] return []
if _detect_provider(base) == "anthropic": if provider == "anthropic":
# Try Anthropic's /v1/models endpoint first # Try Anthropic's /v1/models endpoint first
url = build_models_url(base) url = _safe_build_models_url(base)
headers = {"anthropic-version": "2023-06-01"} headers = {"anthropic-version": "2023-06-01"}
if api_key: if api_key:
headers["x-api-key"] = 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 [] return []
logger.warning(f"Anthropic /v1/models failed, using hardcoded list: {e}") logger.warning(f"Anthropic /v1/models failed, using hardcoded list: {e}")
return list(ANTHROPIC_MODELS) return list(ANTHROPIC_MODELS)
url = build_models_url(base) url = _safe_build_models_url(base)
if not url: headers = _safe_build_headers(api_key, base)
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)
try: try:
r = httpx.get(url, headers=headers, timeout=timeout, verify=llm_verify()) r = httpx.get(url, headers=headers, timeout=timeout, verify=llm_verify())
r.raise_for_status() 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 list(fallback)
return [] return []
def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) -> Dict[str, Any]: 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.""" """Reachability probe that does not require installed/listed models."""
from src.endpoint_resolver import resolve_url from src.endpoint_resolver import resolve_url
base = resolve_url(_normalize_base(base_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, # Ollama exposes /v1/models (OpenAI-compatible) AND native /api/version,
# /api/tags. Probe native paths for Ollama-style endpoints, but avoid using # /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() 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]: def _result_from_response(r) -> Dict[str, Any]:
if 300 <= r.status_code < 400: if 300 <= r.status_code < 400:
loc = r.headers.get("location", "") 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 last_error: Optional[str] = None
try: try:
# APFEL does not behave like Ollama; use its health endpoint. if looks_like_ollama:
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:
root = base root = base
for suffix in ("/v1", "/api"): for suffix in ("/v1", "/api"):
if root.endswith(suffix): if root.endswith(suffix):
@@ -847,17 +824,11 @@ def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) ->
try: try:
r = httpx.get(base, headers=headers, timeout=timeout, verify=llm_verify()) r = httpx.get(base, headers=headers, timeout=timeout, verify=llm_verify())
result = _result_from_response(r) result = _result_from_response(r)
# If the bare base URL returns a non-auth 4xx (e.g. 404), try /models if result["reachable"]:
# as a fallback. OpenAI-compatible servers like llama-swap return 404 return result
# on the base /v1 prefix but 200 on /v1/models. Auth failures (401/403) sc = result.get("status_code") or 0
# are definitive — probing /models would just repeat the same rejection. if 400 <= sc < 500 and sc not in (401, 403):
if ( models_url = _safe_build_models_url(base)
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)
try: try:
r2 = httpx.get(models_url, headers=headers, timeout=timeout, verify=llm_verify()) r2 = httpx.get(models_url, headers=headers, timeout=timeout, verify=llm_verify())
result2 = _result_from_response(r2) 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 return result2
except Exception: except Exception:
pass pass
return result if sc:
return result
last_error = result.get("error") or last_error
except Exception as e: except Exception as e:
last_error = str(e)[:120] last_error = str(e)[:120]
return {"reachable": False, "status_code": None, "error": last_error} return {"reachable": False, "status_code": None, "error": last_error}
def _model_endpoint_error_message(base_url: str, ping: Dict[str, Any] = None) -> str: def _model_endpoint_error_message(base_url: str, ping: Dict[str, Any] = None) -> str:
"""Return a provider-aware error message for failed endpoint probes.""" """Return a provider-aware error message for failed endpoint probes."""
ping = ping or {} ping = ping or {}
@@ -1068,17 +1043,6 @@ def setup_model_routes(model_discovery):
ok, info = _should_refresh_endpoint(ep, now, force=force) ok, info = _should_refresh_endpoint(ep, now, force=force)
if not ok: if not ok:
continue 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"], { groups.setdefault(info["key"], {
"base": info["base"], "base": info["base"],
"api_key": info["api_key"], "api_key": info["api_key"],
@@ -1156,7 +1120,7 @@ def setup_model_routes(model_discovery):
for ep in endpoints: for ep in endpoints:
base = _normalize_base(ep.base_url) base = _normalize_base(ep.base_url)
provider = _detect_provider(base) provider = _safe_detect_provider(base)
# Merge cached + pinned models, then filter out hidden ones # Merge cached + pinned models, then filter out hidden ones
ep_model_type = getattr(ep, "model_type", None) or "llm" ep_model_type = getattr(ep, "model_type", None) or "llm"
model_ids = _visible_models( model_ids = _visible_models(
@@ -1233,8 +1197,8 @@ def setup_model_routes(model_discovery):
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error('Auth gate error in GET /api/models, failing closed: %s', e) logger.error("Auth gate error in GET /api/models, failing closed: %s", e)
raise HTTPException(status_code=500, detail='Internal error') raise HTTPException(status_code=500, detail="Internal error")
# Admins see every endpoint (they manage the global pool); regular # Admins see every endpoint (they manage the global pool); regular
# users get the owner-scoped view. # users get the owner-scoped view.
_is_admin = False _is_admin = False
@@ -1298,7 +1262,14 @@ def setup_model_routes(model_discovery):
t0 = _time.time() t0 = _time.time()
try: try:
import asyncio as _asyncio 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 23 min to
# warm); /v1/models can take 5002500 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) lat = round((_time.time() - t0) * 1000)
return { return {
"alive": bool(ping.get("reachable")), "alive": bool(ping.get("reachable")),
@@ -1336,7 +1307,7 @@ def setup_model_routes(model_discovery):
results = [] results = []
for ep in endpoints: for ep in endpoints:
base = _normalize_base(ep.base_url) base = _normalize_base(ep.base_url)
provider = _detect_provider(base) provider = _safe_detect_provider(base)
kind = _effective_endpoint_kind(ep, base) kind = _effective_endpoint_kind(ep, base)
cached_count = len(_cached_model_ids(ep)) cached_count = len(_cached_model_ids(ep))
entry = { entry = {
@@ -1348,20 +1319,12 @@ def setup_model_routes(model_discovery):
"endpoint_kind": kind, "endpoint_kind": kind,
} }
try: try:
if _is_discovery_only_provider(provider): t0 = _time.time()
# No general health endpoint — an unauthenticated GET just ping = _ping_endpoint(base, ep.api_key, timeout=1.5)
# 401s. Report status from cached models instead of pinging. entry["latency_ms"] = round((_time.time() - t0) * 1000)
entry["latency_ms"] = None entry["status"] = "online" if ping.get("reachable") or cached_count else "offline"
entry["status"] = "online" if cached_count else "offline" entry["error"] = ping.get("error")
entry["error"] = None entry["model_count"] = cached_count or (len(ANTHROPIC_MODELS) if provider == "anthropic" else 0)
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)
except Exception as e: except Exception as e:
entry["latency_ms"] = None entry["latency_ms"] = None
entry["status"] = "online" if cached_count else "offline" 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: if ep_id and ep_id not in endpoints_cache:
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first() ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
if ep: 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) ep_data = endpoints_cache.get(ep_id)
if not ep_data: if not ep_data:
# Try to find by base_url from the model's endpoint field # 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, "id": ep.id,
"name": ep.name, "name": ep.name,
"base_url": ep.base_url, "base_url": ep.base_url,
"api_key": _resolve_probe_key(ep), "api_key": ep.api_key,
}) })
finally: finally:
db.close() db.close()
@@ -1522,14 +1485,37 @@ def setup_model_routes(model_discovery):
# Endpoint counts as reachable if it has any model — including # Endpoint counts as reachable if it has any model — including
# admin-pinned IDs that a probe would never surface. # admin-pinned IDs that a probe would never surface.
status = "online" if (all_models or pinned) else "offline" status = "online" if (all_models or pinned) else "offline"
base = _normalize_base(r.base_url)
ping = None ping = None
# Discovery-only providers have no health endpoint — an # When cached_models is empty, do a quick reachability probe.
# unauthenticated ping just 401s, so don't bother. # Bumped 1.0s → 3.5s because the user reported endpoints they
if not all_models and not pinned and r.is_enabled and not _is_discovery_only_provider(_detect_provider(base)): # were ACTIVELY chatting with showed "offline" — the previous
ping = _ping_endpoint(r.base_url, r.api_key, timeout=1.0) # 1s timeout was clipping live cloud endpoints (DeepSeek can
# take 1.52.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"): if ping.get("reachable"):
status = "empty" 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) kind = _effective_endpoint_kind(r, base)
results.append({ results.append({
"id": r.id, "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) explicit_timeout = _explicit_model_list_timeout(base_url, requested_kind, refresh_timeout)
# Dedupe: if an endpoint with the same base_url and compatible # Dedupe: if an endpoint with the same base_url already exists and
# credentials already exists and is reachable by the caller (shared or # is reachable by the caller (shared or owned by them), return it
# owned by them), return it instead of creating a duplicate row. Keep # instead of creating a duplicate row. Fixes "Scan for Servers"
# same-url/different-key rows distinct so users can group the same # re-adding manually-added endpoints under their host:port name.
# provider URL under multiple credentials.
from src.auth_helpers import get_current_user as _gcu_dedup from src.auth_helpers import get_current_user as _gcu_dedup
_caller = _gcu_dedup(request) or None _caller = _gcu_dedup(request) or None
_incoming_api_key = api_key.strip() _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() ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
if not ep: if not ep:
raise HTTPException(404, "Endpoint not found") 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: finally:
db.close() db.close()
@@ -1869,7 +1854,7 @@ def setup_model_routes(model_discovery):
category = _classify_endpoint(base, kind) category = _classify_endpoint(base, kind)
timeout = _manual_refresh_timeout(ep, category, refresh_timeout) timeout = _manual_refresh_timeout(ep, category, refresh_timeout)
try: try:
probed = _probe_endpoint(base, _resolve_probe_key(ep), timeout=timeout) probed = _probe_endpoint(base, ep.api_key, timeout=timeout)
except Exception as exc: except Exception as exc:
logger.warning("Manual model refresh failed for endpoint %s at %s: %s", ep_id, base, exc) logger.warning("Manual model refresh failed for endpoint %s at %s: %s", ep_id, base, exc)
probed = [] probed = []
@@ -2105,8 +2090,6 @@ def setup_model_routes(model_discovery):
"name": ep.name, "name": ep.name,
"model_type": ep.model_type, "model_type": ep.model_type,
"base_url": ep.base_url, "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)), "pinned_models": _normalize_model_ids(getattr(ep, "pinned_models", None)),
"endpoint_kind": getattr(ep, "endpoint_kind", None) or "auto", "endpoint_kind": getattr(ep, "endpoint_kind", None) or "auto",
"model_refresh_mode": getattr(ep, "model_refresh_mode", None) or "auto", "model_refresh_mode": getattr(ep, "model_refresh_mode", None) or "auto",
+3 -5
View File
@@ -11,11 +11,9 @@ from src.rag_singleton import get_rag_manager
from src.auth_helpers import require_privilege, require_user from src.auth_helpers import require_privilege, require_user
from core.middleware import require_admin from core.middleware import require_admin
from src.upload_handler import secure_filename from src.upload_handler import secure_filename
from src.upload_limits import PERSONAL_UPLOAD_MAX_BYTES
UPLOADS_DIR = PERSONAL_UPLOADS_DIR 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__) logger = logging.getLogger(__name__)
@@ -208,8 +206,8 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
for upload in files: for upload in files:
try: try:
file_path, stored_name, safe_name = _unique_personal_upload_path(upload_dir, upload.filename) file_path, stored_name, safe_name = _unique_personal_upload_path(upload_dir, upload.filename)
content_bytes = await upload.read(MAX_PERSONAL_UPLOAD_BYTES + 1) content_bytes = await upload.read(PERSONAL_UPLOAD_MAX_BYTES + 1)
if len(content_bytes) > MAX_PERSONAL_UPLOAD_BYTES: if len(content_bytes) > PERSONAL_UPLOAD_MAX_BYTES:
logger.warning(f"Rejected oversized personal upload: {upload.filename!r}") logger.warning(f"Rejected oversized personal upload: {upload.filename!r}")
total_failed += 1 total_failed += 1
continue continue
+5 -1
View File
@@ -10,8 +10,9 @@ import logging
from core.session_manager import SessionManager from core.session_manager import SessionManager
from core.models import ChatMessage from core.models import ChatMessage
from src.request_models import SessionResponse 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.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: 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)) db.query(DbMsg.session_id, _sa_func.count(DbMsg.id))
.filter(DbMsg.role == "assistant").group_by(DbMsg.session_id).all() .filter(DbMsg.role == "assistant").group_by(DbMsg.session_id).all()
) )
cleanup_now = utcnow_naive()
for row in rows: for row in rows:
# Never delete important sessions # Never delete important sessions
if getattr(row, 'is_important', False): 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'): if hasattr(session_manager, 'delete_session'):
session_manager.delete_session(row.id) session_manager.delete_session(row.id)
continue continue
if is_session_recently_active(row, now=cleanup_now):
continue
msg_count = _counts.get(row.id, 0) msg_count = _counts.get(row.id, 0)
should_delete = False should_delete = False
if msg_count == 0: if msg_count == 0:
+1 -3
View File
@@ -4,12 +4,10 @@
from fastapi import APIRouter, HTTPException, UploadFile, File from fastapi import APIRouter, HTTPException, UploadFile, File
import logging 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__) logger = logging.getLogger(__name__)
STT_MAX_AUDIO_BYTES = 25 * 1024 * 1024
def setup_stt_routes(stt_service): def setup_stt_routes(stt_service):
"""Setup STT routes with the provided STT service""" """Setup STT routes with the provided STT service"""
+9
View File
@@ -519,6 +519,15 @@ def setup_task_routes(task_scheduler) -> APIRouter:
else bool(req.notifications_enabled) if req.notifications_enabled is not None else bool(req.notifications_enabled) if req.notifications_enabled is not None
else True 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( task = ScheduledTask(
id=task_id, id=task_id,
owner=user, owner=user,
-56
View File
@@ -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
+24 -1
View File
@@ -14036,6 +14036,29 @@
"vision" "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", "name": "google/gemma-4-31B-it",
"provider": "Google", "provider": "Google",
@@ -19121,4 +19144,4 @@
], ],
"_discovered": true "_discovered": true
} }
] ]
+15
View File
@@ -243,6 +243,20 @@ async def maybe_extract_skill(
logger.debug("[skill-extract] '%s' already exists — dropped as duplicate", title) logger.debug("[skill-extract] '%s' already exists — dropped as duplicate", title)
return None 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( entry = skills_manager.add_skill(
title=title, title=title,
problem=data.get("problem", ""), problem=data.get("problem", ""),
@@ -253,6 +267,7 @@ async def maybe_extract_skill(
confidence=data.get("confidence", 0.7), confidence=data.get("confidence", 0.7),
session_id=getattr(session, "session_id", None), session_id=getattr(session, "session_id", None),
owner=owner, owner=owner,
status=_initial_status,
) )
try: try:
from src.event_bus import fire_event from src.event_bus import fire_event
+12 -7
View File
@@ -134,9 +134,10 @@ _NEWS_HINTS = ("news", "nyheter", "headlines", "breaking", "latest", "today", "i
_GENERAL_ENGINES = os.environ.get("SEARXNG_GENERAL_ENGINES", "bing,mojeek,presearch") _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]: time_filter: Optional[str] = None) -> List[dict]:
"""Search using SearXNG JSON API. Returns list of {title, url, snippet}.""" """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() instance = _get_search_instance()
api_key = "" api_key = ""
headers = {"User-Agent": "Mozilla/5.0"} headers = {"User-Agent": "Mozilla/5.0"}
@@ -282,8 +283,9 @@ def searxng_search(query, max_results=10):
# ── Brave ── # ── 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.""" """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 "" 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}) 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 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.""" """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]: def _html_fallback() -> List[dict]:
try: try:
response = httpx.get( response = httpx.get(
@@ -452,7 +454,7 @@ def duckduckgo_search(query: str, count: int = 10, time_filter: Optional[str] =
# ── Google Programmable Search Engine ── # ── 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). """Search using Google PSE (Custom Search JSON API).
Requires two keys in settings: 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) - google_pse_cx: Programmable Search Engine ID (cx)
Or env vars GOOGLE_API_KEY and GOOGLE_PSE_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() settings = _get_search_settings()
api_key = _get_provider_key("google_pse") or os.environ.get("GOOGLE_API_KEY", "") 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", "") 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 ── # ── 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.""" """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", "") api_key = _get_provider_key("tavily") or os.environ.get("TAVILY_API_KEY", "")
if not api_key: if not api_key:
logger.warning("Tavily: no API key configured") 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 ── # ── 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.""" """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", "") api_key = _get_provider_key("serper") or os.environ.get("SERPER_API_KEY", "")
if not api_key: if not api_key:
logger.warning("Serper: no API key configured") logger.warning("Serper: no API key configured")
+271 -29
View File
@@ -172,6 +172,120 @@ _API_AGENT_RULES = """\
- After `create_session` returns id `89effa28`: "Created [New Chat](#session-89effa28) — click to switch." - 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\"""" - 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. # Each tool section is keyed by tool name(s) it covers.
# Sections with multiple tools use a tuple key. # Sections with multiple tools use a tuple key.
TOOL_SECTIONS = { 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.", "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.", "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.", "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.", "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.", "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.", "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}.", f"Available tools: {tool_list}.",
_API_AGENT_RULES, _API_AGENT_RULES,
] ]
parts.extend(_domain_rules_for_tools(included))
return "\n\n".join(parts) return "\n\n".join(parts)
parts = [_AGENT_PREAMBLE] 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(f"(Other tools available when needed: {hint})")
parts.append(_AGENT_RULES) parts.append(_AGENT_RULES)
parts.extend(_domain_rules_for_tools(included))
return "\n\n".join(parts) return "\n\n".join(parts)
@@ -574,6 +690,117 @@ def _extract_last_user_message(messages: List[Dict]) -> str:
return "" 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: 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 """Build the tool-retrieval query from the last few USER turns, not just
the latest one. the latest one.
@@ -1480,7 +1707,6 @@ async def stream_agent_loop(
owner: Optional[str] = None, owner: Optional[str] = None,
relevant_tools: Optional[Set[str]] = None, relevant_tools: Optional[Set[str]] = None,
fallbacks: Optional[List[tuple]] = None, fallbacks: Optional[List[tuple]] = None,
workspace: Optional[str] = None,
plan_mode: bool = False, plan_mode: bool = False,
approved_plan: Optional[str] = None, approved_plan: Optional[str] = None,
tool_policy: Optional[ToolPolicy] = None, tool_policy: Optional[ToolPolicy] = None,
@@ -1522,9 +1748,18 @@ async def stream_agent_loop(
_t0 = time.time() _t0 = time.time()
_needs_admin = _detect_admin_intent(messages) _needs_admin = _detect_admin_intent(messages)
_last_user = _extract_last_user_message(messages) _last_user = _extract_last_user_message(messages)
# Tool retrieval keys on recent conversation context (last few user turns), _intent = _classify_agent_request(messages, _last_user)
# not just the latest message, so short follow-ups don't drop just-used tools. # Tool retrieval uses the latest message by default. It may inherit recent
_retrieval_query = _recent_context_for_retrieval(messages) or _last_user # 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 {} _mcp_disabled_map = _load_mcp_disabled_map() if mcp_mgr else {}
if plan_mode and mcp_mgr: if plan_mode and mcp_mgr:
# Allow read-only MCP tools to investigate, block write/unknown ones: # Allow read-only MCP tools to investigate, block write/unknown ones:
@@ -1541,6 +1776,10 @@ async def stream_agent_loop(
_t1 = time.time() _t1 = time.time()
if _relevant_tools: if _relevant_tools:
logger.info(f"[tool-rag] Using caller-provided relevant_tools ({len(_relevant_tools)} 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: if not guide_only and not _relevant_tools:
try: try:
from src.tool_index import get_tool_index, ALWAYS_AVAILABLE 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(): for keywords, tools in ToolIndex._KEYWORD_HINTS.items():
if any(kw in ql for kw in keywords): if any(kw in ql for kw in keywords):
_relevant_tools.update(tools) _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)}") 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 # If a document is open the model needs the editing tools available
# regardless of which selection path (RAG, keyword, caller-provided) ran # regardless of which selection path (RAG, keyword, caller-provided) ran
# or what keywords were in the latest user message. # or what keywords were in the latest user message.
if _relevant_tools is not None and active_document is not None: if _relevant_tools is not None and active_document is not None:
_relevant_tools.update({"edit_document", "update_document", "suggest_document"}) _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 prep_timings["tool_selection"] = time.time() - _t1
_t2 = time.time() _t2 = time.time()
@@ -1670,27 +1934,6 @@ async def stream_agent_loop(
owner=owner, owner=owner,
suppress_local_context=guide_only, 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: if plan_mode and not guide_only:
# Steer the model to investigate-then-propose. Hard tool gating handles # Steer the model to investigate-then-propose. Hard tool gating handles
# every write path except shell; this directive is what keeps the # every write path except shell; this directive is what keeps the
@@ -2384,7 +2627,6 @@ async def stream_agent_loop(
tool_policy=tool_policy, tool_policy=tool_policy,
owner=owner, owner=owner,
progress_cb=_push_progress, progress_cb=_push_progress,
workspace=workspace,
) )
finally: finally:
# Sentinel so the drainer knows to stop. # Sentinel so the drainer knows to stop.
+1 -1
View File
@@ -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) toggle <name> <on|off> Toggle a setting (web, bash, rag, research, incognito, document_editor)
set_mode <agent|chat> Switch between agent and chat mode set_mode <agent|chat> Switch between agent and chat mode
switch_model <model> Change the model for the current session 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 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_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 open_email_reply <uid> [folder] [reply|reply-all|ai-reply] Open a reply draft document for an email; does not send
+6 -2
View File
@@ -17,8 +17,6 @@ from typing import Any, Dict, Optional
import httpx import httpx
from fastapi import HTTPException from fastapi import HTTPException
from core.database import ProviderAuthSession, SessionLocal, utcnow_naive
DEFAULT_CHATGPT_SUBSCRIPTION_BASE_URL = ( DEFAULT_CHATGPT_SUBSCRIPTION_BASE_URL = (
os.getenv("CHATGPT_SUBSCRIPTION_BASE_URL", "").strip().rstrip("/") os.getenv("CHATGPT_SUBSCRIPTION_BASE_URL", "").strip().rstrip("/")
or "https://chatgpt.com/backend-api/codex" or "https://chatgpt.com/backend-api/codex"
@@ -33,6 +31,11 @@ _AUTH_REFRESH_LOCKS: dict[str, threading.Lock] = {}
_AUTH_REFRESH_LOCKS_GUARD = 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: def _refresh_lock_for(auth_id: str) -> threading.Lock:
with _AUTH_REFRESH_LOCKS_GUARD: with _AUTH_REFRESH_LOCKS_GUARD:
lock = _AUTH_REFRESH_LOCKS.get(auth_id) 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]: 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() db = SessionLocal()
try: try:
q = db.query(ProviderAuthSession).filter( q = db.query(ProviderAuthSession).filter(
+26
View File
@@ -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/")) 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: def _ollama_api_root(url: str) -> str:
"""Return a native Ollama API root such as https://ollama.com/api.""" """Return a native Ollama API root such as https://ollama.com/api."""
url = (url or "").strip().rstrip("/") url = (url or "").strip().rstrip("/")
@@ -1344,6 +1362,9 @@ async def llm_call_async(
if max_tokens and max_tokens > 0: if max_tokens and max_tokens > 0:
tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens" tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens"
payload[tok_key] = 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): if _is_host_dead(target_url):
raise HTTPException(503, f"Upstream {_host_key(target_url)} marked unreachable (cooldown active)") 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 payload[tok_key] = max_tokens
if tools: if tools:
payload["tools"] = 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) h = _provider_headers(provider, headers)
if provider == "copilot": if provider == "copilot":
from src.copilot import apply_request_headers from src.copilot import apply_request_headers
+35 -4
View File
@@ -8,7 +8,7 @@ and the task scheduler / builtin actions system.
import json import json
import logging import logging
import re import re
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -23,6 +23,34 @@ _THROWAWAY_NAMES = {
} }
_THROWAWAY_MAX_MESSAGES = 4 _THROWAWAY_MAX_MESSAGES = 4
_FRESH_EMPTY_SESSION_GRACE = timedelta(minutes=10) _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: 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 []), *([DbSession.owner == owner] if owner else []),
).all() ).all()
cleanup_now = _utcnow_naive()
for row in rows: for row in rows:
if getattr(row, 'is_important', False): if getattr(row, 'is_important', False):
continue continue
created_at = row.created_at or row.updated_at or datetime.utcnow() created_at = _as_naive_utc(row.created_at or row.updated_at) or _utcnow_naive()
is_fresh = (datetime.utcnow() - created_at) < _FRESH_EMPTY_SESSION_GRACE is_fresh = (_utcnow_naive() - created_at) < _FRESH_EMPTY_SESSION_GRACE
if (row.name or "").strip() == "Incognito": if (row.name or "").strip() == "Incognito":
deleted_throwaway += 1 deleted_throwaway += 1
db.delete(row) db.delete(row)
continue continue
if is_session_recently_active(row, now=cleanup_now):
continue
msg_count = db.query(DbMsg.id).filter( msg_count = db.query(DbMsg.id).filter(
DbMsg.session_id == row.id 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() db_sess = db.query(DbSession).filter(DbSession.id == full_id).first()
if db_sess: if db_sess:
db_sess.folder = folder_name db_sess.folder = folder_name
db_sess.updated_at = datetime.utcnow() db_sess.updated_at = _utcnow_naive()
updated += 1 updated += 1
db.commit() db.commit()
+19 -67
View File
@@ -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. """Exact string-replacement edit of an on-disk file.
content is JSON: {"path", "old_string", "new_string", "replace_all"?}. content is JSON: {"path", "old_string", "new_string", "replace_all"?}.
Fails if old_string is missing or non-unique (unless replace_all) so the 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. 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: try:
args = json.loads(content) if content.strip().startswith("{") else {} 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)) replace_all = bool(args.get("replace_all", False))
if not raw_path: if not raw_path:
return {"error": "edit_file: path required", "exit_code": 1} return {"error": "edit_file: path required", "exit_code": 1}
# Confine to the workspace when set, else the same allowlist + sensitive-file # Allowlist + sensitive-file policy as read/write_file.
# policy as read/write_file.
try: try:
path = (_resolve_tool_path_in_workspace(workspace, raw_path) path = _resolve_tool_path(raw_path)
if workspace else _resolve_tool_path(raw_path))
except ValueError as e: except ValueError as e:
return {"error": f"edit_file: {e}", "exit_code": 1} return {"error": f"edit_file: {e}", "exit_code": 1}
if old == "": 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 # Bash + python tools used to share a single 60s timeout. That's
# enough for one-shot commands but starves real workloads (pip # enough for one-shot commands but starves real workloads (pip
# install, ffmpeg conversions, etc.) — and worse, the agent saw the # install, ffmpeg conversions, etc.) — and worse, the agent saw the
@@ -341,19 +305,13 @@ _CODENAV_MAX_HITS = 200
_CODENAV_MAX_LINE = 400 _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). """Resolve + confine a code-nav path (grep/glob/ls).
With a workspace set, the workspace folder is the root and supplied paths are An empty path defaults to the agent's primary root (project data dir) and a
confined inside it (same policy as read_file). Without one, an empty path supplied path is confined by the global allowlist + sensitive-file policy.
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() 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: if not raw:
roots = _tool_path_roots() roots = _tool_path_roots()
return roots[0] if roots else os.path.realpath(".") return roots[0] if roots else os.path.realpath(".")
@@ -564,12 +522,11 @@ async def _call_mcp_tool(
tool: str, tool: str,
content: str, content: str,
progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None, progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None,
workspace: Optional[str] = None,
) -> Dict: ) -> Dict:
"""Route a legacy tool call through the MCP manager, with direct fallbacks.""" """Route a legacy tool call through the MCP manager, with direct fallbacks."""
mcp = get_mcp_manager() mcp = get_mcp_manager()
if not mcp: 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] server_id, tool_name = _MCP_TOOL_MAP[tool]
qualified = f"mcp__{server_id}__{tool_name}" 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 MCP server not connected, try direct fallback
if isinstance(result, dict) and result.get("exit_code") == 1 and "not connected" in result.get("error", ""): 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: if fallback:
return fallback return fallback
@@ -636,7 +593,6 @@ async def _direct_fallback(
tool: str, tool: str,
content: str, content: str,
progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None, progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None,
workspace: Optional[str] = None,
) -> Optional[Dict]: ) -> Optional[Dict]:
"""In-process execution path for the eight tools that used to live as """In-process execution path for the eight tools that used to live as
stdio MCP servers under mcp_servers/. Those servers were deleted in stdio MCP servers under mcp_servers/. Those servers were deleted in
@@ -670,7 +626,7 @@ async def _direct_fallback(
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
env=_subproc_env, env=_subproc_env,
cwd=workspace or _AGENT_WORKDIR, cwd=_AGENT_WORKDIR,
) )
stdout, stderr, rc, timed_out = await _run_subprocess_streaming( stdout, stderr, rc, timed_out = await _run_subprocess_streaming(
proc, proc,
@@ -697,7 +653,7 @@ async def _direct_fallback(
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
env=_subproc_env, env=_subproc_env,
cwd=workspace or _AGENT_WORKDIR, cwd=_AGENT_WORKDIR,
) )
stdout, stderr, rc, timed_out = await _run_subprocess_streaming( stdout, stderr, rc, timed_out = await _run_subprocess_streaming(
proc, proc,
@@ -727,8 +683,7 @@ async def _direct_fallback(
except (json.JSONDecodeError, TypeError, ValueError): except (json.JSONDecodeError, TypeError, ValueError):
pass pass
try: try:
path = (_resolve_tool_path_in_workspace(workspace, raw_path) path = _resolve_tool_path(raw_path)
if workspace else _resolve_tool_path(raw_path))
except ValueError as e: except ValueError as e:
return {"error": f"read_file: {e}", "exit_code": 1} return {"error": f"read_file: {e}", "exit_code": 1}
try: try:
@@ -771,8 +726,7 @@ async def _direct_fallback(
raw_path = lines[0].strip() raw_path = lines[0].strip()
body = lines[1] if len(lines) > 1 else "" body = lines[1] if len(lines) > 1 else ""
try: try:
path = (_resolve_tool_path_in_workspace(workspace, raw_path) path = _resolve_tool_path(raw_path)
if workspace else _resolve_tool_path(raw_path))
except ValueError as e: except ValueError as e:
return {"error": f"write_file: {e}", "exit_code": 1} return {"error": f"write_file: {e}", "exit_code": 1}
try: try:
@@ -825,7 +779,7 @@ async def _direct_fallback(
max_hits = _CODENAV_MAX_HITS max_hits = _CODENAV_MAX_HITS
max_hits = max(1, min(max_hits, _CODENAV_MAX_HITS)) max_hits = max(1, min(max_hits, _CODENAV_MAX_HITS))
try: try:
root = _resolve_search_root(str(args.get("path", "")), workspace) root = _resolve_search_root(str(args.get("path", "")))
except ValueError as e: except ValueError as e:
return {"error": f"grep: {e}", "exit_code": 1} return {"error": f"grep: {e}", "exit_code": 1}
@@ -909,7 +863,7 @@ async def _direct_fallback(
if not pattern: if not pattern:
return {"error": "glob: pattern is required", "exit_code": 1} return {"error": "glob: pattern is required", "exit_code": 1}
try: try:
root = _resolve_search_root(str(args.get("path", "")), workspace) root = _resolve_search_root(str(args.get("path", "")))
except ValueError as e: except ValueError as e:
return {"error": f"glob: {e}", "exit_code": 1} return {"error": f"glob: {e}", "exit_code": 1}
@@ -956,7 +910,7 @@ async def _direct_fallback(
else: else:
raw_path = _s.split("\n", 1)[0].strip() raw_path = _s.split("\n", 1)[0].strip()
try: try:
root = _resolve_search_root(raw_path, workspace) root = _resolve_search_root(raw_path)
except ValueError as e: except ValueError as e:
return {"error": f"ls: {e}", "exit_code": 1} return {"error": f"ls: {e}", "exit_code": 1}
@@ -1121,7 +1075,6 @@ async def execute_tool_block(
tool_policy: Optional[ToolPolicy] = None, tool_policy: Optional[ToolPolicy] = None,
owner: Optional[str] = None, owner: Optional[str] = None,
progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None, progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None,
workspace: Optional[str] = None,
) -> Tuple[str, Dict]: ) -> Tuple[str, Dict]:
"""Execute a single tool block. Returns (description, result_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) _is_bg, _bg_cmd = _split_bg_marker(content)
if _is_bg and _bg_cmd: if _is_bg and _bg_cmd:
from src import bg_jobs 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] short = _bg_cmd.strip().split(chr(10))[0][:80]
desc = f"bash (background): {short}" desc = f"bash (background): {short}"
result = { result = {
@@ -1318,13 +1271,12 @@ async def execute_tool_block(
if tool in _MCP_TOOL_MAP: if tool in _MCP_TOOL_MAP:
first_line = content.split(chr(10))[0][:80] first_line = content.split(chr(10))[0][:80]
desc = f"{tool}: {first_line}" 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"): elif tool in ("grep", "glob", "ls"):
# Code-navigation tools — no MCP server; run the direct implementation. # 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] first_line = content.split(chr(10))[0][:80]
desc = f"{tool}: {first_line}" 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} or {"error": f"{tool}: execution failed", "exit_code": 1}
elif tool == "create_document": elif tool == "create_document":
title = content.split("\n")[0].strip()[:60] title = content.split("\n")[0].strip()[:60]
@@ -1429,7 +1381,7 @@ async def execute_tool_block(
desc = "edit_image" desc = "edit_image"
result = await do_edit_image(content, owner=owner) result = await do_edit_image(content, owner=owner)
elif tool == "edit_file": 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" desc = result.get("output") or result.get("error") or "edit_file"
elif tool == "trigger_research": elif tool == "trigger_research":
desc = "trigger_research" desc = "trigger_research"
+141 -9
View File
@@ -664,6 +664,17 @@ async def do_manage_skills(content: str, owner: Optional[str] = None) -> Dict:
proc = args.get("steps") or [] proc = args.get("steps") or []
if not proc and not args.get("body_extra") and not args.get("solution"): 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} 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( entry = sm.add_skill(
name=args.get("name"), name=args.get("name"),
description=(args.get("description") or args.get("title") or "").strip(), 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, procedure=proc,
pitfalls=args.get("pitfalls") or [], pitfalls=args.get("pitfalls") or [],
verification=args.get("verification") or [], verification=args.get("verification") or [],
status=args.get("status") or "draft", status=_status_arg,
version=args.get("version") or "1.0.0", version=args.get("version") or "1.0.0",
confidence=args.get("confidence", 0.8), confidence=args.get("confidence", 0.8),
source=args.get("source", "learned"), 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, def _infer_serve_port(cmd: str) -> int:
cmd: str, task_type: str = "serve") -> bool: """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 """Append a task entry to cookbook_state.json after the agent
launches via /api/model/serve or /api/model/download. The route launches via /api/model/serve or /api/model/download. The route
spawns tmux but leaves state-writing to the UI; the agent needs to 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": "", "sshPort": "",
"platform": "linux", "platform": "linux",
"_serveReady": False, "_serveReady": False,
"_endpointAdded": False, "_endpointAdded": bool(endpoint_added),
"_endpointId": endpoint_id or "",
}) })
state["tasks"] = tasks state["tasks"] = tasks
try: try:
@@ -3008,7 +3102,12 @@ async def do_download_model(content: str, owner: Optional[str] = None) -> Dict:
if _servers.get("default_host"): if _servers.get("default_host"):
host = _servers["default_host"] host = _servers["default_host"]
_host_defaulted = True _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} payload = {"repo_id": repo_id}
if backend:
payload["backend"] = backend
if host: if host:
payload["remote_host"] = host payload["remote_host"] = host
if args.get("include"): 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", "?") sid = data.get("session_id", "?")
registered = await _cookbook_register_task( registered = await _cookbook_register_task(
session_id=sid, model=repo_id, host=host, 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)" note = "" if registered else " (state-write failed — download may not show in UI)"
where = host or "local" where = host or "local"
default_note = " (defaulted to the cookbook's selected server — pass host= or local=true to override)" if _host_defaulted else "" 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} return {"error": data.get("error", "Download failed"), "exit_code": 1}
except Exception as e: except Exception as e:
return {"error": str(e), "exit_code": 1} 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() data = resp.json()
if data.get("ok"): if data.get("ok"):
sid = data.get("session_id", "?") 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( registered = await _cookbook_register_task(
session_id=sid, model=repo_id, session_id=sid, model=repo_id,
host=host, cmd=cmd, task_type="serve", 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)" 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`. # FastAPI HTTPException puts the message under `detail`, not `error`.
# Surface BOTH so the agent sees "Invalid characters in cmd" (from # Surface BOTH so the agent sees "Invalid characters in cmd" (from
# _validate_serve_cmd rejecting `&&`/`source`/`cd`) instead of # _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("gpus"): payload["gpus"] = env_cfg["gpus"]
if env_cfg.get("hf_token"): payload["hf_token"] = env_cfg["hf_token"] 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("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: try:
async with httpx.AsyncClient(timeout=30) as client: 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() data = resp.json()
if data.get("ok"): if data.get("ok"):
sid = data.get("session_id", "?") 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( registered = await _cookbook_register_task(
session_id=sid, model=repo_id, host=host, session_id=sid, model=repo_id, host=host,
cmd=cmd, task_type="serve", 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)" 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} return {"error": data.get("error", "Serve failed"), "exit_code": 1}
except Exception as e: except Exception as e:
return {"error": str(e), "exit_code": 1} return {"error": str(e), "exit_code": 1}
+17 -31
View File
@@ -28,34 +28,11 @@ except ImportError:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Tools that are ALWAYS included regardless of retrieval results. # 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({ 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 # Memory is ambient — "remember this" can follow any message regardless
# of topic. Without this, RAG drops it and the agent falls back to # 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. # 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 r"|\bat\s+\d{1,2}(?::\d{2})?\s*(?:a\.?m\.?|p\.?m\.?)\b", # at 7:30 am / at 7am
re.I, 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: if the query mentions these words, force-include the tools.
_KEYWORD_HINTS = { _KEYWORD_HINTS = {
@@ -362,7 +343,7 @@ class ToolIndex:
# request (e.g. "visit <url> and tell me the title"), force-including the # 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 # whole email toolset and crowding out the relevant tools — the model then
# believed it had only email tools and refused web/other tasks (#1707). # 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"}, {"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"}): frozenset({"calendar", "event", "meeting", "schedule", "appointment"}):
{"manage_calendar"}, {"manage_calendar"},
@@ -426,14 +407,14 @@ class ToolIndex:
# Document edit/update intent # Document edit/update intent
frozenset({"edit", "change", "fix", "rewrite", "update", frozenset({"edit", "change", "fix", "rewrite", "update",
"replace", "add a", "tweak", "modify", "rename", "paragraph", "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"}, {"edit_document", "update_document", "create_document", "suggest_document"},
# Document deletion / management — include generic open/find/read/show # Document deletion / management — include generic open/find/read/show
# verbs + file/doc synonyms so "open my <X>", "find the <X>", "delete # verbs + file/doc synonyms so "open my <X>", "find the <X>", "delete
# <X>" reach manage_documents even without the literal word "document". # <X>" reach manage_documents even without the literal word "document".
frozenset({"delete this doc", "delete the doc", "delete document", frozenset({"delete this doc", "delete the doc", "delete document",
"remove document", "remove the doc", "trash", "list documents", "remove document", "remove the doc", "trash", "list document", "list documents",
"list docs", "all my docs", "my documents", "my docs", "my files", "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", "open the", "open my", "open document", "open doc", "find the",
"find my", "find document", "read the", "read my", "show me the", "find my", "find document", "read the", "read my", "show me the",
"show my", "the file", "my file", "the report", "the write-up", "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. # the agent can actually create the cron job instead of fumbling.
if self._SCHEDULE_RE.search(ql): if self._SCHEDULE_RE.search(ql):
base.add("manage_tasks") 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 return base
+1 -1
View File
@@ -406,7 +406,7 @@ FUNCTION_TOOL_SCHEMAS = [
"type": "function", "type": "function",
"function": { "function": {
"name": "ui_control", "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": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
+28
View File
@@ -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) 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: async def read_upload_limited(upload: UploadFile, limit: int, label: str = "Upload") -> bytes:
"""Read an UploadFile with a hard byte cap.""" """Read an UploadFile with a hard byte cap."""
data = await upload.read(limit + 1) data = await upload.read(limit + 1)
+3 -87
View File
@@ -4,7 +4,6 @@
// ============================================ // ============================================
import Storage from './js/storage.js'; import Storage from './js/storage.js';
import uiModule from './js/ui.js'; import uiModule from './js/ui.js';
import workspaceModule from './js/workspace.js';
import fileHandlerModule from './js/fileHandler.js'; import fileHandlerModule from './js/fileHandler.js';
import modelsModule from './js/models.js'; import modelsModule from './js/models.js';
import ragModule from './js/rag.js'; import ragModule from './js/rag.js';
@@ -1555,7 +1554,6 @@ function initializeEventListeners() {
const MODE_TOOLS = [ const MODE_TOOLS = [
{ btnId: 'web-toggle-btn', checkboxId: 'web-toggle', stateKey: 'web' }, { btnId: 'web-toggle-btn', checkboxId: 'web-toggle', stateKey: 'web' },
{ btnId: 'bash-toggle-btn', checkboxId: 'bash-toggle', stateKey: 'bash' }, { 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}`; } function _modeKey(stateKey, mode) { return `${stateKey}_${mode}`; }
@@ -1564,9 +1562,6 @@ function initializeEventListeners() {
const state = loadToggleState(); const state = loadToggleState();
const key = _modeKey(stateKey, mode); const key = _modeKey(stateKey, mode);
if (Object.prototype.hasOwnProperty.call(state, key)) return !!state[key]; 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 return mode === 'agent'; // default: ON in agent, OFF in chat
} }
@@ -1579,7 +1574,6 @@ function initializeEventListeners() {
const TOOL_TOGGLE_TOAST_LABELS = { const TOOL_TOGGLE_TOAST_LABELS = {
web: 'Web search', web: 'Web search',
bash: 'Shell', bash: 'Shell',
plan: 'Plan mode',
}; };
function showToolToggleToast(stateKey, active) { function showToolToggleToast(stateKey, active) {
@@ -1592,8 +1586,8 @@ function initializeEventListeners() {
MODE_TOOLS.forEach(({ btnId, checkboxId, stateKey }) => { MODE_TOOLS.forEach(({ btnId, checkboxId, stateKey }) => {
const btn = el(btnId); const btn = el(btnId);
if (!btn) return; if (!btn) return;
// Hide bash and plan buttons in chat mode // Hide bash button in chat mode
if (mode === 'chat' && (stateKey === 'bash' || stateKey === 'plan')) { if (mode === 'chat' && stateKey === 'bash') {
btn.style.display = 'none'; btn.style.display = 'none';
return; return;
} }
@@ -1614,12 +1608,10 @@ function initializeEventListeners() {
const state = loadToggleState(); const state = loadToggleState();
let currentMode = state.mode || 'chat'; 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') { if (currentMode === 'chat') {
const bashBtn = el('bash-toggle-btn'); const bashBtn = el('bash-toggle-btn');
const planBtn = el('plan-toggle-btn');
if (bashBtn) bashBtn.style.display = 'none'; if (bashBtn) bashBtn.style.display = 'none';
if (planBtn) planBtn.style.display = 'none';
} }
function setMode(mode) { function setMode(mode) {
@@ -1709,82 +1701,6 @@ function initializeEventListeners() {
} }
setupToggle('web-toggle-btn', 'web-toggle', 'web'); setupToggle('web-toggle-btn', 'web-toggle', 'web');
setupToggle('bash-toggle-btn', 'bash-toggle', 'bash'); 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) // Document editor toggle (special: uses module panel, not a checkbox)
const overflowDocBtn = el('overflow-doc-btn'); const overflowDocBtn = el('overflow-doc-btn');
+92 -62
View File
@@ -1040,13 +1040,6 @@
<span>RAG</span> <span>RAG</span>
<span class="overflow-active-dot"></span> <span class="overflow-active-dot"></span>
</button> </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 <!-- Inline "deep research mode" toggle removed (superseded by the
Deep Research sidebar / trigger_research). The hidden Deep Research sidebar / trigger_research). The hidden
#research-toggle checkbox is kept inert so existing JS refs #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"/> <polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>
</svg> </svg>
</button> </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) --> <!-- 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;"> <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"> <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 --> <!-- Hidden checkboxes for state -->
<input type="checkbox" id="web-toggle" style="display:none;"> <input type="checkbox" id="web-toggle" style="display:none;">
<input type="checkbox" id="bash-toggle" style="display:none;"> <input type="checkbox" id="bash-toggle" style="display:none;">
<input type="checkbox" id="plan-toggle" style="display:none;">
</div> </div>
<form id="chat-form" autocomplete="off" action="javascript:void(0);" style="display:none;"></form> <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 id="set-researchMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div>
</div> </div>
</div> </div>
<div class="admin-card"> <!-- Agent card moved to the Agent Tools tab. -->
<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>
<!-- Image Generation removed — only inpaint remains in this build, <!-- Image Generation removed — only inpaint remains in this build,
and inpaint is configured via the gallery editor not this card. and inpaint is configured via the gallery editor not this card.
Keeping the DOM (hidden) so JS wiring against the inputs Keeping the DOM (hidden) so JS wiring against the inputs
@@ -2055,30 +2021,37 @@
<div class="admin-model-form"> <div class="admin-model-form">
<div class="admin-model-form-row"> <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"> <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>
<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"> <input id="adm-epLocalApiKey" type="password" placeholder="API key (optional — for protected local endpoints)" autocomplete="off" style="flex:1">
</div> </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"> <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> <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-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;">
<button class="admin-btn-add" id="adm-epLocalAddBtn" style="width:55px;text-align:center;">Add</button> <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
</div> </button>
<div class="adm-quickstart-section collapsed" id="adm-add-local-quickstart"> <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;">
<div class="adm-quickstart-toggle" role="button" tabindex="0" aria-expanded="false"> <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
<span>Quickstart</span> </button>
<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> <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;">
</div> <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
<div class="adm-quickstart-body"> </button>
<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>
</div> </div>
<div id="adm-epLocalMsg" class="adm-ep-inline-msg"></div> <div id="adm-epLocalMsg" class="adm-ep-inline-msg"></div>
</div> </div>
@@ -2123,19 +2096,33 @@
<option value="https://opencode.ai/zen/go/v1" data-logo="opencode">OpenCode Go</option> <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> <option value="https://api.z.ai/api/coding/paas/v4" data-logo="zhipu">Z.AI Coding Plan</option>
</select> </select>
<div class="admin-model-form-row"> <!-- API key row stays in DOM, hidden until Key button is
<input id="adm-epApiKey" type="password" placeholder="API key"> 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;"> <select id="adm-epKind" style="padding:5px;width:82px;">
<option value="proxy">Proxy</option> <option value="proxy">Proxy</option>
<option value="api">API</option> <option value="api">API</option>
</select> </select>
<select id="adm-epType" style="padding:5px;width:80px;"> <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">LLM</option> <option value="llm" selected>LLM</option>
<option value="image">Image</option> <option value="image">Image</option>
</select> </select></label>
<button class="admin-btn-sm" id="adm-epApiTestBtn" style="width:55px;text-align:center;">Test</button> <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-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>
<div id="adm-epApiMsg" class="adm-ep-inline-msg"></div> <div id="adm-epApiMsg" class="adm-ep-inline-msg"></div>
<div id="adm-deviceAuthStatus" class="adm-ep-inline-msg"></div> <div id="adm-deviceAuthStatus" class="adm-ep-inline-msg"></div>
@@ -2143,7 +2130,15 @@
</div> </div>
</div> </div>
<div class="admin-card"> <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="admin-toggle-sub" style="margin-bottom:10px">Manage the endpoints you've added.</div>
<div class="adm-ep-section"> <div class="adm-ep-section">
<div class="adm-ep-section-head"> <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> <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> </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> </div>
<!-- ═══ TOOLS TAB ═══ --> <!-- ═══ TOOLS TAB ═══ -->
<div data-settings-panel="tools" class="hidden"> <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;"> <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> <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> <div class="admin-toggle-sub" style="margin-bottom:8px">Enable or disable tools available to the AI agent.</div>
+153 -3
View File
@@ -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. // Local "Add" button — sibling form for self-hosted base URLs.
const localAddBtn = el('adm-epLocalAddBtn'); const localAddBtn = el('adm-epLocalAddBtn');
const localTestBtn = el('adm-epLocalTestBtn'); const localTestBtn = el('adm-epLocalTestBtn');
@@ -2073,17 +2211,28 @@ async function loadTokens() {
} }
function initTokenForm() { 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 msg = el('adm-tokenMsg');
const reveal = el('adm-tokenReveal'); const reveal = el('adm-tokenReveal');
msg.textContent = ''; msg.className = ''; reveal.style.display = 'none'; msg.textContent = ''; msg.className = ''; reveal.style.display = 'none';
const name = el('adm-tokenName').value.trim(); const name = el('adm-tokenName').value.trim();
if (!name) { msg.textContent = 'Token name is required'; msg.className = 'admin-error'; return; } if (!name) { msg.textContent = 'Token name is required'; msg.className = 'admin-error'; return; }
const fd = new FormData(); fd.append('name', name); const fd = new FormData(); fd.append('name', name);
const scopes = (el('adm-tokenScopes')?.value || '').trim();
if (scopes) fd.append('scopes', scopes);
try { try {
const res = await fetch('/api/tokens', { method: 'POST', body: fd, credentials: 'same-origin' }); const res = await fetch('/api/tokens', { method: 'POST', body: fd, credentials: 'same-origin' });
const data = await res.json(); 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'; } else { msg.textContent = data.detail || 'Failed'; msg.className = 'admin-error'; }
} catch (e) { msg.textContent = 'Request failed'; msg.className = 'admin-error'; } } catch (e) { msg.textContent = 'Request failed'; msg.className = 'admin-error'; }
}); });
@@ -2344,7 +2493,7 @@ function initDangerZone() {
*/ */
function initAll() { function initAll() {
modalEl = el('settings-modal'); 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) { for (const fn of inits) {
try { fn(); } catch (e) { console.error('Admin init error in', fn.name || 'anonymous', e); } try { fn(); } catch (e) { console.error('Admin init error in', fn.name || 'anonymous', e); }
} }
@@ -2357,6 +2506,7 @@ function refreshAll() {
loadEndpoints(); loadEndpoints();
loadBuiltinTools(); loadBuiltinTools();
loadMcpServers(); loadMcpServers();
loadTokens();
} }
/* /*
-105
View File
@@ -13,7 +13,6 @@ import chatStream from './chatStream.js';
import { addAITTSButton } from './tts-ai.js'; import { addAITTSButton } from './tts-ai.js';
import markdownModule from './markdown.js'; import markdownModule from './markdown.js';
import { svgifyEmoji } from './markdown.js'; import { svgifyEmoji } from './markdown.js';
import planWindowModule from './planWindow.js';
import spinnerModule from './spinner.js'; import spinnerModule from './spinner.js';
import presetsModule from './presets.js'; import presetsModule from './presets.js';
import fileHandlerModule from './fileHandler.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 _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 _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 _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. */ /** Check if an SSE reader is still actively connected for a session. */
function hasActiveStream(sessionId) { function hasActiveStream(sessionId) {
@@ -839,22 +809,6 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
if (el('bash-toggle').checked) { if (el('bash-toggle').checked) {
fd.append('allow_bash', 'true'); 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'); const ragChk = el('rag-toggle');
if (ragChk && !ragChk.checked) { if (ragChk && !ragChk.checked) {
fd.append('use_rag', 'false'); fd.append('use_rag', 'false');
@@ -863,10 +817,6 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
if (incognitoChk && incognitoChk.checked) { if (incognitoChk && incognitoChk.checked) {
fd.append('incognito', 'true'); fd.append('incognito', 'true');
} }
const _ws = (Storage.KEYS && Storage.get(Storage.KEYS.WORKSPACE, '')) || '';
if (_ws) {
fd.append('workspace', _ws);
}
if (presetsModule.getSelectedPreset()) { if (presetsModule.getSelectedPreset()) {
fd.append('preset_id', 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) // 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; const footerTarget = (roundHolder && roundHolder !== holder && roundHolder.style.display !== 'none') ? roundHolder : holder;
footerTarget.appendChild(createMsgFooter(footerTarget)); 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 // Add "View Report" link for completed research
if (_researchingStreamIds.has(streamSessionId)) { if (_researchingStreamIds.has(streamSessionId)) {
_appendViewReportLink(footerTarget, streamSessionId); _appendViewReportLink(footerTarget, streamSessionId);
+22
View File
@@ -2118,6 +2118,28 @@ export function addMessage(role, content, modelName, metadata) {
return lastWrap; 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 --- // --- Standard single-bubble message ---
const wrap = document.createElement('div'); const wrap = document.createElement('div');
wrap.className = 'msg ' + (role === 'user' ? 'msg-user' : 'msg-ai'); wrap.className = 'msg ' + (role === 'user' ? 'msg-user' : 'msg-ai');
+39 -4
View File
@@ -610,12 +610,47 @@ export function _showDiagnosis(panel, diagnosis, sourceText) {
? `Suggested action: ${fixes[0].label}.` ? `Suggested action: ${fixes[0].label}.`
: 'Suggested action: copy the error and adjust the serve settings.'); : '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; 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'); const body = document.createElement('div');
body.className = 'cookbook-diag-body'; body.className = 'cookbook-diag-body';
const msg = document.createElement('div'); const msg = document.createElement('div');
+159 -8
View File
@@ -416,9 +416,11 @@ function _hwfitShowError(list, host, detail) {
if (rb) rb.addEventListener('click', () => { _resetGpuToggleState(); _hwfitFetch(true); }); if (rb) rb.addEventListener('click', () => { _resetGpuToggleState(); _hwfitFetch(true); });
} }
// Client-side "Engine" filter (llama.cpp / vLLM / SGLang). Empty = show all. // Client-side "Engine" filter (llama.cpp / vLLM / SGLang / Ollama). Empty =
// Uses the same _detectBackend() the serve commands use, so what you filter to // show all. Uses the same _detectBackend() the serve commands use, so what you
// is exactly what would be launched. Pure view filter — no refetch needed. // 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) { function _applyEngineFilter(models) {
const want = document.getElementById('hwfit-engine')?.value || ''; const want = document.getElementById('hwfit-engine')?.value || '';
if (!want || !Array.isArray(models)) return models || []; 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) { export async function _hwfitFetch(fresh = false) {
const _tk = ++_hwfitFetchToken; const _tk = ++_hwfitFetchToken;
const useCase = document.getElementById('hwfit-usecase')?.value || ''; const useCase = document.getElementById('hwfit-usecase')?.value || '';
@@ -475,7 +557,12 @@ export async function _hwfitFetch(fresh = false) {
_setLastCacheHost(remoteKey); _setLastCacheHost(remoteKey);
const _cacheSrv = _serverByVal(_envState.remoteServerKey || remoteHost); const _cacheSrv = _serverByVal(_envState.remoteServerKey || remoteHost);
const _cachePort = _cacheSrv?.port || ''; 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' }) fetch(`/api/model/cached?${_cacheParams}`, { credentials: 'same-origin' })
.then(r => r.json()) .then(r => r.json())
.then(d => { .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 // 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. // mid-probe) — drop this stale response so it can't clobber the new one.
if (_tk !== _hwfitFetchToken) { try { wp.destroy(); } catch {} return; } 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(); let data = await res.json();
if (_tk !== _hwfitFetchToken) { try { wp.destroy(); } catch {} return; } if (_tk !== _hwfitFetchToken) { try { wp.destroy(); } catch {} return; }
if (!isImageMode && quantPref && !data.error && Array.isArray(data.models) && data.models.length === 0) { 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 = ''; } if (!_cached) { _hwfitShowError(list, remoteHost, data.error); if (hw) hw.innerHTML = ''; }
return; 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; _hwfitCache = data;
_hwfitRenderHw(hw, data.system); _hwfitRenderHw(hw, data.system);
// Propagate local platform from hardware probe so _isWindows(task) works // Propagate local platform from hardware probe so _isWindows(task) works
@@ -964,14 +1079,36 @@ export function _hwfitRenderList(el, models) {
html += `</div>`; html += `</div>`;
} }
el.innerHTML = html; 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 => { el.querySelectorAll('.hwfit-row:not(.hwfit-header)').forEach(row => {
row.addEventListener('click', () => { row.addEventListener('click', () => {
const name = row.dataset.model; const name = row.dataset.model;
if (!name) return; if (!name) return;
// Find model data from cache
const modelData = (_hwfitCache?.models || []).find(m => m.name === name); const modelData = (_hwfitCache?.models || []).find(m => m.name === name);
if (!modelData) return; 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); _expandModelRow(row, modelData);
}); });
}); });
@@ -1297,7 +1434,7 @@ export function _hwfitInit() {
if (sort) sort.addEventListener('change', () => _hwfitFetch()); if (sort) sort.addEventListener('change', () => _hwfitFetch());
if (qpref) qpref.addEventListener('change', () => _hwfitFetch()); if (qpref) qpref.addEventListener('change', () => _hwfitFetch());
// Engine filter is a pure client-side view filter over the already-fetched // 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'); const engine = document.getElementById('hwfit-engine');
if (engine) engine.addEventListener('change', () => { if (engine) engine.addEventListener('change', () => {
const list = document.getElementById('hwfit-list'); const list = document.getElementById('hwfit-list');
@@ -1694,6 +1831,15 @@ export function _hwfitInit() {
saveBtn.addEventListener('click', () => { saveBtn.addEventListener('click', () => {
_syncServers(); _syncServers();
_rebuildServerSelect(); _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.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'; 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(); entry.remove();
_syncServers(); _syncServers();
_rebuildServerSelect(); _rebuildServerSelect();
try {
document.dispatchEvent(new CustomEvent('cookbook:servers-changed', {
detail: { servers: _envState.servers.slice() },
}));
} catch (_) {}
_hwfitCache = null; _hwfitCache = null;
_hwfitFetch(); _hwfitFetch();
}); });
+265 -196
View File
@@ -72,7 +72,7 @@ function _platformIcon(platform) {
return ''; 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 _lastCacheHostVal = null;
let _cookbookOpeningSpinners = []; let _cookbookOpeningSpinners = [];
export function _lastCacheHost() { return _lastCacheHostVal; } export function _lastCacheHost() { return _lastCacheHostVal; }
@@ -89,8 +89,8 @@ function _setCookbookOpening(on) {
].filter(Boolean); ].filter(Boolean);
if (!on) { if (!on) {
_cookbookOpeningSpinners.forEach(({ spinner, wrap, target }) => { _cookbookOpeningSpinners.forEach(({ spinner, wrap, target }) => {
try { spinner?.stop?.(); } catch { } try { spinner?.stop?.(); } catch {}
try { wrap?.remove?.(); } catch { } try { wrap?.remove?.(); } catch {}
target?.classList?.remove('cookbook-opening'); target?.classList?.remove('cookbook-opening');
}); });
_cookbookOpeningSpinners = []; _cookbookOpeningSpinners = [];
@@ -128,11 +128,12 @@ export function _serverKey(s) {
].map(v => encodeURIComponent(String(v).trim())).join('|'); ].map(v => encodeURIComponent(String(v).trim())).join('|');
} }
function _serverByVal(val) { export function _serverByVal(val) {
if (val == null || val === 'local' || val === '') return null; if (val == null || val === 'local' || val === '') return null;
const raw = String(val); const raw = String(val);
let s = _envState.servers.find(x => _serverKey(x) === raw); 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.host === raw);
if (!s) s = _envState.servers.find(x => x.name === raw);
if (!s && /^\d+$/.test(String(val))) s = _envState.servers[parseInt(val)]; if (!s && /^\d+$/.test(String(val))) s = _envState.servers[parseInt(val)];
return s || null; return s || null;
} }
@@ -152,6 +153,19 @@ export function _currentServerValue() {
return _envState.remoteHost || 'local'; 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) { function _buildServerOpts(excludeLocal = false) {
// The local server is ALWAYS represented by the synthetic value="local" option // The local server is ALWAYS represented by the synthetic value="local" option
// (showing its custom name from the "server name" feature). We must therefore // (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 '' */ /** Get platform for a given host (or task object). Returns 'windows', 'termux', 'linux', or '' */
export function _getPlatform(hostOrTask) { export function _getPlatform(hostOrTask) {
const isWinBrowser = (window.navigator.userAgent || window.navigator.platform || '').toLowerCase().includes('win'); if (!hostOrTask) return _envState.platform || '';
// The browser's OS is NOT the server's OS when the UI is opened remotely — if (typeof hostOrTask === 'object') return hostOrTask.platform || _getPlatform(hostOrTask.remoteServerKey || hostOrTask.remoteHost);
// 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);
}
const selected = hostOrTask === _envState.remoteHost ? _selectedServer() : null; const selected = hostOrTask === _envState.remoteHost ? _selectedServer() : null;
const srv = selected || _serverByVal(hostOrTask); const srv = selected || _serverByVal(hostOrTask);
return srv?.platform || ''; return srv?.platform || '';
@@ -237,19 +228,6 @@ export function _isMetal() {
return ['metal', 'mps', 'apple'].includes(String(_hwfitCache?.system?.backend || '').toLowerCase()); 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 */ /** Detect model-specific vLLM optimizations */
function _detectModelOptimizations(modelName) { function _detectModelOptimizations(modelName) {
const n = (modelName || '').toLowerCase(); const n = (modelName || '').toLowerCase();
@@ -326,7 +304,10 @@ export function _detectToolParser(modelName) {
// ── Backend detection ── // ── Backend detection ──
export function _detectBackend(model) { 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' }; return { backend: 'ollama', label: 'Ollama' };
} }
const q = (model.quant || '').toUpperCase(); const q = (model.quant || '').toUpperCase();
@@ -585,9 +566,34 @@ export function _buildServeCmd(f, modelName, backend) {
} }
} else if (backend === 'ollama') { } else if (backend === 'ollama') {
const ollamaPort = f.port || '11434'; const ollamaPort = f.port || '11434';
const bindHost = _envState.remoteHost ? '0.0.0.0' : '127.0.0.1'; // GGUF + Ollama: delegate to the iGPU-bound ollama-test container via
const hostEnv = ollamaPort !== '11434' ? `OLLAMA_HOST=${bindHost}:${ollamaPort} ` : ''; // its /usr/local/bin/ollama-import helper. Plain `ollama serve` errors
cmd = `${hostEnv}ollama serve`; // 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') { } else if (backend === 'diffusers') {
const gpuStr = f.gpus?.trim(); const gpuStr = f.gpus?.trim();
if (gpuStr) cmd += `CUDA_VISIBLE_DEVICES=${gpuStr} `; if (gpuStr) cmd += `CUDA_VISIBLE_DEVICES=${gpuStr} `;
@@ -630,7 +636,7 @@ function _fallbackCopy(text) {
ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px'; ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px';
document.body.appendChild(ta); document.body.appendChild(ta);
ta.select(); ta.select();
try { document.execCommand('copy'); } catch (_) { } try { document.execCommand('copy'); } catch (_) {}
document.body.removeChild(ta); document.body.removeChild(ta);
return Promise.resolve(); return Promise.resolve();
} }
@@ -663,7 +669,7 @@ function _readStoredEnvState() {
export function _persistEnvState() { export function _persistEnvState() {
try { localStorage.setItem(LAST_STATE_KEY, JSON.stringify(_envStateForStorage())); } try { localStorage.setItem(LAST_STATE_KEY, JSON.stringify(_envStateForStorage())); }
catch (_) { } catch (_) {}
_saveTasks(_loadTasks()); _saveTasks(_loadTasks());
} }
@@ -712,24 +718,22 @@ async function _fetchDependencies() {
const data = await resp.json(); const data = await resp.json();
const pkgs = data.packages || []; const pkgs = data.packages || [];
if (!pkgs.length) { list.innerHTML = '<div class="hwfit-loading">No packages found</div>'; return; } 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) => { const _statusTag = (pkg, isLocal, isSystemDep, winBlocked) => {
if (winBlocked) return `<span class="cookbook-dep-tag cookbook-dep-na">N/A</span>`; if (winBlocked) return `<span class="cookbook-dep-tag cookbook-dep-na">N/A</span>`;
const hasCustomInstall = !!pkg.install_cmd; if (pkg.installed && isSystemDep) return `<span class="cookbook-dep-tag cookbook-dep-installed" title="Found on selected server">Installed</span>`;
const hasCustomUpdate = !!pkg.update_cmd; if (pkg.installed && pkg.pip_update_available === false) {
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) {
const tip = esc(pkg.update_note || pkg.status_note || 'Found externally; update outside Odysseus.'); 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>`; 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">&#9662;</span></button>`; 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">&#9662;</span></button>`;
if (isSystemDep && !hasCustomInstall) { if (isSystemDep) {
const depTip = esc(pkg.install_hint || 'Install this OS package on the selected server.'); const depTip = esc(pkg.install_hint || 'Install this OS package on the selected server.');
const depLabel = pkg.applicable === false ? 'N/A ?' : 'Missing'; const depLabel = pkg.applicable === false ? 'N/A ?' : 'Missing';
return `<span class="cookbook-dep-tag cookbook-dep-na" title="${depTip}">${depLabel}</span>`; 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) => { const _depRow = (pkg) => {
@@ -752,7 +756,7 @@ async function _fetchDependencies() {
} else if (pkg.name === 'sglang' && pkg.installed) { } 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>`; _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="cookbook-dep-info">`
+ `<div class="memory-item-title">${esc(pkg.name)}</div>` + `<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>` + `<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 // Shared install/update routine — used by the Install button and the
// "Update" item in an installed package's ⋮ menu. `upgrade` adds pip -U; // "Update" item in an installed package's ⋮ menu. `upgrade` adds pip -U;
// `statusEl`, when given, shows "Installing…/Updating…" and is disabled. // `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) { if (isLocalOnly) {
_envState.remoteHost = ''; _envState.remoteHost = '';
_envState.env = 'none'; _envState.env = 'none';
@@ -827,43 +831,6 @@ async function _fetchDependencies() {
envPrefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _shellQuote(_envState.envPath); 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 { try {
const reqBody = { const reqBody = {
repo_id: pipName, repo_id: pipName,
@@ -902,9 +869,8 @@ async function _fetchDependencies() {
btn.addEventListener('click', async (e) => { btn.addEventListener('click', async (e) => {
e.stopPropagation(); e.stopPropagation();
const pipName = btn.dataset.depPip; const pipName = btn.dataset.depPip;
const installCmd = btn.dataset.depInstallCmd || '';
const pkgName = btn.closest('.cookbook-dep-row')?.querySelector('.memory-item-title')?.textContent || pipName; 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'); const it = document.createElement('div');
it.className = 'dropdown-item-compact'; it.className = 'dropdown-item-compact';
it.innerHTML = `<span class="dropdown-icon">${upIco}</span><span>Update</span>`; 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) => { it.addEventListener('click', async (e) => {
e.stopPropagation(); e.stopPropagation();
dropdown.remove(); dropdown.remove();
const updateCmd = row.dataset.depUpdateCmd || ''; await _installDep(pipName, pkgName, isLocalOnly, true, null);
await _installDep(pipName, pkgName, isLocalOnly, true, null, updateCmd);
}); });
dropdown.appendChild(it); dropdown.appendChild(it);
document.body.appendChild(dropdown); document.body.appendChild(dropdown);
@@ -986,6 +951,7 @@ function _applyServerSelection(val) {
const _want = _currentServerValue(); const _want = _currentServerValue();
document.querySelectorAll('#hwfit-server-select, #hwfit-dl-server, #hwfit-cache-server, #hwfit-deps-server').forEach(sel => { document.querySelectorAll('#hwfit-server-select, #hwfit-dl-server, #hwfit-cache-server, #hwfit-deps-server').forEach(sel => {
if (!sel || sel.tagName !== 'SELECT') return; if (!sel || sel.tagName !== 'SELECT') return;
// Option values are host strings now ('local' for the local box).
sel.value = _want; sel.value = _want;
// If the host isn't among this select's current options (stale options after // 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 // 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. // re-apply; fall back to 'local' only if it's genuinely gone.
if (sel.selectedIndex < 0) { if (sel.selectedIndex < 0) {
sel.innerHTML = _buildServerOpts(sel.id === 'hwfit-dl-server'); sel.innerHTML = _buildServerOpts(sel.id === 'hwfit-dl-server');
sel.value = _currentServerValue(); sel.value = _want;
if (sel.selectedIndex < 0) sel.value = 'local'; 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 // Ignore swipes that start in a horizontally-scrollable tag row — those
// should scroll the chips, not flip the tab. // should scroll the chips, not flip the tab.
if (window.innerWidth > 768 || e.touches.length !== 1 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; _sx = e.touches[0].clientX; _sy = e.touches[0].clientY;
}, { passive: true }); }, { passive: true });
body.addEventListener('touchend', (e) => { body.addEventListener('touchend', (e) => {
@@ -1081,13 +1047,11 @@ function _wireTabEvents(body) {
const remotes = servers.filter(s => !_isLocalEntry(s)); const remotes = servers.filter(s => !_isLocalEntry(s));
if (remotes.length === 1) { if (remotes.length === 1) {
_envState.remoteHost = remotes[0].host; _envState.remoteHost = remotes[0].host;
_envState.remoteServerKey = _serverKey(remotes[0]);
_envState.env = remotes[0].env || 'none'; _envState.env = remotes[0].env || 'none';
_envState.envPath = remotes[0].envPath || ''; _envState.envPath = remotes[0].envPath || '';
} }
} }
const activeSrv = _selectedServer(); const activeSrv = servers.find(s => s.host === _envState.remoteHost);
if (activeSrv) _envState.remoteServerKey = _serverKey(activeSrv);
_envState.platform = activeSrv?.platform || ''; _envState.platform = activeSrv?.platform || '';
localStorage.setItem('cookbook-last-state', JSON.stringify(_envStateForStorage())); localStorage.setItem('cookbook-last-state', JSON.stringify(_envStateForStorage()));
_saveTasks(_loadTasks()); _saveTasks(_loadTasks());
@@ -1361,14 +1325,28 @@ function _wireTabEvents(body) {
if (!m) return { repo: raw, include: null }; if (!m) return { repo: raw, include: null };
return { repo: m[1], include: `*${m[2]}*` }; 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 triggerDownload = () => {
const rawRepo = _stripHfUrl(dlInput.value); const rawRepo = _stripHfUrl(dlInput.value);
if (!rawRepo) return; 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 // 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. // at snapshot_download time with a raw traceback, so reject it up front.
if (!/^[^\s/]+\/[^\s/]+$/.test(repo)) { // Ollama names (single-segment with a tag) skip this check — they go
uiModule.showToast('Enter a full HuggingFace repo ID like "org/model-name" (or paste the full HF URL).'); // 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(); dlInput.focus();
return; return;
} }
@@ -1383,12 +1361,13 @@ function _wireTabEvents(body) {
if (srvVal !== 'local') { if (srvVal !== 'local') {
host = _serverByVal(srvVal)?.host || ''; 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 env = host ? (_hsrv.env || 'none') : _envState.env;
let envPath = host ? (_hsrv.envPath || '') : _envState.envPath; let envPath = host ? (_hsrv.envPath || '') : _envState.envPath;
const payload = { repo_id: repo }; const payload = { repo_id: repo };
if (ollamaName) payload.backend = 'ollama';
if (autoInclude) payload.include = autoInclude; 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; } if (host) { payload.remote_host = host; const _sp3 = _getPort(host); if (_sp3) payload.ssh_port = _sp3; }
const srvPlatform = _getPlatform(host); const srvPlatform = _getPlatform(host);
if (srvPlatform) payload.platform = srvPlatform; if (srvPlatform) payload.platform = srvPlatform;
@@ -1432,7 +1411,7 @@ function _wireTabEvents(body) {
// the section is collapsed (the body's content normally provides // the section is collapsed (the body's content normally provides
// separation; with no body visible, the line gives the h2 definition). // separation; with no body visible, the line gives the h2 definition).
dlFold.classList.toggle('is-folded', !folded); 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'); const hfToggle = document.getElementById('cookbook-hf-latest-toggle');
@@ -1478,7 +1457,7 @@ function _wireTabEvents(body) {
_hwCache[cacheKey] = hw; _hwCache[cacheKey] = hw;
return hw; return hw;
} }
} catch { } } catch {}
_hwCache[cacheKey] = { vram: 0, backend: '' }; _hwCache[cacheKey] = { vram: 0, backend: '' };
return _hwCache[cacheKey]; return _hwCache[cacheKey];
} }
@@ -1591,6 +1570,84 @@ function _wireTabEvents(body) {
document.getElementById('hwfit-server-select')?.addEventListener('change', _onServerChange); 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 // Server add button, row removal, model-dir add/remove, and per-row wiring
// are ALL owned by cookbook-hwfit.js's _hwfitInit / _wireServerEntry. // are ALL owned by cookbook-hwfit.js's _hwfitInit / _wireServerEntry.
// A duplicate add handler used to live here and fired alongside the hwfit // A duplicate add handler used to live here and fired alongside the hwfit
@@ -1603,7 +1660,7 @@ function _wireTabEvents(body) {
hfInput.addEventListener('change', async () => { hfInput.addEventListener('change', async () => {
const val = hfInput.value.trim(); const val = hfInput.value.trim();
_envState.hfToken = val; _envState.hfToken = val;
try { await _persistEnvState(); } catch { } try { await _persistEnvState(); } catch {}
if (val) { if (val) {
_envState.hfTokenConfigured = true; _envState.hfTokenConfigured = true;
const masked = val.length > 6 ? val.slice(0, 3) + '…' + val.slice(-3) : '••••'; 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 = ''; let html = '';
html += `<div class="cookbook-server-entry" data-idx="${i}" data-platform="${esc(s.platform || '')}">`; 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 _srvTitle = s.name || (isLocal ? 'Local' : (s.host || `Server ${i + 1}`));
const _srvKey = isLocal ? 'local' : _serverKey(s); const _srvKey = isLocal ? 'local' : (s.host || '');
const _legacyDefault = !String(defaultServer || '').startsWith('srv:') && !isLocal && (defaultServer || '') === (s.host || ''); const _isDefaultSrv = (defaultServer || '') === _srvKey;
const _isDefaultSrv = (defaultServer || '') === _srvKey || _legacyDefault;
const _pIco = _platformIcon(s.platform); 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 _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>`; 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 += `<button class="memory-toolbar-btn cookbook-dl-add-server" title="Add server in Settings" style="height:28px;">add server</button>`;
html += `</div>`; html += `</div>`;
html += `<div class="cookbook-dl-input" style="margin-top:0;">`; 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 += `<button class="cookbook-btn cookbook-dl-btn" id="cookbook-dl-btn">Download</button>`;
html += `</div>`; 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 // Latest HF models that fit — collapsible card list
html += `<div style="margin-top:5px;position:relative;top:-3px;">`; html += `<div style="margin-top:5px;position:relative;top:-3px;">`;
html += `<div style="display:flex;gap:4px;align-items:center;">`; 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="general" selected>Standard</option><option value="coding">Coding</option>';
html += '<option value="reasoning">Reasoning</option><option value="chat">Chat</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 // 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>'; html += '<option value="multimodal">Vision</option></select>';
// Engine sits next to the type filter so the "what category / which serving // Engine sits next to the type filter so the "what category / which serving
// path" filters live together; Quant + Context are storage-format and budget // 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 += '<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="">Engine</option>';
html += '<option value="llamacpp">llama.cpp</option>'; html += '<option value="llamacpp">llama.cpp</option>';
html += '<option value="ollama">Ollama</option>';
html += '<option value="vllm">vLLM</option>'; html += '<option value="vllm">vLLM</option>';
html += '<option value="sglang">SGLang</option>'; html += '<option value="sglang">SGLang</option>';
html += '</select>'; html += '</select>';
@@ -1869,13 +1939,13 @@ function _renderRecipes() {
// Footer: link to the public discussion where users can request additions // 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 // to the curated model list. Sits below the list so it reads as a callout
// after browsing, not a header. // 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;">' html += '<div class="hwfit-list-footer" style="display:none;">'
+ 'Don\'t see a model? ' + '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;">' + '<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 →' + '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>' + '<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>' + '</a>'
+ '</div>'; + '</div>';
html += '</div></div>'; html += '</div></div>';
@@ -1885,7 +1955,7 @@ function _renderRecipes() {
html += '<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;">'; 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 += '<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>'; 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); 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 += '<div class="cookbook-serve-dirs" style="margin-top:6px;">';
html += _srvDirs.map(d => `<span class="cookbook-serve-dir-pill">${esc(d)}</span>`).join(''); 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 += '<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 += '<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 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>';
html += '<div class="doclib-grid hwfit-cached-list" id="hwfit-cached-list"></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 += '<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>'; html += '<h2 style="margin:0;padding:0;line-height:1;">Servers</h2>';
// Reuse the calendar +New pill: spinning plus, label fades in idea uses // 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 += '<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 += '</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>'; 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); _setCookbookOpening(true);
try { try {
// Invalidate any pending close() animation handlers so they won't re-hide us // Invalidate any pending close() animation handlers so they won't re-hide us
_closeGen++; _closeGen++;
// Clear any leftover inline styles from a previous swipe-dismiss or close animation // Clear any leftover inline styles from a previous swipe-dismiss or close animation
const _content = modal.querySelector('.modal-content'); const _content = modal.querySelector('.modal-content');
if (_content) { if (_content) {
_content.classList.remove('modal-closing', 'sheet-ready', 'cookbook-modal-entering'); _content.classList.remove('modal-closing', 'sheet-ready', 'cookbook-modal-entering');
_content.style.transform = ''; _content.style.transform = '';
_content.style.transition = ''; _content.style.transition = '';
_content.style.animation = ''; _content.style.animation = '';
_content.style.opacity = ''; _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', { // Re-render on every open AFTER sync so the freshly-fetched state (servers,
railBtnId: 'rail-cookbook', // HF token, presets) is always reflected. Gating this to once-per-page used
sidebarBtnId: 'tool-cookbook-btn', // to freeze a stale/empty servers list whenever the first sync raced or
closeFn: () => _doClose(), // returned before hydration — and since close/reopen doesn't reset the page,
restoreFn: () => { _renderRunningTab(); }, // only a full reload recovered it. Re-rendering is cheap and the in-progress
}); // Running tab is rendered separately just below.
_wireCookbookDrag(modal); _renderRecipes();
await _syncFromServer(); _rendered = true;
// `_syncFromServer` lives in cookbookRunning.js and populates *its* _envState _clearCookbookNotif();
// (a different object reference than this module's), then mirrors the merged _renderRunningTab();
// state to localStorage. So ALWAYS hydrate our _envState from that mirror — // Self-heal: revive any download tasks whose tmux session is still alive
// on a successful sync it holds the freshly-fetched servers; on failure it // but were persisted as done/error (covers the "restarted server while a
// holds the last-known state. Gating this on `!synced` left the render's // big multi-shard download was in flight" case — the task survived in
// _envState empty whenever sync succeeded → "servers don't show". // tmux, the cookbook just lost track of it).
try { Object.assign(_envState, _readStoredEnvState()); } catch { } try { _selfHealStaleTasks({ oneShot: true }); } catch {}
// Honour a user-set default server: always land on it when Cookbook opens, so if (_content) {
// every dropdown (scan/download/serve/cache/deps) starts on the same machine. // Put the panel in its entering state before it becomes visible. On
if (_envState.defaultServer) { // mobile, showing first and adding the class a frame later can paint the
const _dk = _envState.defaultServer; // sheet at its final position, which makes the slide-up look like a snap.
if (_dk === 'local') { _content.classList.add('cookbook-modal-entering');
_envState.remoteHost = ''; _envState.remoteServerKey = ''; _envState.env = 'none'; _envState.envPath = ''; _envState.platform = ''; }
} else { modal.classList.remove('hidden');
const _ds = _serverByVal(_dk); if (_content) {
if (_ds) { _envState.remoteHost = _ds.host; _envState.remoteServerKey = _serverKey(_ds); _envState.env = _ds.env || 'none'; _envState.envPath = _ds.envPath || ''; _envState.platform = _ds.platform || ''; } void _content.offsetWidth;
} _content.addEventListener('animationend', () => {
} _content.classList.remove('cookbook-modal-entering');
// Re-render on every open AFTER sync so the freshly-fetched state (servers, }, { once: true });
// 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 setTimeout(_applyIntent, 0);
// 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 { } finally {
_setCookbookOpening(false); _setCookbookOpening(false);
} }
@@ -2216,10 +2286,9 @@ const shared = {
_sshCmd, _sshCmd,
_getPort, _getPort,
_sshPrefix, _sshPrefix,
_getPlatform,
_serverByVal, _serverByVal,
_selectedServer, _selectedServer,
_currentServerValue, _getPlatform,
_isWindows, _isWindows,
_isMetal, _isMetal,
_buildEnvPrefix, _buildEnvPrefix,
@@ -2271,7 +2340,7 @@ export {
_startBackgroundMonitor, _startBackgroundMonitor,
_setPanelField, _setPanelCheckbox, _setPanelField, _setPanelCheckbox,
_wirePanelEvents, _runPanelCmd, _runModelDownload, _buildDownloadCmd, _wirePanelEvents, _runPanelCmd, _runModelDownload, _buildDownloadCmd,
_serverByVal, _isLocalEntry, _isLocalEntry,
}; };
const cookbookModule = { open, close, isVisible, startBackgroundMonitor: _startBackgroundMonitor }; const cookbookModule = { open, close, isVisible, startBackgroundMonitor: _startBackgroundMonitor };
+5 -7
View File
@@ -242,11 +242,7 @@ export function _wirePanelEvents(panel, model, backend) {
const dlBtn = panel.querySelector('.hwfit-dl-btn'); const dlBtn = panel.querySelector('.hwfit-dl-btn');
if (dlBtn) { if (dlBtn) {
dlBtn.addEventListener('click', () => { dlBtn.addEventListener('click', () => {
if (backend === 'ollama') { _runModelDownload(panel, model, backend)
_runPanelCmd(panel, _buildDownloadCmd(model, backend), { timeout: 0 });
} else {
_runModelDownload(panel, model, backend);
}
}); });
} }
@@ -459,7 +455,9 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
uiModule.showToast(_missingGgufMessage(model)); uiModule.showToast(_missingGgufMessage(model));
return; 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; const include = backend === 'llamacpp' ? _ggufIncludePattern(model, ggufSource) : null;
_syncEnvFromPanel(panel); _syncEnvFromPanel(panel);
@@ -494,7 +492,7 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
const platform = host ? (srv.platform || '') : (_envState.platform || ''); const platform = host ? (srv.platform || '') : (_envState.platform || '');
const isWin = host ? (platform === 'windows') : _isWindows(); const isWin = host ? (platform === 'windows') : _isWindows();
const payload = { repo_id: repo }; const payload = { repo_id: repo, backend };
if (include) payload.include = include; if (include) payload.include = include;
// Large downloads are where hf_transfer most often dies near the end. Use the // 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 // plain HuggingFace downloader up front for big model files; it is slower, but
+9
View File
@@ -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 }; 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); _addTask(data.session_id, shortName, 'serve', payload);
uiModule.showToast(`Serving ${shortName}...`); 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) { } catch (e) {
uiModule.showToast('Failed: ' + e.message); uiModule.showToast('Failed: ' + e.message);
} }
@@ -3032,6 +3036,11 @@ async function _reconnectTask(el, task) {
if (info.status === 'ready' && !task._serveReady) { if (info.status === 'ready' && !task._serveReady) {
task._serveReady = true; task._serveReady = true;
_updateTask(task.sessionId, { _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) { if (info.phase) {
badge.textContent = info.phase; badge.textContent = info.phase;
+18 -18
View File
@@ -129,7 +129,7 @@ try { (function () {
</label> </label>
</div> </div>
<div class="hwfit-schedule-row"> <div class="hwfit-schedule-row hwfit-schedule-when-row">
<label class="hwfit-schedule-field"> <label class="hwfit-schedule-field">
<span>From</span> <span>From</span>
<input type="time" class="hwfit-sched-start cookbook-field-input" value="09:00" /> <input type="time" class="hwfit-sched-start cookbook-field-input" value="09:00" />
@@ -138,24 +138,24 @@ try { (function () {
<span>Until</span> <span>Until</span>
<input type="time" class="hwfit-sched-end cookbook-field-input" value="17:00" /> <input type="time" class="hwfit-sched-end cookbook-field-input" value="17:00" />
</label> </label>
</div> <label class="hwfit-schedule-field hwfit-schedule-days-field">
<span>Days</span>
<div class="hwfit-schedule-row hwfit-schedule-days-row"> <div class="hwfit-sched-days">
<span class="hwfit-schedule-label">Days</span> ${DAYS.map(d => `
<div class="hwfit-sched-days"> <button type="button" class="hwfit-sched-day-chip${WEEKDAYS.has(d.k) ? " is-on" : ""}" data-day="${d.k}">${d.l}</button>
${DAYS.map(d => ` `).join("")}
<button type="button" class="hwfit-sched-day-chip${WEEKDAYS.has(d.k) ? " is-on" : ""}" data-day="${d.k}">${d.l}</button> </div>
`).join("")} </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> </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>
<div class="hwfit-sched-err"></div> <div class="hwfit-sched-err"></div>
+163 -127
View File
@@ -14,7 +14,6 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
let _envState; let _envState;
let _sshCmd; let _sshCmd;
let _getPort; let _getPort;
let _serverByVal;
let _sshPrefix; let _sshPrefix;
let _getPlatform; let _getPlatform;
let _isWindows; let _isWindows;
@@ -115,7 +114,7 @@ function _selectedServeTarget(panel) {
: (server?.name || 'local server'); : (server?.name || 'local server');
return { return {
host, host,
port: host ? (server?.port || _getPort(host) || '') : '', port: host ? (_getPort(host) || server?.port || '') : '',
venv, venv,
label, label,
}; };
@@ -243,21 +242,6 @@ function _shellPathExpr(path) {
function _selectedGgufExpr(model, repo, relPath) { function _selectedGgufExpr(model, repo, relPath) {
const rel = String(relPath || '').replace(/^\/+/, ''); const rel = String(relPath || '').replace(/^\/+/, '');
if (!rel) return ''; 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) { if (model.is_local_dir && model.path) {
const base = String(model.path || '').replace(/\/+$/, ''); const base = String(model.path || '').replace(/\/+$/, '');
return `$(printf %s ${_shellPathExpr(`${base}/${repo}/${rel}`)})`; return `$(printf %s ${_shellPathExpr(`${base}/${repo}/${rel}`)})`;
@@ -271,15 +255,6 @@ function _selectedGgufExpr(model, repo, relPath) {
} }
function _ggufSearchDirExpr(model, repo) { 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.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`); if (model.path) return _shellQuote(`${String(model.path || '').replace(/\/+$/, '')}/models--${repo.replace(/\//g, '--')}/snapshots`);
return `"$HOME/.cache/huggingface/hub/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>` + `<button type="button" class="cookbook-slot-btn cookbook-saved-arrow" title="${esc(_arrowTitle)}">${_arrowLabel}</button>`
+ `</div>`; + `</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 — // Warn when serving a model whose download hasn't fully completed —
// the user CAN still hit Launch (vLLM/llama-server will start, then // the user CAN still hit Launch (vLLM/llama-server will start, then
// crash trying to read missing shards), but they should know. // 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>`; _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>`; 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>`;
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>`; 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) { if (_ggufChoices.length > 1) {
panelHtml += `<div class="hwfit-serve-row hwfit-backend-llamacpp">`; // Show the GGUF File dropdown for BOTH llama.cpp and Ollama — Ollama
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>`; // 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>`; panelHtml += `</div>`;
} else if (_defaultGguf) { } else if (_defaultGguf) {
panelHtml += `<input type="hidden" class="hwfit-sf" data-field="gguf_file" value="${esc(_defaultGguf)}" />`; panelHtml += `<input type="hidden" class="hwfit-sf" data-field="gguf_file" value="${esc(_defaultGguf)}" />`;
} }
// Row 2: Core settings // Row 2: Core settings — the handful you actually touch every launch.
panelHtml += `<div class="hwfit-serve-row hwfit-backend-vllm hwfit-backend-sglang hwfit-backend-llamacpp">`; // 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>`; 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 // 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). // 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('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>${_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.01.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 hwfit-backend-sglang">${_l('GPU Mem','Fraction of GPU memory (0.01.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 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 += `<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>`; 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 // Attention backend selector — pin the kernel impl. Default `auto` lets
// vLLM pick FlashInfer (which JITs on first use and breaks on older // 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'] 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(''); .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('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 // Free-text env-vars field. Anything pasted here is prepended to the
// launch command verbatim. Use for CUDACXX, PATH overrides, NCCL_* // launch command verbatim. Use for CUDACXX, PATH overrides, NCCL_*
// tuning, or any other KEY=VALUE pair that doesn't have a dedicated // 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. // 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 += `<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>`; 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 // Row 2b: Diffusers settings
const diffDtypeOpts = ['bfloat16','float16','float32'].map(d => `<option value="${d}"${sv('diff_dtype','bfloat16')===d?' selected':''}>${d}</option>`).join(''); 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(''); 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 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(''); 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 += `<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>${_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="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>`; 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 // explicit overrides for known-good advanced presets; blank keeps
// llama.cpp/profile defaults. // llama.cpp/profile defaults.
panelHtml += `<div class="hwfit-serve-row hwfit-backend-llamacpp">`; 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('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('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('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>`; 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 // 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 // /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. // 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, // even for models the auto-detector doesn't recognize. Expert-parallel,
// reasoning-parser and MoE-env still only appear when auto-detected. // reasoning-parser and MoE-env still only appear when auto-detected.
const _opts2 = _detectModelOptimizations(repo); 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.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>`; } 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>`; 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>`; panelHtml += `</div>`;
// ── End Advanced fold ──
panelHtml += `</details>`;
// Command preview + actions. Wrap the textarea so a floating Copy // Command preview + actions. Wrap the textarea so a floating Copy
// button can sit at its top-right corner — same pattern as the chat // button can sit at its top-right corner — same pattern as the chat
// run-output panel. // run-output panel.
@@ -825,27 +828,17 @@ function _rerenderCachedModels() {
// model the file lives under "<path>/<repo>" — search there just like we // 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 // search the HF snapshots dir, so serving a GGUF from a custom dir works
// instead of handing llama.cpp a directory (which fails). // instead of handing llama.cpp a directory (which fails).
const _ldir = m.path const _ldir = m.path ? _shellQuote(`${m.path}/${repo}`) : '""';
? (_isWindows() ? `${m.path.replace(/\//g, '\\')}\\${repo.replace(/\//g, '\\')}` : _shellQuote(`${m.path}/${repo}`)) f._gguf_path = selectedGguf
: (_isWindows() ? '' : '""'); ? _selectedGgufExpr(m, repo, selectedGguf.rel_path)
if (selectedGguf) { : m.is_local_dir && m.path
f._gguf_path = _selectedGgufExpr(m, repo, selectedGguf.rel_path); ? `$({ find ${_ldir} -name '*-00001-of-*.gguf' 2>/dev/null | sort; find ${_ldir} -name '*.gguf' 2>/dev/null | sort; } | head -1)`
} else if (_isWindows()) { : `$({ find ${dir} -name '*-00001-of-*.gguf' 2>/dev/null | sort; find ${dir} -name '*.gguf' 2>/dev/null | sort; } | head -1)`;
// 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)`;
}
// Vision: auto-find the mmproj (CLIP/projector) file in the same dir. // 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 // Resolved at runtime so the toggle just works if an mmproj-*.gguf is
// present (downloaded alongside the model). Empty if none → cmd omits it. // present (downloaded alongside the model). Empty if none → cmd omits it.
const _vsearchdir = (m.is_local_dir && m.path) ? _ldir : dir; const _vsearchdir = (m.is_local_dir && m.path) ? _ldir : dir;
f._mmproj_path = _isWindows() f._mmproj_path = `$(find ${_vsearchdir} -iname 'mmproj*.gguf' 2>/dev/null | sort | head -1)`;
? (_vsearchdir ? `${_vsearchdir}\\mmproj*.gguf` : '')
: `$(find ${_vsearchdir} -iname 'mmproj*.gguf' 2>/dev/null | sort | head -1)`;
} }
if (f.reasoning_parser) { if (f.reasoning_parser) {
const _rpEl2 = panel.querySelector('[data-field="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 _clampCtx(false); // fix any stale/preset value already present
} }
// Auto profiles — fetch hardware-computed llama.cpp profiles and render // Tighten the ctx slider's upper bound to the model's trained limit.
// them as clickable chips. Clicking one fills the ctx/CPU-MoE/KV/flash // Asking llama.cpp for ctx > n_ctx_train overflows and, with a quantized
// fields and rebuilds the command. Computed from detected VRAM (see // KV cache, can crash the GPU (radv ErrorDeviceLost). The auto-profile
// services/hwfit/profiles.py); rough on t/s, accurate on fit. // chip row that used to also live here was removed — visual fit with
async function _loadServeProfiles() { // the rest of the serve panel was off — but this clamp is essential.
const wrap = panel.querySelector('.hwfit-profile-btns'); (async () => {
if (!wrap) return;
try { try {
const host = (_es.remoteHost || '').trim(); const host = (_es.remoteHost || '').trim();
const selected = _serverByVal?.(_es.remoteServerKey || host);
const params = new URLSearchParams({ model: repo }); const params = new URLSearchParams({ model: repo });
if (host) { if (host) {
params.set('host', 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); 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 res = await fetch(`/api/hwfit/profiles?${params}`);
const data = await res.json(); 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; const ctxMax = Number(data && data.model_ctx_max) || 0;
if (ctxMax > 0) { if (ctxMax > 0) {
panel._modelCtxMax = ctxMax; // tighten the clamp to the real limit panel._modelCtxMax = ctxMax;
_clampCtx(false); // re-apply now that we know the model's max _clampCtx(false);
} }
const profs = (data && Array.isArray(data.profiles)) ? data.profiles : []; } catch { /* clamp falls back to the static default */ }
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();
// Live GPU-memory monitor: poll /api/cookbook/gpus and show VRAM usage + // 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 // 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 if (!el || !document.body.contains(el)) return false; // panel closed → stop
try { try {
const host = (_es.remoteHost || '').trim(); const host = (_es.remoteHost || '').trim();
const selected = _serverByVal?.(_es.remoteServerKey || host);
const params = new URLSearchParams(); const params = new URLSearchParams();
if (host) { if (host) {
params.set('host', 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); if (_sp) params.set('ssh_port', _sp);
} }
const res = await fetch('/api/cookbook/gpus' + (params.toString() ? '?' + params : '')); 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.byIdx = new Map(data.gpus.map(g => [g.index, g]));
panel._gpuProbe.host = remoteHost; 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 => { panel.querySelectorAll('.cookbook-gpu-btn').forEach(b => {
const idx = parseInt(b.dataset.gpu); const idx = parseInt(b.dataset.gpu);
const g = panel._gpuProbe.byIdx.get(idx); const g = panel._gpuProbe.byIdx.get(idx);
@@ -1861,12 +1842,20 @@ function _rerenderCachedModels() {
} }
// Save in the { _byRepo, _lastUsed } schema — no legacy flat keys at // Save in the { _byRepo, _lastUsed } schema — no legacy flat keys at
// the root so per-model state doesn't leak between models. // 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 { try {
let cur = {}; let cur = {};
try { cur = JSON.parse(localStorage.getItem(SERVE_STATE_KEY)) || {}; } catch {} try { cur = JSON.parse(localStorage.getItem(SERVE_STATE_KEY)) || {}; } catch {}
const byRepo = (cur && cur._byRepo && typeof cur._byRepo === 'object') ? cur._byRepo : {}; const byRepo = (cur && cur._byRepo && typeof cur._byRepo === 'object') ? cur._byRepo : {};
byRepo[repo] = serveState; const _saved = { ...serveState, _forceBackend: true };
localStorage.setItem(SERVE_STATE_KEY, JSON.stringify({ _byRepo: byRepo, _lastUsed: serveState })); byRepo[repo] = _saved;
localStorage.setItem(SERVE_STATE_KEY, JSON.stringify({ _byRepo: byRepo, _lastUsed: _saved }));
} catch {} } catch {}
const origEnv = _envState.env; const origEnv = _envState.env;
const origEnvPath = _envState.envPath; const origEnvPath = _envState.envPath;
@@ -1938,10 +1927,24 @@ function _rerenderCachedModels() {
function _resolveCacheHost() { function _resolveCacheHost() {
let host = _envState.remoteHost || ''; let host = _envState.remoteHost || '';
const cacheSrv = document.getElementById('hwfit-cache-server'); 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) { if (cacheSrv) {
const val = cacheSrv.value; const val = cacheSrv.value;
if (val === 'local') host = ''; if (val === 'local') {
else { const s = _serverByVal?.(val) || _envState.servers[parseInt(val)]; if (s) host = s.host; } host = '';
} else {
const s = _serverByCacheValue(val);
if (s) host = s.host;
}
} }
return host; return host;
} }
@@ -2071,8 +2074,12 @@ export async function openServePanelForRepo(repo, fields) {
let cur = {}; let cur = {};
try { cur = JSON.parse(localStorage.getItem(SERVE_STATE_KEY)) || {}; } catch {} try { cur = JSON.parse(localStorage.getItem(SERVE_STATE_KEY)) || {}; } catch {}
const byRepo = (cur && cur._byRepo && typeof cur._byRepo === 'object') ? cur._byRepo : {}; const byRepo = (cur && cur._byRepo && typeof cur._byRepo === 'object') ? cur._byRepo : {};
byRepo[repo] = fields; // Mirror the launch-time save: stamp _forceBackend so the panel's
localStorage.setItem(SERVE_STATE_KEY, JSON.stringify({ _byRepo: byRepo, _lastUsed: fields })); // 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 {} } catch {}
} }
// Switch to the Serve tab (its click handler triggers _fetchCachedModels). // 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); .find(el => (el.dataset.repo || '').split('/').pop() === _short);
} }
if (card) { 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 {} try { card.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch {}
return true; return true;
} }
@@ -2130,6 +2148,14 @@ export async function _fetchCachedModels() {
try { try {
let host = _envState.remoteHost || ''; let host = _envState.remoteHost || '';
let selectedServer = null; 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'); const cacheSrv = document.getElementById('hwfit-cache-server');
if (cacheSrv) { if (cacheSrv) {
const val = cacheSrv.value; const val = cacheSrv.value;
@@ -2137,11 +2163,11 @@ export async function _fetchCachedModels() {
host = ''; host = '';
selectedServer = _envState.servers.find(s => !s.host || s.host === 'local') || _envState.servers[0]; selectedServer = _envState.servers.find(s => !s.host || s.host === 'local') || _envState.servers[0];
} else { } else {
const s = _serverByVal?.(val) || _envState.servers[parseInt(val)]; const s = _serverByCacheValue(val);
if (s) { host = s.host; selectedServer = s; } if (s) { host = s.host; selectedServer = s; }
} }
} else { } 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) // Read extra model dirs from the SELECTED server's modelDirs (canonical source)
const modelDirs = []; const modelDirs = [];
@@ -2171,7 +2197,18 @@ export async function _fetchCachedModels() {
if (modelDirs.length) qp.set('model_dir', modelDirs.join(',')); if (modelDirs.length) qp.set('model_dir', modelDirs.join(','));
const params = qp.toString() ? `?${qp}` : ''; const params = qp.toString() ? `?${qp}` : '';
const res = await fetch(`/api/model/cached${params}`); 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(); const data = await res.json();
_dlWp.destroy(); _dlWp.destroy();
@@ -2268,7 +2305,6 @@ export function initServe(shared) {
_envState = shared._envState; _envState = shared._envState;
_sshCmd = shared._sshCmd; _sshCmd = shared._sshCmd;
_getPort = shared._getPort; _getPort = shared._getPort;
_serverByVal = shared._serverByVal;
_sshPrefix = shared._sshPrefix; _sshPrefix = shared._sshPrefix;
_getPlatform = shared._getPlatform; _getPlatform = shared._getPlatform;
_isWindows = shared._isWindows; _isWindows = shared._isWindows;
+3 -4
View File
@@ -578,13 +578,12 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
const pieces = []; const pieces = [];
if (doc.session_name) pieces.push(`<span>${_esc(doc.session_name)}</span>`); if (doc.session_name) pieces.push(`<span>${_esc(doc.session_name)}</span>`);
if (doc.language && doc.language !== 'text') { if (doc.language && doc.language !== 'text') {
const ic = langIcon(doc.language, 11, { style: 'vertical-align:-2px;flex-shrink:0;opacity:0.65;color:currentColor;' }); // Per-language icon lives in the title row above; just the language
pieces.push(`<span style="display:inline-flex;align-items:center;gap:3px;">${ic}${_esc(doc.language)}</span>`); // 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>`); pieces.push(`<span>${_esc(libraryRelativeTime(doc.updated_at))}</span>`);
meta.innerHTML = pieces.join('<span style="opacity:0.5;">\u00b7</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); content.appendChild(meta);
card.appendChild(content); card.appendChild(content);
+1 -1
View File
@@ -788,7 +788,7 @@ export function openEmailLibrary(opts = {}) {
<div class="admin-card" style="flex:1;flex-direction:column;display:flex;overflow:hidden;"> <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> <p class="memory-desc doclib-desc">All emails. Click to open as a document.</p>
<div class="email-accounts-row"> <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"> <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> <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 New
+149 -2
View File
@@ -36,6 +36,17 @@ function linkHtml(text, url) {
return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer">${safeText}</a>`; 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 * Sanitize the raw-HTML fragments that mdToHtml deliberately preserves from
* the source text <details> blocks (collapsible agent output) and <a> tags * 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 * Process text and render with thinking sections
*/ */
@@ -422,6 +444,9 @@ export function processWithThinking(text) {
const { thinkingBlocks, content, thinkingTime } = extractThinkingBlocks(text); const { thinkingBlocks, content, thinkingTime } = extractThinkingBlocks(text);
let html = ''; 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) // Add thinking sections (collapsed by default)
thinkingBlocks.forEach((block, index) => { thinkingBlocks.forEach((block, index) => {
@@ -429,8 +454,12 @@ export function processWithThinking(text) {
}); });
// Add the actual content // Add the actual content
if (content) { if (doneOnly) {
html += mdToHtml(content); 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; return _useSvgEmoji() ? svgifyEmoji(html) : html;
@@ -885,3 +914,121 @@ document.addEventListener('click', function(e) {
start(); 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();
}
})();
+4 -7
View File
@@ -327,13 +327,10 @@ function _initModelPickerDropdown() {
// hover so the suffix/variant tag is still discoverable (#1982). // hover so the suffix/variant tag is still discoverable (#1982).
nameSpan.title = m.display; nameSpan.title = m.display;
row.appendChild(nameSpan); row.appendChild(nameSpan);
if (m.stale) { // Offline state is already conveyed by the row's reduced opacity —
const badge = document.createElement('span'); // a redundant "offline" pill on top of that just added clutter.
badge.className = 'model-switch-stale-badge'; // (Class kept on `row` so the opacity rule still applies; the text
badge.textContent = 'offline'; // badge is gone.)
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);
}
const epSpan = document.createElement('span'); const epSpan = document.createElement('span');
epSpan.className = 'model-switch-ep'; epSpan.className = 'model-switch-ep';
// Don't show endpoint name if it matches the model name (local self-hosted) // Don't show endpoint name if it matches the model name (local self-hosted)
+8 -1
View File
@@ -178,7 +178,14 @@ export async function refreshModels(force = false) {
_loadingSpinner.start(); _loadingSpinner.start();
try { try {
if (!_fetchInflight) { 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) => { .then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json(); return res.json();
-79
View File
@@ -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 &amp; 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 };
+9 -3
View File
@@ -1559,6 +1559,7 @@ async function initResearchSearchSettings() {
async function initAgentSettings() { async function initAgentSettings() {
var toolsInput = el('set-agentMaxTools'); var toolsInput = el('set-agentMaxTools');
var roundsInput = el('set-agentMaxRounds'); var roundsInput = el('set-agentMaxRounds');
var supInput = el('set-agentSupervisorLadder');
var msg = el('set-agentMsg'); var msg = el('set-agentMsg');
if (!toolsInput) return; if (!toolsInput) return;
@@ -1567,6 +1568,7 @@ async function initAgentSettings() {
var settings = await res.json(); var settings = await res.json();
if (settings.agent_max_tool_calls) toolsInput.value = settings.agent_max_tool_calls; 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 (roundsInput && settings.agent_max_rounds) roundsInput.value = settings.agent_max_rounds;
if (supInput) supInput.checked = !!settings.agent_supervisor_ladder;
} catch (e) {} } catch (e) {}
// Clamp + coerce a raw input to an int in [lo, hi]; falls back to `dflt` // 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; if (roundsInput) roundsInput.value = rounds;
var payload = { agent_max_tool_calls: tools }; var payload = { agent_max_tool_calls: tools };
if (rounds != null) payload.agent_max_rounds = rounds; if (rounds != null) payload.agent_max_rounds = rounds;
if (supInput) payload.agent_supervisor_ladder = !!supInput.checked;
try { try {
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload) body: JSON.stringify(payload)
}); });
msg.textContent = (tools > 0 ? 'Limit: ' + tools + ' tool calls' : 'Unlimited tool calls') + 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)'; msg.style.color = 'var(--fg)';
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; } } catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
} }
toolsInput.addEventListener('change', save); toolsInput.addEventListener('change', save);
if (roundsInput) roundsInput.addEventListener('change', save); if (roundsInput) roundsInput.addEventListener('change', save);
if (supInput) supInput.addEventListener('change', save);
var cur = parseInt(toolsInput.value, 10) || 0; var cur = parseInt(toolsInput.value, 10) || 0;
var curR = roundsInput ? (parseInt(roundsInput.value, 10) || 20) : null; var curR = roundsInput ? (parseInt(roundsInput.value, 10) || 20) : null;
msg.textContent = (cur > 0 ? 'Limit: ' + cur + ' tool calls' : 'Unlimited tool calls') + 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 => { formEl.querySelectorAll('.uf-codex-revoke').forEach(btn => {
btn.addEventListener('click', async () => { 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' }); await fetch(`/api/tokens/${btn.dataset.tokenId}`, { method: 'DELETE', credentials: 'same-origin' });
formEl.style.display = 'none'; formEl.style.display = 'none';
await renderList(); await renderList();
+4 -4
View File
@@ -890,10 +890,10 @@ function renderSkillsList() {
}); });
} }
// Background-load the visible skills' SKILL.md so expanding any of them is // Do not eager-load every visible SKILL.md. On large skill libraries this
// instant (no first-time async fetch → no jump). Deferred so it never // creates dozens of simultaneous /api/skills/<name>/markdown requests during
// competes with the render/cascade paint. // app startup and can peg uvicorn. Markdown is fetched lazily when a card is
setTimeout(_preloadVisibleMarkdown, 0); // expanded.
} }
// ---- Card expand / edit / actions ---- // ---- Card expand / edit / actions ----
-62
View File
@@ -17,7 +17,6 @@ import chatRenderer from './chatRenderer.js';
import spinnerModule from './spinner.js'; import spinnerModule from './spinner.js';
import themeModule from './theme.js'; import themeModule from './theme.js';
import documentModule from './document.js'; import documentModule from './document.js';
import workspaceModule from './workspace.js';
import settingsModule from './settings.js'; import settingsModule from './settings.js';
import cookbookModule from './cookbook.js'; import cookbookModule from './cookbook.js';
import { EVAL_PROMPTS } from './compare/index.js'; import { EVAL_PROMPTS } from './compare/index.js';
@@ -1226,51 +1225,6 @@ async function _cmdToggleDoc(args, ctx) {
return true; 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) { async function _cmdToggleShow(args, ctx) {
const name = (args[0] || '').toLowerCase(); const name = (args[0] || '').toLowerCase();
const val = (args[1] || '').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' }, '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' }, 'research': { handler: _cmdToggleResearch, alias: ['r'], help: 'Toggle deep research', usage: '/toggle research' },
'doc': { handler: _cmdToggleDoc, alias: [], help: 'Toggle document editor', usage: '/toggle doc' }, '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]' }, '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' } '_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: { memory: {
alias: ['m'], alias: ['m'],
category: 'Memory', category: 'Memory',
+1 -3
View File
@@ -23,9 +23,7 @@ export const KEYS = {
MCP_ACTIVE: 'odysseus-mcp-active', MCP_ACTIVE: 'odysseus-mcp-active',
SECTION_ORDER: 'sidebar-section-order', SECTION_ORDER: 'sidebar-section-order',
ADMIN_LAST_TAB: 'admin-last-tab', ADMIN_LAST_TAB: 'admin-last-tab',
DENSITY: 'odysseus-density', DENSITY: 'odysseus-density'
WORKSPACE: 'odysseus-workspace',
PLAN: 'odysseus-plan'
}; };
/** /**
-160
View File
@@ -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
View File
@@ -2048,12 +2048,64 @@ body.bg-pattern-sparkles {
.msg-user .body { .msg-user .body {
color: var(--fg); color: var(--fg);
} }
.msg-ai .body { .msg-ai .body {
color: var(--fg); color: var(--fg);
} }
.rag-sources { .model-endpoint-add-btn {
margin-top: 12px; display: inline-flex;
border: 1px solid var(--border); 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; border-radius: 6px;
padding: 8px; padding: 8px;
font-size: 12px; font-size: 12px;
@@ -2182,7 +2234,7 @@ body.bg-pattern-sparkles {
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; right: 0;
z-index: 2; z-index: 250;
transform-origin: top right; transform-origin: top right;
transition: opacity 0.22s ease, transform 0.22s ease; transition: opacity 0.22s ease, transform 0.22s ease;
will-change: opacity, transform; will-change: opacity, transform;
@@ -2307,48 +2359,7 @@ body.bg-pattern-sparkles {
color: var(--fg); color: var(--fg);
background: color-mix(in srgb, var(--fg) 9%, transparent); background: color-mix(in srgb, var(--fg) 9%, transparent);
} }
/* Plan mode: "Approve & Run" affordance under a proposed plan */ /* GitHub-style task lists (- [ ] / - [x]) */
.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 */
li.task-item { li.task-item {
list-style: none; list-style: none;
margin-left: -1.2em; margin-left: -1.2em;
@@ -2745,7 +2756,7 @@ body.bg-pattern-sparkles {
position: absolute; position: absolute;
bottom: calc(100% + 16px); bottom: calc(100% + 16px);
right: 0; right: 0;
z-index: 300; z-index: 250;
min-width: 260px; min-width: 260px;
max-width: 360px; max-width: 360px;
background: var(--panel); background: var(--panel);
@@ -8408,6 +8419,14 @@ body.hide-thinking .thinking-section { display: none !important; }
transition: background 0.2s ease; transition: background 0.2s ease;
} }
.thinking-header > .token-new {
display: none;
}
.thinking-header > div:last-child {
flex-shrink: 0;
}
.thinking-header:hover { .thinking-header:hover {
background: color-mix(in srgb, var(--red) 12%, transparent); background: color-mix(in srgb, var(--red) 12%, transparent);
} }
@@ -8423,6 +8442,7 @@ body.hide-thinking .thinking-section { display: none !important; }
min-width: 0; min-width: 0;
} }
.thinking-header-left span { .thinking-header-left span {
display: block;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -8801,6 +8821,22 @@ body.hide-thinking .thinking-section { display: none !important; }
.agent-thread-node + .agent-thread-node { .agent-thread-node + .agent-thread-node {
margin-top: 2px; 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 { .agent-thread-dot {
position: absolute; position: absolute;
left: -20px; 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 { .hwfit-cached-list {
max-height: min(75vh, 900px) !important; max-height: none !important;
overflow-y: auto; 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 /* Drag-and-drop visual hint for the email compose pane. Subtle accent
outline + tinted overlay so it's obvious files will attach if dropped. */ 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 { #cookbook-modal .cookbook-group > .admin-card {
min-height: 0; min-height: 0;
overflow-y: auto !important; /* Let .cookbook-body be the SINGLE scroll surface. Nesting another
overflow-x: hidden !important; 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 { #cookbook-modal .cookbook-section-body {
min-height: 0; min-height: 0;
@@ -18774,6 +18831,13 @@ body.gallery-selecting .gallery-dl-btn,
justify-content: flex-end; justify-content: flex-end;
margin-bottom: 4px; 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 { .cookbook-slot-btn {
min-width: 22px; height: 22px; min-width: 22px; height: 22px;
padding: 0 6px; padding: 0 6px;
@@ -18938,6 +19002,8 @@ body.gallery-selecting .gallery-dl-btn,
appearance: none; appearance: none;
-webkit-appearance: none; -webkit-appearance: none;
-moz-appearance: none; -moz-appearance: none;
position: relative;
top: -2px;
} }
.cookbook-dep-rebuild:hover { .cookbook-dep-rebuild:hover {
background: color-mix(in srgb, var(--accent, var(--red)) 18%, transparent); 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); background: color-mix(in srgb, var(--color-error) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--color-error) 30%, transparent); border: 1px solid color-mix(in srgb, var(--color-error) 30%, transparent);
border-radius: 6px; 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 { .cookbook-diag-header {
display: flex; display: flex;
@@ -20439,6 +20520,14 @@ body.gallery-selecting .gallery-dl-btn,
opacity: 0.5; opacity: 0.5;
font-family: inherit; 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 { .cookbook-dl-btn {
background: var(--accent, var(--red)); background: var(--accent, var(--red));
color: #fff; color: #fff;
@@ -22485,6 +22574,88 @@ input.settings-select::placeholder { color: color-mix(in srgb, var(--fg) 35%, tr
text-align: right; text-align: right;
} }
.settings-fallback-row .settings-select { flex: 1; min-width: 0; } .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 { .settings-fallback-remove {
flex-shrink: 0; flex-shrink: 0;
margin-right: 4px; 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; transition: border-color 0.12s, color 0.12s, background 0.12s;
position: relative; position: relative;
top: -6px; 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 { .settings-fallback-remove:hover {
border-color: var(--red); 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 /* Only the direct-child compose button gets pushed right; nested chips
inside #email-lib-accounts pack to the left as normal flex items. */ 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-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 { .email-accounts-loading-whirlpool {
width: 14px; width: 14px;
height: 14px; height: 14px;
@@ -36172,49 +36363,6 @@ body.theme-frosted .modal {
line-height: 1.4; line-height: 1.4;
color: color-mix(in srgb, var(--fg) 45%, transparent); 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 */ /* Cookbook serve panel: Launch + ^ split button pair */
.hwfit-serve-launch-group { .hwfit-serve-launch-group {
display: inline-flex; display: inline-flex;
@@ -36237,6 +36385,16 @@ body.theme-frosted .modal {
justify-content: center; 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 /* Schedule form mounted inside the cookbook serve panel. Uses the
theme tokens (--bg, --panel, --border, --accent, --red) so it theme tokens (--bg, --panel, --border, --accent, --red) so it
matches the rest of the cookbook chrome instead of inline whites. */ matches the rest of the cookbook chrome instead of inline whites. */
@@ -36288,6 +36446,18 @@ body.theme-frosted .modal {
flex-wrap: wrap; flex-wrap: wrap;
gap: 5px; 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 { .hwfit-sched-day-chip {
width: 32px; width: 32px;
height: 32px; height: 32px;
+112
View File
@@ -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"]))
+85
View File
@@ -22,10 +22,12 @@ from routes.cookbook_helpers import (
_user_shell_path_bootstrap, _user_shell_path_bootstrap,
_venv_safe_local_pip_install_cmd, _venv_safe_local_pip_install_cmd,
_validate_gpus, _validate_gpus,
_validate_local_dir,
_validate_repo_id, _validate_repo_id,
_validate_serve_cmd, _validate_serve_cmd,
_validate_serve_model_id, _validate_serve_model_id,
_validate_ssh_port, _validate_ssh_port,
_shell_path,
run_ssh_command_async, run_ssh_command_async,
) )
@@ -110,6 +112,89 @@ def test_validate_ssh_port_rejects_shell_payload():
assert _validate_ssh_port("2222") == "2222" 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(): def test_validate_gpus_accepts_indexes_only():
assert _validate_gpus("0,1,2") == "0,1,2" assert _validate_gpus("0,1,2") == "0,1,2"
with pytest.raises(HTTPException): with pytest.raises(HTTPException):
+1 -1
View File
@@ -48,7 +48,7 @@ def test_direct_upload_routes_use_bounded_reads():
"read_upload_limited(file, MEMORY_IMPORT_MAX_BYTES", "read_upload_limited(file, MEMORY_IMPORT_MAX_BYTES",
], ],
"routes/calendar_routes.py": [ "routes/calendar_routes.py": [
"read_upload_limited(file, _ICS_MAX_BYTES", "read_upload_limited(file, ICS_MAX_BYTES",
], ],
"routes/email_routes.py": [ "routes/email_routes.py": [
"read_upload_limited(file, EMAIL_COMPOSE_UPLOAD_MAX_BYTES", "read_upload_limited(file, EMAIL_COMPOSE_UPLOAD_MAX_BYTES",
+165
View File
@@ -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
+166
View File
@@ -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()
-30
View File
@@ -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) 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): def test_guide_only_skips_intent_without_action_nudge(monkeypatch):
_patch_loop_basics(monkeypatch) _patch_loop_basics(monkeypatch)
+110
View File
@@ -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
-128
View File
@@ -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"]