mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-08 14:12:22 +01:00
Compare commits
82
Commits
cf5c5118d8
...
e0e250d023
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0e250d023 | ||
|
|
2ba77e3aa3 | ||
|
|
fbd34334a5 | ||
|
|
e2f449f4ef | ||
|
|
f8aaeab245 | ||
|
|
1f40fbe140 | ||
|
|
f19ac6ed03 | ||
|
|
51e668ce60 | ||
|
|
ae48ea7064 | ||
|
|
b9a0586edc | ||
|
|
19a3fc59c9 | ||
|
|
f8cf791491 | ||
|
|
1d80bf5e65 | ||
|
|
85334e8f3d | ||
|
|
f9c81f3c8d | ||
|
|
9be2862e4e | ||
|
|
1801ba9a0d | ||
|
|
28b296a712 | ||
|
|
23fb5e169a | ||
|
|
795782917f | ||
|
|
134c608466 | ||
|
|
2be3779e6e | ||
|
|
7b4365fe57 | ||
|
|
a260e0abd4 | ||
|
|
fb852bd62e | ||
|
|
7ddc5eaef4 | ||
|
|
70812955d1 | ||
|
|
64d65b73c1 | ||
|
|
a54f41037d | ||
|
|
3426e0cb5e | ||
|
|
e69298888b | ||
|
|
67782e684e | ||
|
|
baf9179d94 | ||
|
|
a8d0c117bb | ||
|
|
1cd0aa2b8c | ||
|
|
ca32b43b38 | ||
|
|
24220155af | ||
|
|
9964f1382f | ||
|
|
ca8ca38a32 | ||
|
|
b59bbe80ce | ||
|
|
e113c10d01 | ||
|
|
01c99c3990 | ||
|
|
3ae89599f3 | ||
|
|
ed933ac232 | ||
|
|
33425a9c6c | ||
|
|
ab5311c44d | ||
|
|
6d511f6e66 | ||
|
|
0ead3a4eb2 | ||
|
|
dd707ddb1e | ||
|
|
7188737294 | ||
|
|
b98ee04e2f | ||
|
|
abe04436a0 | ||
|
|
bc83479f94 | ||
|
|
4ed48baf68 | ||
|
|
40cbfb7b94 | ||
|
|
531f426557 | ||
|
|
ff8f9f2188 | ||
|
|
a19b6d2d4d | ||
|
|
c12c2aa233 | ||
|
|
7ce6ec7f50 | ||
|
|
20cc23c9bd | ||
|
|
34c9a8adb1 | ||
|
|
8bc16ef245 | ||
|
|
050283c145 | ||
|
|
935eb05c63 | ||
|
|
3b292403dc | ||
|
|
1f00fff837 | ||
|
|
7443c36bd9 | ||
|
|
147d1fbde6 | ||
|
|
8bfd79fe8e | ||
|
|
66fba78011 | ||
|
|
c916224510 | ||
|
|
a7e60ca7ec | ||
|
|
dfccd8256d | ||
|
|
07e69ddf84 | ||
|
|
dd1fa7e1c4 | ||
|
|
9112861d8e | ||
|
|
39825867a4 | ||
|
|
019f8d614f | ||
|
|
2c7349503a | ||
|
|
e9dfd1a747 | ||
|
|
379a60e5d6 |
@@ -25,7 +25,7 @@ Fixes #
|
||||
## Checklist
|
||||
|
||||
- [ ] I searched [open issues](https://github.com/pewdiepie-archdaemon/odysseus/issues) and [open PRs](https://github.com/pewdiepie-archdaemon/odysseus/pulls) — this is not a duplicate.
|
||||
- [ ] This PR targets `main`
|
||||
- [ ] This PR targets `dev`
|
||||
- [ ] My changes are limited to the scope described above — no unrelated refactors or whitespace changes mixed in.
|
||||
- [ ] I actually ran the app (`docker compose up` or `uvicorn app:app`) and verified the change works end-to-end. Type-checks and unit tests are not enough.
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
# Least privilege: none of the jobs write to the repo.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Cancel superseded runs on the same ref to save Actions minutes.
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
python-syntax:
|
||||
name: Python syntax (compileall)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
# Byte-compile sources — catches syntax errors without installing deps.
|
||||
- run: python -m compileall -q app.py core routes src services scripts tests
|
||||
|
||||
node-syntax:
|
||||
name: JS syntax (node --check)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: "20"
|
||||
# Syntax-check our own JS (skip vendored libs in static/lib).
|
||||
- name: node --check
|
||||
run: |
|
||||
shopt -s globstar nullglob
|
||||
for f in static/app.js static/js/**/*.js; do
|
||||
node --check "$f"
|
||||
done
|
||||
|
||||
python-tests:
|
||||
name: Python tests (pytest)
|
||||
runs-on: ubuntu-latest
|
||||
# Informational for now: the suite has known flaky / environment-dependent
|
||||
# failures (test isolation + embedding-model assertions). Tracked under the
|
||||
# ROADMAP "fresh install smoke tests" item; make this required once green.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
- run: pip install -r requirements.txt
|
||||
- run: mkdir -p data # sqlite DB lives at ./data/app.db
|
||||
- run: python -m pytest -q
|
||||
+6
-3
@@ -22,9 +22,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install Python deps first (layer cache)
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
# Install Python deps first (layer cache). Optional extras (PyMuPDF AGPL, etc.)
|
||||
# are opt-in so the default image stays MIT-core; see requirements-optional.txt.
|
||||
ARG INSTALL_OPTIONAL=false
|
||||
COPY requirements.txt requirements-optional.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt \
|
||||
&& if [ "$INSTALL_OPTIONAL" = "true" ]; then pip install --no-cache-dir -r requirements-optional.txt; fi
|
||||
|
||||
# Copy app code
|
||||
COPY . .
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
A self-hosted AI workspace -- meant to be the self-hosted version of the UI experience you get from ChatGPT and Claude. But with more jank and fun. Running on your own hardware, with your own data -- local-first, privacy-first, and no trojan.
|
||||
|
||||
## Features
|
||||
- **Chat** -- chat with any local model or API; adding them is super simple.<br> <sub>vLLM · llama.cpp · Ollama · OpenRouter · OpenAI</sub>
|
||||
- **Chat** -- chat with any local model or API; adding them is super simple.<br> <sub>vLLM · llama.cpp · Ollama · OpenRouter · OpenAI · GitHub Copilot</sub>
|
||||
- **Agent** -- hand it tools and let it run the whole task itself.<br> <sub>built on [opencode](https://github.com/anomalyco/opencode) · MCP · web · files · shell · skills · memory</sub>
|
||||
- **Cookbook** -- Scans your hardware, recommends models, click to download and serve.. easy!<br> <sub>built on [llmfit](https://github.com/AlexsJones/llmfit) · VRAM-aware · GGUF / FP8 / AWQ · fit scoring · vLLM / llama.cpp serving</sub>
|
||||
- **Deep Research** -- multi-step runs that gather, read, and synthesize sources into a nice visual report.<br> <sub>adapted from [Tongyi DeepResearch](https://github.com/Alibaba-NLP/DeepResearch)</sub>
|
||||
@@ -64,6 +64,8 @@ cd odysseus
|
||||
cp .env.example .env # optional, but recommended for explicit defaults
|
||||
docker compose up -d --build
|
||||
```
|
||||
To include optional extras in the image (PDF viewer, Office extraction; includes AGPL PyMuPDF), build with `docker compose build --build-arg INSTALL_OPTIONAL=true` before `up`.
|
||||
|
||||
Open `http://localhost:7000` when the containers are healthy. Docker Compose
|
||||
binds the web UI to `127.0.0.1` by default. If the port is taken, set
|
||||
`APP_PORT=7001` in `.env` and recreate the container. Set `APP_BIND=0.0.0.0`
|
||||
|
||||
@@ -34,7 +34,6 @@ from dotenv import load_dotenv
|
||||
# is silently ignored and the user is unexpectedly forced to log in (issue #142).
|
||||
# utf-8-sig reads plain UTF-8 (no BOM) identically, so this is safe everywhere.
|
||||
load_dotenv(encoding="utf-8-sig")
|
||||
import uuid
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
@@ -526,6 +525,9 @@ upload_cleanup_task = None
|
||||
from routes.emoji_routes import setup_emoji_routes
|
||||
app.include_router(setup_emoji_routes())
|
||||
|
||||
from routes.workspace_routes import setup_workspace_routes
|
||||
app.include_router(setup_workspace_routes())
|
||||
|
||||
# Sessions
|
||||
from routes.session_routes import setup_session_routes
|
||||
session_config = {"REQUEST_TIMEOUT": REQUEST_TIMEOUT, "OPENAI_API_KEY": OPENAI_API_KEY, "SESSIONS_FILE": SESSIONS_FILE}
|
||||
@@ -588,6 +590,10 @@ app.include_router(setup_embedding_routes())
|
||||
from routes.model_routes import setup_model_routes
|
||||
app.include_router(setup_model_routes(model_discovery))
|
||||
|
||||
# GitHub Copilot device-flow login
|
||||
from routes.copilot_routes import setup_copilot_routes
|
||||
app.include_router(setup_copilot_routes())
|
||||
|
||||
# TTS
|
||||
from routes.tts_routes import setup_tts_routes
|
||||
app.include_router(setup_tts_routes(tts_service))
|
||||
@@ -1061,6 +1067,16 @@ async def _startup_event():
|
||||
logger.warning(f"Nightly skill audit failed: {e}")
|
||||
|
||||
_startup_tasks.append(asyncio.create_task(_skill_audit_nightly_loop()))
|
||||
|
||||
# Cookbook serve lifecycle — kills scheduler-launched serves whose
|
||||
# window-end has passed. Paired with the cookbook_serve builtin
|
||||
# action; both are no-ops unless a scheduled task actually launches
|
||||
# something with end_after_min set. Removing this line + the
|
||||
# cookbook_serve entry in BUILTIN_ACTIONS + src/cookbook_serve_lifecycle.py
|
||||
# removes the feature.
|
||||
from src.cookbook_serve_lifecycle import cookbook_serve_lifecycle_loop
|
||||
_startup_tasks.append(asyncio.create_task(cookbook_serve_lifecycle_loop()))
|
||||
|
||||
logger.info("Application startup complete")
|
||||
|
||||
async def _shutdown_event():
|
||||
|
||||
@@ -375,6 +375,7 @@ class McpServer(TimestampMixin, Base):
|
||||
is_enabled = Column(Boolean, default=True)
|
||||
oauth_config = Column(Text, nullable=True) # JSON: provider, keys_file, token_file, scopes
|
||||
disabled_tools = Column(Text, nullable=True) # JSON array of tool names to hide from LLM
|
||||
oauth_tokens = Column(EncryptedText, nullable=True) # JSON {tokens, client_info} for generic MCP OAuth, encrypted at rest
|
||||
|
||||
|
||||
class Comparison(TimestampMixin, Base):
|
||||
@@ -1311,6 +1312,23 @@ def _migrate_add_disabled_tools():
|
||||
except Exception as e:
|
||||
logging.getLogger(__name__).warning(f"disabled_tools migration: {e}")
|
||||
|
||||
def _migrate_add_mcp_oauth_tokens_column():
|
||||
"""Add oauth_tokens column to mcp_servers table if missing.
|
||||
|
||||
The model declares this column as EncryptedText, but the SQL type is plain
|
||||
TEXT on purpose: EncryptedText is a SQLAlchemy TypeDecorator that encrypts at
|
||||
the Python layer and stores the ciphertext as TEXT, so the DB column type is
|
||||
TEXT. This matches the existing encrypted columns (see _migrate_encrypt_*)."""
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
cols = [r[1] for r in conn.execute(text("PRAGMA table_info(mcp_servers)"))]
|
||||
if "oauth_tokens" not in cols:
|
||||
conn.execute(text("ALTER TABLE mcp_servers ADD COLUMN oauth_tokens TEXT"))
|
||||
conn.commit()
|
||||
logging.getLogger(__name__).info("Added oauth_tokens column to mcp_servers")
|
||||
except Exception as e:
|
||||
logging.getLogger(__name__).warning(f"oauth_tokens migration: {e}")
|
||||
|
||||
def _migrate_add_task_v2_columns():
|
||||
"""Add cron_expression, then_task_id, webhook_token to scheduled_tasks."""
|
||||
new_cols = {
|
||||
@@ -1467,6 +1485,10 @@ class CalendarEvent(TimestampMixin, Base):
|
||||
importance = Column(String, default="normal") # low | normal | high | critical
|
||||
event_type = Column(String, nullable=True) # work | personal | health | travel | meal | social | admin | other
|
||||
last_pinged = Column(DateTime, nullable=True) # last time the assistant pinged about this event
|
||||
# "caldav" = pulled from a CalDAV server (so the sync may prune it when it
|
||||
# vanishes upstream). NULL/local = created locally (agent, email triage, or
|
||||
# a UI event whose write-back failed) and must NOT be pruned by the sync.
|
||||
origin = Column(String, nullable=True, index=True)
|
||||
|
||||
calendar = relationship("CalendarCal", back_populates="events")
|
||||
|
||||
@@ -1589,6 +1611,7 @@ def init_db():
|
||||
_migrate_add_oauth_config()
|
||||
_migrate_add_task_automation_columns()
|
||||
_migrate_add_disabled_tools()
|
||||
_migrate_add_mcp_oauth_tokens_column()
|
||||
_migrate_add_task_v2_columns()
|
||||
_migrate_add_notifications_enabled()
|
||||
_migrate_drop_ping_notes_tasks()
|
||||
@@ -1598,6 +1621,7 @@ def init_db():
|
||||
_migrate_seed_email_account()
|
||||
_migrate_add_calendar_metadata()
|
||||
_migrate_add_calendar_is_utc()
|
||||
_migrate_add_calendar_origin()
|
||||
_migrate_encrypt_email_passwords()
|
||||
_migrate_encrypt_signatures()
|
||||
_migrate_encrypt_endpoint_keys()
|
||||
@@ -1740,6 +1764,28 @@ def _migrate_add_calendar_is_utc():
|
||||
logging.getLogger(__name__).warning(f"is_utc migration failed: {e}")
|
||||
|
||||
|
||||
def _migrate_add_calendar_origin():
|
||||
"""Add `origin` to calendar_events so the CalDAV sync can tell server-pulled
|
||||
rows (prunable when they vanish upstream) from locally-created ones (agent /
|
||||
email triage / failed write-back), which must never be pruned. Idempotent."""
|
||||
import sqlite3
|
||||
db_path = DATABASE_URL.replace("sqlite:///", "")
|
||||
if not os.path.exists(db_path):
|
||||
return
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.execute("PRAGMA table_info(calendar_events)")
|
||||
columns = [row[1] for row in cursor.fetchall()]
|
||||
if columns and "origin" not in columns:
|
||||
conn.execute("ALTER TABLE calendar_events ADD COLUMN origin TEXT")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS ix_calendar_events_origin ON calendar_events(origin)")
|
||||
conn.commit()
|
||||
logging.getLogger(__name__).info("Migrated: added 'origin' column to calendar_events")
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logging.getLogger(__name__).warning(f"calendar_events.origin migration failed: {e}")
|
||||
|
||||
|
||||
def _migrate_add_calendar_metadata():
|
||||
"""Add importance/event_type/last_pinged columns to calendar_events table."""
|
||||
import sqlite3
|
||||
|
||||
+14
-2
@@ -76,8 +76,20 @@ class Session:
|
||||
_session_manager._persist_message(self.id, message)
|
||||
|
||||
def get_context_messages(self) -> List[Dict[str, Any]]:
|
||||
"""Get messages in format for LLM API."""
|
||||
return [msg.to_dict() for msg in self.history]
|
||||
"""Get messages in format for LLM API.
|
||||
|
||||
Slash-command / setup replies are persisted to history so they render
|
||||
in the transcript, but they are UI chatter (e.g. ``/setup ...`` and its
|
||||
status lines) the user never meant as conversation. They carry
|
||||
``metadata.source == "slash"``; exclude them here so they never reach
|
||||
the model. Display/history-load paths use the raw ``history`` and are
|
||||
unaffected.
|
||||
"""
|
||||
return [
|
||||
msg.to_dict()
|
||||
for msg in self.history
|
||||
if (msg.metadata or {}).get("source") != "slash"
|
||||
]
|
||||
|
||||
def get(self, key: str, default=None):
|
||||
"""Dict-like access for compatibility."""
|
||||
|
||||
@@ -273,7 +273,10 @@ class SessionManager:
|
||||
|
||||
db_session = db.query(DbSession).filter(DbSession.id == session_id).first()
|
||||
if db_session:
|
||||
db_session.message_count = keep_count
|
||||
# keep_count can exceed the real message total (e.g. the AI tool
|
||||
# defaults to keep_count=10 on a short session); message_count must
|
||||
# track the rows that actually remain, not the requested cap.
|
||||
db_session.message_count = min(keep_count, len(db_messages))
|
||||
db_session.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
db.commit()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: odysseus
|
||||
description: Use when the user asks Claude Code to read or write Odysseus data (todos, email, calendar, memory, documents) through the scoped Claude Agent API. Requires ODYSSEUS_URL and ODYSSEUS_API_TOKEN.
|
||||
description: Use when the user asks Claude Code to read or write Odysseus data (todos, email, calendar, memory, documents) or to launch/monitor/stop a Cookbook model-serve task through the scoped Claude Agent API. Requires ODYSSEUS_URL and ODYSSEUS_API_TOKEN.
|
||||
---
|
||||
|
||||
# Odysseus
|
||||
@@ -105,6 +105,49 @@ python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py POST /api/codex/memory
|
||||
- `POST /api/codex/emails/draft` — body matches `SendEmailRequest` (`to`, `cc`, `bcc`, `subject`, `body`, `body_html`, `attachments`, `account_id`, `in_reply_to`, `references`). Requires `email:draft` (or `email:send`).
|
||||
- `POST /api/codex/emails/send` — same body. Requires `email:send`. Never send without explicit user instruction.
|
||||
|
||||
## Cookbook serve (debug a failing model launch)
|
||||
|
||||
The Cookbook surface lets you reproduce what a human would do in Odysseus → Cookbook: read which serves are running, tail their tmux output to see why they crashed, edit the launch command, relaunch, kill a stuck one. Use this when the user is debugging a model server that won't come up (compute-capability errors, OOM, missing kernels, wrong attention backend, etc.).
|
||||
|
||||
- `GET /api/codex/cookbook/tasks` — list active serve/download/install tasks (sessionId, type, status, repo_id, remoteHost, payload._cmd). Requires `cookbook:read`.
|
||||
- `GET /api/codex/cookbook/servers` — list configured servers (name, host, port, env type + path, model dirs). Requires `cookbook:read`.
|
||||
- `GET /api/codex/cookbook/cached?host=<NAME>` — list models already cached on the named server (HF cache + Ollama + extra modelDirs). Call BEFORE `serve` to see what's already on disk. Requires `cookbook:read`.
|
||||
- `GET /api/codex/cookbook/presets` — list saved serve presets (model + host + port + cmd). The user's saved preset usually has a working cmd — try `preset NAME` before composing your own. Requires `cookbook:read`.
|
||||
- `GET /api/codex/cookbook/output/{session_id}?tail=400` — read the last N lines of the task's persistent log file (preferred) or tmux pane (fallback). The log file persists across vllm crashes, so this returns the actual Python traceback even after the bash prompt + neofetch banner overwrites the pane. Default tail=400. Requires `cookbook:read`.
|
||||
- `POST /api/codex/cookbook/serve` — launch a serve task. Body matches `ServeRequest`: `{ repo_id, cmd, remote_host?, ssh_port?, env_prefix?, gpus?, platform? }`. The `cmd` is validated: leading binary must be `vllm`/`python3`/`sglang`/`llama-server`/`ollama`/`node`/`npx`. NEVER prefix with `cd …`, `source …`, or chain with `&&`/`||`/`;`/`$(...)` — the validator rejects shell metacharacters. The venv activation (`env_prefix`) is added automatically from the host's saved settings, so pass the bare binary + args. Requires `cookbook:launch`.
|
||||
- `POST /api/codex/cookbook/preset/{name}` — launch a saved preset by name. Reuses the working cmd + host the user already saved. Requires `cookbook:launch`.
|
||||
- `POST /api/codex/cookbook/adopt` — register an externally-launched tmux session into cookbook tracking. Body: `{ tmux_session, model, host?, port? }`. Use this when serve_model rejected a cmd and you fell back to direct ssh+tmux — without adoption, the session is invisible to the UI. Requires `cookbook:launch`.
|
||||
- `POST /api/codex/cookbook/stop/{session_id}` — kill the tmux session for that task. Requires `cookbook:launch`.
|
||||
|
||||
```bash
|
||||
# Survey what's running
|
||||
python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py cookbook tasks
|
||||
|
||||
# Tail the failing one (sessionId from `cookbook tasks`)
|
||||
python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py cookbook output serve-abc12345 400
|
||||
|
||||
# Stop the previous attempt before you try a new flag set
|
||||
python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py cookbook stop serve-abc12345
|
||||
|
||||
# Relaunch with new flags. cmd MUST begin with one of the allowlisted binaries.
|
||||
python3 ~/.claude/skills/odysseus/scripts/odysseus_api.py cookbook serve \
|
||||
/mnt/HADES/models/Qwen3.5-397B-A17B-AWQ \
|
||||
"vllm serve /mnt/HADES/models/Qwen3.5-397B-A17B-AWQ --host 0.0.0.0 --port 8001 --tensor-parallel-size 8 --max-model-len 262144 --gpu-memory-utilization 0.90 --dtype auto --max-num-seqs 8 --trust-remote-code --enable-expert-parallel --enable-auto-tool-choice --tool-call-parser qwen3_coder --reasoning-parser qwen3" \
|
||||
pewds@192.168.1.12
|
||||
```
|
||||
|
||||
**Debug loop pattern:** when a serve is failing, the productive sequence is
|
||||
|
||||
1. `cookbook tasks` → find the failing sessionId.
|
||||
2. `cookbook output SID 600` → read the last 600 lines, find the actual root-cause line (often above the visible tail because tmux scrollback rolled — request a larger `tail` if the error references "above").
|
||||
3. `cookbook stop SID` — kill the previous attempt before relaunching; two serves on the same `--port` collide.
|
||||
4. `cookbook serve repo "new cmd"` — try the next variation. Wait ~20s, then `cookbook output` on the new sessionId.
|
||||
|
||||
**Hard limits this surface enforces:**
|
||||
- `cookbook serve` cmd allowlist + shell-metacharacter rejection — you cannot run arbitrary shell, only model-server binaries.
|
||||
- `cookbook stop` only targets task sessionIds matching `[a-zA-Z0-9_-]+`.
|
||||
- The agent CAN spawn GPU-pinning long-lived processes — always `cookbook stop` your previous attempt before relaunching, and check `cookbook tasks` for collisions on the same `--port` before launching.
|
||||
|
||||
## Forbidden Bypass Pattern
|
||||
|
||||
If you are about to reach the Odysseus host/container, import app internals, query the database, or call MCP helper modules directly, stop. Those paths bypass Odysseus Settings and token scopes. Ask the user to enable the relevant Claude Agent tool toggle instead.
|
||||
|
||||
@@ -17,6 +17,15 @@ def _usage() -> int:
|
||||
print(" odysseus_api.py todos add TITLE", file=sys.stderr)
|
||||
print(" odysseus_api.py emails list [limit]", file=sys.stderr)
|
||||
print(" odysseus_api.py emails read UID", file=sys.stderr)
|
||||
print(" odysseus_api.py cookbook tasks", file=sys.stderr)
|
||||
print(" odysseus_api.py cookbook servers", file=sys.stderr)
|
||||
print(" odysseus_api.py cookbook cached [HOST]", file=sys.stderr)
|
||||
print(" odysseus_api.py cookbook presets", file=sys.stderr)
|
||||
print(" odysseus_api.py cookbook output SESSION_ID [tail]", file=sys.stderr)
|
||||
print(" odysseus_api.py cookbook serve REPO_ID 'CMD' [REMOTE_HOST]", file=sys.stderr)
|
||||
print(" odysseus_api.py cookbook preset NAME", file=sys.stderr)
|
||||
print(" odysseus_api.py cookbook adopt SESSION_ID MODEL [HOST] [PORT]", file=sys.stderr)
|
||||
print(" odysseus_api.py cookbook stop SESSION_ID", file=sys.stderr)
|
||||
print(" odysseus_api.py METHOD /api/codex/path [json-body]", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
@@ -72,6 +81,61 @@ def main() -> int:
|
||||
body = None
|
||||
else:
|
||||
return _usage()
|
||||
elif command == "cookbook":
|
||||
if len(sys.argv) < 3:
|
||||
return _usage()
|
||||
action = sys.argv[2].lower()
|
||||
if action == "tasks":
|
||||
method = "GET"
|
||||
path = "/api/codex/cookbook/tasks"
|
||||
body = None
|
||||
elif action == "servers":
|
||||
method = "GET"
|
||||
path = "/api/codex/cookbook/servers"
|
||||
body = None
|
||||
elif action == "output" and len(sys.argv) >= 4:
|
||||
method = "GET"
|
||||
sid = sys.argv[3]
|
||||
tail = sys.argv[4] if len(sys.argv) >= 5 else "400"
|
||||
path = f"/api/codex/cookbook/output/{sid}?tail={tail}"
|
||||
body = None
|
||||
elif action == "cached":
|
||||
method = "GET"
|
||||
if len(sys.argv) >= 4:
|
||||
from urllib.parse import quote
|
||||
path = f"/api/codex/cookbook/cached?host={quote(sys.argv[3])}"
|
||||
else:
|
||||
path = "/api/codex/cookbook/cached"
|
||||
body = None
|
||||
elif action == "presets":
|
||||
method = "GET"
|
||||
path = "/api/codex/cookbook/presets"
|
||||
body = None
|
||||
elif action == "preset" and len(sys.argv) >= 4:
|
||||
from urllib.parse import quote
|
||||
method = "POST"
|
||||
path = f"/api/codex/cookbook/preset/{quote(sys.argv[3])}"
|
||||
body = None
|
||||
elif action == "adopt" and len(sys.argv) >= 5:
|
||||
method = "POST"
|
||||
path = "/api/codex/cookbook/adopt"
|
||||
payload = {"tmux_session": sys.argv[3], "model": sys.argv[4]}
|
||||
if len(sys.argv) >= 6: payload["host"] = sys.argv[5]
|
||||
if len(sys.argv) >= 7: payload["port"] = int(sys.argv[6])
|
||||
body = json.dumps(payload)
|
||||
elif action == "serve" and len(sys.argv) >= 5:
|
||||
method = "POST"
|
||||
path = "/api/codex/cookbook/serve"
|
||||
payload = {"repo_id": sys.argv[3], "cmd": sys.argv[4]}
|
||||
if len(sys.argv) >= 6:
|
||||
payload["remote_host"] = sys.argv[5]
|
||||
body = json.dumps(payload)
|
||||
elif action == "stop" and len(sys.argv) >= 4:
|
||||
method = "POST"
|
||||
path = f"/api/codex/cookbook/stop/{sys.argv[3]}"
|
||||
body = None
|
||||
else:
|
||||
return _usage()
|
||||
else:
|
||||
if len(sys.argv) < 3:
|
||||
return _usage()
|
||||
|
||||
@@ -17,6 +17,15 @@ def _usage() -> int:
|
||||
print(" odysseus_api.py todos add TITLE", file=sys.stderr)
|
||||
print(" odysseus_api.py emails list [limit]", file=sys.stderr)
|
||||
print(" odysseus_api.py emails read UID", file=sys.stderr)
|
||||
print(" odysseus_api.py cookbook tasks", file=sys.stderr)
|
||||
print(" odysseus_api.py cookbook servers", file=sys.stderr)
|
||||
print(" odysseus_api.py cookbook cached [HOST]", file=sys.stderr)
|
||||
print(" odysseus_api.py cookbook presets", file=sys.stderr)
|
||||
print(" odysseus_api.py cookbook output SESSION_ID [tail]", file=sys.stderr)
|
||||
print(" odysseus_api.py cookbook serve REPO_ID 'CMD' [REMOTE_HOST]", file=sys.stderr)
|
||||
print(" odysseus_api.py cookbook preset NAME", file=sys.stderr)
|
||||
print(" odysseus_api.py cookbook adopt SESSION_ID MODEL [HOST] [PORT]", file=sys.stderr)
|
||||
print(" odysseus_api.py cookbook stop SESSION_ID", file=sys.stderr)
|
||||
print(" odysseus_api.py METHOD /api/codex/path [json-body]", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
@@ -72,6 +81,61 @@ def main() -> int:
|
||||
body = None
|
||||
else:
|
||||
return _usage()
|
||||
elif command == "cookbook":
|
||||
if len(sys.argv) < 3:
|
||||
return _usage()
|
||||
action = sys.argv[2].lower()
|
||||
if action == "tasks":
|
||||
method = "GET"
|
||||
path = "/api/codex/cookbook/tasks"
|
||||
body = None
|
||||
elif action == "servers":
|
||||
method = "GET"
|
||||
path = "/api/codex/cookbook/servers"
|
||||
body = None
|
||||
elif action == "output" and len(sys.argv) >= 4:
|
||||
method = "GET"
|
||||
sid = sys.argv[3]
|
||||
tail = sys.argv[4] if len(sys.argv) >= 5 else "400"
|
||||
path = f"/api/codex/cookbook/output/{sid}?tail={tail}"
|
||||
body = None
|
||||
elif action == "cached":
|
||||
method = "GET"
|
||||
if len(sys.argv) >= 4:
|
||||
from urllib.parse import quote
|
||||
path = f"/api/codex/cookbook/cached?host={quote(sys.argv[3])}"
|
||||
else:
|
||||
path = "/api/codex/cookbook/cached"
|
||||
body = None
|
||||
elif action == "presets":
|
||||
method = "GET"
|
||||
path = "/api/codex/cookbook/presets"
|
||||
body = None
|
||||
elif action == "preset" and len(sys.argv) >= 4:
|
||||
from urllib.parse import quote
|
||||
method = "POST"
|
||||
path = f"/api/codex/cookbook/preset/{quote(sys.argv[3])}"
|
||||
body = None
|
||||
elif action == "adopt" and len(sys.argv) >= 5:
|
||||
method = "POST"
|
||||
path = "/api/codex/cookbook/adopt"
|
||||
payload = {"tmux_session": sys.argv[3], "model": sys.argv[4]}
|
||||
if len(sys.argv) >= 6: payload["host"] = sys.argv[5]
|
||||
if len(sys.argv) >= 7: payload["port"] = int(sys.argv[6])
|
||||
body = json.dumps(payload)
|
||||
elif action == "serve" and len(sys.argv) >= 5:
|
||||
method = "POST"
|
||||
path = "/api/codex/cookbook/serve"
|
||||
payload = {"repo_id": sys.argv[3], "cmd": sys.argv[4]}
|
||||
if len(sys.argv) >= 6:
|
||||
payload["remote_host"] = sys.argv[5]
|
||||
body = json.dumps(payload)
|
||||
elif action == "stop" and len(sys.argv) >= 4:
|
||||
method = "POST"
|
||||
path = f"/api/codex/cookbook/stop/{sys.argv[3]}"
|
||||
body = None
|
||||
else:
|
||||
return _usage()
|
||||
else:
|
||||
if len(sys.argv) < 3:
|
||||
return _usage()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: odysseus
|
||||
description: Use when the user asks Codex to read or write Odysseus data from a terminal Codex session through the scoped Codex Agent API. Requires ODYSSEUS_URL and ODYSSEUS_API_TOKEN.
|
||||
description: Use when the user asks Codex to read or write Odysseus data (todos, email, calendar, memory, documents) or to launch/monitor/stop a Cookbook model-serve task through the scoped Codex Agent API. Requires ODYSSEUS_URL and ODYSSEUS_API_TOKEN.
|
||||
---
|
||||
|
||||
# Odysseus
|
||||
@@ -105,6 +105,37 @@ python3 integrations/codex/scripts/odysseus_api.py POST /api/codex/memory '{"tex
|
||||
- `POST /api/codex/emails/draft` — body matches `SendEmailRequest` (`to`, `cc`, `bcc`, `subject`, `body`, `body_html`, `attachments`, `account_id`, `in_reply_to`, `references`). Requires `email:draft` (or `email:send`).
|
||||
- `POST /api/codex/emails/send` — same body. Requires `email:send`. Never send without explicit user instruction.
|
||||
|
||||
## Cookbook serve (debug a failing model launch)
|
||||
|
||||
The Cookbook surface lets you reproduce what a human would do in Odysseus → Cookbook: read which serves are running, tail their tmux output to see why they crashed, edit the launch command, relaunch, kill a stuck one. Use this when the user is debugging a model server that won't come up (compute-capability errors, OOM, missing kernels, wrong attention backend, etc.).
|
||||
|
||||
- `GET /api/codex/cookbook/tasks` — list active serve/download/install tasks (sessionId, type, status, repo_id, remoteHost, payload._cmd). Requires `cookbook:read`.
|
||||
- `GET /api/codex/cookbook/servers` — list configured servers (name, host, port, env type + path, model dirs). Requires `cookbook:read`.
|
||||
- `GET /api/codex/cookbook/cached?host=<NAME>` — list models already cached on the named server (HF cache + Ollama + extra modelDirs). Call BEFORE `serve` to see what's already on disk. Requires `cookbook:read`.
|
||||
- `GET /api/codex/cookbook/presets` — list saved serve presets (model + host + port + cmd). The user's saved preset usually has a working cmd — try `preset NAME` before composing your own. Requires `cookbook:read`.
|
||||
- `GET /api/codex/cookbook/output/{session_id}?tail=400` — read the last N lines of the task's persistent log file (preferred) or tmux pane (fallback). The log file persists across vllm crashes, so this returns the actual Python traceback even after the bash prompt + neofetch banner overwrites the pane. Default tail=400. Requires `cookbook:read`.
|
||||
- `POST /api/codex/cookbook/serve` — launch a serve task. Body matches `ServeRequest`: `{ repo_id, cmd, remote_host?, ssh_port?, env_prefix?, gpus?, platform? }`. The `cmd` is validated: leading binary must be `vllm`/`python3`/`sglang`/`llama-server`/`ollama`/`node`/`npx`. NEVER prefix with `cd …`, `source …`, or chain with `&&`/`||`/`;`/`$(...)` — the validator rejects shell metacharacters. The venv activation (`env_prefix`) is added automatically from the host's saved settings, so pass the bare binary + args. Requires `cookbook:launch`.
|
||||
- `POST /api/codex/cookbook/preset/{name}` — launch a saved preset by name. Reuses the working cmd + host the user already saved. Requires `cookbook:launch`.
|
||||
- `POST /api/codex/cookbook/adopt` — register an externally-launched tmux session into cookbook tracking. Body: `{ tmux_session, model, host?, port? }`. Use this when serve_model rejected a cmd and you fell back to direct ssh+tmux — without adoption, the session is invisible to the UI. Requires `cookbook:launch`.
|
||||
- `POST /api/codex/cookbook/stop/{session_id}` — kill the tmux session. Requires `cookbook:launch`.
|
||||
|
||||
```bash
|
||||
python3 ~/plugins/odysseus/scripts/odysseus_api.py cookbook tasks
|
||||
python3 ~/plugins/odysseus/scripts/odysseus_api.py cookbook output serve-abc12345 400
|
||||
python3 ~/plugins/odysseus/scripts/odysseus_api.py cookbook stop serve-abc12345
|
||||
python3 ~/plugins/odysseus/scripts/odysseus_api.py cookbook serve \
|
||||
/mnt/HADES/models/Qwen3.5-397B-A17B-AWQ \
|
||||
"vllm serve /mnt/HADES/models/Qwen3.5-397B-A17B-AWQ --host 0.0.0.0 --port 8001 --tensor-parallel-size 8 --max-model-len 262144 --gpu-memory-utilization 0.90 --dtype auto --max-num-seqs 8 --trust-remote-code --enable-expert-parallel --enable-auto-tool-choice --tool-call-parser qwen3_coder --reasoning-parser qwen3" \
|
||||
pewds@192.168.1.12
|
||||
```
|
||||
|
||||
**Debug loop pattern:** `tasks` → `output SID 600` (find root cause; request larger `tail` if it references "above") → `stop SID` → `serve repo "new cmd"` → wait ~20s → `output` on the new sessionId.
|
||||
|
||||
**Hard limits this surface enforces:**
|
||||
- `cookbook serve` cmd allowlist + shell-metacharacter rejection.
|
||||
- `cookbook stop` requires sessionIds matching `[a-zA-Z0-9_-]+`.
|
||||
- Agent CAN spawn GPU-pinning long-lived processes — always `cookbook stop` your previous attempt before relaunching.
|
||||
|
||||
## Forbidden Bypass Pattern
|
||||
|
||||
If you are about to reach the Odysseus host/container, import app internals, query the database, or call MCP helper modules directly, stop. Those paths bypass Odysseus Settings and token scopes. Ask the user to enable the relevant Codex Agent tool toggle instead.
|
||||
|
||||
+25
-2
@@ -340,6 +340,14 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
ok = auth_manager.rename_user(old_username, new_username, user)
|
||||
if not ok:
|
||||
raise HTTPException(400, "Cannot rename user")
|
||||
# The owner-rename loop above updated ApiToken.owner in the DB, but the
|
||||
# bearer-token cache still maps each token to the OLD owner. Without
|
||||
# refreshing it, the renamed user's API tokens resolve to the old (now
|
||||
# non-existent) owner and stop reaching their data until the cache next
|
||||
# goes dirty. Invalidate it now, like the token CRUD routes do.
|
||||
invalidator = getattr(request.app.state, "invalidate_token_cache", None)
|
||||
if callable(invalidator):
|
||||
invalidator()
|
||||
return {"ok": True, "username": new_username, "renamed_self": old_username == user}
|
||||
|
||||
@router.post("/signup-toggle", deprecated=True)
|
||||
@@ -430,9 +438,24 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
raise HTTPException(403, "Admin only")
|
||||
body = await request.json()
|
||||
current = _load_settings()
|
||||
# Per-key validation for numeric settings: coerce to int and clamp to a
|
||||
# sane range so a bad value can't disable the agent or let it run away.
|
||||
_INT_RANGES = {
|
||||
"agent_max_rounds": (1, 200),
|
||||
"agent_max_tool_calls": (0, 1000), # 0 = unlimited
|
||||
}
|
||||
for key in DEFAULT_SETTINGS:
|
||||
if key in body:
|
||||
current[key] = body[key]
|
||||
if key not in body:
|
||||
continue
|
||||
val = body[key]
|
||||
if key in _INT_RANGES:
|
||||
lo, hi = _INT_RANGES[key]
|
||||
try:
|
||||
val = int(val)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, f"{key} must be an integer")
|
||||
val = max(lo, min(val, hi))
|
||||
current[key] = val
|
||||
_save_settings(current)
|
||||
return current
|
||||
|
||||
|
||||
+15
-3
@@ -589,6 +589,8 @@ def _normalize_thinking(text: str) -> str:
|
||||
import re
|
||||
if not text:
|
||||
return text
|
||||
from src.text_helpers import normalize_thinking_markup
|
||||
text = normalize_thinking_markup(text)
|
||||
reasoning_prefix_re = re.compile(
|
||||
r'^\s*(?:thinking(?:\s+process)?\s*:|the user |i need |i should |i will |they are |the question |i can )',
|
||||
re.IGNORECASE,
|
||||
@@ -699,6 +701,10 @@ def _extract_thinking_meta(text: str) -> dict | None:
|
||||
import re
|
||||
if not text:
|
||||
return None
|
||||
from src.text_helpers import normalize_thinking_markup
|
||||
original_text = text
|
||||
text = normalize_thinking_markup(text)
|
||||
normalized_changed = text != original_text
|
||||
|
||||
# Check for <think> tags (native or injected)
|
||||
time_match = re.search(r'<think(?:ing)?\s+time="([\d.]+)"', text)
|
||||
@@ -729,6 +735,9 @@ def _extract_thinking_meta(text: str) -> dict | None:
|
||||
if thinking and reply:
|
||||
return {"thinking": thinking, "reply": reply, "time": think_time}
|
||||
|
||||
if normalized_changed and text.strip() and text.strip() != original_text.strip():
|
||||
return {"thinking": "", "reply": text.strip(), "time": think_time}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -737,7 +746,8 @@ def clean_thinking_for_save(content: str, metadata: dict | None = None) -> tuple
|
||||
md = dict(metadata) if metadata else {}
|
||||
info = _extract_thinking_meta(content)
|
||||
if info:
|
||||
md["thinking"] = info["thinking"]
|
||||
if info.get("thinking"):
|
||||
md["thinking"] = info["thinking"]
|
||||
if info.get("time"):
|
||||
md["thinking_time"] = info["time"]
|
||||
return info["reply"], md
|
||||
@@ -781,8 +791,10 @@ def save_assistant_response(
|
||||
# Extract thinking into metadata (don't pollute message content with <think> tags)
|
||||
_think_info = _extract_thinking_meta(full_response)
|
||||
if _think_info:
|
||||
md["thinking"] = _think_info["thinking"]
|
||||
md["thinking_time"] = _think_info.get("time")
|
||||
if _think_info.get("thinking"):
|
||||
md["thinking"] = _think_info["thinking"]
|
||||
if _think_info.get("time"):
|
||||
md["thinking_time"] = _think_info.get("time")
|
||||
_content = _think_info["reply"]
|
||||
else:
|
||||
_content = full_response
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
from datetime import datetime
|
||||
@@ -394,6 +395,12 @@ def setup_chat_routes(
|
||||
compare_mode = str(form_data.get("compare_mode", "")).lower() == "true"
|
||||
incognito = str(form_data.get("incognito", "")).lower() == "true"
|
||||
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 ""
|
||||
# Did the USER explicitly pick agent mode? (vs. us auto-escalating
|
||||
# below). Skill extraction should only learn from real agent sessions,
|
||||
# not chats we quietly promoted for a notes/calendar intent.
|
||||
@@ -981,7 +988,15 @@ def setup_chat_routes(
|
||||
_answered_by = None # set if the selected model failed and a fallback answered
|
||||
try:
|
||||
from src.settings import get_setting
|
||||
from src.agent_tools import MAX_AGENT_ROUNDS as _DEFAULT_ROUNDS
|
||||
_tool_budget = int(get_setting("agent_max_tool_calls", 0))
|
||||
# Per-message round cap from settings; clamp defensively in
|
||||
# case settings.json was hand-edited to a bad value.
|
||||
try:
|
||||
_max_rounds = int(get_setting("agent_max_rounds", _DEFAULT_ROUNDS) or _DEFAULT_ROUNDS)
|
||||
except (TypeError, ValueError):
|
||||
_max_rounds = _DEFAULT_ROUNDS
|
||||
_max_rounds = max(1, min(_max_rounds, 200))
|
||||
|
||||
async for chunk in stream_agent_loop(
|
||||
sess.endpoint_url,
|
||||
@@ -992,12 +1007,14 @@ def setup_chat_routes(
|
||||
max_tokens=ctx.preset.max_tokens,
|
||||
prompt_type=preset_id,
|
||||
max_tool_calls=_tool_budget,
|
||||
max_rounds=_max_rounds,
|
||||
context_length=ctx.context_length,
|
||||
active_document=active_doc,
|
||||
session_id=session,
|
||||
disabled_tools=disabled_tools if disabled_tools else None,
|
||||
owner=_user,
|
||||
fallbacks=_fallback_candidates,
|
||||
workspace=workspace or None,
|
||||
):
|
||||
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
|
||||
try:
|
||||
@@ -1017,6 +1034,7 @@ def setup_chat_routes(
|
||||
"tool_start", "tool_output", "agent_step",
|
||||
"doc_stream_open", "doc_stream_delta",
|
||||
"doc_update", "doc_suggestions", "ui_control",
|
||||
"rounds_exhausted",
|
||||
):
|
||||
if data.get("type") == "agent_step":
|
||||
_agent_rounds = max(_agent_rounds, data.get("round", 1))
|
||||
|
||||
@@ -19,6 +19,8 @@ from src.auth_helpers import require_user
|
||||
from src.tool_implementations import do_manage_notes
|
||||
|
||||
|
||||
COOKBOOK_READ_SCOPES = {"cookbook:read", "cookbook:launch"}
|
||||
COOKBOOK_LAUNCH_SCOPES = {"cookbook:launch"}
|
||||
TODO_READ_SCOPES = {"todos:read", "todos:write"}
|
||||
TODO_WRITE_SCOPES = {"todos:write"}
|
||||
EMAIL_READ_SCOPES = {"email:read", "email:draft", "email:send"}
|
||||
@@ -130,6 +132,11 @@ def setup_codex_routes(
|
||||
"actions": ["library", "read", "create", "delete"],
|
||||
"available": documents_library_endpoint is not None,
|
||||
},
|
||||
"cookbook": {
|
||||
"read": scoped(COOKBOOK_READ_SCOPES),
|
||||
"launch": scoped(COOKBOOK_LAUNCH_SCOPES),
|
||||
"actions": ["tasks", "servers", "output", "serve", "stop"],
|
||||
},
|
||||
},
|
||||
"safety": {
|
||||
"email_send_requires_confirmation": True,
|
||||
@@ -373,6 +380,374 @@ def setup_codex_routes(
|
||||
raise HTTPException(400, f"Invalid document payload: {exc}")
|
||||
return await _as_owner(request, owner, documents_create_endpoint, request, req)
|
||||
|
||||
# ── Cookbook surface ──
|
||||
# Lets the agent run the same launch / monitor / kill loop the user
|
||||
# would do by hand in the Cookbook UI: read the current task list +
|
||||
# tmux output, launch a serve task, stop one. Two scopes:
|
||||
# cookbook:read — list tasks + tail output + list servers
|
||||
# cookbook:launch — also start/stop serves (host shell exec)
|
||||
# `cookbook:launch` is genuinely powerful: /api/model/serve runs SSH'd
|
||||
# commands on the user's hosts. The existing _validate_serve_cmd
|
||||
# allowlist (vllm/python3/sglang/llama-server/etc., no shell metachars)
|
||||
# keeps the agent inside the same sandbox the UI uses.
|
||||
|
||||
async def _run_shell(cmd: str, timeout: float = 15.0) -> dict:
|
||||
"""Run a shell command, return {exit_code, stdout, stderr}."""
|
||||
import asyncio as _asyncio
|
||||
try:
|
||||
proc = await _asyncio.create_subprocess_shell(
|
||||
cmd,
|
||||
stdout=_asyncio.subprocess.PIPE,
|
||||
stderr=_asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
stdout_b, stderr_b = await _asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
except _asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
return {"exit_code": -1, "stdout": "", "stderr": "timed out"}
|
||||
return {
|
||||
"exit_code": proc.returncode,
|
||||
"stdout": stdout_b.decode(errors="replace"),
|
||||
"stderr": stderr_b.decode(errors="replace"),
|
||||
}
|
||||
except Exception as exc:
|
||||
return {"exit_code": -1, "stdout": "", "stderr": str(exc)}
|
||||
|
||||
def _read_cookbook_state() -> dict:
|
||||
from pathlib import Path as _Path
|
||||
import os as _os, json as _json
|
||||
p = _Path(_os.environ.get("DATA_DIR", "data")) / "cookbook_state.json"
|
||||
if not p.exists():
|
||||
return {}
|
||||
try:
|
||||
return _json.loads(p.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def _redact_task(t: dict) -> dict:
|
||||
"""Strip secrets before returning to the agent."""
|
||||
clean = {k: v for k, v in t.items() if k not in ("hf_token", "_secrets")}
|
||||
if isinstance(clean.get("payload"), dict):
|
||||
pl = clean["payload"]
|
||||
clean["payload"] = {k: v for k, v in pl.items()
|
||||
if k not in ("hf_token", "_secrets")}
|
||||
return clean
|
||||
|
||||
@router.get("/cookbook/tasks")
|
||||
async def codex_cookbook_tasks(request: Request):
|
||||
_scope_owner(request, COOKBOOK_READ_SCOPES)
|
||||
state = _read_cookbook_state()
|
||||
tasks = state.get("tasks") or []
|
||||
return {"tasks": [_redact_task(t) for t in tasks]}
|
||||
|
||||
@router.get("/cookbook/servers")
|
||||
async def codex_cookbook_servers(request: Request):
|
||||
_scope_owner(request, COOKBOOK_READ_SCOPES)
|
||||
state = _read_cookbook_state()
|
||||
servers = state.get("env", {}).get("servers") or []
|
||||
# Strip ssh creds / passwords; keep only what's needed to pick a host.
|
||||
cleaned = []
|
||||
for s in servers:
|
||||
cleaned.append({
|
||||
"name": s.get("name"),
|
||||
"host": s.get("host"),
|
||||
"port": s.get("port"),
|
||||
"env": s.get("env"),
|
||||
"envPath": s.get("envPath"),
|
||||
"platform": s.get("platform"),
|
||||
"modelDirs": s.get("modelDirs"),
|
||||
})
|
||||
return {"servers": cleaned}
|
||||
|
||||
@router.get("/cookbook/output/{session_id}")
|
||||
async def codex_cookbook_output(request: Request, session_id: str, tail: int = 400):
|
||||
_scope_owner(request, COOKBOOK_READ_SCOPES)
|
||||
# Defensive: session_id must be the tmux-style id we issue
|
||||
# (`serve-XXXX` / `cookbook-XXXX` / `queue-XXXX`); anything else
|
||||
# would let the agent run arbitrary `tmux capture-pane` targets.
|
||||
import re as _re
|
||||
if not _re.fullmatch(r"[a-zA-Z0-9_-]+", session_id):
|
||||
raise HTTPException(400, "Invalid session id")
|
||||
tail = max(20, min(int(tail or 400), 4000))
|
||||
# Resolve the task's host (if any) from cookbook state so we can
|
||||
# ssh to the right box, exactly as the UI does in _reconnectTask.
|
||||
state = _read_cookbook_state()
|
||||
tasks = state.get("tasks") or []
|
||||
task = next((t for t in tasks if t.get("sessionId") == session_id), None)
|
||||
if task is None:
|
||||
raise HTTPException(404, "task not found")
|
||||
host = (task.get("remoteHost") or "").strip()
|
||||
ssh_port = (task.get("sshPort") or "").strip()
|
||||
# Prefer the persisted log file over the tmux pane. The pane gets
|
||||
# overwritten by the post-crash neofetch banner + bash prompt the
|
||||
# moment vllm exits; the log file is the raw stdout/stderr and
|
||||
# survives unchanged. Falls back to pane for older tasks predating
|
||||
# the tee-to-log runner change.
|
||||
log_path = f"/tmp/odysseus-tmux/{session_id}.log"
|
||||
inner = (
|
||||
f"if [ -s {log_path} ]; then tail -n {tail} {log_path}; "
|
||||
f"else tmux capture-pane -t {session_id} -p -S -{tail}; fi"
|
||||
)
|
||||
if host:
|
||||
port_flag = f"-p {ssh_port} " if ssh_port and ssh_port != "22" else ""
|
||||
import shlex
|
||||
cmd = f"ssh {port_flag}{host} {shlex.quote(inner)}"
|
||||
else:
|
||||
cmd = inner
|
||||
result = await _run_shell(cmd, timeout=15)
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"host": host or "local",
|
||||
"exit_code": result.get("exit_code"),
|
||||
"output": result.get("stdout", ""),
|
||||
"task": _redact_task(task),
|
||||
}
|
||||
|
||||
@router.post("/cookbook/serve")
|
||||
async def codex_cookbook_serve(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
|
||||
_scope_owner(request, COOKBOOK_LAUNCH_SCOPES)
|
||||
# Wraps /api/model/serve with the SAME validation the UI uses.
|
||||
# _validate_serve_cmd (called inside model_serve) rejects shell
|
||||
# metachars and requires the leading binary to be in the
|
||||
# cookbook allowlist (vllm / python3 / sglang / llama-server / ...).
|
||||
from routes.cookbook_helpers import ServeRequest
|
||||
# Accept friendly aliases agents naturally reach for. Without these,
|
||||
# passing `host` silently maps to nothing and the serve runs LOCAL
|
||||
# instead of on the intended remote — exactly the bug an agent
|
||||
# would never debug on its own.
|
||||
norm = dict(body or {})
|
||||
if "host" in norm and "remote_host" not in norm:
|
||||
norm["remote_host"] = norm.pop("host")
|
||||
if "model" in norm and "repo_id" not in norm:
|
||||
norm["repo_id"] = norm.pop("model")
|
||||
if "ssh_port" not in norm and "port" in norm and (str(norm.get("port") or "").isdigit() and int(norm["port"]) >= 1000):
|
||||
# Heuristic: if `port` looks like an SSH port (≥1000) and there's
|
||||
# no explicit ssh_port, treat it as such. UI ports (8000, 8001,
|
||||
# 30000) belong inside the cmd string, not here.
|
||||
pass # leave as-is — user's `port` here is ambiguous; skip remap.
|
||||
try:
|
||||
req = ServeRequest(**norm)
|
||||
except Exception as exc:
|
||||
raise HTTPException(400, f"Invalid serve payload: {exc}")
|
||||
serve_endpoint = _find_endpoint(None, "POST", "/api/model/serve")
|
||||
# Fall back to importing from the cookbook router registered on app.
|
||||
if serve_endpoint is None:
|
||||
from fastapi import FastAPI
|
||||
app: FastAPI = request.app
|
||||
for route in app.routes:
|
||||
if getattr(route, "path", None) == "/api/model/serve" and "POST" in getattr(route, "methods", set()):
|
||||
serve_endpoint = route.endpoint
|
||||
break
|
||||
if serve_endpoint is None:
|
||||
raise HTTPException(503, "model serve endpoint unavailable")
|
||||
return await serve_endpoint(request, req)
|
||||
|
||||
@router.post("/cookbook/stop/{session_id}")
|
||||
async def codex_cookbook_stop(request: Request, session_id: str):
|
||||
_scope_owner(request, COOKBOOK_LAUNCH_SCOPES)
|
||||
import re as _re
|
||||
if not _re.fullmatch(r"[a-zA-Z0-9_-]+", session_id):
|
||||
raise HTTPException(400, "Invalid session id")
|
||||
state = _read_cookbook_state()
|
||||
tasks = state.get("tasks") or []
|
||||
task = next((t for t in tasks if t.get("sessionId") == session_id), None)
|
||||
host = ((task or {}).get("remoteHost") or "").strip()
|
||||
ssh_port = ((task or {}).get("sshPort") or "").strip()
|
||||
if host:
|
||||
port_flag = f"-p {ssh_port} " if ssh_port and ssh_port != "22" else ""
|
||||
cmd = f"ssh {port_flag}{host} \"tmux kill-session -t {session_id}\""
|
||||
else:
|
||||
cmd = f"tmux kill-session -t {session_id}"
|
||||
result = await _run_shell(cmd, timeout=10)
|
||||
return {"session_id": session_id, "exit_code": result.get("exit_code"), "host": host or "local"}
|
||||
|
||||
@router.get("/cookbook/cached")
|
||||
async def codex_cookbook_cached(request: Request, host: str | None = None):
|
||||
"""List cached models on a configured server (or local if host is omitted).
|
||||
Mirrors `list_cached_models` from the chat agent so external agents have
|
||||
the same inventory view before deciding what to serve/download."""
|
||||
_scope_owner(request, COOKBOOK_READ_SCOPES)
|
||||
# Hit /api/model/cached internally, with the same modelDirs the chat
|
||||
# agent's list_cached_models would resolve from cookbook state.
|
||||
state = _read_cookbook_state()
|
||||
env = state.get("env") if isinstance(state, dict) else {}
|
||||
servers = (env.get("servers") if isinstance(env, dict) else None) or []
|
||||
HF_DEFAULTS = {"~/.cache/huggingface/hub", "~/.cache/huggingface"}
|
||||
def _dirs_for(srv: dict) -> str:
|
||||
mds = srv.get("modelDirs") if isinstance(srv, dict) else None
|
||||
if isinstance(mds, list):
|
||||
extras = [d for d in mds if isinstance(d, str) and d.strip() and d.strip() not in HF_DEFAULTS]
|
||||
return ",".join(extras)
|
||||
if isinstance(mds, str) and mds.strip() not in HF_DEFAULTS:
|
||||
return mds
|
||||
return ""
|
||||
# Resolve friendly host name → real host (matches list_cached_models flow).
|
||||
resolved_host = host or ""
|
||||
srv: dict[str, Any] = {}
|
||||
if host:
|
||||
srv = next(
|
||||
(s for s in servers if isinstance(s, dict)
|
||||
and (s.get("name") == host or s.get("host") == host)),
|
||||
{},
|
||||
)
|
||||
if srv and srv.get("host"):
|
||||
resolved_host = srv["host"]
|
||||
else:
|
||||
srv = next((s for s in servers if isinstance(s, dict) and not (s.get("host") or "").strip()), {})
|
||||
params: dict[str, str] = {}
|
||||
if resolved_host:
|
||||
params["host"] = resolved_host
|
||||
md = _dirs_for(srv)
|
||||
if md:
|
||||
params["model_dir"] = md
|
||||
if srv.get("port"):
|
||||
params["ssh_port"] = str(srv["port"])
|
||||
if srv.get("platform"):
|
||||
params["platform"] = srv["platform"]
|
||||
cached_endpoint = _find_endpoint(None, "GET", "/api/model/cached")
|
||||
if cached_endpoint is None:
|
||||
from fastapi import FastAPI
|
||||
app: FastAPI = request.app
|
||||
for route in app.routes:
|
||||
if getattr(route, "path", None) == "/api/model/cached" and "GET" in getattr(route, "methods", set()):
|
||||
cached_endpoint = route.endpoint
|
||||
break
|
||||
if cached_endpoint is None:
|
||||
raise HTTPException(503, "model cached endpoint unavailable")
|
||||
# The endpoint reads host/model_dir/ssh_port/platform as kwargs.
|
||||
return await cached_endpoint(
|
||||
request,
|
||||
host=params.get("host") or None,
|
||||
model_dir=params.get("model_dir") or None,
|
||||
ssh_port=params.get("ssh_port") or None,
|
||||
platform=params.get("platform") or None,
|
||||
)
|
||||
|
||||
@router.get("/cookbook/presets")
|
||||
async def codex_cookbook_presets(request: Request):
|
||||
"""List saved serve presets (model + host + port + launch cmd).
|
||||
Counterpart to `list_serve_presets`. Use BEFORE composing a `serve`
|
||||
body — the user's saved preset usually has the working cmd already."""
|
||||
_scope_owner(request, COOKBOOK_READ_SCOPES)
|
||||
state = _read_cookbook_state()
|
||||
presets = state.get("presets") or []
|
||||
out = []
|
||||
for p in presets:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
out.append({
|
||||
"name": p.get("name"),
|
||||
"model": p.get("model") or p.get("modelId"),
|
||||
"host": p.get("host") or p.get("remoteHost"),
|
||||
"port": p.get("port"),
|
||||
"cmd": p.get("cmd"),
|
||||
})
|
||||
return {"presets": out, "default_host": (state.get("env") or {}).get("defaultServer", "")}
|
||||
|
||||
@router.post("/cookbook/preset/{name}")
|
||||
async def codex_cookbook_serve_preset(request: Request, name: str):
|
||||
"""Launch a saved preset by name. Reuses the working cmd + host the
|
||||
user already saved, avoiding the cmd-allowlist trial-and-error loop."""
|
||||
_scope_owner(request, COOKBOOK_LAUNCH_SCOPES)
|
||||
import re as _re
|
||||
if not _re.fullmatch(r"[A-Za-z0-9 _.:@\-]+", name):
|
||||
raise HTTPException(400, "Invalid preset name")
|
||||
state = _read_cookbook_state()
|
||||
presets = state.get("presets") or []
|
||||
lname = name.lower().strip()
|
||||
chosen = next(
|
||||
(p for p in presets if isinstance(p, dict) and (p.get("name") or "").lower() == lname),
|
||||
None,
|
||||
)
|
||||
if chosen is None:
|
||||
chosen = next(
|
||||
(p for p in presets if isinstance(p, dict) and lname in (p.get("name") or "").lower()),
|
||||
None,
|
||||
)
|
||||
if chosen is None:
|
||||
raise HTTPException(404, f"No preset matching {name!r}")
|
||||
repo_id = chosen.get("model") or chosen.get("modelId") or ""
|
||||
cmd = (chosen.get("cmd") or "").strip()
|
||||
host = chosen.get("host") or chosen.get("remoteHost") or ""
|
||||
if not repo_id or not cmd or cmd.startswith("(adopted"):
|
||||
raise HTTPException(400, f"Preset {chosen.get('name')!r} has no launchable cmd "
|
||||
"(adopted from external launch). Use POST /cookbook/serve "
|
||||
"with the actual cmd instead.")
|
||||
# Reuse the serve handler we already validated.
|
||||
from routes.cookbook_helpers import ServeRequest
|
||||
body = {"repo_id": repo_id, "cmd": cmd}
|
||||
if host:
|
||||
body["remote_host"] = host
|
||||
try:
|
||||
req = ServeRequest(**body)
|
||||
except Exception as exc:
|
||||
raise HTTPException(400, f"Preset payload invalid: {exc}")
|
||||
serve_endpoint = _find_endpoint(None, "POST", "/api/model/serve")
|
||||
if serve_endpoint is None:
|
||||
from fastapi import FastAPI
|
||||
app: FastAPI = request.app
|
||||
for route in app.routes:
|
||||
if getattr(route, "path", None) == "/api/model/serve" and "POST" in getattr(route, "methods", set()):
|
||||
serve_endpoint = route.endpoint
|
||||
break
|
||||
if serve_endpoint is None:
|
||||
raise HTTPException(503, "model serve endpoint unavailable")
|
||||
return await serve_endpoint(request, req)
|
||||
|
||||
@router.post("/cookbook/adopt")
|
||||
async def codex_cookbook_adopt(request: Request, body: dict[str, Any] = Body(default_factory=dict)):
|
||||
"""Adopt an existing tmux session (one started via raw ssh+tmux) into
|
||||
cookbook tracking. Needed when serve_model rejects a cmd and the
|
||||
agent falls back to direct ssh — without adoption the session is
|
||||
invisible to the UI. Body: {tmux_session, model, host?, port?}."""
|
||||
_scope_owner(request, COOKBOOK_LAUNCH_SCOPES)
|
||||
norm = dict(body or {})
|
||||
sess = (norm.get("tmux_session") or norm.get("session_id") or "").strip()
|
||||
model = (norm.get("model") or norm.get("repo_id") or "").strip()
|
||||
host = (norm.get("host") or norm.get("remote_host") or "").strip()
|
||||
port = norm.get("port") or 8000
|
||||
import re as _re
|
||||
if not sess or not _re.fullmatch(r"[a-zA-Z0-9_-]+", sess):
|
||||
raise HTTPException(400, "tmux_session required, [a-zA-Z0-9_-]+ only")
|
||||
if not model:
|
||||
raise HTTPException(400, "model required")
|
||||
# Verify the tmux session exists on the target host before adopting.
|
||||
import shlex
|
||||
if host:
|
||||
check = f"ssh {shlex.quote(host)} 'tmux has-session -t {shlex.quote(sess)}'"
|
||||
else:
|
||||
check = f"tmux has-session -t {shlex.quote(sess)}"
|
||||
chk = await _run_shell(check, timeout=8)
|
||||
if chk.get("exit_code") not in (0, None):
|
||||
raise HTTPException(404, f"tmux session {sess!r} not found on {host or 'local'}")
|
||||
# Write into cookbook_state.json.
|
||||
import time as _t, json as _json
|
||||
from core.atomic_io import atomic_write_json
|
||||
from pathlib import Path as _Path
|
||||
cookbook_state_path = _Path("/app/data/cookbook_state.json")
|
||||
try:
|
||||
state = _json.loads(cookbook_state_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
state = {}
|
||||
tasks = state.setdefault("tasks", [])
|
||||
if any(isinstance(t, dict) and t.get("sessionId") == sess for t in tasks):
|
||||
return {"ok": True, "already_tracked": True, "session_id": sess}
|
||||
tasks.append({
|
||||
"id": sess, "sessionId": sess,
|
||||
"name": model.split("/")[-1] if "/" in model else model,
|
||||
"type": "serve", "status": "running",
|
||||
"output": f"Adopted externally-launched session {sess!r} on {host or 'local'}.",
|
||||
"ts": int(_t.time() * 1000),
|
||||
"payload": {"repo_id": model, "remote_host": host, "_cmd": "(adopted — launched outside cookbook)", "port": int(port)},
|
||||
"remoteHost": host, "sshPort": "", "platform": "linux",
|
||||
"_serveReady": False, "_endpointAdded": False, "_adoptedExternally": True,
|
||||
})
|
||||
try:
|
||||
atomic_write_json(cookbook_state_path, state)
|
||||
except Exception as exc:
|
||||
raise HTTPException(500, f"state write failed: {exc}")
|
||||
return {"ok": True, "session_id": sess, "host": host or "local"}
|
||||
|
||||
return router
|
||||
|
||||
|
||||
|
||||
@@ -546,6 +546,13 @@ def _append_serve_preflight_exit_lines(runner_lines: list[str], *, keep_shell_op
|
||||
runner_lines.append('if [ -n "$ODYSSEUS_PREFLIGHT_EXIT" ]; then')
|
||||
runner_lines.append(' echo ""; echo "=== Process exited with code $ODYSSEUS_PREFLIGHT_EXIT ==="')
|
||||
if keep_shell_open:
|
||||
# Decouple the post-crash interactive shell from the persistent log
|
||||
# file. fds 3/4 were saved BEFORE the tee redirect at the top of
|
||||
# the runner; restoring them here means the neofetch banner the
|
||||
# user's .zshrc prints lands on the tmux pane only, not in the
|
||||
# log file the agent's tail_serve_output reads.
|
||||
runner_lines.append(' exec 1>&3 2>&4 3>&- 4>&- 2>/dev/null || true')
|
||||
runner_lines.append(' sleep 0.2 # let tee child flush + exit')
|
||||
runner_lines.append(' exec "${SHELL:-/bin/bash}"')
|
||||
else:
|
||||
runner_lines.append(' exit "$ODYSSEUS_PREFLIGHT_EXIT"')
|
||||
@@ -563,7 +570,11 @@ def _append_serve_exit_code_lines(
|
||||
if is_pip_install:
|
||||
runner_lines.append('if [ $ODYSSEUS_CMD_EXIT -eq 0 ]; then echo ""; echo "DOWNLOAD_OK"; fi')
|
||||
if keep_shell_open:
|
||||
runner_lines.append('echo ""; echo "=== Process exited with code $ODYSSEUS_CMD_EXIT ==="; exec "${SHELL:-/bin/bash}"')
|
||||
runner_lines.append('echo ""; echo "=== Process exited with code $ODYSSEUS_CMD_EXIT ==="')
|
||||
# See preflight branch above for the rationale on restoring fds 3/4.
|
||||
runner_lines.append('exec 1>&3 2>&4 3>&- 4>&- 2>/dev/null || true')
|
||||
runner_lines.append('sleep 0.2 # let tee child flush + exit')
|
||||
runner_lines.append('exec "${SHELL:-/bin/bash}"')
|
||||
else:
|
||||
runner_lines.append('echo ""; echo "=== Process exited with code $ODYSSEUS_CMD_EXIT ==="')
|
||||
runner_lines.append('exit "$ODYSSEUS_CMD_EXIT"')
|
||||
|
||||
+280
-33
@@ -801,6 +801,55 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def _pick_free_port_for_ollama(
|
||||
remote: str | None, ssh_port: str | None, start_port: int, max_offset: int
|
||||
) -> int | None:
|
||||
"""Return the first free port in [start_port, start_port+max_offset] on
|
||||
the target host. Used to pick a real bind for `ollama serve` so we
|
||||
don't reattach to an external systemd ollama (or other listener) the
|
||||
Cookbook Stop button can't kill."""
|
||||
import socket
|
||||
if remote:
|
||||
# Probe over SSH. Bash's /dev/tcp gives a portable "is anything
|
||||
# listening" check without requiring ss/netstat/nmap.
|
||||
ssh_base = ["ssh", "-o", "ConnectTimeout=4", "-o", "StrictHostKeyChecking=no"]
|
||||
if ssh_port and str(ssh_port) != "22":
|
||||
if not _SSH_PORT_RE.match(str(ssh_port)):
|
||||
return None
|
||||
ssh_base.extend(["-p", str(ssh_port)])
|
||||
host_arg = remote
|
||||
if not _REMOTE_HOST_RE.match(host_arg):
|
||||
return None
|
||||
probe_ports = " ".join(str(start_port + i) for i in range(max_offset + 1))
|
||||
script = (
|
||||
f"for p in {probe_ports}; do "
|
||||
"if ! (exec 3<>/dev/tcp/127.0.0.1/$p) 2>/dev/null; then "
|
||||
"echo $p; exit 0; fi; exec 3<&-; exec 3>&-; done; exit 1"
|
||||
)
|
||||
try:
|
||||
import subprocess
|
||||
r = subprocess.run(
|
||||
ssh_base + [host_arg, script],
|
||||
capture_output=True, text=True, timeout=8,
|
||||
)
|
||||
if r.returncode == 0:
|
||||
out = (r.stdout or "").strip().splitlines()
|
||||
if out and out[0].isdigit():
|
||||
return int(out[0])
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
# Local: just try to connect.
|
||||
for off in range(max_offset + 1):
|
||||
p = start_port + off
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.settimeout(0.25)
|
||||
try:
|
||||
s.connect(("127.0.0.1", p))
|
||||
except (ConnectionRefusedError, socket.timeout, OSError):
|
||||
return p
|
||||
return None
|
||||
|
||||
def _auto_register_llm_endpoint(req: ServeRequest, remote: str | None) -> str | None:
|
||||
"""Register a freshly-served LLM as a model endpoint so it appears in the
|
||||
model picker without a manual /setup step — the text-model sibling of
|
||||
@@ -815,21 +864,37 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
import re
|
||||
from core.database import SessionLocal, ModelEndpoint
|
||||
|
||||
# Port: an explicit --port wins. Otherwise fall back by backend — Ollama
|
||||
# is the only server in our generated commands that omits --port.
|
||||
# Port: ordered fallbacks so we match whatever the user actually
|
||||
# asked for, not a hardcoded default:
|
||||
# 1. explicit `--port N` (vllm / sglang / llama-server)
|
||||
# 2. `OLLAMA_HOST=host:port` (the way Ollama specifies its bind)
|
||||
# 3. fallback by backend (11434 ollama / 8080 llama.cpp)
|
||||
# Previously the OLLAMA_HOST form was silently ignored and we
|
||||
# registered every Ollama endpoint at 11434 — even if the user
|
||||
# set OLLAMA_HOST=0.0.0.0:11435 to avoid colliding with an
|
||||
# existing systemd Ollama, the registered endpoint pointed at
|
||||
# the OLD port and showed as offline.
|
||||
port_match = re.search(r'--port\s+(\d+)', req.cmd)
|
||||
ollama_host_match = re.search(r'OLLAMA_HOST=[^\s]*?:(\d+)', req.cmd)
|
||||
if port_match:
|
||||
port = int(port_match.group(1))
|
||||
elif ollama_host_match:
|
||||
port = int(ollama_host_match.group(1))
|
||||
elif "ollama" in req.cmd:
|
||||
port = 11434
|
||||
else:
|
||||
port = 8080 # llama.cpp's llama-server default — the Apple Silicon path
|
||||
|
||||
# Determine host (mirrors the image path: SSH alias for remote serves).
|
||||
# For local serves while Odysseus runs inside Docker, "localhost"
|
||||
# resolves to the container itself — useless. Use host.docker.internal
|
||||
# which compose maps to the actual host, matching what /setup adds
|
||||
# for Ollama by hand.
|
||||
if remote:
|
||||
host = remote.split("@")[-1] if "@" in remote else remote
|
||||
else:
|
||||
host = "localhost"
|
||||
from routes.model_routes import _docker_host_gateway_reachable
|
||||
host = "host.docker.internal" if _docker_host_gateway_reachable() else "localhost"
|
||||
|
||||
base_url = f"http://{host}:{port}/v1"
|
||||
|
||||
@@ -927,6 +992,19 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
session_id = f"serve-{uuid.uuid4().hex[:8]}"
|
||||
remote = req.remote_host
|
||||
is_windows = req.platform == "windows"
|
||||
|
||||
# Ollama: if the user didn't pin a port, resolve the actual port we'll
|
||||
# bind to here (before runner construction) by probing the target host.
|
||||
# Otherwise the runner script picks one at runtime and `_auto_register`
|
||||
# below still registers the stale 11434 default — which on a host with
|
||||
# a systemd ollama lands on the wrong (unreachable-from-docker) service.
|
||||
if "ollama" in req.cmd and "OLLAMA_HOST=" not in req.cmd:
|
||||
_ollama_bind_host = "0.0.0.0" if remote else "127.0.0.1"
|
||||
_ollama_chosen_port = _pick_free_port_for_ollama(
|
||||
remote, req.ssh_port, start_port=11434, max_offset=10,
|
||||
)
|
||||
if _ollama_chosen_port:
|
||||
req.cmd = f"OLLAMA_HOST={_ollama_bind_host}:{_ollama_chosen_port} {req.cmd}"
|
||||
# LOCAL execution on a native-Windows host never uses tmux (detached
|
||||
# process path below), regardless of the UI-supplied platform.
|
||||
local_windows = IS_WINDOWS and not remote
|
||||
@@ -998,6 +1076,21 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
else:
|
||||
# ── Linux/Termux: bash + tmux (existing flow) ──
|
||||
runner_lines = ["#!/bin/bash"]
|
||||
# Mirror every line of stdout+stderr into a persistent log file
|
||||
# on the host running the serve. This is the file tail_serve_output
|
||||
# reads when the tmux pane has been overwritten by the post-crash
|
||||
# bash prompt — without it, the agent's diagnostic tool sees the
|
||||
# neofetch banner instead of the actual Python traceback.
|
||||
# We save the original fds to 3/4 so we can RESTORE them before
|
||||
# `exec ${SHELL}` at the end of the script. Without that restore,
|
||||
# the post-crash interactive shell's neofetch banner ALSO gets
|
||||
# teed into the log file and `tail -N` returns ONLY the banner —
|
||||
# the actual traceback ends up earlier than the tail window.
|
||||
runner_lines.append("mkdir -p /tmp/odysseus-tmux 2>/dev/null || true")
|
||||
runner_lines.append("exec 3>&1 4>&2")
|
||||
runner_lines.append(
|
||||
f"exec > >(tee -a /tmp/odysseus-tmux/{session_id}.log) 2>&1"
|
||||
)
|
||||
runner_lines.extend(_user_shell_path_bootstrap())
|
||||
runner_lines.append('ODYSSEUS_PREFLIGHT_EXIT=""')
|
||||
# Put Odysseus's own venv bin on PATH (local runs only) so the serve
|
||||
@@ -1074,38 +1167,24 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
req.cmd,
|
||||
default_host=_ollama_default_host,
|
||||
)
|
||||
# Ollama can be a host binary, a system service, or a Docker
|
||||
# container. If the HTTP API is already reachable, the model is
|
||||
# already served and we should not require a host `ollama` CLI.
|
||||
# Always launch a fresh ollama under tmux so Stop reliably
|
||||
# kills it. If the requested port is busy (e.g. a systemd
|
||||
# ollama on 11434), scan upward for a free one rather than
|
||||
# silently reattaching to an external service that Stop
|
||||
# can't reach.
|
||||
runner_lines.append(f'ODYSSEUS_OLLAMA_HOST={_bash_squote(_ollama_host)}')
|
||||
runner_lines.append(f'ODYSSEUS_OLLAMA_PORT="{_ollama_port}"')
|
||||
runner_lines.append('ODYSSEUS_OLLAMA_URL=""')
|
||||
runner_lines.append('for _ody_ollama_try in $(seq 1 20); do')
|
||||
runner_lines.append(' for _ody_ollama_port in "$ODYSSEUS_OLLAMA_PORT" 11434; do')
|
||||
runner_lines.append(' [ -z "$_ody_ollama_port" ] && continue')
|
||||
runner_lines.append(' for _ody_ollama_host in 127.0.0.1 localhost host.docker.internal; do')
|
||||
runner_lines.append(' _ody_ollama_url="http://${_ody_ollama_host}:${_ody_ollama_port}"')
|
||||
runner_lines.append(' if curl -sf "$_ody_ollama_url/api/tags" >/dev/null 2>&1; then')
|
||||
runner_lines.append(' ODYSSEUS_OLLAMA_URL="$_ody_ollama_url"')
|
||||
runner_lines.append(' ODYSSEUS_OLLAMA_PORT="$_ody_ollama_port"')
|
||||
runner_lines.append(' break 3')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' done')
|
||||
runner_lines.append(' done')
|
||||
runner_lines.append(' [ "$_ody_ollama_try" -eq 1 ] && echo "[odysseus] Waiting for an existing Ollama API on ports ${ODYSSEUS_OLLAMA_PORT}/11434..."')
|
||||
runner_lines.append(' sleep 1')
|
||||
runner_lines.append('done')
|
||||
runner_lines.append('if [ -n "$ODYSSEUS_OLLAMA_URL" ]; then')
|
||||
runner_lines.append(' if [ "$ODYSSEUS_OLLAMA_PORT" != "' + _ollama_port + '" ]; then')
|
||||
runner_lines.append(' echo "[odysseus] Selected Ollama port ' + _ollama_port + ' was not reachable; using running Ollama on port ${ODYSSEUS_OLLAMA_PORT}."')
|
||||
runner_lines.append('for _ody_off in 0 1 2 3 4 5 6 7 8 9; do')
|
||||
runner_lines.append(' _ody_try_port=$((ODYSSEUS_OLLAMA_PORT + _ody_off))')
|
||||
runner_lines.append(' if ! (exec 3<>/dev/tcp/127.0.0.1/$_ody_try_port) 2>/dev/null; then')
|
||||
runner_lines.append(' exec 3<&-; exec 3>&-')
|
||||
runner_lines.append(' ODYSSEUS_OLLAMA_PORT="$_ody_try_port"')
|
||||
runner_lines.append(' break')
|
||||
runner_lines.append(' fi')
|
||||
runner_lines.append(' echo "[odysseus] Ollama API ready on port ${ODYSSEUS_OLLAMA_PORT}: ${ODYSSEUS_OLLAMA_URL}"')
|
||||
runner_lines.append(' echo "[odysseus] This task is monitoring an existing Ollama server; stopping it here will not stop an external Docker/system service."')
|
||||
runner_lines.append(' exec bash -i')
|
||||
runner_lines.append('fi')
|
||||
runner_lines.append(' exec 3<&-; exec 3>&-')
|
||||
runner_lines.append('done')
|
||||
runner_lines.append('if ! command -v ollama &>/dev/null; then')
|
||||
runner_lines.append(' echo "ERROR: Ollama not found and no Ollama API is reachable on 127.0.0.1, localhost, or host.docker.internal (ports ${ODYSSEUS_OLLAMA_PORT}/11434)."')
|
||||
runner_lines.append(' echo "Install Ollama, start an Ollama service/container on this server, or pick the port where it is already listening."')
|
||||
runner_lines.append(' echo "ERROR: Ollama not found on this server. Install it from https://ollama.com/download or `curl -fsSL https://ollama.com/install.sh | sh`."')
|
||||
runner_lines.append(' echo')
|
||||
runner_lines.append(' echo "=== Process exited with code 127 ==="')
|
||||
runner_lines.append(' exec bash -i')
|
||||
@@ -1940,6 +2019,153 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
|
||||
return {"models": out}
|
||||
|
||||
# Rate-limit for the orphan-tmux adoption sweep. The UI polls
|
||||
# tasks/status every ~3s; we don't want to SSH every host on every
|
||||
# poll. 20s is fast enough that a model the agent launched in the
|
||||
# background shows up "almost immediately" in the UI without being
|
||||
# wasteful.
|
||||
_last_orphan_sweep_ts = [0.0]
|
||||
_ORPHAN_SWEEP_MIN_INTERVAL_S = 20.0
|
||||
|
||||
def _maybe_sweep_orphans(tasks: list, state: dict) -> None:
|
||||
"""Scan each configured cookbook server for `serve-*` tmux sessions
|
||||
the cookbook doesn't know about and adopt them into state.tasks.
|
||||
|
||||
Writes are conditional: if no orphans are found, nothing is touched.
|
||||
Rate-limited so polling UIs don't trigger SSH on every refresh.
|
||||
"""
|
||||
import time as _time
|
||||
import subprocess
|
||||
logger.info(f"_maybe_sweep_orphans: entered, last_ts={_last_orphan_sweep_ts[0]}")
|
||||
now = _time.monotonic()
|
||||
if now - _last_orphan_sweep_ts[0] < _ORPHAN_SWEEP_MIN_INTERVAL_S:
|
||||
logger.info(f"_maybe_sweep_orphans: rate-limited, {now - _last_orphan_sweep_ts[0]:.1f}s since last")
|
||||
return
|
||||
_last_orphan_sweep_ts[0] = now
|
||||
|
||||
env = state.get("env") if isinstance(state, dict) else {}
|
||||
servers = env.get("servers") if isinstance(env, dict) else []
|
||||
logger.info(f"orphan sweep starting: {len(servers) if isinstance(servers, list) else 0} server(s), known_sids={len([t for t in tasks if isinstance(t, dict) and t.get('sessionId')])}")
|
||||
if not isinstance(servers, list):
|
||||
return
|
||||
|
||||
known_sids = {
|
||||
t.get("sessionId") for t in tasks
|
||||
if isinstance(t, dict) and t.get("sessionId")
|
||||
}
|
||||
|
||||
adopted_any = False
|
||||
for srv in servers:
|
||||
if not isinstance(srv, dict):
|
||||
continue
|
||||
host = (srv.get("host") or "").strip()
|
||||
if not host:
|
||||
continue # local-only entry; the /proc scan handles it
|
||||
if not _REMOTE_HOST_RE.match(host):
|
||||
continue
|
||||
sport = str(srv.get("port") or "").strip()
|
||||
ssh_base = ["ssh", "-o", "ConnectTimeout=4", "-o", "StrictHostKeyChecking=no"]
|
||||
if sport and sport != "22":
|
||||
if not _SSH_PORT_RE.match(sport):
|
||||
continue
|
||||
ssh_base.extend(["-p", sport])
|
||||
|
||||
try:
|
||||
ls = subprocess.run(
|
||||
ssh_base + [host, "tmux ls 2>/dev/null"],
|
||||
timeout=6, capture_output=True, text=True,
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
for line in (ls.stdout or "").splitlines():
|
||||
sid = line.split(":", 1)[0].strip()
|
||||
if not sid or not _SESSION_ID_RE.match(sid):
|
||||
continue
|
||||
if sid in known_sids:
|
||||
continue
|
||||
# Adopt any session whose pane is currently running a
|
||||
# known model-server process (checked below). The earlier
|
||||
# prefix gate (serve-/cookbook-) dropped legitimate
|
||||
# serves whenever tmux fell back to numeric IDs, leaving
|
||||
# them invisible in the Cookbook UI — so the user could
|
||||
# neither see nor stop them.
|
||||
# Skip zombie / idle-shell sessions. A tmux session left
|
||||
# over from a crashed vllm just shows a bash prompt —
|
||||
# adopting it would pollute the UI with "running" tasks
|
||||
# that aren't actually serving anything. pane_current_command
|
||||
# is the foreground process in the pane right now; only
|
||||
# real model serves leave a python/vllm/etc. process there.
|
||||
try:
|
||||
pc = subprocess.run(
|
||||
ssh_base + [host, "tmux", "list-panes", "-t", sid,
|
||||
"-F", "#{pane_current_command}"],
|
||||
timeout=4, capture_output=True, text=True,
|
||||
)
|
||||
cur = (pc.stdout or "").strip().splitlines()
|
||||
except Exception:
|
||||
cur = []
|
||||
LIVE_PROCS = {"python", "python3", "vllm", "llama-server",
|
||||
"llama_cpp_main", "sglang", "lmdeploy",
|
||||
"ollama", "node", "uvicorn"}
|
||||
if not any(c in LIVE_PROCS for c in cur):
|
||||
continue
|
||||
# Try to recover a plausible repo_id + port from the
|
||||
# pane buffer. Cheap heuristic — if we can't, register
|
||||
# with placeholder fields; the UI still shows it.
|
||||
try:
|
||||
cap = subprocess.run(
|
||||
ssh_base + [host, "tmux", "capture-pane", "-t", sid, "-p", "-S", "-300"],
|
||||
timeout=6, capture_output=True, text=True,
|
||||
)
|
||||
pane = cap.stdout or ""
|
||||
except Exception:
|
||||
pane = ""
|
||||
import re as _re_orphan
|
||||
# vLLM banner: "model /path/...". Falls back to the
|
||||
# raw vllm-serve command if the banner already scrolled.
|
||||
m_model = _re_orphan.search(r"model\s+(\S+)", pane)
|
||||
model = m_model.group(1) if m_model else ""
|
||||
if not model:
|
||||
m_serve = _re_orphan.search(r"vllm\s+serve\s+(\S+)", pane)
|
||||
model = m_serve.group(1) if m_serve else f"adopted:{sid}"
|
||||
m_port = _re_orphan.search(r"--port\s+(\d+)", pane)
|
||||
port = int(m_port.group(1)) if m_port else 0
|
||||
|
||||
import time as _t2
|
||||
tasks.append({
|
||||
"id": sid,
|
||||
"sessionId": sid,
|
||||
"name": model.split("/")[-1] if "/" in model else model,
|
||||
"type": "serve",
|
||||
"status": "running",
|
||||
"output": f"Auto-adopted from orphan tmux session on {host}. "
|
||||
"Open the task to see live output.",
|
||||
"ts": int(_t2.time() * 1000),
|
||||
"payload": {
|
||||
"repo_id": model,
|
||||
"remote_host": host,
|
||||
"_cmd": "(orphan tmux session — original launch cmd unknown)",
|
||||
"port": port,
|
||||
},
|
||||
"remoteHost": host,
|
||||
"sshPort": sport,
|
||||
"platform": "linux",
|
||||
"_serveReady": False,
|
||||
"_endpointAdded": False,
|
||||
"_adoptedExternally": True,
|
||||
})
|
||||
known_sids.add(sid)
|
||||
adopted_any = True
|
||||
logger.info(f"auto-adopted orphan tmux session {sid!r} on {host}")
|
||||
|
||||
if adopted_any:
|
||||
try:
|
||||
from core.atomic_io import atomic_write_json
|
||||
state["tasks"] = tasks
|
||||
atomic_write_json(_cookbook_state_path, state)
|
||||
except Exception as e:
|
||||
logger.warning(f"orphan sweep: state write failed: {e}")
|
||||
|
||||
@router.get("/api/cookbook/tasks/status")
|
||||
async def cookbook_tasks_status(request: Request):
|
||||
"""Check status of all active cookbook tmux sessions.
|
||||
@@ -1993,6 +2219,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
|
||||
# Load saved tasks from cookbook state
|
||||
tasks = []
|
||||
state = {}
|
||||
if _cookbook_state_path.exists():
|
||||
try:
|
||||
state = json.loads(_cookbook_state_path.read_text(encoding="utf-8"))
|
||||
@@ -2004,6 +2231,21 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Orphan-tmux auto-adoption sweep. When the agent (or anyone)
|
||||
# SSH-launches a `serve-*` tmux session — usually because
|
||||
# serve_model rejected `source ... && vllm ...` or because of a
|
||||
# manual relaunch via tmux send-keys — that session is invisible
|
||||
# to the cookbook UI even though it's a live model server. The
|
||||
# sweep finds those orphans on each configured remote host and
|
||||
# writes them into state.tasks with _adoptedExternally=True, so
|
||||
# they show up in the UI on the next poll without anyone having
|
||||
# to remember to call adopt_served_model. Rate-limited via the
|
||||
# module-level _last_orphan_sweep so we don't SSH every 3s.
|
||||
try:
|
||||
_maybe_sweep_orphans(tasks, state)
|
||||
except Exception as _sweep_e:
|
||||
logger.warning(f"orphan sweep failed (non-fatal): {_sweep_e!r}")
|
||||
|
||||
results = []
|
||||
for task in tasks:
|
||||
session_id = task.get("sessionId", "")
|
||||
@@ -2063,7 +2305,12 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
if _tport and _tport != "22":
|
||||
ssh_base.extend(["-p", str(_tport)])
|
||||
check_cmd = ssh_base + [remote, "tmux", "has-session", "-t", session_id]
|
||||
capture_cmd = ssh_base + [remote, "tmux", "capture-pane", "-t", session_id, "-p", "-S", "-50"]
|
||||
# Capture 500 lines (was 50) so a Python traceback survives
|
||||
# the post-crash neofetch banner + bash prompt that otherwise
|
||||
# fills the visible tail. Without this, output_tail ends up
|
||||
# as just "Locale: C / Ubuntu_Odysseus ❯" and the agent
|
||||
# can't diagnose the actual error.
|
||||
capture_cmd = ssh_base + [remote, "tmux", "capture-pane", "-t", session_id, "-p", "-S", "-500"]
|
||||
elif IS_WINDOWS:
|
||||
# LOCAL Windows task: launched as a detached process (no tmux).
|
||||
# Liveness comes from the <session>.pid file, output from the
|
||||
@@ -2072,7 +2319,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
capture_cmd = None
|
||||
else:
|
||||
check_cmd = ["tmux", "has-session", "-t", session_id]
|
||||
capture_cmd = ["tmux", "capture-pane", "-t", session_id, "-p", "-S", "-50"]
|
||||
capture_cmd = ["tmux", "capture-pane", "-t", session_id, "-p", "-S", "-500"]
|
||||
|
||||
local_win_task = (not remote) and IS_WINDOWS
|
||||
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
# routes/copilot_routes.py
|
||||
"""GitHub Copilot device-flow login.
|
||||
|
||||
Drives the GitHub OAuth *device flow* and, on success, creates (or refreshes)
|
||||
an owner-scoped ``ModelEndpoint`` pointing at the Copilot API with the
|
||||
device-flow access token stored as its (encrypted) ``api_key``. After that the
|
||||
endpoint behaves like any other OpenAI-compatible provider — the Copilot-
|
||||
specific request headers are injected centrally by ``build_headers`` /
|
||||
``_provider_headers`` (see :mod:`src.copilot`).
|
||||
|
||||
Flow:
|
||||
1. ``POST /api/copilot/device/start`` → returns a ``poll_id`` plus the
|
||||
``user_code`` + ``verification_uri`` to show the user. The secret
|
||||
``device_code`` is kept server-side, never sent to the browser.
|
||||
2. The browser polls ``POST /api/copilot/device/poll`` with ``poll_id``.
|
||||
While pending it returns ``{status: "pending"}``; once the user authorises
|
||||
it provisions the endpoint and returns ``{status: "authorized", ...}``.
|
||||
|
||||
All routes are admin-gated (endpoint/provider management is an admin action).
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
import logging
|
||||
import threading
|
||||
from typing import Dict, Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Request, Form, HTTPException
|
||||
|
||||
from core.database import SessionLocal, ModelEndpoint
|
||||
from core.middleware import require_admin
|
||||
from src.auth_helpers import get_current_user
|
||||
from src import copilot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Pending device-flow logins, keyed by an opaque poll_id. The device_code is a
|
||||
# bearer-like secret, so it lives here (server memory) rather than in the
|
||||
# browser. Entries expire with the GitHub device code.
|
||||
#
|
||||
# NOTE: this is per-process state. The device flow assumes a single worker
|
||||
# (Odysseus' default): with multiple uvicorn workers, the poll request can land
|
||||
# on a worker that never saw the start, returning "Unknown or expired login
|
||||
# session". Move this to a shared store (DB/Redis) if running multi-worker.
|
||||
_PENDING: Dict[str, Dict] = {}
|
||||
_PENDING_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _prune_expired() -> None:
|
||||
now = time.time()
|
||||
with _PENDING_LOCK:
|
||||
for k in [k for k, v in _PENDING.items() if v.get("expires_at", 0) < now]:
|
||||
_PENDING.pop(k, None)
|
||||
|
||||
|
||||
def _provision_endpoint(token: str, base: str, owner: Optional[str]) -> Dict:
|
||||
"""Create or update the owner's Copilot endpoint with a fresh token."""
|
||||
try:
|
||||
models = copilot.fetch_models(base, token)
|
||||
except Exception as e:
|
||||
logger.warning(f"Copilot model fetch failed during provisioning: {e}")
|
||||
models = []
|
||||
model_ids = [m["id"] for m in models]
|
||||
# Copilot picker models support OpenAI-style tool calling; mark the endpoint
|
||||
# tool-capable so the agent loop sends native tool schemas.
|
||||
# Tool-capable if any picker model advertises tool_calls. When the model
|
||||
# fetch failed (empty list) default to True, since Copilot picker models
|
||||
# support OpenAI-style tool calling.
|
||||
supports_tools = bool(not models or any(m.get("tool_calls") for m in models))
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = (
|
||||
db.query(ModelEndpoint)
|
||||
.filter(ModelEndpoint.base_url == base)
|
||||
.filter((ModelEndpoint.owner.is_(None)) | (ModelEndpoint.owner == owner))
|
||||
.order_by(ModelEndpoint.owner.desc())
|
||||
.first()
|
||||
)
|
||||
if ep is None:
|
||||
ep = ModelEndpoint(
|
||||
id=str(uuid.uuid4())[:8],
|
||||
name="GitHub Copilot",
|
||||
base_url=base,
|
||||
model_type="llm",
|
||||
owner=owner,
|
||||
)
|
||||
db.add(ep)
|
||||
ep.api_key = token
|
||||
ep.is_enabled = True
|
||||
ep.supports_tools = supports_tools
|
||||
if model_ids:
|
||||
ep.cached_models = json.dumps(model_ids)
|
||||
db.commit()
|
||||
result = {
|
||||
"id": ep.id,
|
||||
"name": ep.name,
|
||||
"base_url": ep.base_url,
|
||||
"models": model_ids,
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Best-effort: refresh the model cache so the new endpoint shows up.
|
||||
try:
|
||||
from routes.model_routes import _invalidate_models_cache
|
||||
_invalidate_models_cache()
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
def setup_copilot_routes() -> APIRouter:
|
||||
router = APIRouter(prefix="/api/copilot", tags=["copilot"])
|
||||
|
||||
@router.post("/device/start")
|
||||
def device_start(request: Request, enterprise_url: str = Form("")):
|
||||
require_admin(request)
|
||||
_prune_expired()
|
||||
host = copilot.GITHUB_HOST
|
||||
ent = (enterprise_url or "").strip()
|
||||
if ent:
|
||||
host = copilot.normalize_domain(ent)
|
||||
try:
|
||||
data = copilot.request_device_code(host)
|
||||
except httpx.HTTPStatusError as e:
|
||||
status = e.response.status_code if e.response is not None else "unknown"
|
||||
raise HTTPException(502, f"GitHub device-code request failed (HTTP {status})")
|
||||
except Exception as e:
|
||||
raise HTTPException(502, f"GitHub device-code request failed: {e}")
|
||||
|
||||
device_code = data.get("device_code")
|
||||
if not device_code:
|
||||
raise HTTPException(502, "GitHub did not return a device code")
|
||||
interval = int(data.get("interval") or 5)
|
||||
expires_in = int(data.get("expires_in") or 900)
|
||||
poll_id = uuid.uuid4().hex
|
||||
with _PENDING_LOCK:
|
||||
_PENDING[poll_id] = {
|
||||
"device_code": device_code,
|
||||
"host": host,
|
||||
"enterprise_url": ent,
|
||||
"interval": interval,
|
||||
"owner": get_current_user(request) or None,
|
||||
"expires_at": time.time() + expires_in,
|
||||
"next_poll_at": 0.0,
|
||||
}
|
||||
# verification_uri_complete embeds the user code, so the browser tab we
|
||||
# open lands the user straight on GitHub's "Authorize" screen with the
|
||||
# code pre-filled — one click, no manual code entry.
|
||||
return {
|
||||
"poll_id": poll_id,
|
||||
"user_code": data.get("user_code"),
|
||||
"verification_uri": data.get("verification_uri"),
|
||||
"verification_uri_complete": data.get("verification_uri_complete"),
|
||||
"interval": interval,
|
||||
"expires_in": expires_in,
|
||||
}
|
||||
|
||||
@router.post("/device/poll")
|
||||
def device_poll(request: Request, poll_id: str = Form(...)):
|
||||
require_admin(request)
|
||||
_prune_expired()
|
||||
with _PENDING_LOCK:
|
||||
pending = _PENDING.get(poll_id)
|
||||
if not pending:
|
||||
raise HTTPException(404, "Unknown or expired login session")
|
||||
|
||||
# Enforce GitHub's polling interval server-side so a chatty client
|
||||
# can't trip slow_down.
|
||||
now = time.time()
|
||||
if now < pending.get("next_poll_at", 0):
|
||||
return {"status": "pending"}
|
||||
|
||||
try:
|
||||
data = copilot.poll_access_token(pending["host"], pending["device_code"])
|
||||
except Exception as e:
|
||||
return {"status": "pending", "detail": f"poll error: {e}"}
|
||||
|
||||
token = data.get("access_token")
|
||||
if token:
|
||||
base = copilot.enterprise_base(pending["enterprise_url"]) if pending["enterprise_url"] else copilot.COPILOT_BASE
|
||||
try:
|
||||
result = _provision_endpoint(token, base, pending["owner"])
|
||||
except Exception as e:
|
||||
logger.exception("Copilot endpoint provisioning failed")
|
||||
with _PENDING_LOCK:
|
||||
_PENDING.pop(poll_id, None)
|
||||
raise HTTPException(500, f"Login succeeded but provisioning failed: {e}")
|
||||
with _PENDING_LOCK:
|
||||
_PENDING.pop(poll_id, None)
|
||||
return {"status": "authorized", "endpoint": result}
|
||||
|
||||
err = data.get("error")
|
||||
if err == "authorization_pending":
|
||||
with _PENDING_LOCK:
|
||||
if poll_id in _PENDING:
|
||||
_PENDING[poll_id]["next_poll_at"] = now + pending["interval"]
|
||||
return {"status": "pending"}
|
||||
if err == "slow_down":
|
||||
new_interval = int(data.get("interval") or (pending["interval"] + 5))
|
||||
with _PENDING_LOCK:
|
||||
if poll_id in _PENDING:
|
||||
_PENDING[poll_id]["interval"] = new_interval
|
||||
_PENDING[poll_id]["next_poll_at"] = now + new_interval
|
||||
return {"status": "pending"}
|
||||
if err in ("expired_token", "access_denied"):
|
||||
with _PENDING_LOCK:
|
||||
_PENDING.pop(poll_id, None)
|
||||
return {"status": "failed", "error": err}
|
||||
# Unknown error — surface but keep the session for another try.
|
||||
return {"status": "pending", "detail": err or "unknown"}
|
||||
|
||||
@router.post("/device/cancel")
|
||||
def device_cancel(request: Request, poll_id: str = Form(...)):
|
||||
require_admin(request)
|
||||
with _PENDING_LOCK:
|
||||
_PENDING.pop(poll_id, None)
|
||||
return {"status": "cancelled"}
|
||||
|
||||
return router
|
||||
@@ -153,7 +153,6 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
with a `pdf_source` marker so the viewer renders the pages without
|
||||
overlays.
|
||||
"""
|
||||
from src.constants import UPLOAD_DIR
|
||||
from src.pdf_forms import has_form_fields, extract_fields
|
||||
from src.pdf_form_doc import (
|
||||
save_field_sidecar,
|
||||
@@ -950,7 +949,6 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
any wrong values before triggering the actual download.
|
||||
"""
|
||||
from src.pdf_form_doc import find_source_upload_id, parse_markdown_to_values, load_field_sidecar
|
||||
from src.constants import UPLOAD_DIR
|
||||
|
||||
user = get_current_user(request)
|
||||
db = SessionLocal()
|
||||
@@ -1015,7 +1013,6 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
Frontend overlays HTML form controls at those positions.
|
||||
"""
|
||||
from src.pdf_form_doc import find_source_upload_id, parse_markdown_to_values, load_field_sidecar
|
||||
from src.constants import UPLOAD_DIR
|
||||
|
||||
user = get_current_user(request)
|
||||
db = SessionLocal()
|
||||
@@ -1083,7 +1080,6 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
frontend overlays HTML form inputs on top)."""
|
||||
from fastapi.responses import Response
|
||||
from src.pdf_form_doc import find_source_upload_id
|
||||
from src.constants import UPLOAD_DIR
|
||||
|
||||
user = get_current_user(request)
|
||||
db = SessionLocal()
|
||||
@@ -1132,7 +1128,6 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
import json
|
||||
import fitz
|
||||
from src.pdf_form_doc import find_source_upload_id
|
||||
from src.constants import UPLOAD_DIR
|
||||
from src.document_processor import _resolve_vl_model, _load_vl_settings
|
||||
from src.llm_core import llm_call_async
|
||||
|
||||
@@ -1275,7 +1270,6 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
from starlette.background import BackgroundTask
|
||||
from src.pdf_form_doc import find_source_upload_id, parse_markdown_to_values, parse_markdown_annotations
|
||||
from src.pdf_forms import fill_fields, stamp_annotations
|
||||
from src.constants import UPLOAD_DIR
|
||||
from core.database import Signature
|
||||
|
||||
# Track temp files for this request so they get unlinked AFTER
|
||||
@@ -1370,7 +1364,6 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
from starlette.background import BackgroundTask
|
||||
from src.pdf_form_doc import find_source_upload_id, parse_markdown_to_values, load_field_sidecar, parse_markdown_annotations
|
||||
from src.pdf_forms import fill_fields, stamp_signatures, stamp_annotations
|
||||
from src.constants import UPLOAD_DIR
|
||||
from core.database import Signature
|
||||
|
||||
_to_unlink: list[str] = []
|
||||
@@ -1512,7 +1505,6 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
load_field_sidecar, parse_markdown_annotations,
|
||||
)
|
||||
from src.pdf_forms import fill_fields, stamp_signatures, stamp_annotations
|
||||
from src.constants import UPLOAD_DIR
|
||||
from core.database import Signature
|
||||
# COMPOSE_UPLOADS_DIR lives in email_routes — re-derive here so we
|
||||
# don't import from a routes file (cycle-prone). Same env override
|
||||
|
||||
+68
-17
@@ -266,6 +266,48 @@ COMPOSE_UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
SCHEDULED_DB = DATA_DIR / "scheduled_emails.db"
|
||||
|
||||
|
||||
OWNER_SCOPED_EMAIL_CACHE_TABLES = {
|
||||
"email_summaries",
|
||||
"email_ai_replies",
|
||||
"email_calendar_extractions",
|
||||
"email_urgency_alerts",
|
||||
}
|
||||
|
||||
|
||||
def _email_cache_owner_clause(owner: str = "") -> tuple[str, tuple[str, ...]]:
|
||||
owner = (owner or "").strip()
|
||||
if owner:
|
||||
return "owner = ?", (owner,)
|
||||
return "(owner = '' OR owner IS NULL)", ()
|
||||
|
||||
|
||||
def _ensure_owner_scoped_email_cache_table(conn, table: str, create_sql: str, columns: list[str]):
|
||||
"""Rebuild legacy Message-ID-only cache tables with owner in the PK."""
|
||||
conn.execute(create_sql)
|
||||
try:
|
||||
info = conn.execute(f"PRAGMA table_info({table})").fetchall()
|
||||
cols = [r[1] for r in info]
|
||||
pk_cols = [r[1] for r in sorted((r for r in info if r[5]), key=lambda r: r[5])]
|
||||
if "owner" in cols and pk_cols == ["message_id", "owner"]:
|
||||
return
|
||||
|
||||
conn.execute(f"ALTER TABLE {table} RENAME TO {table}__old")
|
||||
conn.execute(create_sql)
|
||||
old_cols = [r[1] for r in conn.execute(f"PRAGMA table_info({table}__old)").fetchall()]
|
||||
copy_cols = [c for c in columns if c != "owner" and c in old_cols]
|
||||
source_owner = "COALESCE(owner, '')" if "owner" in old_cols else "''"
|
||||
target_cols = ["owner", *copy_cols]
|
||||
select_exprs = [source_owner, *copy_cols]
|
||||
conn.execute(
|
||||
f"INSERT OR IGNORE INTO {table} ({', '.join(target_cols)}) "
|
||||
f"SELECT {', '.join(select_exprs)} FROM {table}__old"
|
||||
)
|
||||
conn.execute(f"DROP TABLE {table}__old")
|
||||
except Exception as _mig_e:
|
||||
import logging as _lg
|
||||
_lg.getLogger(__name__).warning(f"{table} owner-migration skipped: {_mig_e}")
|
||||
|
||||
|
||||
def attachment_extract_dir(folder: str, uid: str) -> Path:
|
||||
"""Containment-safe extraction directory for an attachment.
|
||||
|
||||
@@ -301,30 +343,35 @@ def _init_scheduled_db():
|
||||
owner TEXT DEFAULT ''
|
||||
)
|
||||
""")
|
||||
# Email summary cache (keyed by Message-ID)
|
||||
conn.execute("""
|
||||
# Email summary cache. SECURITY: Message-IDs are global, so AI-derived
|
||||
# cache rows must be owner-scoped just like email_tags.
|
||||
_ensure_owner_scoped_email_cache_table(conn, "email_summaries", """
|
||||
CREATE TABLE IF NOT EXISTS email_summaries (
|
||||
message_id TEXT PRIMARY KEY,
|
||||
message_id TEXT,
|
||||
owner TEXT DEFAULT '',
|
||||
uid TEXT,
|
||||
folder TEXT,
|
||||
subject TEXT,
|
||||
sender TEXT,
|
||||
summary TEXT NOT NULL,
|
||||
model_used TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (message_id, owner)
|
||||
)
|
||||
""")
|
||||
""", ["message_id", "owner", "uid", "folder", "subject", "sender", "summary", "model_used", "created_at"])
|
||||
# Email AI reply cache (pre-generated draft replies)
|
||||
conn.execute("""
|
||||
_ensure_owner_scoped_email_cache_table(conn, "email_ai_replies", """
|
||||
CREATE TABLE IF NOT EXISTS email_ai_replies (
|
||||
message_id TEXT PRIMARY KEY,
|
||||
message_id TEXT,
|
||||
owner TEXT DEFAULT '',
|
||||
uid TEXT,
|
||||
folder TEXT,
|
||||
reply TEXT NOT NULL,
|
||||
model_used TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (message_id, owner)
|
||||
)
|
||||
""")
|
||||
""", ["message_id", "owner", "uid", "folder", "reply", "model_used", "created_at"])
|
||||
# Email tags / spam classification cache. SECURITY: keyed by
|
||||
# (message_id, owner) because Message-IDs are GLOBAL (a newsletter goes
|
||||
# to many users with the same Message-ID). Without owner-scoping, a
|
||||
@@ -384,17 +431,20 @@ def _init_scheduled_db():
|
||||
# Best-effort — log via the module logger if available
|
||||
import logging as _lg
|
||||
_lg.getLogger(__name__).warning(f"email_tags owner-migration skipped: {_mig_e}")
|
||||
conn.execute("""
|
||||
_ensure_owner_scoped_email_cache_table(conn, "email_calendar_extractions", """
|
||||
CREATE TABLE IF NOT EXISTS email_calendar_extractions (
|
||||
message_id TEXT PRIMARY KEY,
|
||||
message_id TEXT,
|
||||
owner TEXT DEFAULT '',
|
||||
uid TEXT,
|
||||
events_created INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (message_id, owner)
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
""", ["message_id", "owner", "uid", "events_created", "created_at"])
|
||||
_ensure_owner_scoped_email_cache_table(conn, "email_urgency_alerts", """
|
||||
CREATE TABLE IF NOT EXISTS email_urgency_alerts (
|
||||
message_id TEXT PRIMARY KEY,
|
||||
message_id TEXT,
|
||||
owner TEXT DEFAULT '',
|
||||
uid TEXT,
|
||||
folder TEXT,
|
||||
subject TEXT,
|
||||
@@ -402,9 +452,10 @@ def _init_scheduled_db():
|
||||
urgency TEXT,
|
||||
reason TEXT,
|
||||
alerted INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (message_id, owner)
|
||||
)
|
||||
""")
|
||||
""", ["message_id", "owner", "uid", "folder", "subject", "sender", "urgency", "reason", "alerted", "created_at"])
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS email_event_seen (
|
||||
owner TEXT NOT NULL,
|
||||
|
||||
+29
-16
@@ -39,7 +39,7 @@ from routes.email_helpers import (
|
||||
_extract_attachment_text, _extract_text,
|
||||
_pre_retrieve_context,
|
||||
_attach_compose_uploads, _cleanup_compose_uploads, _q,
|
||||
SCHEDULED_DB, _EMAIL_REPLY_SYS_PROMPT_BASE,
|
||||
SCHEDULED_DB, _EMAIL_REPLY_SYS_PROMPT_BASE, _email_cache_owner_clause,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -243,8 +243,15 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
||||
await _emit_progress(progress_cb, f"Found {len(uid_list)} recent email(s); checking cache…")
|
||||
|
||||
_c = _sql3.connect(SCHEDULED_DB)
|
||||
_sum_existing = {r[0] for r in _c.execute("SELECT message_id FROM email_summaries").fetchall()}
|
||||
_reply_existing = {r[0] for r in _c.execute("SELECT message_id FROM email_ai_replies").fetchall()}
|
||||
_cache_owner_clause, _cache_owner_params = _email_cache_owner_clause(account_owner)
|
||||
_sum_existing = {r[0] for r in _c.execute(
|
||||
f"SELECT message_id FROM email_summaries WHERE {_cache_owner_clause}",
|
||||
_cache_owner_params,
|
||||
).fetchall()}
|
||||
_reply_existing = {r[0] for r in _c.execute(
|
||||
f"SELECT message_id FROM email_ai_replies WHERE {_cache_owner_clause}",
|
||||
_cache_owner_params,
|
||||
).fetchall()}
|
||||
if auto_tag or auto_spam:
|
||||
if account_owner:
|
||||
_tag_existing = {r[0] for r in _c.execute("SELECT message_id FROM email_tags WHERE owner=?", (account_owner,)).fetchall()}
|
||||
@@ -252,12 +259,18 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
||||
_tag_existing = {r[0] for r in _c.execute("SELECT message_id FROM email_tags WHERE owner='' OR owner IS NULL").fetchall()}
|
||||
else:
|
||||
_tag_existing = set()
|
||||
_cal_existing = {r[0] for r in _c.execute("SELECT message_id FROM email_calendar_extractions").fetchall()} if auto_cal else set()
|
||||
_cal_existing = {r[0] for r in _c.execute(
|
||||
f"SELECT message_id FROM email_calendar_extractions WHERE {_cache_owner_clause}",
|
||||
_cache_owner_params,
|
||||
).fetchall()} if auto_cal else set()
|
||||
# Urgency is handled by the built-in `check_email_urgency` task. Keep
|
||||
# this legacy poller path disabled so users don't get two independent
|
||||
# urgent-email systems.
|
||||
auto_urgent = False
|
||||
_urgent_existing = {r[0] for r in _c.execute("SELECT message_id FROM email_urgency_alerts").fetchall()} if auto_urgent else set()
|
||||
_urgent_existing = {r[0] for r in _c.execute(
|
||||
f"SELECT message_id FROM email_urgency_alerts WHERE {_cache_owner_clause}",
|
||||
_cache_owner_params,
|
||||
).fetchall()} if auto_urgent else set()
|
||||
_c.close()
|
||||
|
||||
# Hoist the self-address lookup OUT of the per-email loop — fetching
|
||||
@@ -415,9 +428,9 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
||||
_c = _sql3.connect(SCHEDULED_DB)
|
||||
_c.execute("""
|
||||
INSERT OR REPLACE INTO email_summaries
|
||||
(message_id, uid, folder, subject, sender, summary, model_used, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (message_id, uid.decode() if isinstance(uid, bytes) else str(uid), _folder, subject, sender, summary, model, datetime.utcnow().isoformat()))
|
||||
(message_id, owner, uid, folder, subject, sender, summary, model_used, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (message_id, account_owner or "", uid.decode() if isinstance(uid, bytes) else str(uid), _folder, subject, sender, summary, model, datetime.utcnow().isoformat()))
|
||||
_c.commit()
|
||||
_c.close()
|
||||
_sum_existing.add(message_id)
|
||||
@@ -458,9 +471,9 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
||||
_c = _sql3.connect(SCHEDULED_DB)
|
||||
_c.execute("""
|
||||
INSERT OR REPLACE INTO email_ai_replies
|
||||
(message_id, uid, folder, reply, model_used, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""", (message_id, uid.decode() if isinstance(uid, bytes) else str(uid), _folder, reply, model, datetime.utcnow().isoformat()))
|
||||
(message_id, owner, uid, folder, reply, model_used, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""", (message_id, account_owner or "", uid.decode() if isinstance(uid, bytes) else str(uid), _folder, reply, model, datetime.utcnow().isoformat()))
|
||||
_c.commit()
|
||||
_c.close()
|
||||
_reply_existing.add(message_id)
|
||||
@@ -675,8 +688,8 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
||||
_cc = _sql3.connect(SCHEDULED_DB)
|
||||
_cc.execute(
|
||||
"INSERT OR REPLACE INTO email_calendar_extractions "
|
||||
"(message_id, uid, events_created, created_at) VALUES (?, ?, ?, ?)",
|
||||
(message_id, uid.decode() if isinstance(uid, bytes) else str(uid),
|
||||
"(message_id, owner, uid, events_created, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
(message_id, account_owner or "", uid.decode() if isinstance(uid, bytes) else str(uid),
|
||||
_cal_run_count, datetime.utcnow().isoformat())
|
||||
)
|
||||
_cc.commit()
|
||||
@@ -733,9 +746,9 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
||||
_uc = _sql3.connect(SCHEDULED_DB)
|
||||
_uc.execute(
|
||||
"INSERT OR REPLACE INTO email_urgency_alerts "
|
||||
"(message_id, uid, folder, subject, sender, urgency, reason, alerted, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(message_id, uid.decode() if isinstance(uid, bytes) else str(uid),
|
||||
"(message_id, owner, uid, folder, subject, sender, urgency, reason, alerted, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(message_id, account_owner or "", uid.decode() if isinstance(uid, bytes) else str(uid),
|
||||
_folder, subject, sender, urgency, reason,
|
||||
1 if urgency in ("critical", "high") else 0,
|
||||
datetime.utcnow().isoformat())
|
||||
|
||||
+19
-15
@@ -49,7 +49,7 @@ from routes.email_helpers import (
|
||||
_EMAIL_REPLY_SYS_PROMPT_BASE, _POOL_HOOKS,
|
||||
SendEmailRequest, ExtractStyleRequest,
|
||||
ATTACHMENTS_DIR, COMPOSE_UPLOADS_DIR, SCHEDULED_DB,
|
||||
attachment_extract_dir,
|
||||
attachment_extract_dir, _email_cache_owner_clause,
|
||||
)
|
||||
from routes.email_pollers import _start_poller
|
||||
|
||||
@@ -934,9 +934,11 @@ def setup_email_routes():
|
||||
import sqlite3 as _sql3
|
||||
_c = _sql3.connect(SCHEDULED_DB)
|
||||
placeholders = ",".join("?" * len(ids))
|
||||
owner_clause, owner_params = _email_cache_owner_clause(owner)
|
||||
rows = _c.execute(
|
||||
f"SELECT message_id, summary FROM email_summaries WHERE message_id IN ({placeholders})",
|
||||
ids,
|
||||
f"SELECT message_id, summary FROM email_summaries "
|
||||
f"WHERE message_id IN ({placeholders}) AND {owner_clause}",
|
||||
(*ids, *owner_params),
|
||||
).fetchall()
|
||||
_c.close()
|
||||
by_id = {r[0]: r[1] for r in rows}
|
||||
@@ -1219,15 +1221,16 @@ def setup_email_routes():
|
||||
try:
|
||||
import sqlite3 as _sql3
|
||||
_c = _sql3.connect(SCHEDULED_DB)
|
||||
owner_clause, owner_params = _email_cache_owner_clause(owner)
|
||||
_row = _c.execute(
|
||||
"SELECT summary FROM email_summaries WHERE message_id = ?",
|
||||
(message_id.strip(),),
|
||||
f"SELECT summary FROM email_summaries WHERE message_id = ? AND {owner_clause}",
|
||||
(message_id.strip(), *owner_params),
|
||||
).fetchone()
|
||||
if _row:
|
||||
cached_summary = _row[0]
|
||||
_row2 = _c.execute(
|
||||
"SELECT reply FROM email_ai_replies WHERE message_id = ?",
|
||||
(message_id.strip(),),
|
||||
f"SELECT reply FROM email_ai_replies WHERE message_id = ? AND {owner_clause}",
|
||||
(message_id.strip(), *owner_params),
|
||||
).fetchone()
|
||||
if _row2:
|
||||
cached_ai_reply = _apply_email_style_mechanics(_extract_reply(_row2[0] or ""))
|
||||
@@ -2549,10 +2552,10 @@ def setup_email_routes():
|
||||
_c = _sql3.connect(SCHEDULED_DB)
|
||||
_c.execute("""
|
||||
INSERT OR REPLACE INTO email_summaries
|
||||
(message_id, uid, folder, subject, sender, summary, model_used, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(message_id, owner, uid, folder, subject, sender, summary, model_used, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
mid, data.get("uid", ""), data.get("folder", ""),
|
||||
mid, owner, data.get("uid", ""), data.get("folder", ""),
|
||||
subject, sender, content, model, datetime.utcnow().isoformat(),
|
||||
))
|
||||
_c.commit()
|
||||
@@ -2587,9 +2590,10 @@ def setup_email_routes():
|
||||
if message_id:
|
||||
try:
|
||||
_c = _sql3.connect(SCHEDULED_DB)
|
||||
owner_clause, owner_params = _email_cache_owner_clause(owner)
|
||||
_row = _c.execute(
|
||||
"SELECT reply, model_used FROM email_ai_replies WHERE message_id = ?",
|
||||
(message_id,),
|
||||
f"SELECT reply, model_used FROM email_ai_replies WHERE message_id = ? AND {owner_clause}",
|
||||
(message_id, *owner_params),
|
||||
).fetchone()
|
||||
_c.close()
|
||||
if _row and _row[0]:
|
||||
@@ -2791,9 +2795,9 @@ def setup_email_routes():
|
||||
_c = _sql3.connect(SCHEDULED_DB)
|
||||
_c.execute("""
|
||||
INSERT OR REPLACE INTO email_ai_replies
|
||||
(message_id, uid, folder, reply, model_used, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""", (message_id, source_uid, source_folder, reply, model, datetime.utcnow().isoformat()))
|
||||
(message_id, owner, uid, folder, reply, model_used, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""", (message_id, owner, source_uid, source_folder, reply, model, datetime.utcnow().isoformat()))
|
||||
_c.commit()
|
||||
_c.close()
|
||||
except Exception as e:
|
||||
|
||||
@@ -10,11 +10,36 @@ from fastapi import APIRouter, Request, HTTPException
|
||||
from core.models import ChatMessage
|
||||
from core.database import SessionLocal, ChatMessage as DbChatMessage, Session as DbSession
|
||||
from src.topic_analyzer import analyze_topics
|
||||
from routes.session_routes import _verify_session_owner
|
||||
from routes.session_routes import (
|
||||
_message_role,
|
||||
_message_text,
|
||||
_reject_compact_during_active_run,
|
||||
_verify_session_owner,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _merge_continue_rows_to_delete(db_messages, db1, db2):
|
||||
"""DB rows to delete when merging the last two assistant messages.
|
||||
|
||||
Always the second assistant message (db2), plus ONLY the single
|
||||
intervening "continue" user message (the one carrying "previous response
|
||||
was interrupted") — matching the in-memory merge. The previous code
|
||||
deleted the whole index range between the two assistant rows, destroying
|
||||
any tool/system/user messages in between and desyncing the DB from the
|
||||
in-memory history.
|
||||
"""
|
||||
to_delete = [db2]
|
||||
i1 = next((i for i, m in enumerate(db_messages) if m is db1), None)
|
||||
i2 = next((i for i, m in enumerate(db_messages) if m is db2), None)
|
||||
if i1 is not None and i2 is not None and i2 - 1 > i1:
|
||||
between = db_messages[i2 - 1]
|
||||
if getattr(between, "role", "") == "user" and "previous response was interrupted" in (getattr(between, "content", "") or ""):
|
||||
to_delete.append(between)
|
||||
return to_delete
|
||||
|
||||
|
||||
def setup_history_routes(session_manager) -> APIRouter:
|
||||
router = APIRouter(tags=["history"])
|
||||
|
||||
@@ -418,11 +443,13 @@ def setup_history_routes(session_manager) -> APIRouter:
|
||||
db1.content = merged_content
|
||||
db1.meta_data = _json.dumps(merged_meta)
|
||||
|
||||
# Remove the continue user message if between them
|
||||
db_idx2 = db_messages.index(db2)
|
||||
db_idx1 = db_messages.index(db1)
|
||||
for di in range(db_idx2, db_idx1, -1):
|
||||
db.delete(db_messages[di])
|
||||
# Mirror the in-memory deletion: remove the second assistant
|
||||
# message and ONLY the "continue" user message between them
|
||||
# (not arbitrary tool/system/user rows). The old
|
||||
# range-delete destroyed every row between the two assistant
|
||||
# messages, desyncing the DB from the in-memory history.
|
||||
for _row in _merge_continue_rows_to_delete(db_messages, db1, db2):
|
||||
db.delete(_row)
|
||||
|
||||
db.commit()
|
||||
finally:
|
||||
@@ -499,6 +526,7 @@ def setup_history_routes(session_manager) -> APIRouter:
|
||||
session = session_manager.get_session(session_id)
|
||||
except KeyError:
|
||||
raise HTTPException(404, "Session not found")
|
||||
_reject_compact_during_active_run(session_id)
|
||||
|
||||
try:
|
||||
from src.model_context import estimate_tokens, get_context_length
|
||||
@@ -521,8 +549,8 @@ def setup_history_routes(session_manager) -> APIRouter:
|
||||
|
||||
# Build text to summarize
|
||||
convo_text = "\n".join(
|
||||
f"{(m.role if isinstance(m, ChatMessage) else m.get('role', '')).upper()}: "
|
||||
f"{(m.content if isinstance(m, ChatMessage) else m.get('content', ''))[:2000]}"
|
||||
f"{_message_role(m).upper()}: "
|
||||
f"{_message_text(m)[:2000]}"
|
||||
for m in older
|
||||
)
|
||||
|
||||
|
||||
+123
-17
@@ -5,6 +5,7 @@ import os
|
||||
import uuid
|
||||
import urllib.parse
|
||||
import html
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, Form, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse, HTMLResponse
|
||||
import logging
|
||||
@@ -12,6 +13,7 @@ import httpx
|
||||
|
||||
from core.database import McpServer, SessionLocal
|
||||
from core.middleware import require_admin
|
||||
from src.constants import DATA_DIR
|
||||
from src.mcp_manager import McpManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -19,6 +21,75 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/mcp", tags=["mcp"])
|
||||
|
||||
|
||||
def _mcp_oauth_base_dir() -> Path:
|
||||
"""Directory that may contain OAuth files managed by Odysseus."""
|
||||
return (Path(DATA_DIR) / "mcp_oauth").resolve(strict=False)
|
||||
|
||||
|
||||
def _resolve_mcp_oauth_path(raw_path, field_name: str) -> str:
|
||||
"""Resolve an MCP OAuth path and keep it under DATA_DIR/mcp_oauth."""
|
||||
raw = str(raw_path or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
|
||||
base = _mcp_oauth_base_dir()
|
||||
path = Path(os.path.expanduser(raw))
|
||||
if not path.is_absolute():
|
||||
path = base / path
|
||||
resolved = path.resolve(strict=False)
|
||||
|
||||
try:
|
||||
resolved.relative_to(base)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"Invalid OAuth {field_name}: path must stay under {base}",
|
||||
) from exc
|
||||
return str(resolved)
|
||||
|
||||
|
||||
def _sanitize_mcp_oauth_config(oauth_cfg):
|
||||
"""Return an OAuth config copy with file paths confined to mcp_oauth."""
|
||||
if not oauth_cfg:
|
||||
return oauth_cfg
|
||||
if not isinstance(oauth_cfg, dict):
|
||||
return {}
|
||||
sanitized = dict(oauth_cfg)
|
||||
for field_name in ("keys_file", "token_file"):
|
||||
if sanitized.get(field_name):
|
||||
sanitized[field_name] = _resolve_mcp_oauth_path(
|
||||
sanitized[field_name],
|
||||
field_name,
|
||||
)
|
||||
return sanitized
|
||||
|
||||
|
||||
def _mcp_oauth_token_missing(oauth_cfg, *, strict: bool = True) -> bool:
|
||||
"""Check token existence without letting legacy bad paths break listing."""
|
||||
if not isinstance(oauth_cfg, dict):
|
||||
return False
|
||||
try:
|
||||
token_file = _resolve_mcp_oauth_path(oauth_cfg.get("token_file", ""), "token_file")
|
||||
except HTTPException:
|
||||
if strict:
|
||||
raise
|
||||
logger.warning("Ignoring MCP OAuth config with unsafe token_file")
|
||||
return True
|
||||
return bool(token_file and not os.path.exists(token_file))
|
||||
|
||||
|
||||
def _apply_mcp_oauth_env(env: dict, oauth_cfg) -> None:
|
||||
"""Pass sanitized Gmail package paths to MCP servers that honor them."""
|
||||
if not oauth_cfg or not isinstance(env, dict):
|
||||
return
|
||||
keys_file = oauth_cfg.get("keys_file")
|
||||
token_file = oauth_cfg.get("token_file")
|
||||
if keys_file:
|
||||
env["GMAIL_OAUTH_PATH"] = keys_file
|
||||
if token_file:
|
||||
env["GMAIL_CREDENTIALS_PATH"] = token_file
|
||||
|
||||
|
||||
def _load_disabled_map():
|
||||
"""Load per-server disabled tool sets from DB."""
|
||||
db = SessionLocal()
|
||||
@@ -53,8 +124,7 @@ def setup_mcp_routes(mcp_manager: McpManager):
|
||||
oauth_cfg = json.loads(srv.oauth_config) if srv.oauth_config else None
|
||||
needs_oauth = False
|
||||
if oauth_cfg:
|
||||
token_file = os.path.expanduser(oauth_cfg.get("token_file", ""))
|
||||
needs_oauth = token_file and not os.path.exists(token_file)
|
||||
needs_oauth = _mcp_oauth_token_missing(oauth_cfg, strict=False)
|
||||
disabled_list = json.loads(srv.disabled_tools) if srv.disabled_tools else []
|
||||
total_tools = status.get("tool_count", 0)
|
||||
result.append({
|
||||
@@ -71,6 +141,7 @@ def setup_mcp_routes(mcp_manager: McpManager):
|
||||
"disabled_tool_count": len(disabled_list),
|
||||
"enabled_tool_count": max(0, total_tools - len(disabled_list)),
|
||||
"error": status.get("error"),
|
||||
"auth_url": status.get("auth_url"),
|
||||
"has_oauth": oauth_cfg is not None,
|
||||
"needs_oauth": needs_oauth,
|
||||
})
|
||||
@@ -101,6 +172,8 @@ def setup_mcp_routes(mcp_manager: McpManager):
|
||||
raise HTTPException(400, "command is required for stdio transport")
|
||||
if transport == "sse" and not url:
|
||||
raise HTTPException(400, "url is required for SSE transport")
|
||||
if transport == "http" and not url:
|
||||
raise HTTPException(400, "url is required for HTTP transport")
|
||||
|
||||
# Parse JSON fields
|
||||
try:
|
||||
@@ -111,26 +184,33 @@ def setup_mcp_routes(mcp_manager: McpManager):
|
||||
parsed_env = json.loads(env) if env else {}
|
||||
except json.JSONDecodeError:
|
||||
parsed_env = {}
|
||||
if not isinstance(parsed_env, dict):
|
||||
parsed_env = {}
|
||||
|
||||
# Parse OAuth config
|
||||
parsed_oauth_config = None
|
||||
if oauth_config:
|
||||
try:
|
||||
parsed_oauth_config = json.loads(oauth_config)
|
||||
parsed_oauth_config = _sanitize_mcp_oauth_config(json.loads(oauth_config))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
_apply_mcp_oauth_env(parsed_env, parsed_oauth_config)
|
||||
|
||||
# Write OAuth credentials file if provided (for Google MCP servers)
|
||||
logger.info(f"MCP add_server: oauth_file={oauth_file!r}")
|
||||
if oauth_file:
|
||||
try:
|
||||
oauth_data = json.loads(oauth_file)
|
||||
oauth_dir = os.path.expanduser(oauth_data.get("dir", ""))
|
||||
oauth_dir = _resolve_mcp_oauth_path(oauth_data.get("dir", ""), "dir")
|
||||
oauth_filename = oauth_data.get("filename", "")
|
||||
client_id = oauth_data.get("client_id", "")
|
||||
client_secret = oauth_data.get("client_secret", "")
|
||||
if oauth_dir and oauth_filename and client_id and client_secret:
|
||||
os.makedirs(oauth_dir, exist_ok=True)
|
||||
filepath = _resolve_mcp_oauth_path(
|
||||
Path(oauth_dir) / str(oauth_filename),
|
||||
"filename",
|
||||
)
|
||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||
creds = {
|
||||
"installed": {
|
||||
"client_id": client_id,
|
||||
@@ -140,7 +220,6 @@ def setup_mcp_routes(mcp_manager: McpManager):
|
||||
"token_uri": "https://accounts.google.com/o/oauth2/token",
|
||||
}
|
||||
}
|
||||
filepath = os.path.join(oauth_dir, oauth_filename)
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(creds, f, indent=2)
|
||||
logger.info(f"Wrote OAuth credentials to {filepath}")
|
||||
@@ -171,9 +250,7 @@ def setup_mcp_routes(mcp_manager: McpManager):
|
||||
# Check if OAuth token already exists — skip connection attempt if not
|
||||
needs_oauth = False
|
||||
if parsed_oauth_config:
|
||||
token_file = os.path.expanduser(parsed_oauth_config.get("token_file", ""))
|
||||
if token_file and not os.path.exists(token_file):
|
||||
needs_oauth = True
|
||||
needs_oauth = _mcp_oauth_token_missing(parsed_oauth_config)
|
||||
|
||||
connected = False
|
||||
if not needs_oauth:
|
||||
@@ -188,6 +265,7 @@ def setup_mcp_routes(mcp_manager: McpManager):
|
||||
)
|
||||
|
||||
status = mcp_manager.get_server_status(server_id)
|
||||
needs_auth = status.get("status") == "needs_auth"
|
||||
return {
|
||||
"id": server_id,
|
||||
"name": name,
|
||||
@@ -196,6 +274,8 @@ def setup_mcp_routes(mcp_manager: McpManager):
|
||||
"tool_count": status.get("tool_count", 0),
|
||||
"error": "OAuth authorization required" if needs_oauth else status.get("error"),
|
||||
"needs_oauth": needs_oauth,
|
||||
"needs_auth": needs_auth,
|
||||
"auth_url": status.get("auth_url"),
|
||||
}
|
||||
|
||||
@router.post("/servers/{server_id}/reconnect")
|
||||
@@ -228,6 +308,8 @@ def setup_mcp_routes(mcp_manager: McpManager):
|
||||
"status": status.get("status", "disconnected"),
|
||||
"tool_count": status.get("tool_count", 0),
|
||||
"error": status.get("error"),
|
||||
"auth_url": status.get("auth_url"),
|
||||
"needs_auth": status.get("status") == "needs_auth",
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
@@ -349,8 +431,8 @@ def setup_mcp_routes(mcp_manager: McpManager):
|
||||
if not srv.oauth_config:
|
||||
raise HTTPException(400, "Server has no OAuth config")
|
||||
|
||||
oauth_cfg = json.loads(srv.oauth_config)
|
||||
keys_file = os.path.expanduser(oauth_cfg.get("keys_file", ""))
|
||||
oauth_cfg = _sanitize_mcp_oauth_config(json.loads(srv.oauth_config))
|
||||
keys_file = oauth_cfg.get("keys_file", "")
|
||||
if not keys_file or not os.path.exists(keys_file):
|
||||
raise HTTPException(400, "OAuth keys file not found")
|
||||
|
||||
@@ -393,10 +475,18 @@ def setup_mcp_routes(mcp_manager: McpManager):
|
||||
|
||||
@router.get("/oauth/callback")
|
||||
async def oauth_callback(code: str, state: str, request: Request):
|
||||
"""Handle OAuth callback from Google — exchange code for tokens."""
|
||||
"""Handle OAuth callback. Generic MCP OAuth flows resolve via the
|
||||
pending-state registry; Google flows fall through to the legacy path."""
|
||||
require_admin(request)
|
||||
server_id = state
|
||||
return await _exchange_and_connect(server_id, code, request)
|
||||
from src.mcp_oauth import resolve_pending
|
||||
if resolve_pending(state, code):
|
||||
return HTMLResponse(_oauth_result_page(
|
||||
"Authorization Successful",
|
||||
"The MCP server is connecting. You can close this window and return to Odysseus.",
|
||||
success=True,
|
||||
))
|
||||
# Legacy Google path: state is the server_id
|
||||
return await _exchange_and_connect(state, code, request)
|
||||
|
||||
@router.post("/oauth/exchange/{server_id}")
|
||||
async def oauth_exchange(server_id: str, request: Request, callback_url: str = Form(...)):
|
||||
@@ -411,6 +501,17 @@ def setup_mcp_routes(mcp_manager: McpManager):
|
||||
except Exception:
|
||||
return HTMLResponse(_oauth_result_page("Error", "Invalid URL format."), status_code=400)
|
||||
|
||||
# Generic MCP OAuth: if the pasted URL carries a state we are waiting on,
|
||||
# resolve it directly (the background connect finishes the handshake).
|
||||
state = params.get("state", [None])[0]
|
||||
from src.mcp_oauth import resolve_pending
|
||||
if state and resolve_pending(state, code):
|
||||
return HTMLResponse(_oauth_result_page(
|
||||
"Authorization Successful",
|
||||
"The MCP server is connecting. You can close this window and return to Odysseus.",
|
||||
success=True,
|
||||
))
|
||||
|
||||
return await _exchange_and_connect(server_id, code, request)
|
||||
|
||||
async def _exchange_and_connect(server_id: str, code: str, request: Request):
|
||||
@@ -423,9 +524,11 @@ def setup_mcp_routes(mcp_manager: McpManager):
|
||||
if not srv.oauth_config:
|
||||
return HTMLResponse(_oauth_result_page("Error", "No OAuth config."), status_code=400)
|
||||
|
||||
oauth_cfg = json.loads(srv.oauth_config)
|
||||
keys_file = os.path.expanduser(oauth_cfg.get("keys_file", ""))
|
||||
token_file = os.path.expanduser(oauth_cfg.get("token_file", ""))
|
||||
oauth_cfg = _sanitize_mcp_oauth_config(json.loads(srv.oauth_config))
|
||||
keys_file = oauth_cfg.get("keys_file", "")
|
||||
token_file = oauth_cfg.get("token_file", "")
|
||||
if not keys_file or not token_file:
|
||||
raise HTTPException(400, "OAuth keys/token file not configured")
|
||||
|
||||
with open(keys_file, encoding="utf-8") as f:
|
||||
keys_data = json.load(f)
|
||||
@@ -488,6 +591,9 @@ def setup_mcp_routes(mcp_manager: McpManager):
|
||||
"Authorized but Connection Failed",
|
||||
f"Tokens saved, but the server failed to connect: {status.get('error', 'unknown error')}. Try reconnecting from Settings.",
|
||||
))
|
||||
except HTTPException as e:
|
||||
logger.warning(f"OAuth callback rejected: {e.detail}")
|
||||
return HTMLResponse(_oauth_result_page("Error", str(e.detail)), status_code=e.status_code)
|
||||
except Exception as e:
|
||||
logger.exception(f"OAuth callback error: {e}")
|
||||
return HTMLResponse(_oauth_result_page("Error", str(e)), status_code=500)
|
||||
|
||||
+16
-7
@@ -1029,12 +1029,13 @@ def setup_model_routes(model_discovery):
|
||||
for ep in endpoints:
|
||||
base = _normalize_base(ep.base_url)
|
||||
provider = _detect_provider(base)
|
||||
# Use cached models — background refresh keeps them updated
|
||||
model_ids = _cached_model_ids(ep)
|
||||
# Merge cached + pinned models, then filter out hidden ones
|
||||
ep_model_type = getattr(ep, "model_type", None) or "llm"
|
||||
# Filter out hidden (probe-failed) models
|
||||
hidden = _hidden_model_ids(ep)
|
||||
model_ids = [m for m in model_ids if m not in hidden]
|
||||
model_ids = _visible_models(
|
||||
_cached_model_ids(ep),
|
||||
ep.hidden_models,
|
||||
getattr(ep, "pinned_models", None),
|
||||
)
|
||||
# Build correct URL based on provider
|
||||
chat_url = build_chat_url(base)
|
||||
kind = _effective_endpoint_kind(ep, base)
|
||||
@@ -1043,6 +1044,13 @@ def setup_model_routes(model_discovery):
|
||||
if model_ids:
|
||||
curated_key = _match_provider_curated(base, None)
|
||||
curated, extra = _curate_models(model_ids, curated_key)
|
||||
# Pinned models are admin-selected — they always belong in the
|
||||
# primary curated list, not buried in extras.
|
||||
pinned = _normalize_model_ids(getattr(ep, "pinned_models", None))
|
||||
for m in pinned:
|
||||
if m not in curated:
|
||||
curated.append(m)
|
||||
extra = [m for m in extra if m not in pinned]
|
||||
items.append({
|
||||
"host": "custom",
|
||||
"port": 0,
|
||||
@@ -1891,9 +1899,10 @@ def setup_model_routes(model_discovery):
|
||||
if body:
|
||||
if "supports_tools" in body:
|
||||
v = body["supports_tools"]
|
||||
ep.supports_tools = bool(v) if v in (True, False, "true", "false", 1, 0) else None
|
||||
ep.supports_tools = {True: True, False: False, 'true': True, 'false': False, 1: True, 0: False}.get(v)
|
||||
if "is_enabled" in body:
|
||||
ep.is_enabled = bool(body["is_enabled"])
|
||||
v_ie = body['is_enabled']
|
||||
ep.is_enabled = v_ie.lower() in ('true', '1', 'yes') if isinstance(v_ie, str) else bool(v_ie)
|
||||
if "name" in body and isinstance(body["name"], str):
|
||||
ep.name = body["name"].strip() or ep.name
|
||||
if "model_type" in body and isinstance(body["model_type"], str):
|
||||
|
||||
@@ -57,6 +57,40 @@ def _content_to_text(content) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _message_role(message) -> str:
|
||||
if isinstance(message, ChatMessage):
|
||||
return message.role or ""
|
||||
if isinstance(message, dict):
|
||||
return message.get("role", "") or ""
|
||||
return getattr(message, "role", "") or ""
|
||||
|
||||
|
||||
def _message_text(message) -> str:
|
||||
if isinstance(message, ChatMessage):
|
||||
content = message.content
|
||||
elif isinstance(message, dict):
|
||||
content = message.get("content")
|
||||
else:
|
||||
content = getattr(message, "content", None)
|
||||
return _content_to_text(content)
|
||||
|
||||
|
||||
def _message_metadata(message) -> dict:
|
||||
if isinstance(message, ChatMessage):
|
||||
metadata = message.metadata
|
||||
elif isinstance(message, dict):
|
||||
metadata = message.get("metadata")
|
||||
else:
|
||||
metadata = getattr(message, "metadata", None)
|
||||
return metadata if isinstance(metadata, dict) else {}
|
||||
|
||||
|
||||
def _reject_compact_during_active_run(session_id: str) -> None:
|
||||
from src import agent_runs
|
||||
if agent_runs.is_active(session_id):
|
||||
raise HTTPException(409, "Session has an active run; try compacting after it finishes")
|
||||
|
||||
|
||||
def _verify_session_owner(request: Request, session_id: str, session_manager=None):
|
||||
"""Verify the current user owns the session. Raises 404 if not.
|
||||
|
||||
@@ -872,6 +906,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
||||
session = session_manager.get_session(session_id)
|
||||
except KeyError:
|
||||
raise HTTPException(404, f"Session {session_id} not found")
|
||||
_reject_compact_during_active_run(session_id)
|
||||
|
||||
history = list(session.history or [])
|
||||
if len(history) < 6:
|
||||
@@ -897,7 +932,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
||||
|
||||
prior_compactions = sum(
|
||||
1 for m in history
|
||||
if (m.metadata or {}).get("compacted") or "[Conversation summary" in (m.content or "")
|
||||
if _message_metadata(m).get("compacted") or "[Conversation summary" in _message_text(m)
|
||||
)
|
||||
prompt = SELF_SUMMARY_SYSTEM_PROMPT.replace(
|
||||
"{count}", str(len(older))
|
||||
@@ -905,7 +940,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
||||
"{n}", str(prior_compactions + 1)
|
||||
)
|
||||
convo_text = "\n".join(
|
||||
f"{m.role.upper()}: {(m.content or '')[:2000]}"
|
||||
f"{_message_role(m).upper()}: {_message_text(m)[:2000]}"
|
||||
for m in older
|
||||
)
|
||||
try:
|
||||
|
||||
+127
-1
@@ -18,6 +18,119 @@ from routes.prefs_routes import _load_for_user, _save_for_user
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _maybe_cascade_calendar_event(task) -> None:
|
||||
"""Delete the linked calendar event when a cookbook_serve task is
|
||||
removed. Two lookup strategies:
|
||||
|
||||
1. PRIMARY — `cookbook_event_uid` marker stashed in task.prompt
|
||||
by cookbookSchedule.js right after creating the event. Direct
|
||||
UID match, no ambiguity.
|
||||
|
||||
2. FALLBACK — for tasks created before the marker was wired up
|
||||
(or when the PATCH to add the marker failed silently), scan
|
||||
the Cookbook calendar for events whose summary equals the
|
||||
task name and delete the matches.
|
||||
|
||||
Best-effort throughout: errors are logged but never block the task
|
||||
deletion itself."""
|
||||
if not task or task.task_type != "action" or task.action != "cookbook_serve":
|
||||
return
|
||||
|
||||
import httpx
|
||||
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN
|
||||
headers = {INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN}
|
||||
if task.owner:
|
||||
headers["X-Odysseus-Owner"] = task.owner
|
||||
|
||||
# Strategy 1: explicit UID marker in prompt.
|
||||
event_uid = ""
|
||||
if task.prompt:
|
||||
try:
|
||||
cfg = json.loads(task.prompt)
|
||||
if isinstance(cfg, dict):
|
||||
event_uid = (cfg.get("cookbook_event_uid") or "").strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _try_delete(uid: str) -> bool:
|
||||
try:
|
||||
with httpx.Client(timeout=10) as client:
|
||||
r = client.delete(
|
||||
f"http://localhost:7000/api/calendar/events/{uid}",
|
||||
headers=headers,
|
||||
)
|
||||
if r.status_code >= 400:
|
||||
logger.info(
|
||||
f"task delete: cascade calendar event {uid} returned "
|
||||
f"HTTP {r.status_code}"
|
||||
)
|
||||
return False
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"task delete: cascade calendar event {uid} failed: {e}")
|
||||
return False
|
||||
|
||||
if event_uid:
|
||||
_try_delete(event_uid)
|
||||
return
|
||||
|
||||
# Strategy 2: scan the Cookbook calendar for matching summaries.
|
||||
# Only runs for tasks missing the marker (old tasks or PATCH failures).
|
||||
if not task.name:
|
||||
return
|
||||
try:
|
||||
with httpx.Client(timeout=10) as client:
|
||||
# Find the Cookbook calendar.
|
||||
cal_r = client.get("http://localhost:7000/api/calendar/calendars", headers=headers)
|
||||
if cal_r.status_code >= 400:
|
||||
return
|
||||
cals = (cal_r.json() or {}).get("calendars", [])
|
||||
cookbook_cal = next(
|
||||
(c for c in cals if (c.get("name") or "").lower() == "cookbook"),
|
||||
None,
|
||||
)
|
||||
if not cookbook_cal:
|
||||
return
|
||||
cal_href = cookbook_cal.get("href") or cookbook_cal.get("id") or ""
|
||||
# List events in a wide window to catch recurring + upcoming.
|
||||
from datetime import datetime as _dt, timedelta as _td, timezone as _tz
|
||||
now = _dt.now(_tz.utc)
|
||||
start = (now - _td(days=30)).isoformat()
|
||||
end = (now + _td(days=365)).isoformat()
|
||||
ev_r = client.get(
|
||||
"http://localhost:7000/api/calendar/events",
|
||||
params={"start": start, "end": end, "calendar": cal_href},
|
||||
headers=headers,
|
||||
)
|
||||
if ev_r.status_code >= 400:
|
||||
return
|
||||
events = (ev_r.json() or {}).get("events", [])
|
||||
# Match by exact summary. Tasks named "Serve: <model>" are
|
||||
# created from the schedule modal; the event's summary mirrors
|
||||
# the task name 1:1 by design.
|
||||
target = (task.name or "").strip()
|
||||
uids_to_delete = set()
|
||||
for ev in events:
|
||||
if (ev.get("summary") or "").strip() != target:
|
||||
continue
|
||||
uid = ev.get("uid") or ev.get("id") or ""
|
||||
# Strip the "::occurrence" suffix on recurring expansions —
|
||||
# we want to delete the MASTER once, not each instance.
|
||||
if "::" in uid:
|
||||
uid = uid.split("::", 1)[0]
|
||||
if uid:
|
||||
uids_to_delete.add(uid)
|
||||
for uid in uids_to_delete:
|
||||
_try_delete(uid)
|
||||
if uids_to_delete:
|
||||
logger.info(
|
||||
f"task delete: cascade matched {len(uids_to_delete)} calendar event(s) "
|
||||
f"by summary fallback for task {task.id} ({target!r})"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"task delete: cascade fallback scan failed: {e}")
|
||||
|
||||
|
||||
class TaskCreate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
prompt: Optional[str] = None
|
||||
@@ -455,7 +568,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from routes.email_helpers import SCHEDULED_DB
|
||||
from routes.email_helpers import SCHEDULED_DB, OWNER_SCOPED_EMAIL_CACHE_TABLES, _email_cache_owner_clause
|
||||
|
||||
cleared = {}
|
||||
conn = sqlite3.connect(SCHEDULED_DB)
|
||||
@@ -468,6 +581,13 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
||||
(user,),
|
||||
).fetchone()[0]
|
||||
conn.execute("DELETE FROM email_tags WHERE owner = ? OR owner = ''", (user,))
|
||||
elif table in OWNER_SCOPED_EMAIL_CACHE_TABLES and user:
|
||||
owner_clause, owner_params = _email_cache_owner_clause(user)
|
||||
before = conn.execute(
|
||||
f"SELECT COUNT(*) FROM {table} WHERE {owner_clause}",
|
||||
owner_params,
|
||||
).fetchone()[0]
|
||||
conn.execute(f"DELETE FROM {table} WHERE {owner_clause}", owner_params)
|
||||
else:
|
||||
before = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
|
||||
conn.execute(f"DELETE FROM {table}")
|
||||
@@ -609,6 +729,12 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
||||
raise HTTPException(404, "Task not found")
|
||||
if user and task.owner != user:
|
||||
raise HTTPException(403, "Access denied")
|
||||
# Cascade: cookbook_serve tasks may have a linked calendar
|
||||
# event (created via the "Create event in calendar" toggle
|
||||
# in the schedule modal). If so, delete the calendar event
|
||||
# too so the calendar doesn't end up holding a phantom event
|
||||
# for a task that no longer exists.
|
||||
_maybe_cascade_calendar_event(task)
|
||||
db.delete(task)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""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
|
||||
@@ -576,6 +576,7 @@ def rank_models(system, use_case=None, limit=50, search=None, sort="score", quan
|
||||
system_backend = (system.get("backend") or "").lower()
|
||||
apple_silicon = system_backend in ("mps", "metal", "apple")
|
||||
rocm = system_backend == "rocm"
|
||||
is_windows = system.get("platform") == "windows"
|
||||
|
||||
# Consumer AMD Radeon (RDNA, gfx10/11/12): the practical local serving path
|
||||
# is GGUF via llama.cpp. vLLM/SGLang on ROCm are validated for datacenter
|
||||
@@ -615,7 +616,11 @@ def rank_models(system, use_case=None, limit=50, search=None, sort="score", quan
|
||||
# servable path, so a model needs a real GGUF to be recommended.
|
||||
# Otherwise the Cookbook rates vLLM-only AWQ/GPTQ builds "GOOD" on a
|
||||
# Radeon that can't actually serve them.
|
||||
if (apple_silicon or consumer_amd) and not (m.get("is_gguf") or m.get("gguf_sources")):
|
||||
#
|
||||
# Windows is the same: Odysseus only supports llama.cpp on Windows,
|
||||
# which requires GGUF. vLLM/SGLang are explicitly blocked, so AWQ/GPTQ
|
||||
# models without a GGUF source are unservable there.
|
||||
if (apple_silicon or consumer_amd or is_windows) and not (m.get("is_gguf") or m.get("gguf_sources")):
|
||||
continue
|
||||
|
||||
# Format filter: AWQ tab -> only AWQ models, FP4 tab -> FP4-family models, etc.
|
||||
|
||||
@@ -539,6 +539,7 @@ def _detect_windows():
|
||||
"backend": d.get("gpu_backend", "cpu_x86"),
|
||||
"homogeneous": True,
|
||||
"gpu_error": None,
|
||||
"platform": "windows",
|
||||
}
|
||||
# PowerShell only reports aggregate GPU info, not per-card detail, so we
|
||||
# can't tell a mixed box from a uniform one here — assume one homogeneous
|
||||
|
||||
@@ -345,8 +345,17 @@ async def extract_and_store(
|
||||
logger.warning(f"Memory dedup (vector) unavailable, using text fallback: {e}")
|
||||
existing_id = None
|
||||
if existing_id:
|
||||
logger.debug(f"Memory dedup (vector): '{fact_text[:50]}' matches {existing_id}")
|
||||
continue
|
||||
# The vector store is a single shared collection with no
|
||||
# owner metadata, so find_similar can return ANOTHER
|
||||
# tenant's memory. Only treat it as a duplicate when the
|
||||
# match is this user's own (or a legacy unowned) memory —
|
||||
# otherwise the user's freshly-extracted fact would be
|
||||
# silently dropped. Mirror the owner predicate used by the
|
||||
# text dedup below; cross-tenant/stale matches fall through.
|
||||
_match = next((e for e in existing if e.get("id") == existing_id), None)
|
||||
if _match is not None and (_match.get("owner") == _owner or _match.get("owner") is None):
|
||||
logger.debug(f"Memory dedup (vector): '{fact_text[:50]}' matches {existing_id}")
|
||||
continue
|
||||
|
||||
# Text dedup fallback: exact match + fuzzy similarity
|
||||
user_existing = [e for e in existing if e.get("owner") == _owner or e.get("owner") is None] if _owner else existing
|
||||
|
||||
+31
-31
@@ -7,6 +7,7 @@ import os
|
||||
|
||||
from .memory import MemoryManager
|
||||
from .memory_vector import MemoryVectorStore
|
||||
from src.memory_provider import MemoryRecord, NativeMemoryProvider
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -42,6 +43,10 @@ class MemoryService:
|
||||
self.vector_store = MemoryVectorStore(data_dir) if os.path.exists(
|
||||
os.path.join(data_dir, "memory_vectors")
|
||||
) else None
|
||||
self.provider = NativeMemoryProvider(self.manager, self.vector_store)
|
||||
|
||||
def _sync_provider(self) -> None:
|
||||
self.provider.memory_vector = self.vector_store
|
||||
|
||||
@staticmethod
|
||||
def _to_memory(entry: Dict[str, Any], metadata: Optional[Dict[str, Any]] = None) -> Memory:
|
||||
@@ -53,6 +58,19 @@ class MemoryService:
|
||||
metadata=metadata or {},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _record_to_memory(record: MemoryRecord, metadata: Optional[Dict[str, Any]] = None) -> Memory:
|
||||
merged_metadata = dict(record.metadata)
|
||||
if metadata:
|
||||
merged_metadata.update(metadata)
|
||||
return Memory(
|
||||
id=record.id,
|
||||
text=record.text,
|
||||
timestamp=record.timestamp,
|
||||
session_id=record.session_id,
|
||||
metadata=merged_metadata,
|
||||
)
|
||||
|
||||
async def remember(self, text: str, session_id: Optional[str] = None) -> Memory:
|
||||
"""
|
||||
Store a new memory.
|
||||
@@ -64,19 +82,9 @@ class MemoryService:
|
||||
Returns:
|
||||
Created Memory object
|
||||
"""
|
||||
entry = self.manager.add_entry(text)
|
||||
if session_id:
|
||||
entry["session_id"] = session_id
|
||||
|
||||
memories = self.manager.load_all()
|
||||
memories.append(entry)
|
||||
self.manager.save(memories)
|
||||
|
||||
# Also add to vector store if available
|
||||
if self.vector_store and self.vector_store.healthy:
|
||||
self.vector_store.add(entry["id"], entry["text"])
|
||||
|
||||
return self._to_memory(entry)
|
||||
self._sync_provider()
|
||||
record = await self.provider.remember(text, session_id=session_id)
|
||||
return self._record_to_memory(record)
|
||||
|
||||
async def recall(self, query: str, top_k: int = 5) -> MemorySearchResult:
|
||||
"""
|
||||
@@ -89,28 +97,20 @@ class MemoryService:
|
||||
Returns:
|
||||
MemorySearchResult with matching memories
|
||||
"""
|
||||
# Try vector search first
|
||||
all_memories = self.manager.load_all()
|
||||
by_id = {m.get("id"): m for m in all_memories}
|
||||
if self.vector_store and self.vector_store.healthy:
|
||||
results = self.vector_store.search(query, k=top_k)
|
||||
found = []
|
||||
for result in results:
|
||||
entry = by_id.get(result.get("memory_id"))
|
||||
if entry:
|
||||
found.append(self._to_memory(entry, metadata={"score": result.get("score")}))
|
||||
if found:
|
||||
return MemorySearchResult(memories=found, query=query, total=len(found))
|
||||
|
||||
# Fallback to keyword search
|
||||
results = self.manager.get_relevant_memories(query, all_memories, max_items=top_k)
|
||||
memories = [self._to_memory(m) for m in results]
|
||||
self._sync_provider()
|
||||
results = await self.provider.recall(query, top_k=top_k)
|
||||
memories = [
|
||||
self._record_to_memory(hit.memory, metadata={"score": hit.score})
|
||||
if hit.score is not None
|
||||
else self._record_to_memory(hit.memory)
|
||||
for hit in results
|
||||
]
|
||||
return MemorySearchResult(memories=memories, query=query, total=len(memories))
|
||||
|
||||
def get_all(self, limit: int = 100) -> List[Memory]:
|
||||
"""Get all memories."""
|
||||
memories = self.manager.load_all()[:limit]
|
||||
return [self._to_memory(m) for m in memories]
|
||||
records = self.manager.load_all()[:limit]
|
||||
return [self._to_memory(m) for m in records]
|
||||
|
||||
def delete(self, memory_id: str) -> bool:
|
||||
"""Delete a memory by ID."""
|
||||
|
||||
+135
-2
@@ -177,6 +177,7 @@ TOOL_SECTIONS = {
|
||||
<shell command>
|
||||
```
|
||||
Run any shell command. Output is returned to you. Use for: installing packages, checking files, git, curl, system info, etc.
|
||||
NEVER use bash to create or change files — no `>`/`>>` redirects, no heredocs (`cat > f << 'EOF'`), no `tee`, `sed -i`, `awk -i`, no `python -c` that writes. To CREATE or fully rewrite a file use `write_file`; to change part of an existing file use `edit_file`. Those show a diff and are the ONLY allowed way to write files. (bash is for read-only inspection: `ls`, `cat` to READ, `grep`, `git status`/`git diff`, builds, installs.)
|
||||
For LONG-running commands (package installs, pip/npm, ffmpeg, model downloads, training, builds — anything that may take more than ~20s), make the FIRST line `#!bg` to run it in the BACKGROUND. You get a job id back immediately and are automatically re-invoked with the full output when it finishes — so you never block the chat waiting. Example:
|
||||
```bash
|
||||
#!bg
|
||||
@@ -220,6 +221,12 @@ Read a file and return its contents.""",
|
||||
```
|
||||
Write content to a file. First line is the path, rest is the content.""",
|
||||
|
||||
"edit_file": """\
|
||||
```edit_file
|
||||
{"path": "<file path>", "old_string": "<exact text to replace>", "new_string": "<replacement>", "replace_all": false}
|
||||
```
|
||||
Edit an EXISTING file by exact string replacement. PREFER this over bash (sed/echo/redirects) for changing files — it shows a before/after diff. `old_string` must match the file exactly and be unique unless `replace_all` is true. Use write_file to create a new file.""",
|
||||
|
||||
"create_document": """\
|
||||
```create_document
|
||||
<title>
|
||||
@@ -236,7 +243,7 @@ old text to find
|
||||
new replacement text
|
||||
<<<END>>>
|
||||
```
|
||||
PREFERRED way to change an existing document. Find exact text and replace it. Multiple FIND/REPLACE blocks per call OK. Use this for any edit smaller than a full rewrite — adding a function, fixing a bug, tweaking a section, renaming things. **If a document is open in the editor, treat it as the user's current context: don't ask which file they mean, and don't create a new one — just edit_document the active one.** Do NOT re-send the whole file with update_document for small changes.""",
|
||||
Edit a document OPEN IN THE EDITOR PANEL — NOT a file on disk. For files on disk (home folder, project files, any real path like ~/sweden.txt) use `edit_file` instead. Find exact text and replace it. Multiple FIND/REPLACE blocks per call OK. Use for any edit smaller than a full rewrite. **If a document is open in the editor, treat it as the user's current context: don't ask which file they mean, and don't create a new one — just edit_document the active one.** Do NOT re-send the whole file with update_document for small changes.""",
|
||||
|
||||
"update_document": """\
|
||||
```update_document
|
||||
@@ -330,6 +337,7 @@ If the user asks for a reminder/alarm before the event, pass `reminder_minutes`
|
||||
"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.",
|
||||
"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.",
|
||||
"stop_served_model": "- ```stop_served_model``` — Stop a running model server. Args (JSON): {\"session_id\": \"<from list_served_models>\"}. Use for 'kill my cookbook' / 'stop the model' / 'shut down vLLM'.",
|
||||
"tail_serve_output": "- ```tail_serve_output``` — Read the actual tmux stderr/traceback of a CURRENTLY failing cookbook task. Args (JSON): {\"session_id\": \"<from list_served_models>\", \"tail\": 150?}. **Use ONLY after** you just launched something via `serve_model` AND `list_served_models` reports YOUR new task as `crashed`/`error`. DO NOT use it on old stopped/completed download tasks (they're historical noise — won't predict whether a new launch succeeds). DO NOT call it before launching a fresh attempt. When you do call it, bump `tail` to 400+ only if the visible error references 'see root cause above'.",
|
||||
"download_model": "- ```download_model``` — Download a HuggingFace model. Args (JSON): {\"repo_id\": \"Qwen/Qwen3-8B\", \"host\": \"user@gpu-box\"?, \"include\": \"*Q4_K_M*\"?}.",
|
||||
"serve_model": "- ```serve_model``` — Start serving a model with vLLM / SGLang / llama.cpp / Ollama / Diffusers. Args (JSON): {\"repo_id\": \"...\", \"cmd\": \"vllm serve ... --port 8000\" or \"python3 -m sglang.launch_server ... --port 30000\" or \"python3 scripts/diffusion_server.py --model diffusers/stable-diffusion-xl-1.0-inpainting-0.1 --port 8100\", \"host\": \"user@gpu-box\"?}. For image/inpaint/diffusion models, use the `scripts/diffusion_server.py` command exactly. After launch, call `list_served_models`; if it returns a diagnosis with an adjusted command, retry with that command.",
|
||||
"list_downloads": "- ```list_downloads``` — Show in-progress HuggingFace model downloads (filters Cookbook tasks/status to downloads only). NO args. Use for 'what's downloading' / 'show my downloads' / 'check download progress'.",
|
||||
@@ -461,13 +469,14 @@ _API_HOSTS = frozenset([
|
||||
"api.together.xyz", "api.fireworks.ai",
|
||||
"api.perplexity.ai", "api.x.ai",
|
||||
"ollama.com", "api.venice.ai",
|
||||
"api.githubcopilot.com",
|
||||
# Local OpenAI-compatible endpoints (llama.cpp, vLLM, LM Studio, etc.).
|
||||
# Without these, `_is_api_model` falls back to keyword sniffing on the
|
||||
# model name, so well-behaved local servers don't get native tool
|
||||
# schemas and the agent silently degrades to fenced-block parsing.
|
||||
"localhost", "127.0.0.1", "host.docker.internal",
|
||||
])
|
||||
_MCP_KEYWORDS = frozenset(["browse", "browser", "website", "calendar", "event", "email",
|
||||
_MCP_KEYWORDS = frozenset(["mcp", "browse", "browser", "website", "calendar", "event", "email",
|
||||
"gmail", "screenshot", "navigate", "click", "miniflux", "rss", "feed"])
|
||||
_ADMIN_SCHEMA_NAMES = frozenset([
|
||||
"manage_session", "manage_skills", "manage_tasks",
|
||||
@@ -1379,6 +1388,7 @@ async def stream_agent_loop(
|
||||
owner: Optional[str] = None,
|
||||
relevant_tools: Optional[Set[str]] = None,
|
||||
fallbacks: Optional[List[tuple]] = None,
|
||||
workspace: Optional[str] = None,
|
||||
_is_teacher_run: bool = False,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""Streaming agent loop generator.
|
||||
@@ -1545,6 +1555,27 @@ async def stream_agent_loop(
|
||||
compact=_is_api_model,
|
||||
owner=owner,
|
||||
)
|
||||
if workspace:
|
||||
# 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)
|
||||
prep_timings["prompt_build"] = time.time() - _t2
|
||||
|
||||
_t3 = time.time()
|
||||
@@ -1629,12 +1660,39 @@ async def stream_agent_loop(
|
||||
_tool_type_counts: collections.Counter = collections.Counter()
|
||||
_THINK_RE = re.compile(r'<think>.*?</think>', re.DOTALL | re.IGNORECASE)
|
||||
_force_answer = False # set by loop-breaker → next round runs with NO tools
|
||||
# Supervisor: how many times we've nudged the model after it announced
|
||||
# an action without emitting the tool call. Capped to prevent a model
|
||||
# that *can't* call the tool from looping forever.
|
||||
_intent_nudge_count = 0
|
||||
_MAX_INTENT_NUDGES = 2
|
||||
|
||||
# "I said I would, then didn't" detector. The pattern that breaks debug
|
||||
# loops on weak models (deepseek-v4-flash mid-2026): the model writes
|
||||
# "Let me tail the output to see the error" and then ends the turn with
|
||||
# no tool_calls. The intent is sincere but the function call gets dropped.
|
||||
# Match the common phrasings + an action verb that maps to an available
|
||||
# tool, so we don't nudge on harmless transitional text like "let me
|
||||
# know what you think".
|
||||
_INTENT_RE = re.compile(
|
||||
r"(?:^|\n)\s*(?:let me|i'?ll|i will|going to|let's)\s+"
|
||||
r"(?:tail|check|investigate|look at|see|tail|read|fetch|inspect|"
|
||||
r"verify|diagnose|examine|debug|capture|grab|pull|view|run|call|"
|
||||
r"trigger|launch|start|kick off|stop|kill|restart|adopt|serve|"
|
||||
r"register|adopt|list|search|find|query|hit|ping|test)"
|
||||
r"\b[^.\n]{0,140}",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Document streaming state (persists across rounds)
|
||||
_doc_acc = "" # accumulated tool-call JSON arguments
|
||||
_doc_opened = False # whether doc_stream_open was sent
|
||||
_doc_last_len = 0 # last content length sent
|
||||
|
||||
# Set when the loop runs out of rounds while the agent was still actively
|
||||
# using tools — i.e. it was cut off, not finished. Drives a "Continue" event
|
||||
# so the user can resume instead of the turn silently stalling.
|
||||
_exhausted_rounds = False
|
||||
|
||||
for round_num in range(1, max_rounds + 1):
|
||||
round_response = ""
|
||||
round_reasoning = "" # reasoning_content deltas (DeepSeek-thinking, vLLM --reasoning-parser)
|
||||
@@ -1975,6 +2033,46 @@ async def stream_agent_loop(
|
||||
# never re-verify an unchanged state in a loop.
|
||||
_effectful_used = False
|
||||
continue
|
||||
# ── Intent-without-action supervisor ─────────────────────
|
||||
# Catch "Let me tail the output" / "I'll check the logs" /
|
||||
# "Let me investigate" patterns where the model announces an
|
||||
# action but emits no tool_call. The bug shows up most on
|
||||
# smaller models trained to verbalize plans before acting.
|
||||
# We inject one sharp nudge ("you said you would X — call the
|
||||
# actual tool now") and loop again. Capped at
|
||||
# _MAX_INTENT_NUDGES so a model that genuinely cannot use the
|
||||
# tool doesn't pin us in a forever loop.
|
||||
_intent_text = _THINK_RE.sub("", cleaned_round).strip()
|
||||
_intent_match = _INTENT_RE.search(_intent_text) if _intent_text else None
|
||||
# Only nudge when the round REALLY looks like an unfinished
|
||||
# promise: short response (<400 chars), no fenced code/answer,
|
||||
# and an action-intent phrase was matched. Long answers that
|
||||
# happen to contain "let me know" are not stalls.
|
||||
_looks_like_promise = (
|
||||
_intent_match is not None
|
||||
and len(_intent_text) < 400
|
||||
and "```" not in _intent_text
|
||||
and _intent_nudge_count < _MAX_INTENT_NUDGES
|
||||
)
|
||||
if _looks_like_promise:
|
||||
_intent_nudge_count += 1
|
||||
_matched_phrase = _intent_match.group(0).strip()
|
||||
logger.info(f"[agent] intent-without-action nudge #{_intent_nudge_count} on round {round_num}: {_matched_phrase!r}")
|
||||
messages.append({
|
||||
"role": "system",
|
||||
"content": (
|
||||
f"You just wrote: \"{_matched_phrase}\" — but ended the "
|
||||
"turn without making the actual tool call. The user can "
|
||||
"see you announced the action but didn't run it, which "
|
||||
"is the most frustrating thing you can do. "
|
||||
"DO IT NOW: emit the actual function call this turn. "
|
||||
"If you decided not to do it after all, say so plainly in "
|
||||
"one sentence instead of restating the plan."
|
||||
),
|
||||
})
|
||||
# Visible signal in the stream so the user knows we caught it.
|
||||
yield f'data: {json.dumps({"type": "agent_step", "round": round_num + 1})}\n\n'
|
||||
continue
|
||||
break # no tools — done
|
||||
|
||||
# ── Loop-breaker (Terminus-style stall detector) ──────────────
|
||||
@@ -2104,6 +2202,7 @@ async def stream_agent_loop(
|
||||
disabled_tools=disabled_tools,
|
||||
owner=owner,
|
||||
progress_cb=_push_progress,
|
||||
workspace=workspace,
|
||||
)
|
||||
finally:
|
||||
# Sentinel so the drainer knows to stop.
|
||||
@@ -2219,6 +2318,9 @@ async def stream_agent_loop(
|
||||
if result.get("images"):
|
||||
img = result["images"][0]
|
||||
tool_output_data["screenshot"] = f"data:{img['mimeType']};base64,{img['data']}"
|
||||
# Forward a file-write diff for inline before/after rendering
|
||||
if "diff" in result:
|
||||
tool_output_data["diff"] = result["diff"]
|
||||
yield f'data: {json.dumps(tool_output_data)}\n\n'
|
||||
|
||||
# Native document tools open in the editor + carry the REAL doc id.
|
||||
@@ -2246,6 +2348,19 @@ async def stream_agent_loop(
|
||||
_anchor = f"\n\n[Open in Deep Research](#research-{_rsid})\n"
|
||||
yield 'data: ' + json.dumps({"delta": _anchor}) + '\n\n'
|
||||
|
||||
# Same pattern for notes: when manage_notes creates a note
|
||||
# and returns note_id, drop a `[View note](#note-<id>)` link
|
||||
# into the stream so chatRenderer's click handler routes to
|
||||
# the new openNote() in notes.js — opens the notes panel and
|
||||
# scrolls/flashes the matching card. Without this, the agent
|
||||
# would write "View note" as a phrase with no target.
|
||||
_nid = result.get("note_id")
|
||||
if _nid and block.tool_type == "manage_notes":
|
||||
_title = (result.get("note_title") or "").strip()
|
||||
_label = f"View note: {_title}" if _title else "View note"
|
||||
_anchor = f"\n\n[{_label}](#note-{_nid})\n"
|
||||
yield 'data: ' + json.dumps({"delta": _anchor}) + '\n\n'
|
||||
|
||||
# Save for history persistence
|
||||
tool_event = {
|
||||
"round": round_num,
|
||||
@@ -2261,6 +2376,10 @@ async def stream_agent_loop(
|
||||
if result.get("doc_id"):
|
||||
tool_event["doc_id"] = result["doc_id"]
|
||||
tool_event["doc_title"] = result.get("title", "")
|
||||
# Persist the file-write/edit diff so it re-renders on reload — without
|
||||
# this the diff shows live but vanishes from saved history.
|
||||
if result.get("diff"):
|
||||
tool_event["diff"] = result["diff"]
|
||||
tool_events.append(tool_event)
|
||||
if block.tool_type in _VERIFIER_EFFECTFUL_TOOLS:
|
||||
_effectful_used = True
|
||||
@@ -2285,6 +2404,20 @@ async def stream_agent_loop(
|
||||
|
||||
# Separator in accumulated response
|
||||
full_response += "\n\n"
|
||||
else:
|
||||
# The for-loop completed every allowed round WITHOUT an early `break`
|
||||
# (a `break` fires on "done", budget, or error). Reaching this `else`
|
||||
# means the agent kept working until it ran out of rounds — so offer
|
||||
# Continue instead of stopping silently. This catches ALL exhaustion
|
||||
# paths, including a verifier `continue` on the final round (the old
|
||||
# bottom-of-loop flag missed those).
|
||||
_exhausted_rounds = True
|
||||
|
||||
# If the loop hit the round cap while still working, tell the client so it
|
||||
# can show a "Continue" affordance instead of the turn just stopping.
|
||||
if _exhausted_rounds:
|
||||
logger.info("[agent] round cap (%d) reached mid-task — emitting rounds_exhausted", max_rounds)
|
||||
yield f'data: {json.dumps({"type": "rounds_exhausted", "rounds": max_rounds})}\n\n'
|
||||
|
||||
# If the response is completely empty and no tools were executed,
|
||||
# yield a fallback message so the user is not left hanging.
|
||||
|
||||
+3
-2
@@ -19,14 +19,15 @@ logger = logging.getLogger(__name__)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants (kept here — sub-modules import from here)
|
||||
# ---------------------------------------------------------------------------
|
||||
MAX_AGENT_ROUNDS = 20
|
||||
MAX_AGENT_ROUNDS = 50
|
||||
SHELL_TIMEOUT = 60
|
||||
PYTHON_TIMEOUT = 30
|
||||
MAX_OUTPUT_CHARS = 10_000
|
||||
MAX_READ_CHARS = 20_000
|
||||
|
||||
# Tool types that trigger execution
|
||||
TOOL_TAGS = {"bash", "python", "web_search", "web_fetch", "read_file", "write_file",
|
||||
TOOL_TAGS = {"bash", "python", "web_search", "web_fetch", "read_file", "write_file", "edit_file",
|
||||
"grep", "glob", "ls",
|
||||
"create_document", "update_document", "edit_document",
|
||||
"search_chats",
|
||||
"chat_with_model", "create_session", "list_sessions",
|
||||
|
||||
@@ -9,6 +9,7 @@ from src.constants import (
|
||||
SESSIONS_FILE, DEFAULT_HOST, OPENAI_API_KEY
|
||||
)
|
||||
from src.memory import MemoryManager
|
||||
from src.memory_provider import MemoryProviderRegistry, NativeMemoryProvider
|
||||
from services.memory.skills import SkillsManager
|
||||
from core.session_manager import SessionManager
|
||||
from core.models import set_session_manager
|
||||
@@ -73,6 +74,10 @@ def initialize_managers(base_dir: str, rag_manager=None) -> Dict[str, Any]:
|
||||
logger.warning(f"MemoryVectorStore DEGRADED: {e}")
|
||||
memory_vector = None
|
||||
|
||||
memory_provider_registry = MemoryProviderRegistry([
|
||||
NativeMemoryProvider(memory_manager, memory_vector),
|
||||
])
|
||||
|
||||
# Initialize processors
|
||||
chat_processor = ChatProcessor(memory_manager, personal_docs_manager, memory_vector=memory_vector, skills_manager=skills_manager)
|
||||
research_handler = ResearchHandler()
|
||||
@@ -99,6 +104,7 @@ def initialize_managers(base_dir: str, rag_manager=None) -> Dict[str, Any]:
|
||||
return {
|
||||
"memory_manager": memory_manager,
|
||||
"memory_vector": memory_vector,
|
||||
"memory_provider_registry": memory_provider_registry,
|
||||
"skills_manager": skills_manager,
|
||||
"session_manager": session_manager,
|
||||
"upload_handler": upload_handler,
|
||||
|
||||
@@ -2001,6 +2001,197 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
return str(e), False
|
||||
|
||||
|
||||
async def action_cookbook_serve(
|
||||
owner: str,
|
||||
task_name: str = "",
|
||||
progress_cb=None,
|
||||
command: str = "",
|
||||
**kwargs,
|
||||
) -> Tuple[str, bool]:
|
||||
"""Launch a Cookbook model serve as a scheduled task.
|
||||
|
||||
`command` is the JSON config string the task carries in `prompt`,
|
||||
of shape: {"preset": "name"} OR {"repo_id": "...", "cmd": "...", "host": "..."}.
|
||||
Optional `end_after_min: N` schedules a hard-stop N minutes after launch
|
||||
(handled by cookbook_serve_lifecycle_loop in src/cookbook_serve_lifecycle.py).
|
||||
"""
|
||||
import json
|
||||
import time as _time
|
||||
import httpx
|
||||
from pathlib import Path
|
||||
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN
|
||||
from core.atomic_io import atomic_write_json
|
||||
|
||||
headers = {INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN}
|
||||
try:
|
||||
cfg = json.loads(command or "{}")
|
||||
except Exception:
|
||||
return f"Invalid JSON config: {command!r}", False
|
||||
if not isinstance(cfg, dict):
|
||||
return "Config must be a JSON object", False
|
||||
|
||||
# Resolve the preset (if named) OR fall through with explicit fields.
|
||||
preset_name = (cfg.get("preset") or "").strip()
|
||||
repo_id = (cfg.get("repo_id") or "").strip()
|
||||
cmd = (cfg.get("cmd") or "").strip()
|
||||
host = (cfg.get("host") or cfg.get("remote_host") or "").strip()
|
||||
try:
|
||||
end_after_min = int(cfg.get("end_after_min") or 0)
|
||||
except Exception:
|
||||
end_after_min = 0
|
||||
|
||||
state_path = Path("/app/data/cookbook_state.json")
|
||||
try:
|
||||
state = json.loads(state_path.read_text(encoding="utf-8")) if state_path.exists() else {}
|
||||
except Exception:
|
||||
state = {}
|
||||
|
||||
# Preset lookup. Try three matching strategies in order so the
|
||||
# schedule still works even when the user's preset is named
|
||||
# differently from the model's short name:
|
||||
#
|
||||
# 1. Exact preset.name == preset_name (case-insensitive)
|
||||
# 2. preset.model / preset.modelId == repo_id (caller knows the repo)
|
||||
# 3. preset.model's short name (after final /) == preset_name
|
||||
#
|
||||
# Without #2 and #3, scheduling "Qwen3.5-397B-A17B-AWQ" failed when
|
||||
# the saved preset was named "vllm-qwen-397b" or had the model field
|
||||
# populated with the full HF repo path. Either should resolve.
|
||||
def _short(name: str) -> str:
|
||||
return (name or "").rsplit("/", 1)[-1].lower()
|
||||
|
||||
if not cmd or not repo_id:
|
||||
presets = state.get("presets") or []
|
||||
chosen = None
|
||||
# Strategy 1: exact name match.
|
||||
if preset_name:
|
||||
chosen = next(
|
||||
(p for p in presets if isinstance(p, dict)
|
||||
and (p.get("name") or "").lower() == preset_name.lower()),
|
||||
None,
|
||||
)
|
||||
# Strategy 2: repo_id matches the preset's model field.
|
||||
if chosen is None and repo_id:
|
||||
chosen = next(
|
||||
(p for p in presets if isinstance(p, dict)
|
||||
and (p.get("model") or p.get("modelId") or "").lower() == repo_id.lower()),
|
||||
None,
|
||||
)
|
||||
# Strategy 3: model's short name matches the preset_name.
|
||||
if chosen is None and preset_name:
|
||||
chosen = next(
|
||||
(p for p in presets if isinstance(p, dict)
|
||||
and _short(p.get("model") or p.get("modelId") or "") == preset_name.lower()),
|
||||
None,
|
||||
)
|
||||
if chosen is not None:
|
||||
repo_id = repo_id or chosen.get("model") or chosen.get("modelId") or ""
|
||||
cmd = cmd or (chosen.get("cmd") or "").strip()
|
||||
host = host or chosen.get("host") or chosen.get("remoteHost") or ""
|
||||
if not repo_id or not cmd or cmd.startswith("(adopted"):
|
||||
# Surface what we tried so the user can name their preset to match.
|
||||
preset_names = [(p.get("name") or "") for p in (state.get("presets") or []) if isinstance(p, dict)]
|
||||
hint = f" Saved presets: {preset_names!r}" if preset_names else ""
|
||||
return (f"No launchable config for {preset_name!r} (repo_id={repo_id!r}). "
|
||||
f"Check Cookbook → Presets has a real cmd, not 'adopted'.{hint}", False)
|
||||
|
||||
# Resolve env_prefix etc. from the host's saved cookbook server entry,
|
||||
# matching the chat agent's serve_model path.
|
||||
body = {"repo_id": repo_id, "cmd": cmd}
|
||||
if host:
|
||||
body["remote_host"] = host
|
||||
env = (state.get("env") or {})
|
||||
srv = next(
|
||||
(s for s in (env.get("servers") or [])
|
||||
if isinstance(s, dict) and (s.get("host") == host or s.get("name") == host)),
|
||||
{},
|
||||
)
|
||||
if srv.get("env") == "venv" and srv.get("envPath"):
|
||||
body["env_prefix"] = f"source {srv['envPath']}/bin/activate"
|
||||
elif srv.get("env") == "conda" and srv.get("envPath"):
|
||||
body["env_prefix"] = f"conda activate {srv['envPath']}"
|
||||
if srv.get("hfToken"): body["hf_token"] = srv["hfToken"]
|
||||
if srv.get("port"): body["ssh_port"] = str(srv["port"])
|
||||
if srv.get("platform"): body["platform"] = srv["platform"]
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
r = await client.post("http://localhost:7000/api/model/serve",
|
||||
json=body, headers=headers)
|
||||
data = r.json() if r.content else {}
|
||||
except Exception as e:
|
||||
return f"Launch HTTP failed: {e}", False
|
||||
if not data.get("ok"):
|
||||
return f"Launch rejected: {data.get('error') or data.get('detail') or 'unknown'}", False
|
||||
|
||||
sid = data.get("session_id") or ""
|
||||
# Register the new task in cookbook_state.json + stamp it with our
|
||||
# scheduler-owner markers. /api/model/serve spawns the tmux session
|
||||
# but leaves the state-write to the UI — when a scheduled action
|
||||
# launches a serve from server-side, NOBODY writes the task into
|
||||
# state, so the Cookbook tab never shows it. We do the write here.
|
||||
if sid:
|
||||
try:
|
||||
# Re-read fresh (the route may have updated state already).
|
||||
try:
|
||||
fresh = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
fresh = {}
|
||||
if not isinstance(fresh, dict):
|
||||
fresh = {}
|
||||
tasks = fresh.get("tasks") if isinstance(fresh.get("tasks"), list) else []
|
||||
existing = next(
|
||||
(t for t in tasks if isinstance(t, dict) and t.get("sessionId") == sid),
|
||||
None,
|
||||
)
|
||||
if existing is None:
|
||||
display_name = repo_id.split("/")[-1] if "/" in repo_id else repo_id
|
||||
placeholder = (
|
||||
f"Launched by scheduled task {task_name!r} — waiting for tmux output…\n"
|
||||
f" session: {sid}\n"
|
||||
f" target: {host or 'local'}\n"
|
||||
f" cmd: {cmd[:200]}{'…' if len(cmd) > 200 else ''}"
|
||||
)
|
||||
existing = {
|
||||
"id": sid,
|
||||
"sessionId": sid,
|
||||
"name": display_name,
|
||||
"modelId": repo_id,
|
||||
"type": "serve",
|
||||
"status": "running",
|
||||
"output": placeholder,
|
||||
"ts": int(_time.time() * 1000),
|
||||
"payload": {"repo_id": repo_id, "remote_host": host or "", "_cmd": cmd},
|
||||
"remoteHost": host or "",
|
||||
"sshPort": "",
|
||||
"platform": "linux",
|
||||
"_serveReady": False,
|
||||
"_endpointAdded": False,
|
||||
}
|
||||
tasks.append(existing)
|
||||
# Stamp ownership + end-at on the task entry.
|
||||
existing["_scheduledByTask"] = task_name or ""
|
||||
existing["_scheduledByOwner"] = owner or ""
|
||||
if end_after_min > 0:
|
||||
existing["_scheduledStopAtMs"] = int(_time.time() * 1000) + end_after_min * 60 * 1000
|
||||
fresh["tasks"] = tasks
|
||||
atomic_write_json(state_path, fresh)
|
||||
except Exception as e:
|
||||
logger.warning(f"cookbook_serve: state register/stamp failed: {e}")
|
||||
# Don't try to render absolute clock time in the message — the
|
||||
# server runs in UTC (Docker default), the user reads it as local,
|
||||
# and the offset depends on the user's TZ which the action doesn't
|
||||
# have a reliable handle on. The Tasks UI already shows the RUN
|
||||
# timestamp in the user's local time right above this message, so
|
||||
# "stops 8 min after that" gives the user everything they need.
|
||||
if end_after_min:
|
||||
return (
|
||||
f"Launched {repo_id} (session {sid}); stops {end_after_min} min after this ran",
|
||||
True,
|
||||
)
|
||||
return f"Launched {repo_id} (session {sid})", True
|
||||
|
||||
|
||||
BUILTIN_ACTIONS = {
|
||||
"tidy_sessions": action_tidy_sessions,
|
||||
"tidy_documents": action_tidy_documents,
|
||||
@@ -2020,6 +2211,7 @@ BUILTIN_ACTIONS = {
|
||||
"test_skills": action_test_skills,
|
||||
"audit_skills": action_audit_skills,
|
||||
"check_email_urgency": action_check_email_urgency,
|
||||
"cookbook_serve": action_cookbook_serve,
|
||||
# ping_notes removed from the registry — runs only inside `_note_pings_loop`.
|
||||
}
|
||||
|
||||
|
||||
@@ -265,6 +265,7 @@ def _sync_blocking(owner: str, url: str, username: str, password: str) -> dict:
|
||||
existing.all_day = all_day
|
||||
existing.is_utc = row_is_utc
|
||||
existing.rrule = rrule
|
||||
existing.origin = "caldav"
|
||||
else:
|
||||
new_ev = CalendarEvent(
|
||||
uid=uid_val,
|
||||
@@ -277,6 +278,7 @@ def _sync_blocking(owner: str, url: str, username: str, password: str) -> dict:
|
||||
all_day=all_day,
|
||||
is_utc=row_is_utc,
|
||||
rrule=rrule,
|
||||
origin="caldav",
|
||||
)
|
||||
db.add(new_ev)
|
||||
pending[uid_val] = new_ev
|
||||
@@ -286,8 +288,13 @@ def _sync_blocking(owner: str, url: str, username: str, password: str) -> dict:
|
||||
# Prune locally-cached CalDAV events that vanished
|
||||
# upstream (only within our sync window — events outside
|
||||
# the window aren't in `objs`, so we'd false-delete them).
|
||||
# Only rows we previously pulled from the server (origin=="caldav")
|
||||
# are prunable; locally-created events (agent / email triage / a
|
||||
# UI event whose write-back failed) carry origin NULL and must
|
||||
# never be deleted just because the server didn't return them.
|
||||
stale = db.query(CalendarEvent).filter(
|
||||
CalendarEvent.calendar_id == local_cal.id,
|
||||
CalendarEvent.origin == "caldav",
|
||||
CalendarEvent.dtstart >= start,
|
||||
CalendarEvent.dtstart <= end,
|
||||
~CalendarEvent.uid.in_(seen_uids) if seen_uids else CalendarEvent.uid.isnot(None),
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Cookbook serve lifecycle: kills scheduler-owned serves whose end-of-
|
||||
window has passed.
|
||||
|
||||
Pairs with action_cookbook_serve in builtin_actions.py — that action
|
||||
stamps the task it launches with `_scheduledStopAtMs`, this loop ticks
|
||||
every 60s and kills any serve whose stamp is in the past.
|
||||
|
||||
Single small module. Delete this file + the registration line in app.py
|
||||
and the feature stops doing anything; scheduler-launched serves just
|
||||
stay up until the user kills them manually.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _internal_headers() -> dict:
|
||||
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN
|
||||
return {INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN}
|
||||
|
||||
|
||||
async def _delete_endpoint_for_task(task: dict) -> None:
|
||||
"""Drop the auto-registered model endpoint for a scheduled-stop serve.
|
||||
|
||||
Without this, killing the tmux session leaves the endpoint sitting in
|
||||
the picker (probe goes offline; chats still try to route there) and
|
||||
the user has to delete it by hand in Settings -> Endpoints.
|
||||
"""
|
||||
import re as _re
|
||||
payload = task.get("payload") or {}
|
||||
cmd = str(payload.get("_cmd") or "")
|
||||
remote = task.get("remoteHost") or ""
|
||||
# Build host the same way _auto_register_llm_endpoint does so URL match wins.
|
||||
if remote:
|
||||
host = remote.split("@")[-1] if "@" in remote else remote
|
||||
else:
|
||||
host = "host.docker.internal"
|
||||
port_match = _re.search(r"--port\s+(\d+)", cmd)
|
||||
ollama_host_match = _re.search(r"OLLAMA_HOST=[^\s]*?:(\d+)", cmd)
|
||||
if port_match:
|
||||
port = int(port_match.group(1))
|
||||
elif ollama_host_match:
|
||||
port = int(ollama_host_match.group(1))
|
||||
elif "ollama" in cmd:
|
||||
port = 11434
|
||||
else:
|
||||
port = 8080
|
||||
base_url = f"http://{host}:{port}/v1"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=8) as client:
|
||||
r = await client.get(
|
||||
"http://localhost:7000/api/model-endpoints",
|
||||
headers=_internal_headers(),
|
||||
)
|
||||
if r.status_code >= 400:
|
||||
return
|
||||
eps = r.json() if r.content else []
|
||||
# Prefer exact URL match; fall back to host:port substring so we
|
||||
# still catch the case where 0.0.0.0 vs the registered host
|
||||
# representation diverged.
|
||||
ep = next((e for e in eps if e.get("base_url") == base_url), None)
|
||||
if not ep:
|
||||
hostport = f"{host}:{port}"
|
||||
ep = next((e for e in eps if hostport in (e.get("base_url") or "")), None)
|
||||
if ep:
|
||||
await client.delete(
|
||||
f"http://localhost:7000/api/model-endpoints/{ep['id']}",
|
||||
headers=_internal_headers(),
|
||||
)
|
||||
logger.info(
|
||||
f"cookbook_serve_lifecycle: deleted endpoint {ep.get('id')} "
|
||||
f"({ep.get('base_url')}) after scheduled stop"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"cookbook_serve_lifecycle: endpoint delete failed: {e}")
|
||||
|
||||
|
||||
async def _stop_serve(session_id: str, remote_host: str = "", ssh_port: str = "") -> bool:
|
||||
"""Kill the tmux session that hosts the serve.
|
||||
|
||||
There's no `/api/model/stop` route — the cookbook UI and the chat
|
||||
agent both kill via `/api/shell/exec` running a `tmux kill-session`
|
||||
(wrapped in ssh for remote hosts). Mirror that here so the
|
||||
lifecycle loop can actually stop scheduler-launched serves at
|
||||
window-end. Without this, the action stamped `_scheduledStopAtMs`
|
||||
correctly but every kill attempt failed silently (the route
|
||||
returned 404 and the result was logged as "failed").
|
||||
"""
|
||||
import shlex
|
||||
if remote_host:
|
||||
port_flag = f"-p {shlex.quote(str(ssh_port))} " if ssh_port and str(ssh_port) != "22" else ""
|
||||
cmd = (
|
||||
f"ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no "
|
||||
f"{port_flag}{shlex.quote(remote_host)} "
|
||||
f"'tmux kill-session -t {shlex.quote(session_id)}'"
|
||||
)
|
||||
else:
|
||||
cmd = f"tmux kill-session -t {shlex.quote(session_id)}"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
r = await client.post(
|
||||
"http://localhost:7000/api/shell/exec",
|
||||
json={"command": cmd},
|
||||
headers=_internal_headers(),
|
||||
)
|
||||
if r.status_code >= 400:
|
||||
return False
|
||||
data = r.json() if r.content else {}
|
||||
ec = data.get("exit_code")
|
||||
# tmux returns non-zero when the session is already gone
|
||||
# ("can't find session: ..."). That's still "stop succeeded"
|
||||
# from our POV — the goal is no live session at the end.
|
||||
if ec in (None, 0):
|
||||
return True
|
||||
stderr = (data.get("stderr") or "").lower()
|
||||
return "no server" in stderr or "can't find session" in stderr or "session not found" in stderr
|
||||
except Exception as e:
|
||||
logger.warning(f"cookbook_serve_lifecycle: stop {session_id} failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def _tick() -> None:
|
||||
state_path = Path("/app/data/cookbook_state.json")
|
||||
if not state_path.exists():
|
||||
return
|
||||
try:
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return
|
||||
tasks = state.get("tasks") or []
|
||||
now_ms = int(time.time() * 1000)
|
||||
to_stop = []
|
||||
for t in tasks:
|
||||
if not isinstance(t, dict):
|
||||
continue
|
||||
stop_at = t.get("_scheduledStopAtMs")
|
||||
if not isinstance(stop_at, (int, float)):
|
||||
continue
|
||||
if stop_at > now_ms:
|
||||
continue
|
||||
if (t.get("status") or "").lower() in {"stopped", "ended", "killed", "crashed"}:
|
||||
continue
|
||||
sid = t.get("sessionId") or t.get("id")
|
||||
if not sid:
|
||||
continue
|
||||
to_stop.append((sid, t.get("remoteHost") or "", t.get("sshPort") or ""))
|
||||
if not to_stop:
|
||||
return
|
||||
# Re-read state once before writing so we capture any updates from
|
||||
# concurrent UI syncs.
|
||||
stopped_any = False
|
||||
for sid, host, port in to_stop:
|
||||
ok = await _stop_serve(sid, host, port)
|
||||
logger.info(f"cookbook_serve_lifecycle: stop {sid} (host={host or 'local'}): {'ok' if ok else 'failed'}")
|
||||
if ok:
|
||||
stopped_any = True
|
||||
# Drop the auto-registered endpoint so the model picker and
|
||||
# the chat router don't keep pointing at a dead server.
|
||||
for t in tasks:
|
||||
if isinstance(t, dict) and (t.get("sessionId") == sid or t.get("id") == sid):
|
||||
if t.get("type") == "serve":
|
||||
await _delete_endpoint_for_task(t)
|
||||
t["status"] = "stopped"
|
||||
t["_scheduledStopAtMs"] = None
|
||||
t["_lastStatusFlipAt"] = now_ms
|
||||
break
|
||||
if stopped_any:
|
||||
try:
|
||||
from core.atomic_io import atomic_write_json
|
||||
state["tasks"] = tasks
|
||||
atomic_write_json(state_path, state)
|
||||
except Exception as e:
|
||||
logger.warning(f"cookbook_serve_lifecycle: state write failed: {e}")
|
||||
|
||||
|
||||
async def cookbook_serve_lifecycle_loop() -> None:
|
||||
"""Forever-loop. Registered as a startup task in app.py."""
|
||||
await asyncio.sleep(20) # let the rest of startup settle
|
||||
while True:
|
||||
try:
|
||||
await _tick()
|
||||
except Exception as e:
|
||||
logger.warning(f"cookbook_serve_lifecycle tick failed: {e}")
|
||||
await asyncio.sleep(60)
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
# src/copilot.py
|
||||
"""GitHub Copilot provider support.
|
||||
|
||||
Copilot exposes an OpenAI-compatible API at ``https://api.githubcopilot.com``
|
||||
(``/chat/completions`` + ``/models``). Authentication is a GitHub OAuth
|
||||
**device flow**: the user authorises a device code in their browser and we
|
||||
receive a long-lived ``access_token`` that is sent directly as
|
||||
``Authorization: Bearer <token>`` — there is no separate Copilot-token
|
||||
exchange and no refresh (mirrors how editors / opencode talk to Copilot).
|
||||
|
||||
The only provider-specific wrinkle beyond the bearer token is a handful of
|
||||
required request headers (API version, intent, an editor-style User-Agent,
|
||||
and ``x-initiator`` for agent-vs-user request accounting). Those live in
|
||||
:func:`copilot_headers`.
|
||||
|
||||
This module holds the constants + pure helpers; the HTTP device-flow calls
|
||||
live in :mod:`routes.copilot_routes` so they can be auth-gated.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Dict, List, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# GitHub OAuth client id used for the device flow. Copilot's token endpoint
|
||||
# only accepts client ids that GitHub has allow-listed for Copilot access, so
|
||||
# we reuse the public VS Code client id (the de-facto standard third-party
|
||||
# clients use). Override via env if you register your own allow-listed app.
|
||||
COPILOT_CLIENT_ID = os.environ.get(
|
||||
"ODYSSEUS_COPILOT_CLIENT_ID", "01ab8ac9400c4e429b23"
|
||||
)
|
||||
|
||||
# Dated API version header required by the Copilot API (models + chat).
|
||||
COPILOT_API_VERSION = os.environ.get(
|
||||
"ODYSSEUS_COPILOT_API_VERSION", "2026-06-01"
|
||||
)
|
||||
|
||||
# Public Copilot API base. GitHub Enterprise uses ``copilot-api.<domain>``.
|
||||
COPILOT_BASE = "https://api.githubcopilot.com"
|
||||
|
||||
# Copilot wants an editor-like User-Agent + integration id. These identify the
|
||||
# client to GitHub; keep them stable.
|
||||
COPILOT_USER_AGENT = os.environ.get(
|
||||
"ODYSSEUS_COPILOT_USER_AGENT", "Odysseus/1.0"
|
||||
)
|
||||
COPILOT_INTEGRATION_ID = os.environ.get(
|
||||
"ODYSSEUS_COPILOT_INTEGRATION_ID", "vscode-chat"
|
||||
)
|
||||
COPILOT_EDITOR_VERSION = os.environ.get(
|
||||
"ODYSSEUS_COPILOT_EDITOR_VERSION", "Odysseus/1.0"
|
||||
)
|
||||
|
||||
# OAuth scope requested during the device flow.
|
||||
COPILOT_SCOPE = "read:user"
|
||||
|
||||
# Default GitHub host for the device flow (public github.com).
|
||||
GITHUB_HOST = "github.com"
|
||||
|
||||
|
||||
def device_code_url(host: str = GITHUB_HOST) -> str:
|
||||
return f"https://{host}/login/device/code"
|
||||
|
||||
|
||||
def access_token_url(host: str = GITHUB_HOST) -> str:
|
||||
return f"https://{host}/login/oauth/access_token"
|
||||
|
||||
|
||||
def normalize_domain(url: str) -> str:
|
||||
"""Strip scheme/trailing slash from a GitHub Enterprise URL or domain."""
|
||||
return (url or "").replace("https://", "").replace("http://", "").rstrip("/")
|
||||
|
||||
|
||||
def enterprise_base(enterprise_url: Optional[str]) -> str:
|
||||
"""Return the Copilot API base for a deployment.
|
||||
|
||||
Public github.com → ``https://api.githubcopilot.com``.
|
||||
Enterprise <domain> → ``https://copilot-api.<domain>``.
|
||||
"""
|
||||
if not enterprise_url:
|
||||
return COPILOT_BASE
|
||||
return f"https://copilot-api.{normalize_domain(enterprise_url)}"
|
||||
|
||||
|
||||
def is_copilot_base(url: Optional[str]) -> bool:
|
||||
"""True if a base URL points at the Copilot API (public or enterprise)."""
|
||||
if not url:
|
||||
return False
|
||||
try:
|
||||
host = (urlparse(url).hostname or "").lower().rstrip(".")
|
||||
except Exception:
|
||||
return False
|
||||
if not host:
|
||||
return False
|
||||
# Public: api.githubcopilot.com (or any *.githubcopilot.com).
|
||||
if host == "githubcopilot.com" or host.endswith(".githubcopilot.com"):
|
||||
return True
|
||||
# Enterprise: copilot-api.<domain>.
|
||||
if host.startswith("copilot-api."):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def copilot_headers(
|
||||
api_key: Optional[str],
|
||||
*,
|
||||
agent: bool = False,
|
||||
vision: bool = False,
|
||||
) -> Dict[str, str]:
|
||||
"""Build the Copilot-specific request headers.
|
||||
|
||||
Args:
|
||||
api_key: the GitHub device-flow access token (sent as Bearer).
|
||||
agent: request originates from the agent loop (a tool-driven turn)
|
||||
rather than a direct user message. Sets ``x-initiator`` for
|
||||
Copilot's agent-vs-user request accounting.
|
||||
vision: the request carries an image part.
|
||||
"""
|
||||
headers: Dict[str, str] = {
|
||||
"X-GitHub-Api-Version": COPILOT_API_VERSION,
|
||||
"Openai-Intent": "conversation-edits",
|
||||
"User-Agent": COPILOT_USER_AGENT,
|
||||
"Editor-Version": COPILOT_EDITOR_VERSION,
|
||||
"Copilot-Integration-Id": COPILOT_INTEGRATION_ID,
|
||||
"x-initiator": "agent" if agent else "user",
|
||||
}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
if vision:
|
||||
headers["Copilot-Vision-Request"] = "true"
|
||||
return headers
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device-flow OAuth (pure HTTP; orchestration lives in routes.copilot_routes)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _oauth_post_headers() -> Dict[str, str]:
|
||||
return {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": COPILOT_USER_AGENT,
|
||||
}
|
||||
|
||||
|
||||
def request_device_code(host: str = GITHUB_HOST, *, timeout: float = 10.0) -> Dict:
|
||||
"""Start the device flow. Returns GitHub's
|
||||
``{device_code, user_code, verification_uri, expires_in, interval}``.
|
||||
"""
|
||||
r = httpx.post(
|
||||
device_code_url(host),
|
||||
headers=_oauth_post_headers(),
|
||||
json={"client_id": COPILOT_CLIENT_ID, "scope": COPILOT_SCOPE},
|
||||
timeout=timeout,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def poll_access_token(host: str, device_code: str, *, timeout: float = 10.0) -> Dict:
|
||||
"""Poll once for the access token. GitHub returns HTTP 200 with an
|
||||
``error`` field (``authorization_pending``/``slow_down``) while the user
|
||||
hasn't authorised yet, or ``{access_token, ...}`` once they have.
|
||||
"""
|
||||
r = httpx.post(
|
||||
access_token_url(host),
|
||||
headers=_oauth_post_headers(),
|
||||
json={
|
||||
"client_id": COPILOT_CLIENT_ID,
|
||||
"device_code": device_code,
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
||||
},
|
||||
timeout=timeout,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def fetch_models(base: str, token: str, *, timeout: float = 15.0) -> List[Dict]:
|
||||
"""Fetch Copilot's model catalogue, filtered to picker-enabled models.
|
||||
|
||||
Returns a list of ``{id, tool_calls, vision}`` dicts. Falls back to the
|
||||
full list if no model advertises ``model_picker_enabled`` (defensive
|
||||
against API-shape drift).
|
||||
"""
|
||||
url = base.rstrip("/") + "/models"
|
||||
r = httpx.get(url, headers=copilot_headers(token), timeout=timeout)
|
||||
r.raise_for_status()
|
||||
data = (r.json() or {}).get("data") or []
|
||||
|
||||
def _parse(item: Dict) -> Optional[Dict]:
|
||||
mid = item.get("id")
|
||||
if not mid:
|
||||
return None
|
||||
supports = ((item.get("capabilities") or {}).get("supports")) or {}
|
||||
return {
|
||||
"id": mid,
|
||||
"tool_calls": bool(supports.get("tool_calls")),
|
||||
"vision": bool(supports.get("vision")),
|
||||
"picker": bool(item.get("model_picker_enabled")),
|
||||
}
|
||||
|
||||
parsed = [p for p in (_parse(it) for it in data) if p]
|
||||
picker = [p for p in parsed if p["picker"]]
|
||||
chosen = picker or parsed
|
||||
for p in chosen:
|
||||
p.pop("picker", None)
|
||||
return chosen
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-request header flags
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_IMAGE_PART_TYPES = ("image_url", "input_image", "image")
|
||||
|
||||
|
||||
def request_flags(messages) -> tuple:
|
||||
"""Derive ``(agent, vision)`` from an OpenAI-style message list.
|
||||
|
||||
Mirrors opencode's logic:
|
||||
* ``agent`` — the last message is *not* a plain user message (i.e. it's a
|
||||
tool result / assistant follow-up), so Copilot should treat the request
|
||||
as agent-initiated for request accounting.
|
||||
* ``vision`` — any message carries an image content part.
|
||||
"""
|
||||
msgs = messages or []
|
||||
last = msgs[-1] if msgs else None
|
||||
agent = bool(last) and last.get("role") != "user"
|
||||
vision = False
|
||||
for m in msgs:
|
||||
content = m.get("content") if isinstance(m, dict) else None
|
||||
if isinstance(content, list) and any(
|
||||
isinstance(p, dict) and p.get("type") in _IMAGE_PART_TYPES for p in content
|
||||
):
|
||||
vision = True
|
||||
break
|
||||
return agent, vision
|
||||
|
||||
|
||||
def apply_request_headers(headers: Dict[str, str], messages) -> Dict[str, str]:
|
||||
"""Set ``x-initiator`` / ``Copilot-Vision-Request`` on a header dict based
|
||||
on the outgoing messages. Mutates and returns ``headers``."""
|
||||
agent, vision = request_flags(messages)
|
||||
headers["x-initiator"] = "agent" if agent else "user"
|
||||
if vision:
|
||||
headers["Copilot-Vision-Request"] = "true"
|
||||
return headers
|
||||
|
||||
@@ -196,6 +196,8 @@ class DeepResearcher:
|
||||
max_content_chars: int = 15000,
|
||||
max_report_tokens: int = 8192,
|
||||
extraction_timeout: int = 90,
|
||||
planning_timeout: int = 90,
|
||||
query_timeout: int = 120,
|
||||
extraction_concurrency: int = 3,
|
||||
min_rounds: int = 2,
|
||||
max_empty_rounds: int = 2,
|
||||
@@ -215,6 +217,8 @@ class DeepResearcher:
|
||||
self.max_content_chars = max_content_chars
|
||||
self.max_report_tokens = max_report_tokens
|
||||
self.extraction_timeout = min(3600, max(15, int(extraction_timeout or 90)))
|
||||
self.planning_timeout = min(3600, max(15, int(planning_timeout or 90)))
|
||||
self.query_timeout = min(3600, max(15, int(query_timeout or 120)))
|
||||
self.extraction_concurrency = min(12, max(1, int(extraction_concurrency or 3)))
|
||||
self.min_rounds = min_rounds
|
||||
self.max_empty_rounds = max_empty_rounds
|
||||
@@ -395,7 +399,7 @@ class DeepResearcher:
|
||||
[{"role": "user", "content": prompt}],
|
||||
temperature=0.3,
|
||||
max_tokens=1024,
|
||||
timeout=30,
|
||||
timeout=getattr(self, "planning_timeout", 90),
|
||||
)
|
||||
# Try to parse as JSON for structured plan
|
||||
parsed = self._parse_json_object(response)
|
||||
@@ -478,6 +482,7 @@ class DeepResearcher:
|
||||
[{"role": "user", "content": prompt}],
|
||||
temperature=0.5,
|
||||
max_tokens=4096,
|
||||
timeout=getattr(self, "query_timeout", 120),
|
||||
)
|
||||
queries = self._parse_json_array(response)
|
||||
# Deduplicate
|
||||
|
||||
@@ -194,6 +194,9 @@ def build_headers(api_key: Optional[str], base: str) -> Dict[str, str]:
|
||||
headers["x-api-key"] = api_key
|
||||
headers["anthropic-version"] = "2023-06-01"
|
||||
return headers
|
||||
if provider == "copilot":
|
||||
from src.copilot import copilot_headers
|
||||
return copilot_headers(api_key)
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
if provider == "openrouter":
|
||||
|
||||
+82
-14
@@ -67,7 +67,7 @@ _host_health_lock = threading.Lock()
|
||||
_model_activity: Dict[str, float] = {}
|
||||
|
||||
def _model_activity_key(url: str, model: str) -> str:
|
||||
return f"{(url or '').strip().rstrip()}|{(model or '').strip()}"
|
||||
return f"{(url or '').strip()}|{(model or '').strip()}"
|
||||
|
||||
def note_model_activity(url: str, model: str):
|
||||
"""Record that a real upstream request used this endpoint/model."""
|
||||
@@ -317,6 +317,9 @@ def _detect_provider(url: str) -> str:
|
||||
return "openrouter"
|
||||
if _host_match(url, "groq.com"):
|
||||
return "groq"
|
||||
from src.copilot import is_copilot_base
|
||||
if is_copilot_base(url):
|
||||
return "copilot"
|
||||
return "openai"
|
||||
|
||||
|
||||
@@ -327,6 +330,14 @@ def _provider_headers(provider: str, headers: Optional[Dict] = None) -> Dict[str
|
||||
if provider == "openrouter":
|
||||
h.setdefault("HTTP-Referer", "https://github.com/pewdiepie-archdaemon/odysseus")
|
||||
h.setdefault("X-OpenRouter-Title", "Odysseus")
|
||||
if provider == "copilot":
|
||||
# Ensure the Copilot-required headers are present even when the caller
|
||||
# didn't pass pre-built headers (e.g. model listing). build_headers()
|
||||
# already injects these for the live chat path; setdefault keeps any
|
||||
# request-specific values (x-initiator/vision) the caller set.
|
||||
from src.copilot import copilot_headers
|
||||
for k, v in copilot_headers(None).items():
|
||||
h.setdefault(k, v)
|
||||
return h
|
||||
|
||||
|
||||
@@ -340,6 +351,8 @@ def _provider_label(url: str) -> str:
|
||||
if _host_match(url, "openai.com"): return "OpenAI"
|
||||
if _host_match(url, "openrouter.ai"): return "OpenRouter"
|
||||
if _host_match(url, "groq.com"): return "Groq"
|
||||
from src.copilot import is_copilot_base
|
||||
if is_copilot_base(url): return "GitHub Copilot"
|
||||
if _host_match(url, "mistral.ai"): return "Mistral"
|
||||
if _host_match(url, "deepseek.com"): return "DeepSeek"
|
||||
if _host_match(url, "googleapis.com"): return "Google"
|
||||
@@ -481,7 +494,7 @@ def _build_anthropic_payload(model, messages, temperature, max_tokens, stream=Fa
|
||||
chat_messages = []
|
||||
for m in messages:
|
||||
if m.get("role") == "system":
|
||||
system_parts.append(m["content"])
|
||||
system_parts.append(m.get("content") or "")
|
||||
elif m.get("role") == "tool":
|
||||
# Convert OpenAI tool result to Anthropic format
|
||||
chat_messages.append({
|
||||
@@ -884,7 +897,7 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
|
||||
non_sys = []
|
||||
for m in messages_copy:
|
||||
if m.get("role") == "system":
|
||||
sys_parts.append(m["content"])
|
||||
sys_parts.append(m.get('content') or '')
|
||||
else:
|
||||
non_sys.append(m)
|
||||
if sys_parts:
|
||||
@@ -911,6 +924,9 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
|
||||
)
|
||||
else:
|
||||
target_url = url
|
||||
if provider == "copilot":
|
||||
from src.copilot import apply_request_headers
|
||||
apply_request_headers(h, messages_copy)
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": messages_copy,
|
||||
@@ -1028,7 +1044,7 @@ async def llm_call_async(
|
||||
non_sys = []
|
||||
for m in messages_copy:
|
||||
if m.get("role") == "system":
|
||||
sys_parts.append(m["content"])
|
||||
sys_parts.append(m.get('content') or '')
|
||||
else:
|
||||
non_sys.append(m)
|
||||
if sys_parts:
|
||||
@@ -1058,6 +1074,9 @@ async def llm_call_async(
|
||||
else:
|
||||
target_url = url
|
||||
h = _provider_headers(provider, headers)
|
||||
if provider == "copilot":
|
||||
from src.copilot import apply_request_headers
|
||||
apply_request_headers(h, messages_copy)
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": messages_copy,
|
||||
@@ -1088,6 +1107,9 @@ async def llm_call_async(
|
||||
f"LLM async call to {target_url} failed in {duration:.2f}s "
|
||||
f"(attempt {attempt}): HTTP {r.status_code} {friendly}"
|
||||
)
|
||||
if r.status_code in (429, 502, 503, 504) and attempt < max_retries:
|
||||
await asyncio.sleep(LLMConfig.RETRY_DELAY)
|
||||
continue
|
||||
raise HTTPException(r.status_code, friendly)
|
||||
logger.info(f"LLM async call to {target_url} succeeded in {duration:.2f}s (attempt {attempt})")
|
||||
_clear_host_dead(target_url)
|
||||
@@ -1109,7 +1131,9 @@ async def llm_call_async(
|
||||
duration = time.time() - start
|
||||
_tail = f" — host cooled for {DEAD_HOST_COOLDOWN:.0f}s" if _cooled else " — transient, will retry"
|
||||
logger.warning(f"LLM async connect to {target_url} failed after {duration:.2f}s: {e}{_tail}")
|
||||
raise HTTPException(503, f"Cannot reach {_host_key(target_url)}: {e}")
|
||||
if _cooled or attempt >= max_retries:
|
||||
raise HTTPException(503, f"Cannot reach {_host_key(target_url)}: {e}")
|
||||
await asyncio.sleep(LLMConfig.RETRY_DELAY)
|
||||
except (httpx.RequestError, httpx.HTTPStatusError) as e:
|
||||
duration = time.time() - start
|
||||
logger.warning(f"LLM async call attempt {attempt} failed after {duration:.2f}s: {e}")
|
||||
@@ -1138,7 +1162,7 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
||||
non_sys = []
|
||||
for m in messages_copy:
|
||||
if m.get("role") == "system":
|
||||
sys_parts.append(m["content"])
|
||||
sys_parts.append(m.get('content') or '')
|
||||
else:
|
||||
non_sys.append(m)
|
||||
if sys_parts:
|
||||
@@ -1177,6 +1201,9 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
||||
if tools:
|
||||
payload["tools"] = tools
|
||||
h = _provider_headers(provider, headers)
|
||||
if provider == "copilot":
|
||||
from src.copilot import apply_request_headers
|
||||
apply_request_headers(h, messages_copy)
|
||||
|
||||
# Short connect timeout: a reachable peer answers SYN in <100ms even on
|
||||
# Tailscale. 3s is plenty; 30s let one dead upstream wedge the UI.
|
||||
@@ -1358,6 +1385,8 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
||||
# can detect thinking-in-progress (some models output </think> but no <think>)
|
||||
_thinking_model = _supports_thinking(model)
|
||||
_first_content_sent = False
|
||||
_in_think_tag = False # True while consuming <think>…</think> content
|
||||
_think_open_stripped = False # opening <think> tag already removed
|
||||
|
||||
def _emit_tool_calls():
|
||||
"""Build the tool_calls event string if any were accumulated."""
|
||||
@@ -1439,14 +1468,53 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
||||
yield f'data: {json.dumps({"delta": reasoning, "thinking": True})}\n\n'
|
||||
content = delta.get("content") or ""
|
||||
if content:
|
||||
# Some thinking backends start normal content with a
|
||||
# stray closing tag. Repair only that shape; do not
|
||||
# wrap every first token for model families like
|
||||
# MiniMax, which often stream ordinary answers.
|
||||
if _thinking_model and not _first_content_sent and content.lstrip().lower().startswith("</think"):
|
||||
content = "<think>" + content
|
||||
_first_content_sent = True
|
||||
yield f'data: {json.dumps({"delta": content})}\n\n'
|
||||
stripped = content.lstrip()
|
||||
# Auto-detect <think>…</think> in content stream.
|
||||
# Covers Qwen3-derived models (Qwopus, QwQ forks) whose
|
||||
# names don't match _THINKING_MODEL_PATTERNS but still
|
||||
# emit literal <think> markup via llama.cpp --jinja.
|
||||
if not _first_content_sent and not _thinking_model and not _in_think_tag and stripped.lower().startswith("<think"):
|
||||
_thinking_model = True
|
||||
_in_think_tag = True
|
||||
if _in_think_tag:
|
||||
close_idx = content.lower().find("</think>")
|
||||
if close_idx != -1:
|
||||
# Split: up-to-</think> → thinking, remainder → content
|
||||
think_part = content[:close_idx]
|
||||
if not _think_open_stripped:
|
||||
# Strip the opening <think[...] > from the first chunk.
|
||||
# Use a dedicated flag — _first_content_sent stays False
|
||||
# throughout the think block, so it must not be reused.
|
||||
tag_end = think_part.lower().find(">")
|
||||
if tag_end != -1:
|
||||
think_part = think_part[tag_end + 1:]
|
||||
_think_open_stripped = True
|
||||
regular_part = content[close_idx + len("</think>"):]
|
||||
_in_think_tag = False
|
||||
if think_part:
|
||||
yield f'data: {json.dumps({"delta": think_part, "thinking": True})}\n\n'
|
||||
if regular_part:
|
||||
_first_content_sent = True
|
||||
yield f'data: {json.dumps({"delta": regular_part})}\n\n'
|
||||
else:
|
||||
# Still inside <think>: route to thinking channel
|
||||
if not _think_open_stripped:
|
||||
# Strip the opening <think[...] > tag (first chunk only)
|
||||
tag_end = stripped.lower().find(">")
|
||||
if tag_end != -1:
|
||||
content = stripped[tag_end + 1:]
|
||||
_think_open_stripped = True
|
||||
if content:
|
||||
yield f'data: {json.dumps({"delta": content, "thinking": True})}\n\n'
|
||||
else:
|
||||
# Some thinking backends start normal content with a
|
||||
# stray closing tag. Repair only that shape; do not
|
||||
# wrap every first token for model families like
|
||||
# MiniMax, which often stream ordinary answers.
|
||||
if _thinking_model and not _first_content_sent and stripped.lower().startswith("</think"):
|
||||
content = "<think>" + content
|
||||
_first_content_sent = True
|
||||
yield f'data: {json.dumps({"delta": content})}\n\n'
|
||||
# Native tool calls — accumulate across chunks
|
||||
for tc in delta.get("tool_calls") or []:
|
||||
if tc is None:
|
||||
|
||||
+164
-3
@@ -8,6 +8,7 @@ Each server exposes tools that are made available to the agent loop.
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -30,6 +31,64 @@ def _format_mcp_connection_error(name: str, command: str = "", args: Optional[Li
|
||||
return raw_error
|
||||
|
||||
|
||||
# Caps for rendering untrusted MCP tool schemas into the agent prompt (issue #2660).
|
||||
# MCP servers are third-party/user-added, so field names and parameter counts are
|
||||
# untrusted input — bound them so an odd or hostile schema cannot distort the prompt.
|
||||
_MCP_PARAM_MAX = 12 # max params rendered per tool
|
||||
_MCP_TOKEN_MAX = 40 # max chars per rendered name / type token
|
||||
_MCP_HINT_MAX = 300 # total-length backstop for the whole hint
|
||||
|
||||
|
||||
def _sanitize_schema_token(value: Any, limit: int = _MCP_TOKEN_MAX) -> str:
|
||||
"""Make an untrusted JSON-Schema token safe to splice into the prompt.
|
||||
|
||||
Replaces control chars / newlines with a space, collapses whitespace, and
|
||||
length-caps the result, so a weird field name or type cannot inject newlines
|
||||
or run on. Normal short identifiers pass through unchanged.
|
||||
"""
|
||||
text = re.sub(r"[\x00-\x1f\x7f]+", " ", str(value))
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
if len(text) > limit:
|
||||
text = text[:limit].rstrip() + "…"
|
||||
return text
|
||||
|
||||
|
||||
def _format_mcp_params(input_schema: Any) -> str:
|
||||
"""Render an MCP tool's JSON-Schema inputs as a compact prompt hint.
|
||||
|
||||
Without this the agent only sees a tool's name + description and has to
|
||||
guess its arguments (issue #2509). Produces e.g.
|
||||
` Args (JSON): {"path": string (required), "limit": integer}` — names,
|
||||
coarse types, and required-ness, kept short so it stays prompt-friendly.
|
||||
Returns "" when there are no parameters.
|
||||
|
||||
MCP servers are third-party, so names/types are sanitized and the parameter
|
||||
count + total length are capped (issue #2660); normal schemas are unaffected.
|
||||
"""
|
||||
if not isinstance(input_schema, dict):
|
||||
return ""
|
||||
props = input_schema.get("properties")
|
||||
if not isinstance(props, dict) or not props:
|
||||
return ""
|
||||
required = set(input_schema.get("required") or [])
|
||||
parts = []
|
||||
for pname, pinfo in list(props.items())[:_MCP_PARAM_MAX]:
|
||||
pinfo = pinfo if isinstance(pinfo, dict) else {}
|
||||
ptype = pinfo.get("type") or "any"
|
||||
if isinstance(ptype, list):
|
||||
ptype = "|".join(str(x) for x in ptype)
|
||||
tag = f'"{_sanitize_schema_token(pname)}": {_sanitize_schema_token(ptype)}'
|
||||
if pname in required:
|
||||
tag += " (required)"
|
||||
parts.append(tag)
|
||||
extra = len(props) - len(parts)
|
||||
if extra > 0:
|
||||
parts.append(f"…+{extra} more")
|
||||
hint = " Args (JSON): {" + ", ".join(parts) + "}"
|
||||
if len(hint) > _MCP_HINT_MAX:
|
||||
hint = hint[:_MCP_HINT_MAX - 1].rstrip() + "…"
|
||||
return hint
|
||||
|
||||
|
||||
class McpManager:
|
||||
"""Manages MCP server connections and tool routing."""
|
||||
@@ -43,7 +102,9 @@ class McpManager:
|
||||
self._sessions: Dict[str, Any] = {}
|
||||
# server_id -> exit stack (for cleanup)
|
||||
self._stacks: Dict[str, Any] = {}
|
||||
# Tracking updates to tools/connections for RAG indexing
|
||||
# server_id -> background connect task (HTTP transport / OAuth)
|
||||
self._connect_tasks: Dict[str, Any] = {}
|
||||
# Tracking updates to tools/connections for RAG indexing / prompt cache
|
||||
self._generation = 0
|
||||
|
||||
async def connect_server(
|
||||
@@ -56,12 +117,14 @@ class McpManager:
|
||||
env: Optional[Dict[str, str]] = None,
|
||||
url: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Connect to an MCP server via stdio or SSE transport."""
|
||||
"""Connect to an MCP server via stdio, SSE, or Streamable HTTP transport."""
|
||||
try:
|
||||
if transport == "stdio":
|
||||
res = await self._connect_stdio(server_id, name, command, args or [], env or {})
|
||||
elif transport == "sse":
|
||||
res = await self._connect_sse(server_id, name, url)
|
||||
elif transport == "http":
|
||||
res = await self._start_http_connect(server_id, name, url)
|
||||
else:
|
||||
logger.error(f"Unknown MCP transport: {transport}")
|
||||
res = False
|
||||
@@ -184,8 +247,101 @@ class McpManager:
|
||||
self._connections[server_id] = {"status": "error", "error": "mcp package not installed", "name": name}
|
||||
return False
|
||||
|
||||
async def _start_http_connect(self, server_id: str, name: str, url: str, wait: float = 8.0) -> bool:
|
||||
"""Begin a Streamable HTTP connect in the background. Returns within
|
||||
`wait` seconds: True if it connected (cached-token path), otherwise the
|
||||
flow is awaiting browser authorization and status becomes 'needs_auth'."""
|
||||
import asyncio
|
||||
self._connections[server_id] = {"status": "connecting", "name": name, "transport": "http"}
|
||||
task = asyncio.create_task(self._connect_http(server_id, name, url))
|
||||
self._connect_tasks[server_id] = task
|
||||
done, _ = await asyncio.wait({task}, timeout=wait)
|
||||
if task in done:
|
||||
try:
|
||||
return task.result()
|
||||
except Exception as e:
|
||||
self._connections[server_id] = {"status": "error", "error": str(e), "name": name}
|
||||
return False
|
||||
# Still running → either awaiting authorization, or discovery/DCR is
|
||||
# still in flight. If _on_redirect already published needs_auth+auth_url,
|
||||
# leave it; otherwise mark needs_auth (auth_url filled in once it fires).
|
||||
from src.mcp_oauth import pop_auth_url
|
||||
cur = self._connections.get(server_id, {})
|
||||
if cur.get("status") != "needs_auth":
|
||||
self._connections[server_id] = {
|
||||
"status": "needs_auth", "name": name, "transport": "http",
|
||||
"auth_url": pop_auth_url(server_id),
|
||||
}
|
||||
return False
|
||||
|
||||
async def _connect_http(self, server_id: str, name: str, url: str) -> bool:
|
||||
"""Connect to a Streamable HTTP MCP server (with automatic OAuth)."""
|
||||
try:
|
||||
from mcp import ClientSession
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
from contextlib import AsyncExitStack
|
||||
from src.mcp_oauth import build_provider, clear_auth_url
|
||||
|
||||
def _on_redirect(auth_url):
|
||||
# Publish needs_auth the moment the URL is known, independent of
|
||||
# how long discovery/DCR took (may exceed the bounded start wait).
|
||||
self._connections[server_id] = {
|
||||
"status": "needs_auth", "name": name, "transport": "http",
|
||||
"auth_url": auth_url,
|
||||
}
|
||||
|
||||
provider = build_provider(server_id, url, on_redirect=_on_redirect)
|
||||
stack = AsyncExitStack()
|
||||
transport = await stack.enter_async_context(streamablehttp_client(url, auth=provider))
|
||||
read_stream, write_stream, _get_session_id = transport
|
||||
session = await stack.enter_async_context(ClientSession(read_stream, write_stream))
|
||||
await session.initialize()
|
||||
|
||||
tools_result = await session.list_tools()
|
||||
tools = []
|
||||
for tool in tools_result.tools:
|
||||
tools.append({
|
||||
"name": tool.name,
|
||||
"description": tool.description or "",
|
||||
"input_schema": tool.inputSchema if hasattr(tool, "inputSchema") else {},
|
||||
})
|
||||
|
||||
self._sessions[server_id] = session
|
||||
self._stacks[server_id] = stack
|
||||
self._tools[server_id] = tools
|
||||
self._connections[server_id] = {
|
||||
"status": "connected", "name": name, "transport": "http",
|
||||
"tool_count": len(tools),
|
||||
}
|
||||
clear_auth_url(server_id)
|
||||
# Tools changed (this can complete after connect_server already
|
||||
# returned, via the background OAuth flow), so bump the generation
|
||||
# to invalidate the tool-prompt cache.
|
||||
self._generation += 1
|
||||
logger.info(f"MCP server connected: {name} ({server_id}) - {len(tools)} tools via http")
|
||||
return True
|
||||
except ImportError:
|
||||
logger.warning("MCP package not installed. Install with: pip install mcp")
|
||||
self._connections[server_id] = {"status": "error", "error": "mcp package not installed", "name": name}
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect HTTP MCP server {name} ({server_id}): {e}")
|
||||
self._connections[server_id] = {"status": "error", "error": str(e), "name": name}
|
||||
return False
|
||||
|
||||
async def disconnect_server(self, server_id: str):
|
||||
"""Disconnect from an MCP server."""
|
||||
# Cancel any in-flight HTTP/OAuth background connect so it stops
|
||||
# publishing status for a server that may be getting deleted.
|
||||
task = self._connect_tasks.pop(server_id, None)
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
try:
|
||||
from src.mcp_oauth import clear_auth_url
|
||||
clear_auth_url(server_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
stack = self._stacks.pop(server_id, None)
|
||||
if stack:
|
||||
try:
|
||||
@@ -376,6 +532,7 @@ class McpManager:
|
||||
"name": tool["name"],
|
||||
"qualified_name": f"mcp__{server_id}__{tool['name']}",
|
||||
"description": tool.get("description", ""),
|
||||
"input_schema": tool.get("input_schema") or {},
|
||||
"is_disabled": tool["name"] in disabled,
|
||||
})
|
||||
return result
|
||||
@@ -439,7 +596,11 @@ class McpManager:
|
||||
for t in server_tools:
|
||||
# Truncate long descriptions
|
||||
desc = t['description'][:120] + '...' if len(t['description']) > 120 else t['description']
|
||||
lines.append(f" - {t['qualified_name']}: {desc}")
|
||||
# Include the tool's declared inputs so the model calls it with
|
||||
# real argument names instead of guessing from the description
|
||||
# alone (issue #2509).
|
||||
args_hint = _format_mcp_params(t.get("input_schema"))
|
||||
lines.append(f" - {t['qualified_name']}: {desc}{args_hint}")
|
||||
|
||||
result = "\n".join(lines)
|
||||
self._cached_prompt_desc = result
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"""mcp_oauth.py — generic OAuth for remote (Streamable HTTP) MCP servers.
|
||||
|
||||
Bridges the mcp SDK's OAuthClientProvider (RFC 9728 discovery, Dynamic Client
|
||||
Registration, authorization-code + PKCE, token refresh) to Odysseus's web
|
||||
callback route. Tokens and the dynamic registration persist per-server,
|
||||
encrypted, so the interactive flow runs only once.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Dict, Optional, Tuple
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# OAuth redirect URI registered with every authorization server via DCR. Loopback
|
||||
# is allowed for native/desktop clients (RFC 8252); remote users finish via the
|
||||
# paste-back flow. Deployments not reachable at http://localhost:7000 (custom
|
||||
# port, reverse proxy, or public domain) must set OAUTH_REDIRECT_BASE_URL (or
|
||||
# APP_PUBLIC_URL) to their externally reachable origin so the redirect lands back
|
||||
# on Odysseus. APP_PORT is intentionally not used: it is only the Docker host
|
||||
# port-map; the app always listens on 7000 inside the container.
|
||||
_REDIRECT_BASE = (
|
||||
os.environ.get("OAUTH_REDIRECT_BASE_URL")
|
||||
or os.environ.get("APP_PUBLIC_URL")
|
||||
or "http://localhost:7000"
|
||||
).rstrip("/")
|
||||
REDIRECT_URI = f"{_REDIRECT_BASE}/api/mcp/oauth/callback"
|
||||
|
||||
# How long the background connect waits for the user to authorize before giving up.
|
||||
AUTH_WAIT_SECONDS = 300
|
||||
|
||||
_pending: Dict[str, asyncio.Future] = {} # state -> Future[(code, state)]
|
||||
_pending_ts: Dict[str, float] = {} # state -> monotonic timestamp, for pruning
|
||||
_auth_urls: Dict[str, str] = {} # server_id -> authorization URL
|
||||
|
||||
|
||||
def _prune_stale() -> None:
|
||||
"""Drop abandoned flows whose authorization window has elapsed so the
|
||||
module-level registries don't grow unbounded (e.g. a user who never
|
||||
finishes the browser step)."""
|
||||
now = time.monotonic()
|
||||
for state in [s for s, ts in _pending_ts.items() if now - ts > AUTH_WAIT_SECONDS]:
|
||||
fut = _pending.pop(state, None)
|
||||
_pending_ts.pop(state, None)
|
||||
if fut is not None and not fut.done():
|
||||
fut.cancel()
|
||||
|
||||
|
||||
def _discard_pending(state: Optional[str]) -> None:
|
||||
if state is None:
|
||||
return
|
||||
_pending.pop(state, None)
|
||||
_pending_ts.pop(state, None)
|
||||
|
||||
|
||||
def register_pending(state: str) -> asyncio.Future:
|
||||
_prune_stale()
|
||||
fut = asyncio.get_running_loop().create_future()
|
||||
_pending[state] = fut
|
||||
_pending_ts[state] = time.monotonic()
|
||||
return fut
|
||||
|
||||
|
||||
def resolve_pending(state: str, code: str) -> bool:
|
||||
fut = _pending.get(state)
|
||||
if fut is not None and not fut.done():
|
||||
fut.set_result((code, state))
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def pop_auth_url(server_id: str) -> Optional[str]:
|
||||
return _auth_urls.get(server_id)
|
||||
|
||||
|
||||
def clear_auth_url(server_id: str) -> None:
|
||||
_auth_urls.pop(server_id, None)
|
||||
|
||||
|
||||
class DbTokenStorage:
|
||||
"""SDK TokenStorage backed by the encrypted McpServer.oauth_tokens column."""
|
||||
|
||||
def __init__(self, server_id: str, session_factory=None):
|
||||
self.server_id = server_id
|
||||
if session_factory is None:
|
||||
from core.database import SessionLocal
|
||||
session_factory = SessionLocal
|
||||
self._sf = session_factory
|
||||
|
||||
def _load(self) -> dict:
|
||||
from core.database import McpServer
|
||||
db = self._sf()
|
||||
try:
|
||||
srv = db.query(McpServer).filter(McpServer.id == self.server_id).first()
|
||||
if srv and srv.oauth_tokens:
|
||||
return json.loads(srv.oauth_tokens)
|
||||
finally:
|
||||
db.close()
|
||||
return {}
|
||||
|
||||
def _update(self, key: str, value: dict) -> None:
|
||||
"""Load, set one key, and persist the oauth_tokens JSON in a single
|
||||
session/commit (avoids the load+save double round-trip per write)."""
|
||||
from core.database import McpServer
|
||||
db = self._sf()
|
||||
try:
|
||||
srv = db.query(McpServer).filter(McpServer.id == self.server_id).first()
|
||||
if srv is None:
|
||||
return
|
||||
data = json.loads(srv.oauth_tokens) if srv.oauth_tokens else {}
|
||||
data[key] = value
|
||||
srv.oauth_tokens = json.dumps(data)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
async def get_tokens(self):
|
||||
from mcp.shared.auth import OAuthToken
|
||||
data = self._load().get("tokens")
|
||||
return OAuthToken.model_validate(data) if data else None
|
||||
|
||||
async def set_tokens(self, tokens) -> None:
|
||||
self._update("tokens", json.loads(tokens.model_dump_json()))
|
||||
|
||||
async def get_client_info(self):
|
||||
from mcp.shared.auth import OAuthClientInformationFull
|
||||
data = self._load().get("client_info")
|
||||
return OAuthClientInformationFull.model_validate(data) if data else None
|
||||
|
||||
async def set_client_info(self, client_info) -> None:
|
||||
self._update("client_info", json.loads(client_info.model_dump_json()))
|
||||
|
||||
|
||||
def build_provider(server_id: str, url: str, on_redirect=None):
|
||||
"""Construct an OAuthClientProvider that drives the browser flow via the
|
||||
Odysseus callback route.
|
||||
|
||||
on_redirect(authorization_url): optional sync callback invoked the moment
|
||||
the authorization URL is known (after discovery + DCR). The manager uses it
|
||||
to publish 'needs_auth' + auth_url to connection state regardless of how
|
||||
long discovery/DCR took.
|
||||
"""
|
||||
from mcp.client.auth import OAuthClientProvider
|
||||
from mcp.shared.auth import OAuthClientMetadata
|
||||
|
||||
client_metadata = OAuthClientMetadata(
|
||||
client_name="Odysseus",
|
||||
redirect_uris=[REDIRECT_URI],
|
||||
grant_types=["authorization_code", "refresh_token"],
|
||||
response_types=["code"],
|
||||
# Leave scope unset: the SDK applies the MCP scope-selection strategy and
|
||||
# overwrites this from the server's WWW-Authenticate / protected-resource
|
||||
# metadata before building the auth URL. Hardcoding an OIDC scope here
|
||||
# would break the many MCP servers that are not OpenID providers.
|
||||
scope=None,
|
||||
token_endpoint_auth_method="none",
|
||||
)
|
||||
|
||||
async def redirect_handler(authorization_url: str) -> None:
|
||||
state = (parse_qs(urlparse(authorization_url).query).get("state") or [None])[0]
|
||||
if state:
|
||||
register_pending(state)
|
||||
_auth_urls[server_id] = authorization_url
|
||||
if on_redirect is not None:
|
||||
try:
|
||||
on_redirect(authorization_url)
|
||||
except Exception as e:
|
||||
logger.warning(f"MCP OAuth on_redirect callback failed: {e}")
|
||||
logger.info(f"MCP OAuth: server {server_id} awaiting authorization (state={state})")
|
||||
|
||||
async def callback_handler() -> Tuple[str, Optional[str]]:
|
||||
auth_url = _auth_urls.get(server_id)
|
||||
state = (parse_qs(urlparse(auth_url).query).get("state") or [None])[0] if auth_url else None
|
||||
fut = _pending.get(state)
|
||||
if fut is None:
|
||||
raise RuntimeError("No pending OAuth flow for this server")
|
||||
try:
|
||||
code, ret_state = await asyncio.wait_for(fut, timeout=AUTH_WAIT_SECONDS)
|
||||
return code, ret_state
|
||||
finally:
|
||||
_discard_pending(state)
|
||||
_auth_urls.pop(server_id, None)
|
||||
|
||||
return OAuthClientProvider(
|
||||
server_url=url,
|
||||
client_metadata=client_metadata,
|
||||
storage=DbTokenStorage(server_id),
|
||||
redirect_handler=redirect_handler,
|
||||
callback_handler=callback_handler,
|
||||
)
|
||||
@@ -0,0 +1,320 @@
|
||||
"""Memory provider interfaces for native and external memory systems."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoryRecord:
|
||||
"""Provider-neutral memory entry."""
|
||||
|
||||
id: str
|
||||
text: str
|
||||
timestamp: int = 0
|
||||
category: str = "fact"
|
||||
source: str = "unknown"
|
||||
owner: Optional[str] = None
|
||||
session_id: Optional[str] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemorySearchHit:
|
||||
"""A memory returned by provider recall."""
|
||||
|
||||
memory: MemoryRecord
|
||||
provider_id: str
|
||||
score: Optional[float] = None
|
||||
|
||||
|
||||
class MemoryProvider(ABC):
|
||||
"""Base contract for Odysseus memory providers.
|
||||
|
||||
The native memory provider should always be available. External providers
|
||||
can add recall/write behavior and their own tools without replacing the
|
||||
built-in local memory baseline.
|
||||
"""
|
||||
|
||||
provider_id = "unknown"
|
||||
display_name = "Unknown"
|
||||
enabled = True
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Prepare provider resources before use."""
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
"""Release provider resources."""
|
||||
|
||||
@abstractmethod
|
||||
async def remember(
|
||||
self,
|
||||
text: str,
|
||||
*,
|
||||
owner: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
category: str = "fact",
|
||||
source: str = "user",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> MemoryRecord:
|
||||
"""Store a memory and return the stored record."""
|
||||
|
||||
@abstractmethod
|
||||
async def recall(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
owner: Optional[str] = None,
|
||||
top_k: int = 5,
|
||||
) -> List[MemorySearchHit]:
|
||||
"""Return provider memories relevant to the query."""
|
||||
|
||||
@abstractmethod
|
||||
async def list_memories(
|
||||
self,
|
||||
*,
|
||||
owner: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
) -> List[MemoryRecord]:
|
||||
"""List memories visible to the owner."""
|
||||
|
||||
@abstractmethod
|
||||
async def delete(self, memory_id: str, *, owner: Optional[str] = None) -> bool:
|
||||
"""Delete a memory by ID when allowed by the provider."""
|
||||
|
||||
def get_tool_schemas(self) -> List[Dict[str, Any]]:
|
||||
"""Return provider-defined tool schemas when this provider is enabled."""
|
||||
return []
|
||||
|
||||
async def handle_tool_call(self, name: str, arguments: Dict[str, Any]) -> Any:
|
||||
"""Handle a provider-defined tool call."""
|
||||
raise KeyError(f"Provider {self.provider_id} does not expose tool {name}")
|
||||
|
||||
|
||||
class NativeMemoryProvider(MemoryProvider):
|
||||
"""Provider adapter for Odysseus' built-in memory manager and vector store."""
|
||||
|
||||
provider_id = "native"
|
||||
display_name = "Odysseus native memory"
|
||||
|
||||
_CORE_FIELDS = {
|
||||
"id",
|
||||
"text",
|
||||
"timestamp",
|
||||
"source",
|
||||
"category",
|
||||
"uses",
|
||||
"owner",
|
||||
"session_id",
|
||||
"metadata",
|
||||
}
|
||||
|
||||
def __init__(self, memory_manager, memory_vector=None):
|
||||
self.memory_manager = memory_manager
|
||||
self.memory_vector = memory_vector
|
||||
|
||||
def _to_record(self, entry: Dict[str, Any]) -> MemoryRecord:
|
||||
metadata = {
|
||||
key: value
|
||||
for key, value in entry.items()
|
||||
if key not in self._CORE_FIELDS
|
||||
}
|
||||
stored_metadata = entry.get("metadata")
|
||||
if isinstance(stored_metadata, dict):
|
||||
metadata.update(stored_metadata)
|
||||
|
||||
return MemoryRecord(
|
||||
id=entry.get("id", ""),
|
||||
text=entry.get("text", ""),
|
||||
timestamp=entry.get("timestamp", 0),
|
||||
category=entry.get("category", "fact"),
|
||||
source=entry.get("source", "unknown"),
|
||||
owner=entry.get("owner"),
|
||||
session_id=entry.get("session_id"),
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
async def remember(
|
||||
self,
|
||||
text: str,
|
||||
*,
|
||||
owner: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
category: str = "fact",
|
||||
source: str = "user",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> MemoryRecord:
|
||||
entry = self.memory_manager.add_entry(
|
||||
text,
|
||||
source=source,
|
||||
category=category,
|
||||
owner=owner,
|
||||
)
|
||||
if session_id:
|
||||
entry["session_id"] = session_id
|
||||
if metadata:
|
||||
entry["metadata"] = dict(metadata)
|
||||
|
||||
memories = self.memory_manager.load_all()
|
||||
memories.append(entry)
|
||||
self.memory_manager.save(memories)
|
||||
|
||||
if self._vector_available():
|
||||
self.memory_vector.add(entry["id"], entry["text"])
|
||||
|
||||
return self._to_record(entry)
|
||||
|
||||
async def recall(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
owner: Optional[str] = None,
|
||||
top_k: int = 5,
|
||||
) -> List[MemorySearchHit]:
|
||||
memories = self.memory_manager.load(owner=owner)
|
||||
by_id = {m.get("id"): m for m in memories}
|
||||
|
||||
if self._vector_available():
|
||||
hits: List[MemorySearchHit] = []
|
||||
for result in self.memory_vector.search(query, k=top_k):
|
||||
if not isinstance(result, dict):
|
||||
continue
|
||||
memory_id = result.get("memory_id")
|
||||
entry = by_id.get(memory_id) if memory_id else result
|
||||
if not entry:
|
||||
continue
|
||||
if owner is not None and entry.get("owner") != owner:
|
||||
continue
|
||||
hits.append(
|
||||
MemorySearchHit(
|
||||
memory=self._to_record(entry),
|
||||
provider_id=self.provider_id,
|
||||
score=result.get("score"),
|
||||
)
|
||||
)
|
||||
if hits:
|
||||
return hits
|
||||
|
||||
fallback = self.memory_manager.get_relevant_memories(
|
||||
query,
|
||||
memories,
|
||||
max_items=top_k,
|
||||
)
|
||||
return [
|
||||
MemorySearchHit(
|
||||
memory=self._to_record(entry),
|
||||
provider_id=self.provider_id,
|
||||
score=None,
|
||||
)
|
||||
for entry in fallback
|
||||
]
|
||||
|
||||
async def list_memories(
|
||||
self,
|
||||
*,
|
||||
owner: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
) -> List[MemoryRecord]:
|
||||
return [
|
||||
self._to_record(entry)
|
||||
for entry in self.memory_manager.load(owner=owner)[:limit]
|
||||
]
|
||||
|
||||
async def delete(self, memory_id: str, *, owner: Optional[str] = None) -> bool:
|
||||
memories = self.memory_manager.load_all()
|
||||
remaining = []
|
||||
deleted_id = None
|
||||
|
||||
for entry in memories:
|
||||
if entry.get("id") != memory_id:
|
||||
remaining.append(entry)
|
||||
continue
|
||||
if owner is not None and entry.get("owner") != owner:
|
||||
remaining.append(entry)
|
||||
continue
|
||||
deleted_id = entry.get("id")
|
||||
|
||||
if deleted_id is None:
|
||||
return False
|
||||
|
||||
self.memory_manager.save(remaining)
|
||||
if self._vector_available():
|
||||
self.memory_vector.remove(deleted_id)
|
||||
return True
|
||||
|
||||
def _vector_available(self) -> bool:
|
||||
return bool(self.memory_vector and getattr(self.memory_vector, "healthy", True))
|
||||
|
||||
|
||||
class MemoryProviderRegistry:
|
||||
"""Container for native and optional external memory providers."""
|
||||
|
||||
def __init__(self, providers: Optional[Iterable[MemoryProvider]] = None):
|
||||
self._providers: Dict[str, MemoryProvider] = {}
|
||||
for provider in providers or []:
|
||||
self.register(provider)
|
||||
|
||||
def register(self, provider: MemoryProvider) -> None:
|
||||
if provider.provider_id in self._providers:
|
||||
raise ValueError(f"Memory provider already registered: {provider.provider_id}")
|
||||
self._providers[provider.provider_id] = provider
|
||||
|
||||
def get(self, provider_id: str) -> MemoryProvider:
|
||||
return self._providers[provider_id]
|
||||
|
||||
def all(self) -> List[MemoryProvider]:
|
||||
return list(self._providers.values())
|
||||
|
||||
def active(self) -> List[MemoryProvider]:
|
||||
return [provider for provider in self._providers.values() if provider.enabled]
|
||||
|
||||
def get_tool_schemas(self) -> List[Dict[str, Any]]:
|
||||
schemas: List[Dict[str, Any]] = []
|
||||
seen: Dict[str, str] = {}
|
||||
|
||||
for provider in self.active():
|
||||
for schema in provider.get_tool_schemas():
|
||||
name = self._tool_name(schema)
|
||||
if name in seen:
|
||||
raise ValueError(
|
||||
f"Memory tool name conflict: {name} from "
|
||||
f"{provider.provider_id} already exposed by {seen[name]}"
|
||||
)
|
||||
seen[name] = provider.provider_id
|
||||
schemas.append(schema)
|
||||
|
||||
return schemas
|
||||
|
||||
async def handle_tool_call(self, name: str, arguments: Dict[str, Any]) -> Any:
|
||||
provider_by_tool: Dict[str, MemoryProvider] = {}
|
||||
for provider in self.active():
|
||||
for schema in provider.get_tool_schemas():
|
||||
tool_name = self._tool_name(schema)
|
||||
if tool_name in provider_by_tool:
|
||||
raise ValueError(
|
||||
f"Memory tool name conflict: {tool_name} from "
|
||||
f"{provider.provider_id} already exposed by "
|
||||
f"{provider_by_tool[tool_name].provider_id}"
|
||||
)
|
||||
provider_by_tool[tool_name] = provider
|
||||
|
||||
provider = provider_by_tool.get(name)
|
||||
if provider:
|
||||
return await provider.handle_tool_call(name, arguments)
|
||||
raise KeyError(f"No active memory provider exposes tool {name}")
|
||||
|
||||
@staticmethod
|
||||
def _tool_name(schema: Dict[str, Any]) -> str:
|
||||
if not isinstance(schema, dict):
|
||||
raise ValueError("Memory provider tool schema must be a dict")
|
||||
name = schema.get("name")
|
||||
if isinstance(name, str) and name:
|
||||
return name
|
||||
function = schema.get("function")
|
||||
if isinstance(function, dict):
|
||||
function_name = function.get("name")
|
||||
if isinstance(function_name, str) and function_name:
|
||||
return function_name
|
||||
raise ValueError("Memory provider tool schema is missing a tool name")
|
||||
+21
-6
@@ -7,7 +7,7 @@ Provides token estimation for context usage tracking.
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from typing import Dict, List, Optional
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -208,27 +208,32 @@ KNOWN_CONTEXT_WINDOWS = {
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cache
|
||||
# ---------------------------------------------------------------------------
|
||||
_context_cache: Dict[str, int] = {}
|
||||
_context_cache: Dict[Tuple[str, str], int] = {}
|
||||
|
||||
|
||||
def get_context_length(endpoint_url: str, model: str) -> int:
|
||||
"""Get the context window size for a model.
|
||||
|
||||
Queries /v1/models on the endpoint and looks for context_length
|
||||
or context_window fields. Caches result per model ID.
|
||||
or context_window fields. Caches result per (endpoint, model).
|
||||
Falls back to DEFAULT_CONTEXT if unavailable.
|
||||
"""
|
||||
configured_kind = _configured_endpoint_kind(endpoint_url)
|
||||
is_local = _is_local_endpoint(endpoint_url)
|
||||
if not is_local and model in _context_cache:
|
||||
return _context_cache[model]
|
||||
# Key on (endpoint_url, model): the same model id can be served by two
|
||||
# different remote endpoints with different real context windows (e.g. a
|
||||
# capped proxy vs. the full provider), so caching by model id alone would
|
||||
# serve one endpoint's window for the other (issue #2603).
|
||||
cache_key = (endpoint_url, model)
|
||||
if not is_local and cache_key in _context_cache:
|
||||
return _context_cache[cache_key]
|
||||
|
||||
ctx = _query_context_length(endpoint_url, model)
|
||||
# Only cache non-default values to allow retry on next request.
|
||||
# Local endpoints can restart with a different --max-model-len while keeping
|
||||
# the same model id, so always re-query them instead of serving stale cache.
|
||||
if not is_local and (ctx != DEFAULT_CONTEXT or configured_kind in ("api", "proxy")):
|
||||
_context_cache[model] = ctx
|
||||
_context_cache[cache_key] = ctx
|
||||
logger.info(f"Context length for {model}: {ctx}")
|
||||
return ctx
|
||||
|
||||
@@ -282,6 +287,16 @@ def _query_context_length(endpoint_url: str, model: str) -> int:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# GitHub Copilot's /models requires auth + X-GitHub-Api-Version headers that
|
||||
# aren't available here; an unauthenticated probe just 400s. All Copilot
|
||||
# picker models are major API models covered by the known-context table, so
|
||||
# rely on that instead of a doomed network call.
|
||||
from src.copilot import is_copilot_base
|
||||
if is_copilot_base(endpoint_url):
|
||||
if known:
|
||||
logger.info(f"Using known context window for {model}: {known}")
|
||||
return known or DEFAULT_CONTEXT
|
||||
|
||||
models_url = endpoint_url.replace("/chat/completions", "/models")
|
||||
try:
|
||||
r = httpx.get(models_url, timeout=REQUEST_TIMEOUT)
|
||||
|
||||
@@ -722,6 +722,18 @@ class ResearchHandler:
|
||||
minimum=1,
|
||||
maximum=12,
|
||||
)
|
||||
_planning_timeout = _bounded_int(
|
||||
get_setting("research_planning_timeout_seconds", _extraction_timeout),
|
||||
default=_extraction_timeout,
|
||||
minimum=15,
|
||||
maximum=3600,
|
||||
)
|
||||
_query_timeout = _bounded_int(
|
||||
get_setting("research_query_timeout_seconds", _extraction_timeout),
|
||||
default=_extraction_timeout,
|
||||
minimum=15,
|
||||
maximum=3600,
|
||||
)
|
||||
|
||||
researcher = DeepResearcher(
|
||||
llm_endpoint=llm_endpoint,
|
||||
@@ -732,6 +744,8 @@ class ResearchHandler:
|
||||
max_time=max_time,
|
||||
max_report_tokens=_max_report_tokens,
|
||||
extraction_timeout=_extraction_timeout,
|
||||
planning_timeout=_planning_timeout,
|
||||
query_timeout=_query_timeout,
|
||||
extraction_concurrency=_extraction_concurrency,
|
||||
progress_callback=progress_callback,
|
||||
search_provider=search_provider,
|
||||
|
||||
+8
-137
@@ -1,141 +1,12 @@
|
||||
"""Search analytics, metrics tracking, and exception hierarchy."""
|
||||
"""Compatibility re-export shim for the live analytics module.
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
The real implementation lives in :mod:`services.search.analytics`, which is
|
||||
what the search runtime imports. Alias this module to that implementation so
|
||||
mutable module state such as ``ANALYTICS_FILE`` cannot drift out of sync.
|
||||
"""
|
||||
|
||||
from .cache import cache_metrics
|
||||
import sys
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from services.search import analytics as _analytics
|
||||
|
||||
# Dedicated error logger with file handler
|
||||
_error_log_path = Path(__file__).resolve().parent.parent / "search_engine_error.log"
|
||||
_error_handler = logging.FileHandler(_error_log_path, encoding="utf-8")
|
||||
_error_handler.setLevel(logging.WARNING)
|
||||
_error_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s"))
|
||||
error_logger = logging.getLogger("search_engine_error")
|
||||
error_logger.addHandler(_error_handler)
|
||||
error_logger.propagate = False
|
||||
|
||||
# Analytics file
|
||||
ANALYTICS_FILE = Path(__file__).resolve().parent.parent / "search_analytics.json"
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Custom exception hierarchy
|
||||
# ----------------------------------------------------------------------
|
||||
class SearchEngineError(Exception):
|
||||
"""Base class for all search-engine related errors."""
|
||||
|
||||
|
||||
class NetworkError(SearchEngineError):
|
||||
"""Raised when a network request fails (e.g., timeout, DNS error)."""
|
||||
|
||||
|
||||
class ParseError(SearchEngineError):
|
||||
"""Raised when HTML or other content cannot be parsed."""
|
||||
|
||||
|
||||
class RateLimitError(SearchEngineError):
|
||||
"""Raised when the remote service returns a rate-limit (HTTP 429)."""
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Analytics helpers
|
||||
# ----------------------------------------------------------------------
|
||||
def _default_analytics() -> Dict[str, Any]:
|
||||
"""A fresh analytics document with every counter present."""
|
||||
return {
|
||||
"total_queries": 0,
|
||||
"successful_queries": 0,
|
||||
"failed_queries": 0,
|
||||
"cache_hits": 0,
|
||||
"cache_misses": 0,
|
||||
"query_patterns": {},
|
||||
}
|
||||
|
||||
|
||||
def _load_analytics() -> Dict[str, Any]:
|
||||
"""Load analytics data from the JSON file, creating defaults if missing."""
|
||||
if not ANALYTICS_FILE.exists():
|
||||
default = _default_analytics()
|
||||
_save_analytics(default)
|
||||
return default
|
||||
try:
|
||||
with open(ANALYTICS_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
# Merge over defaults so a file written by an older schema (or a
|
||||
# partial write) still has every counter — _record_query indexes
|
||||
# these keys directly and would otherwise raise KeyError.
|
||||
merged = _default_analytics()
|
||||
if isinstance(data, dict):
|
||||
merged.update(data)
|
||||
return merged
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load analytics file: {e}")
|
||||
return _default_analytics()
|
||||
|
||||
|
||||
def _save_analytics(data: Dict[str, Any]) -> None:
|
||||
"""Persist analytics data to the JSON file."""
|
||||
try:
|
||||
with open(ANALYTICS_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to write analytics file: {e}")
|
||||
|
||||
|
||||
def _record_query(query: str, success: bool, cache_hit: bool) -> None:
|
||||
"""Update analytics for a single query execution."""
|
||||
analytics = _load_analytics()
|
||||
analytics["total_queries"] += 1
|
||||
if success:
|
||||
analytics["successful_queries"] += 1
|
||||
else:
|
||||
analytics["failed_queries"] += 1
|
||||
|
||||
if cache_hit:
|
||||
analytics["cache_hits"] += 1
|
||||
cache_metrics["hits"] += 1
|
||||
else:
|
||||
analytics["cache_misses"] += 1
|
||||
cache_metrics["misses"] += 1
|
||||
|
||||
patterns = analytics["query_patterns"]
|
||||
entry = patterns.get(query, {"count": 0, "successes": 0})
|
||||
entry["count"] += 1
|
||||
if success:
|
||||
entry["successes"] += 1
|
||||
patterns[query] = entry
|
||||
|
||||
_save_analytics(analytics)
|
||||
|
||||
|
||||
def get_search_stats() -> Dict[str, Any]:
|
||||
"""Return aggregated search analytics."""
|
||||
analytics = _load_analytics()
|
||||
total = analytics.get("total_queries", 0) or 1
|
||||
success_rate = analytics.get("successful_queries", 0) / total
|
||||
cache_total = analytics.get("cache_hits", 0) + analytics.get("cache_misses", 0) or 1
|
||||
cache_hit_rate = analytics.get("cache_hits", 0) / cache_total
|
||||
|
||||
pattern_counter = Counter({
|
||||
q: data["count"] for q, data in analytics.get("query_patterns", {}).items()
|
||||
})
|
||||
most_common = [q for q, _ in pattern_counter.most_common(5)]
|
||||
|
||||
return {
|
||||
"most_common_queries": most_common,
|
||||
"success_rate": success_rate,
|
||||
"cache_hit_rate": cache_hit_rate,
|
||||
"total_queries": analytics.get("total_queries", 0),
|
||||
"successful_queries": analytics.get("successful_queries", 0),
|
||||
"failed_queries": analytics.get("failed_queries", 0),
|
||||
"cache_hits": analytics.get("cache_hits", 0),
|
||||
"cache_misses": analytics.get("cache_misses", 0),
|
||||
"cache_evictions": cache_metrics["evictions"],
|
||||
"runtime_cache_hits": cache_metrics["hits"],
|
||||
"runtime_cache_misses": cache_metrics["misses"],
|
||||
}
|
||||
sys.modules[__name__] = _analytics
|
||||
|
||||
+7
-53
@@ -1,57 +1,11 @@
|
||||
"""Search and content caching with LRU eviction."""
|
||||
"""Compatibility wrapper for the canonical services.search.cache module.
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
``src.search.cache`` stays importable for older agent/deep-research code, but the
|
||||
implementation now lives in ``services.search.cache`` so the two cannot drift.
|
||||
"""
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
import sys
|
||||
|
||||
# Cache directories
|
||||
CACHE_DIR = Path(__file__).resolve().parent.parent / "cache"
|
||||
SEARCH_CACHE_DIR = CACHE_DIR / "search"
|
||||
CONTENT_CACHE_DIR = CACHE_DIR / "content"
|
||||
CACHE_MAX_ENTRIES = 1000
|
||||
from services.search import cache as _cache
|
||||
|
||||
# Create cache directories
|
||||
SEARCH_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
CONTENT_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Track cache size for LRU eviction
|
||||
search_cache_index: Dict[str, datetime] = {}
|
||||
content_cache_index: Dict[str, datetime] = {}
|
||||
|
||||
# Cache metrics (shared across modules)
|
||||
cache_metrics = {"hits": 0, "misses": 0, "evictions": 0}
|
||||
|
||||
|
||||
def generate_cache_key(data: str) -> str:
|
||||
"""Generate a unique cache key using SHA-256 hash."""
|
||||
return hashlib.sha256(data.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def cleanup_cache(cache_dir: Path, cache_index: Dict[str, datetime], max_age: timedelta):
|
||||
"""Remove expired cache entries and enforce LRU policy."""
|
||||
current_time = datetime.now()
|
||||
files_in_dir = {f.name.split(".")[0]: f for f in cache_dir.glob("*.cache")}
|
||||
|
||||
to_remove = []
|
||||
for key, timestamp in list(cache_index.items()):
|
||||
if current_time - timestamp > max_age or key not in files_in_dir:
|
||||
to_remove.append(key)
|
||||
if key in files_in_dir:
|
||||
files_in_dir[key].unlink(missing_ok=True)
|
||||
|
||||
for key in to_remove:
|
||||
cache_index.pop(key, None)
|
||||
cache_metrics["evictions"] += 1
|
||||
|
||||
if len(cache_index) > CACHE_MAX_ENTRIES:
|
||||
sorted_items = sorted(cache_index.items(), key=lambda x: x[1])
|
||||
excess_count = len(cache_index) - CACHE_MAX_ENTRIES
|
||||
for key, _ in sorted_items[:excess_count]:
|
||||
cache_index.pop(key, None)
|
||||
cache_file = cache_dir / f"{key}.cache"
|
||||
cache_file.unlink(missing_ok=True)
|
||||
cache_metrics["evictions"] += 1
|
||||
sys.modules[__name__] = _cache
|
||||
|
||||
+7
-415
@@ -1,419 +1,11 @@
|
||||
"""Webpage content fetching with caching, PDF extraction, and summarization helpers."""
|
||||
"""Compatibility wrapper for the canonical services.search.content module.
|
||||
|
||||
import copy
|
||||
import io
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import logging
|
||||
import socket
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List
|
||||
from urllib.parse import urljoin, urlparse
|
||||
``src.search.content`` stays importable for older agent/deep-research code, but the
|
||||
implementation now lives in ``services.search.content`` so the two cannot drift.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
import sys
|
||||
|
||||
from .analytics import RateLimitError, error_logger
|
||||
from .cache import (
|
||||
CONTENT_CACHE_DIR,
|
||||
content_cache_index,
|
||||
generate_cache_key,
|
||||
cleanup_cache,
|
||||
)
|
||||
from services.search import content as _content
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PRIVATE_NETWORKS = (
|
||||
ipaddress.ip_network("0.0.0.0/8"),
|
||||
ipaddress.ip_network("10.0.0.0/8"),
|
||||
ipaddress.ip_network("127.0.0.0/8"),
|
||||
ipaddress.ip_network("169.254.0.0/16"),
|
||||
ipaddress.ip_network("172.16.0.0/12"),
|
||||
ipaddress.ip_network("192.168.0.0/16"),
|
||||
ipaddress.ip_network("::1/128"),
|
||||
ipaddress.ip_network("fc00::/7"),
|
||||
ipaddress.ip_network("fe80::/10"),
|
||||
)
|
||||
|
||||
|
||||
def _is_private_address(addr: ipaddress._BaseAddress) -> bool:
|
||||
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
|
||||
addr = addr.ipv4_mapped
|
||||
return (
|
||||
addr.is_private
|
||||
or addr.is_loopback
|
||||
or addr.is_link_local
|
||||
or addr.is_reserved
|
||||
or addr.is_multicast
|
||||
or addr.is_unspecified
|
||||
or any(addr in net for net in _PRIVATE_NETWORKS)
|
||||
)
|
||||
|
||||
|
||||
def _resolve_hostname_ips(hostname: str) -> List[ipaddress._BaseAddress]:
|
||||
ips = []
|
||||
for family, _, _, _, sockaddr in socket.getaddrinfo(hostname, None):
|
||||
if family in (socket.AF_INET, socket.AF_INET6):
|
||||
ips.append(ipaddress.ip_address(sockaddr[0]))
|
||||
return ips
|
||||
|
||||
|
||||
def _public_http_url(url: str) -> bool:
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||
return False
|
||||
host = parsed.hostname.strip().lower()
|
||||
if host in ("localhost", "metadata.google.internal", "metadata"):
|
||||
return False
|
||||
if host.endswith((".local", ".localhost", ".internal", ".lan", ".intranet")):
|
||||
return False
|
||||
try:
|
||||
return not _is_private_address(ipaddress.ip_address(host))
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
ips = _resolve_hostname_ips(host)
|
||||
except OSError:
|
||||
return False
|
||||
# Fail closed: a hostname that resolves to nothing is treated as
|
||||
# non-public (an empty all(...) would otherwise return True).
|
||||
return bool(ips) and all(not _is_private_address(ip) for ip in ips)
|
||||
|
||||
|
||||
def _get_public_url(url: str, *, headers: dict, timeout: int) -> httpx.Response:
|
||||
if not _public_http_url(url):
|
||||
raise httpx.RequestError(f"Blocked non-public URL: {url}")
|
||||
|
||||
current = url
|
||||
with httpx.Client(headers=headers, timeout=timeout, follow_redirects=False) as client:
|
||||
for _ in range(8):
|
||||
response = client.get(current)
|
||||
if response.status_code not in (301, 302, 303, 307, 308):
|
||||
return response
|
||||
location = response.headers.get("location")
|
||||
if not location:
|
||||
return response
|
||||
current = urljoin(current, location)
|
||||
if not _public_http_url(current):
|
||||
raise httpx.RequestError(f"Blocked redirect to non-public URL: {current}")
|
||||
raise httpx.RequestError("Too many redirects")
|
||||
|
||||
# PDF extraction (optional dependency)
|
||||
try:
|
||||
from pdfminer.high_level import extract_text as pdf_extract_text
|
||||
except ImportError:
|
||||
pdf_extract_text = None # type: ignore
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# HTML extraction helpers
|
||||
# ----------------------------------------------------------------------
|
||||
def _extract_meta(soup: BeautifulSoup) -> dict:
|
||||
"""Pull meta description and keywords if present."""
|
||||
description = ""
|
||||
keywords = ""
|
||||
desc_tag = soup.find("meta", attrs={"name": re.compile("description", re.I)})
|
||||
if desc_tag and desc_tag.get("content"):
|
||||
description = desc_tag["content"].strip()
|
||||
kw_tag = soup.find("meta", attrs={"name": re.compile("keywords", re.I)})
|
||||
if kw_tag and kw_tag.get("content"):
|
||||
keywords = kw_tag["content"].strip()
|
||||
return {"description": description, "keywords": keywords}
|
||||
|
||||
|
||||
def _extract_og_image(soup: BeautifulSoup) -> str:
|
||||
"""Extract the best representative image URL from meta tags.
|
||||
|
||||
Only returns absolute http(s) URLs — skips relative paths and data URIs.
|
||||
"""
|
||||
candidates = []
|
||||
# Open Graph image (most reliable)
|
||||
for prop in ("og:image", "og:image:url", "og:image:secure_url"):
|
||||
tag = soup.find("meta", attrs={"property": prop})
|
||||
if tag and tag.get("content", "").strip():
|
||||
candidates.append(tag["content"].strip())
|
||||
# Twitter card image
|
||||
tag = soup.find("meta", attrs={"name": "twitter:image"})
|
||||
if tag and tag.get("content", "").strip():
|
||||
candidates.append(tag["content"].strip())
|
||||
# Thumbnail meta
|
||||
tag = soup.find("meta", attrs={"name": "thumbnail"})
|
||||
if tag and tag.get("content", "").strip():
|
||||
candidates.append(tag["content"].strip())
|
||||
# Return first absolute http(s) URL
|
||||
for url in candidates:
|
||||
if url.startswith(("https://", "http://")) and not url.endswith((".svg", ".ico")):
|
||||
return url
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_lists(soup: BeautifulSoup) -> List[List[str]]:
|
||||
"""Return a list of lists, each inner list representing a <ul>/<ol>."""
|
||||
all_lists = []
|
||||
for lst in soup.find_all(["ul", "ol"]):
|
||||
items = [li.get_text(separator=" ", strip=True) for li in lst.find_all("li")]
|
||||
if items:
|
||||
all_lists.append(items)
|
||||
return all_lists
|
||||
|
||||
|
||||
def _extract_tables(soup: BeautifulSoup) -> List[List[List[str]]]:
|
||||
"""Return a list of tables, each table is a list of rows, each row a list of cell texts."""
|
||||
tables_data = []
|
||||
for table in soup.find_all("table"):
|
||||
rows = []
|
||||
for tr in table.find_all("tr"):
|
||||
cells = [td.get_text(separator=" ", strip=True) for td in tr.find_all(["td", "th"])]
|
||||
if cells:
|
||||
rows.append(cells)
|
||||
if rows:
|
||||
tables_data.append(rows)
|
||||
return tables_data
|
||||
|
||||
|
||||
def _extract_code_blocks(soup: BeautifulSoup) -> List[str]:
|
||||
"""Collect text from <pre> and <code> blocks."""
|
||||
blocks = []
|
||||
for tag in soup.find_all(["pre", "code"]):
|
||||
txt = tag.get_text(separator=" ", strip=True)
|
||||
if txt:
|
||||
blocks.append(txt)
|
||||
return blocks
|
||||
|
||||
|
||||
def _detect_js_frameworks(soup: BeautifulSoup) -> bool:
|
||||
"""Very naive detection of common JS frameworks."""
|
||||
js_indicators = [
|
||||
"react", "angular", "vue", "svelte", "next", "nuxt",
|
||||
"ember", "backbone", "jquery", "polymer", "mithril",
|
||||
]
|
||||
for script in soup.find_all("script"):
|
||||
src = script.get("src", "").lower()
|
||||
if any(fr in src for fr in js_indicators):
|
||||
return True
|
||||
if script.string:
|
||||
content = script.string.lower()
|
||||
if any(fr in content for fr in js_indicators):
|
||||
return True
|
||||
if soup.find(attrs={"data-reactroot": True}) or soup.find(attrs={"ng-app": True}):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _empty_result(url: str, error: str = "") -> dict:
|
||||
"""Build a standard failure result dict."""
|
||||
return {
|
||||
"url": url,
|
||||
"title": "",
|
||||
"content": "",
|
||||
"lists": [],
|
||||
"tables": [],
|
||||
"code_blocks": [],
|
||||
"meta_description": "",
|
||||
"meta_keywords": "",
|
||||
"js_rendered": False,
|
||||
"js_message": "",
|
||||
"success": False,
|
||||
"error": error,
|
||||
}
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Main content fetcher
|
||||
# ----------------------------------------------------------------------
|
||||
def fetch_webpage_content(url: str, timeout: int = 5, retry_attempt: int = 0) -> dict:
|
||||
"""Fetch and extract meaningful content from a webpage with caching."""
|
||||
cache_key = generate_cache_key(url)
|
||||
cache_file = CONTENT_CACHE_DIR / f"{cache_key}.cache"
|
||||
|
||||
# Check cache
|
||||
if cache_file.exists():
|
||||
try:
|
||||
with open(cache_file, "r", encoding="utf-8") as f:
|
||||
cached_data = json.load(f)
|
||||
timestamp = datetime.fromisoformat(cached_data["timestamp"])
|
||||
if datetime.now() - timestamp < timedelta(hours=2):
|
||||
logger.debug(f"Content cache hit for URL: {url}")
|
||||
return cached_data["data"]
|
||||
else:
|
||||
cache_file.unlink(missing_ok=True)
|
||||
content_cache_index.pop(cache_key, None)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read content cache for {url}: {e}")
|
||||
cache_file.unlink(missing_ok=True)
|
||||
content_cache_index.pop(cache_key, None)
|
||||
|
||||
# Fetch
|
||||
try:
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.5",
|
||||
"Accept-Encoding": "gzip, deflate",
|
||||
"Connection": "keep-alive",
|
||||
}
|
||||
response = _get_public_url(url, headers=headers, timeout=timeout)
|
||||
|
||||
if response.status_code == 429:
|
||||
raise RateLimitError(f"Rate limit hit for {url} (attempt {retry_attempt})")
|
||||
|
||||
response.raise_for_status()
|
||||
except httpx.RequestError as e:
|
||||
error_logger.error(f"NetworkError fetching {url} (attempt {retry_attempt}): {e}")
|
||||
return _empty_result(url, f"NetworkError: {e}")
|
||||
except RateLimitError as e:
|
||||
error_logger.error(str(e))
|
||||
return _empty_result(url, str(e))
|
||||
|
||||
# PDF handling
|
||||
content_type = response.headers.get("Content-Type", "").lower()
|
||||
if "application/pdf" in content_type or url.lower().endswith(".pdf"):
|
||||
if pdf_extract_text is None:
|
||||
logger.error("pdfminer.six is not installed; cannot extract PDF text.")
|
||||
pdf_text = ""
|
||||
else:
|
||||
try:
|
||||
pdf_bytes = io.BytesIO(response.content)
|
||||
pdf_text = pdf_extract_text(pdf_bytes)
|
||||
except Exception as e:
|
||||
logger.warning(f"PDF extraction failed for {url}: {e}")
|
||||
pdf_text = ""
|
||||
result = {
|
||||
"url": url,
|
||||
"title": os.path.basename(url),
|
||||
"content": pdf_text,
|
||||
"lists": [],
|
||||
"tables": [],
|
||||
"code_blocks": [],
|
||||
"meta_description": "",
|
||||
"meta_keywords": "",
|
||||
"js_rendered": False,
|
||||
"js_message": "",
|
||||
"success": bool(pdf_text),
|
||||
"error": "" if pdf_text else "Failed to extract PDF text",
|
||||
}
|
||||
_cache_result(cache_file, cache_key, result, url)
|
||||
return result
|
||||
|
||||
# HTML handling
|
||||
try:
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
except Exception as e:
|
||||
error_logger.error(f"ParseError parsing HTML from {url} (attempt {retry_attempt}): {e}")
|
||||
result = _empty_result(url, f"ParseError: {e}")
|
||||
_cache_result(cache_file, cache_key, result, url)
|
||||
return result
|
||||
|
||||
title_tag = soup.find("title")
|
||||
title_text = title_tag.get_text(strip=True) if title_tag else ""
|
||||
meta_info = _extract_meta(soup)
|
||||
og_image = _extract_og_image(soup)
|
||||
js_rendered = _detect_js_frameworks(soup)
|
||||
js_message = "Page appears to be rendered by a JavaScript framework; content may be incomplete." if js_rendered else ""
|
||||
|
||||
# Main textual content (heuristic): prefer semantic / "content"-classed
|
||||
# containers to skip nav/footer/boilerplate; tuned for article pages.
|
||||
main_content = ""
|
||||
content_areas = soup.find_all(
|
||||
["main", "article", "section", "div"],
|
||||
class_=re.compile("content|main|body|article|post|entry|text", re.I),
|
||||
)
|
||||
if content_areas:
|
||||
for area in content_areas[:3]:
|
||||
main_content += area.get_text(separator=" ", strip=True) + " "
|
||||
main_content = re.sub(r"\s+", " ", main_content).strip()
|
||||
|
||||
# The class heuristic can latch onto a small wrapper and miss the real
|
||||
# content (app/landing pages, or SSR sites whose body isn't in a
|
||||
# "content"-classed div, so these came back nearly empty before). When the
|
||||
# heuristic returns nothing OR suspiciously little, fall back to the full
|
||||
# <body>, stripping scripts/styles (so JSON/JS doesn't leak into the text)
|
||||
# plus nav/header/footer/aside (boilerplate), and keep whichever yields
|
||||
# more readable text.
|
||||
THIN_CONTENT_CHARS = 600 # below this the heuristic likely missed the page
|
||||
if len(main_content) < THIN_CONTENT_CHARS:
|
||||
body = soup.find("body")
|
||||
if body:
|
||||
# Strip from a copy so the later list/table/code extractors still
|
||||
# see the original soup unmodified.
|
||||
body_copy = copy.copy(body)
|
||||
for _noise in body_copy.find_all(
|
||||
["script", "style", "noscript", "template", "nav", "header", "footer", "aside"]
|
||||
):
|
||||
_noise.extract()
|
||||
body_text = re.sub(r"\s+", " ", body_copy.get_text(separator=" ", strip=True)).strip()
|
||||
if len(body_text) > len(main_content):
|
||||
main_content = body_text
|
||||
|
||||
result = {
|
||||
"url": url,
|
||||
"title": title_text,
|
||||
"content": main_content,
|
||||
"lists": _extract_lists(soup),
|
||||
"tables": _extract_tables(soup),
|
||||
"code_blocks": _extract_code_blocks(soup),
|
||||
"meta_description": meta_info.get("description", ""),
|
||||
"meta_keywords": meta_info.get("keywords", ""),
|
||||
"og_image": og_image,
|
||||
"js_rendered": js_rendered,
|
||||
"js_message": js_message,
|
||||
"success": True,
|
||||
"error": "",
|
||||
}
|
||||
_cache_result(cache_file, cache_key, result, url)
|
||||
return result
|
||||
|
||||
|
||||
def _cache_result(cache_file, cache_key: str, result: dict, url: str):
|
||||
"""Write a result to the content cache."""
|
||||
try:
|
||||
cache_data = {"timestamp": datetime.now().isoformat(), "data": result}
|
||||
with open(cache_file, "w", encoding="utf-8") as f:
|
||||
json.dump(cache_data, f)
|
||||
content_cache_index[cache_key] = datetime.now()
|
||||
cleanup_cache(CONTENT_CACHE_DIR, content_cache_index, timedelta(hours=2))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to write content cache for {url}: {e}")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Content summarization helpers
|
||||
# ----------------------------------------------------------------------
|
||||
def extract_key_points(text: str) -> List[str]:
|
||||
"""Pull out bullet-style key points from a block of text."""
|
||||
points: List[str] = []
|
||||
bullet_pat = re.compile(r"^\s*[-*•]\s+(.*)")
|
||||
numbered_pat = re.compile(r"^\s*\d+[\.\)]\s+(.*)")
|
||||
for line in text.splitlines():
|
||||
m = bullet_pat.match(line) or numbered_pat.match(line)
|
||||
if m:
|
||||
points.append(m.group(1).strip())
|
||||
return points
|
||||
|
||||
|
||||
def get_tldr(text: str, max_sentences: int = 3) -> str:
|
||||
"""Produce a very short TL;DR by taking the first few sentences."""
|
||||
sentences = re.split(r"(?<=[.!?])\s+", text)
|
||||
selected = [s.strip() for s in sentences if s][:max_sentences]
|
||||
return " ".join(selected)
|
||||
|
||||
|
||||
def extract_quotes(text: str) -> List[str]:
|
||||
"""Return quoted excerpts that are at least 15 characters long."""
|
||||
# Backreference the opening quote so the closing quote must match it —
|
||||
# otherwise `"text'` (open double, close single) is treated as a quote.
|
||||
return [m.group(2).strip() for m in re.finditer(r'(["\'])([^"\']{15,}?)\1', text)]
|
||||
|
||||
|
||||
def extract_statistics(text: str) -> List[str]:
|
||||
"""Find numbers, percentages, dates and simple measurements."""
|
||||
# Match a comma-grouped number (1,000,000) OR a plain digit run (50000) —
|
||||
# the old `\d{1,3}(?:,\d{3})*` matched only the first 3 digits of a
|
||||
# comma-less number, and the trailing `\b` dropped a closing `%`.
|
||||
pattern = re.compile(
|
||||
r"\b(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?\s*(%|percent|‰|per cent|[a-zA-Z]+)?",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
return [m.group(0).strip() for m in pattern.finditer(text)]
|
||||
sys.modules[__name__] = _content
|
||||
|
||||
+7
-137
@@ -1,141 +1,11 @@
|
||||
"""Query enhancement, entity extraction, and cache duration helpers."""
|
||||
"""Compatibility wrapper for the canonical services.search.query module.
|
||||
|
||||
import re
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
``src.search.query`` stays importable for older agent/deep-research code, but the
|
||||
implementation now lives in ``services.search.query`` so the two cannot drift.
|
||||
"""
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
import sys
|
||||
|
||||
from services.search import query as _query
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Query processing helpers
|
||||
# ----------------------------------------------------------------------
|
||||
def _detect_question_type(query: str) -> Optional[str]:
|
||||
"""Return the leading question word if present (who, what, when, where, why, how)."""
|
||||
if not isinstance(query, str):
|
||||
return None
|
||||
q = query.strip().lower()
|
||||
for word in ("who", "what", "when", "where", "why", "how"):
|
||||
# Require a whole-word match: a bare prefix mis-flags ordinary queries
|
||||
# like "whatsapp pricing" (-> what) or "however ..." (-> how), which
|
||||
# then get spurious boost terms OR-appended in enhance_query.
|
||||
if q == word or q.startswith(word + " "):
|
||||
return word
|
||||
return None
|
||||
|
||||
|
||||
def _extract_entities(query: str) -> Dict[str, List[str]]:
|
||||
"""Lightweight entity extraction: capitalized words and date patterns."""
|
||||
entities: Dict[str, List[str]] = {"names": [], "dates": []}
|
||||
qtype = _detect_question_type(query)
|
||||
cleaned = query
|
||||
if qtype:
|
||||
cleaned = re.sub(rf"^{qtype}\b", "", cleaned, flags=re.I).strip()
|
||||
for token in re.findall(r"\b[A-Z][a-zA-Z]+\b", cleaned):
|
||||
entities["names"].append(token)
|
||||
for year in re.findall(r"\b(?:19|20)\d{2}\b", cleaned):
|
||||
entities["dates"].append(year)
|
||||
month_day_year = re.findall(
|
||||
r"\b(?:Jan|January|Feb|February|Mar|March|Apr|April|May|Jun|June|Jul|July|Aug|August|Sep|Sept|September|Oct|October|Nov|November|Dec|December)\s+\d{1,2},?\s*\d{4}\b",
|
||||
cleaned,
|
||||
flags=re.I,
|
||||
)
|
||||
entities["dates"].extend(month_day_year)
|
||||
return entities
|
||||
|
||||
|
||||
def _split_multi_part(query: str) -> List[str]:
|
||||
"""Split a query into sub-queries on common conjunctions."""
|
||||
if not isinstance(query, str):
|
||||
return []
|
||||
parts = re.split(r"\s+and\s+|\s+or\s+|;", query, flags=re.I)
|
||||
return [p.strip() for p in parts if p.strip()]
|
||||
|
||||
|
||||
def _extract_site_filter(query: str) -> Tuple[str, Optional[str]]:
|
||||
"""Detect a 'site:example.com' token. Returns (query_without_token, site_or_None)."""
|
||||
if not isinstance(query, str):
|
||||
return "", None
|
||||
match = re.search(r"\bsite:([^\s]+)", query, flags=re.I)
|
||||
if match:
|
||||
site = match.group(1)
|
||||
new_query = re.sub(r"\bsite:[^\s]+", "", query, flags=re.I).strip()
|
||||
return new_query, site
|
||||
return query, None
|
||||
|
||||
|
||||
def _boost_entities_in_query(base_query: str, entities: Dict[str, List[str]]) -> str:
|
||||
"""Append extracted entities to the query using OR to increase relevance."""
|
||||
parts = [base_query]
|
||||
if entities.get("names"):
|
||||
parts.append(" OR ".join(f'"{n}"' for n in entities["names"]))
|
||||
if entities.get("dates"):
|
||||
parts.append(" OR ".join(f'"{d}"' for d in entities["dates"]))
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def enhance_query(original_query: str) -> Tuple[str, Optional[str]]:
|
||||
"""Process the original query: site filter, question type boosts, entity extraction."""
|
||||
if not isinstance(original_query, str):
|
||||
original_query = ""
|
||||
query_without_site, site = _extract_site_filter(original_query)
|
||||
sub_queries = _split_multi_part(query_without_site)
|
||||
|
||||
enhanced_subs: List[str] = []
|
||||
for sub in sub_queries:
|
||||
qtype = _detect_question_type(sub)
|
||||
boost_keywords = []
|
||||
if qtype == "who":
|
||||
boost_keywords.append("person")
|
||||
elif qtype == "when":
|
||||
boost_keywords.append("date")
|
||||
elif qtype == "where":
|
||||
boost_keywords.append("location")
|
||||
elif qtype == "why":
|
||||
boost_keywords.append("reason")
|
||||
elif qtype == "how":
|
||||
boost_keywords.append("method")
|
||||
entities = _extract_entities(sub)
|
||||
boosted = _boost_entities_in_query(sub, entities)
|
||||
if boost_keywords:
|
||||
boosted = f'({boosted}) OR ({" OR ".join(boost_keywords)})'
|
||||
enhanced_subs.append(boosted)
|
||||
|
||||
final_query = " AND ".join(f"({s})" for s in enhanced_subs)
|
||||
if site:
|
||||
final_query = f"{final_query} site:{site}"
|
||||
return final_query, site
|
||||
|
||||
|
||||
def build_enhanced_query(query: str, time_filter: str = None) -> str:
|
||||
"""Build an enhanced search query with optional time filtering."""
|
||||
enhanced_query, _ = enhance_query(query)
|
||||
|
||||
if time_filter:
|
||||
time_map = {"day": "d", "week": "w", "month": "m", "year": "y"}
|
||||
if time_filter in time_map:
|
||||
enhanced_query = f"{enhanced_query} after:{time_map[time_filter]}"
|
||||
logger.info(f"Added time filter '{time_filter}' to query")
|
||||
|
||||
logger.info(f"Enhanced query: '{query}' -> '{enhanced_query}'")
|
||||
return enhanced_query
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Cache duration helpers
|
||||
# ----------------------------------------------------------------------
|
||||
def _is_news_query(query: str) -> bool:
|
||||
"""Lightweight heuristic to decide if a query is news-oriented."""
|
||||
if not isinstance(query, str):
|
||||
return False
|
||||
news_terms = {"news", "latest", "breaking", "today", "today's", "current", "updates", "happening"}
|
||||
tokens = set(re.findall(r"\b\w+\b", query.lower()))
|
||||
return bool(tokens & news_terms)
|
||||
|
||||
|
||||
def _cache_duration_for_query(query: str) -> timedelta:
|
||||
"""News queries -> 30 minutes, reference queries -> 24 hours."""
|
||||
if _is_news_query(query):
|
||||
return timedelta(minutes=30)
|
||||
return timedelta(hours=24)
|
||||
sys.modules[__name__] = _query
|
||||
|
||||
@@ -85,6 +85,11 @@ DEFAULT_SETTINGS = {
|
||||
"research_search_provider": "",
|
||||
"research_max_tokens": 16384,
|
||||
"research_extraction_timeout_seconds": 90,
|
||||
# Lightweight planning/query LLM calls happen before any search starts.
|
||||
# Keep them separately tunable so slow local backends are not capped by
|
||||
# the old 30s/60s per-call defaults.
|
||||
"research_planning_timeout_seconds": 90,
|
||||
"research_query_timeout_seconds": 90,
|
||||
"research_extraction_concurrency": 3,
|
||||
# Hard wall-clock cap on a single deep-research run. The previous 600s
|
||||
# (10 min) default cut off slow local / edge LLMs mid-synthesis; 1800s
|
||||
@@ -95,6 +100,7 @@ DEFAULT_SETTINGS = {
|
||||
# Tune via Settings or by editing data/settings.json.
|
||||
"research_run_timeout_seconds": 1800,
|
||||
"agent_max_tool_calls": 0,
|
||||
"agent_max_rounds": 20, # per-message agent step cap (clamped 1..200)
|
||||
"agent_input_token_budget": 6000,
|
||||
# Ceiling on the *auto-derived* input budget that #1230 introduced. Has
|
||||
# no effect when `agent_input_token_budget` is explicitly set (the user's
|
||||
|
||||
@@ -1018,6 +1018,10 @@ class TaskScheduler:
|
||||
kwargs = {"owner": task.owner, "task_name": task.name, "progress_cb": _progress}
|
||||
if task.action in ("run_script", "run_local", "ssh_command") and task.prompt:
|
||||
kwargs["script" if task.action in ("run_script", "run_local") else "command"] = task.prompt
|
||||
# cookbook_serve carries its JSON config in task.prompt — feed it
|
||||
# through as `command` so action_cookbook_serve can json.loads it.
|
||||
elif task.action == "cookbook_serve" and task.prompt:
|
||||
kwargs["command"] = task.prompt
|
||||
result, success = await action_fn(**kwargs)
|
||||
return result, success
|
||||
except TaskNoop:
|
||||
|
||||
+54
-7
@@ -15,18 +15,33 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
_THINK_TAG_NAME = r"(?:think(?:ing)?|thought)"
|
||||
|
||||
# Closed reasoning blocks. Multi-pass loop in `strip_think` handles nested
|
||||
# `<think><think>...</think></think>` patterns some models emit.
|
||||
_THINK_CLOSED_RE = re.compile(r"<think(?:ing)?>[\s\S]*?</think(?:ing)?>\s*", re.IGNORECASE)
|
||||
_THINK_CLOSED_RE = re.compile(rf"<{_THINK_TAG_NAME}(?:\s+[^>]*)?>[\s\S]*?</{_THINK_TAG_NAME}>\s*", re.IGNORECASE)
|
||||
# Orphan opening or closing tags that survive after the closed-pass.
|
||||
_THINK_TAG_RE = re.compile(r"</?think(?:ing)?[^>]*>\s*", re.IGNORECASE)
|
||||
_THINK_TAG_RE = re.compile(rf"</?{_THINK_TAG_NAME}[^>]*>\s*", re.IGNORECASE)
|
||||
# Dangling opener anywhere in the response with no closer — strip everything
|
||||
# from `<think>` to the end of string.
|
||||
_THINK_OPEN_RE = re.compile(r"<think(?:ing)?>[\s\S]*$", re.IGNORECASE)
|
||||
_THINK_OPEN_RE = re.compile(rf"<{_THINK_TAG_NAME}(?:\s+[^>]*)?>[\s\S]*$", re.IGNORECASE)
|
||||
# Streaming models occasionally emit `<thinking time="0.42">`-style attributes.
|
||||
# Normalize to a plain `<think>` so the regexes above catch them.
|
||||
_THINK_ATTR_RE = re.compile(r"<think(?:ing)?\s+[^>]*>", re.IGNORECASE)
|
||||
_THINK_ATTR_CLOSE_RE = re.compile(r"</think(?:ing)?\s+[^>]*>", re.IGNORECASE)
|
||||
_THINK_ATTR_RE = re.compile(rf"<{_THINK_TAG_NAME}\s+[^>]*>", re.IGNORECASE)
|
||||
_THINK_ATTR_CLOSE_RE = re.compile(rf"</{_THINK_TAG_NAME}\s+[^>]*>", re.IGNORECASE)
|
||||
_GEMMA_THOUGHT_OPEN_RE = re.compile(r"<\|channel>thought\s*\n?[\s\S]*$", re.IGNORECASE)
|
||||
_GEMMA_RESPONSE_CHANNEL_RE = re.compile(
|
||||
r"<\|channel>response\s*\n?([\s\S]*?)<channel\|>",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_GEMMA_RESPONSE_OPEN_RE = re.compile(r"<\|channel>response\s*\n?", re.IGNORECASE)
|
||||
_GEMMA_CHANNEL_CLOSE_RE = re.compile(r"<channel\|>", re.IGNORECASE)
|
||||
_THOUGHT_TAG_OPEN_RE = re.compile(r"<thought(\s+[^>]*)?>", re.IGNORECASE)
|
||||
_THOUGHT_TAG_CLOSE_RE = re.compile(r"</thought>", re.IGNORECASE)
|
||||
_GEMMA_THOUGHT_CHANNEL_CAPTURE_RE = re.compile(
|
||||
r"<\|channel>thought\s*\n?([\s\S]*?)<channel\|>\s*",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Qwen and a few other models prefix the response with a "Thinking Process:"
|
||||
# block before the real answer.
|
||||
_QWEN_THINKING_RE = re.compile(
|
||||
@@ -78,6 +93,30 @@ def _strip_reasoning_prose(text: str) -> str:
|
||||
return "\n\n".join(keep).strip() if keep else text
|
||||
|
||||
|
||||
def normalize_thinking_markup(text: str) -> str:
|
||||
"""Canonicalize supported thinking wrappers to `<think>` markup.
|
||||
|
||||
The chat UI and persistence layer already understand `<think>...</think>`.
|
||||
Gemma 4 may instead emit `<|channel>thought\n...<channel|>`, and some
|
||||
gateways/models emit `<thought>...</thought>`. Normalize those shapes into
|
||||
the existing representation and strip empty thought channels.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
out = _THOUGHT_TAG_OPEN_RE.sub(lambda m: "<think" + (m.group(1) or "") + ">", text)
|
||||
out = _THOUGHT_TAG_CLOSE_RE.sub("</think>", out)
|
||||
|
||||
def _replace_gemma_thought(match: re.Match) -> str:
|
||||
thought = match.group(1).strip()
|
||||
return f"<think>{thought}</think>\n" if thought else ""
|
||||
|
||||
out = _GEMMA_THOUGHT_CHANNEL_CAPTURE_RE.sub(_replace_gemma_thought, out)
|
||||
out = _GEMMA_RESPONSE_CHANNEL_RE.sub(lambda m: m.group(1), out)
|
||||
out = _GEMMA_RESPONSE_OPEN_RE.sub("", out)
|
||||
out = _GEMMA_CHANNEL_CLOSE_RE.sub("", out)
|
||||
return out
|
||||
|
||||
|
||||
def strip_think(text: str, *, prose: bool = False, prompt_echo: bool = True) -> str:
|
||||
"""Strip `<think>` blocks from model output.
|
||||
|
||||
@@ -92,13 +131,21 @@ def strip_think(text: str, *, prose: bool = False, prompt_echo: bool = True) ->
|
||||
"The user asks:" / "We need to" leaked prompt echoes.
|
||||
|
||||
Robust to:
|
||||
* closed `<think>...</think>` (any depth, both `<think>` and `<thinking>`)
|
||||
* dangling unclosed `<think>...`
|
||||
* closed `<think>...</think>` (any depth, plus `<thinking>`/`<thought>`)
|
||||
* dangling unclosed `<think>...` / `<thought>...`
|
||||
* stray opener/closer tags
|
||||
* `<think time="0.42">`-style attributes
|
||||
* Gemma 4 `<|channel>thought...<channel|>` wrappers
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
# Gemma 4 thinking-capable models use channel control tokens rather than
|
||||
# XML tags when the runtime does not split reasoning into a separate field.
|
||||
# The thought channel can be empty in non-thinking mode; either way it is
|
||||
# not user-facing content. A response channel, when present, is only a
|
||||
# wrapper around the final answer.
|
||||
text = normalize_thinking_markup(text)
|
||||
text = _GEMMA_THOUGHT_OPEN_RE.sub("", text)
|
||||
# Normalize attributes so the closed/open regexes can catch them.
|
||||
text = _THINK_ATTR_RE.sub("<think>", text)
|
||||
text = _THINK_ATTR_CLOSE_RE.sub("</think>", text)
|
||||
|
||||
+446
-13
@@ -12,14 +12,127 @@ import collections
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Awaitable, Callable, Dict, Optional, Tuple
|
||||
|
||||
from src.tool_security import is_public_blocked_tool, owner_is_admin_or_single_user
|
||||
|
||||
# Persistent working directory for agent subprocesses.
|
||||
# Resolves to <repo_root>/data, which is the bind-mounted volume in Docker
|
||||
# (/app/data) and the local data directory for manual installs.
|
||||
# Using this as cwd and HOME prevents the agent from silently creating files
|
||||
# in ephemeral container layers that are lost on the next rebuild.
|
||||
_AGENT_WORKDIR = str(pathlib.Path(__file__).parent.parent / "data")
|
||||
|
||||
MAX_OUTPUT_CHARS = 10_000
|
||||
MAX_READ_CHARS = 20_000
|
||||
MAX_DIFF_LINES = 400 # cap unified-diff size returned to the UI
|
||||
|
||||
|
||||
def _unified_diff(old: str, new: str, path: str) -> Optional[Dict[str, Any]]:
|
||||
"""Build a unified diff of a file write for display in the chat.
|
||||
|
||||
Returns {"text": <unified diff>, "added": N, "removed": M, "new_file": bool}
|
||||
or None when there's no textual change. Truncates very large diffs.
|
||||
"""
|
||||
if old == new:
|
||||
return None
|
||||
import difflib
|
||||
|
||||
old_lines = old.splitlines()
|
||||
new_lines = new.splitlines()
|
||||
label = path or "file"
|
||||
diff_lines = list(difflib.unified_diff(
|
||||
old_lines, new_lines,
|
||||
fromfile=f"a/{label}", tofile=f"b/{label}",
|
||||
lineterm="",
|
||||
))
|
||||
added = sum(1 for l in diff_lines if l.startswith("+") and not l.startswith("+++"))
|
||||
removed = sum(1 for l in diff_lines if l.startswith("-") and not l.startswith("---"))
|
||||
truncated = False
|
||||
if len(diff_lines) > MAX_DIFF_LINES:
|
||||
diff_lines = diff_lines[:MAX_DIFF_LINES]
|
||||
truncated = True
|
||||
text = "\n".join(diff_lines)
|
||||
if truncated:
|
||||
text += f"\n… diff truncated at {MAX_DIFF_LINES} lines"
|
||||
return {
|
||||
"text": text,
|
||||
"added": added,
|
||||
"removed": removed,
|
||||
"new_file": old == "",
|
||||
"file": os.path.basename(path) or (path or "file"),
|
||||
}
|
||||
|
||||
|
||||
async def _do_edit_file(content: str, workspace: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Exact string-replacement edit of an on-disk file.
|
||||
|
||||
content is JSON: {"path", "old_string", "new_string", "replace_all"?}.
|
||||
Fails if old_string is missing or non-unique (unless replace_all) so the
|
||||
model can't silently edit the wrong place. Returns a unified diff for the UI.
|
||||
Confined to the workspace when one is set (same policy as write_file).
|
||||
"""
|
||||
try:
|
||||
args = json.loads(content) if content.strip().startswith("{") else {}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
args = {}
|
||||
raw_path = (args.get("path") or "").strip()
|
||||
old = args.get("old_string", "")
|
||||
new = args.get("new_string", "")
|
||||
replace_all = bool(args.get("replace_all", False))
|
||||
if not raw_path:
|
||||
return {"error": "edit_file: path required", "exit_code": 1}
|
||||
# Confine to the workspace when set, else the same allowlist + sensitive-file
|
||||
# policy as read/write_file.
|
||||
try:
|
||||
path = (_resolve_tool_path_in_workspace(workspace, raw_path)
|
||||
if workspace else _resolve_tool_path(raw_path))
|
||||
except ValueError as e:
|
||||
return {"error": f"edit_file: {e}", "exit_code": 1}
|
||||
if old == "":
|
||||
return {"error": "edit_file: old_string required (use write_file to create a file)", "exit_code": 1}
|
||||
if old == new:
|
||||
return {"error": "edit_file: old_string and new_string are identical", "exit_code": 1}
|
||||
|
||||
def _apply():
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
original = f.read()
|
||||
count = original.count(old)
|
||||
if count == 0:
|
||||
return original, None, "not_found"
|
||||
if count > 1 and not replace_all:
|
||||
return original, None, f"not_unique:{count}"
|
||||
updated = original.replace(old, new) if replace_all else original.replace(old, new, 1)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(updated)
|
||||
return original, updated, "ok"
|
||||
|
||||
try:
|
||||
original, updated, status = await asyncio.to_thread(_apply)
|
||||
except FileNotFoundError:
|
||||
return {"error": f"edit_file: {path}: not found (use write_file to create it)", "exit_code": 1}
|
||||
except (IsADirectoryError, UnicodeDecodeError):
|
||||
return {"error": f"edit_file: {path}: not an editable text file", "exit_code": 1}
|
||||
except PermissionError:
|
||||
return {"error": f"edit_file: {path}: permission denied", "exit_code": 1}
|
||||
except OSError as e:
|
||||
return {"error": f"edit_file: {path}: {e}", "exit_code": 1}
|
||||
|
||||
if status == "not_found":
|
||||
return {"error": f"edit_file: old_string not found in {path}. Read the file and match it exactly.", "exit_code": 1}
|
||||
if status.startswith("not_unique"):
|
||||
n = status.split(":", 1)[1]
|
||||
return {"error": f"edit_file: old_string is not unique in {path} ({n} matches). Add surrounding context or set replace_all=true.", "exit_code": 1}
|
||||
|
||||
n = original.count(old)
|
||||
result = {"output": f"Edited {path} ({n} replacement{'s' if n != 1 else ''})", "exit_code": 0}
|
||||
diff = _unified_diff(original, updated, path)
|
||||
if diff:
|
||||
result["diff"] = diff
|
||||
return result
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Path confinement for read_file / write_file
|
||||
@@ -158,6 +271,40 @@ def _resolve_tool_path(raw_path: str) -> str:
|
||||
f"path '{raw_path}' is outside the allowed roots"
|
||||
)
|
||||
|
||||
|
||||
def _resolve_tool_path_in_workspace(workspace: str, raw_path: str) -> str:
|
||||
"""Confine a model-supplied path to the active workspace.
|
||||
|
||||
Layered on top of upstream's path policy: the workspace is the allowed
|
||||
root (relative paths resolve under it; paths that escape it are rejected),
|
||||
and the sensitive-file deny list (.ssh, .gnupg, id_rsa, …) still applies
|
||||
inside it. When no workspace is set, callers use _resolve_tool_path (the
|
||||
default data/tmp allowlist) instead.
|
||||
"""
|
||||
if raw_path is None or not str(raw_path).strip():
|
||||
raise ValueError("path is required")
|
||||
base = os.path.realpath(workspace)
|
||||
expanded = os.path.expanduser(str(raw_path).strip())
|
||||
candidate = expanded if os.path.isabs(expanded) else os.path.join(base, expanded)
|
||||
resolved = os.path.realpath(candidate)
|
||||
if _is_sensitive_path(resolved):
|
||||
raise ValueError(
|
||||
f"path '{raw_path}' is inside a sensitive directory "
|
||||
f"(e.g. .ssh, .gnupg) or matches a sensitive filename"
|
||||
)
|
||||
if resolved != base:
|
||||
# normcase so containment holds on case-insensitive filesystems
|
||||
# (Windows, default macOS): it lowercases on Windows and is a no-op on
|
||||
# POSIX. commonpath raises ValueError across Windows drives (C: vs D:)
|
||||
# or mixed abs/rel — both mean "outside", so the except rejects them.
|
||||
nbase = os.path.normcase(base)
|
||||
try:
|
||||
if os.path.commonpath([os.path.normcase(resolved), nbase]) != nbase:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
raise ValueError(f"path '{raw_path}' is outside the workspace ({workspace})")
|
||||
return resolved
|
||||
|
||||
# Bash + python tools used to share a single 60s timeout. That's
|
||||
# enough for one-shot commands but starves real workloads (pip
|
||||
# install, ffmpeg conversions, etc.) — and worse, the agent saw the
|
||||
@@ -186,6 +333,39 @@ def get_mcp_manager():
|
||||
return agent_tools.get_mcp_manager()
|
||||
|
||||
|
||||
# Directories ignored by the code-nav tools' Python fallbacks so results aren't
|
||||
# polluted by VCS internals / dependency trees / build caches. ripgrep already
|
||||
# honours .gitignore; this is the parity floor for the no-rg path (and the
|
||||
# explicit excludes passed to rg so it skips them even without a .gitignore).
|
||||
_CODENAV_SKIP_DIRS = frozenset({
|
||||
".git", ".hg", ".svn", "node_modules", "venv", ".venv", "__pycache__",
|
||||
".mypy_cache", ".pytest_cache", ".ruff_cache", "dist", "build",
|
||||
".next", ".cache", "site-packages", ".idea", ".tox",
|
||||
})
|
||||
# Per-tool result caps (keep tool output cheap + model-friendly).
|
||||
_CODENAV_MAX_HITS = 200
|
||||
_CODENAV_MAX_LINE = 400
|
||||
|
||||
|
||||
def _resolve_search_root(raw_path: str, workspace: Optional[str] = None) -> str:
|
||||
"""Resolve + confine a code-nav path (grep/glob/ls).
|
||||
|
||||
With a workspace set, the workspace folder is the root and supplied paths are
|
||||
confined inside it (same policy as read_file). Without one, an empty path
|
||||
defaults to the agent's primary root (project data dir) and a supplied path
|
||||
is confined by the global allowlist + sensitive-file policy.
|
||||
"""
|
||||
raw = (raw_path or "").strip()
|
||||
if workspace:
|
||||
if not raw:
|
||||
return os.path.realpath(workspace)
|
||||
return _resolve_tool_path_in_workspace(workspace, raw)
|
||||
if not raw:
|
||||
roots = _tool_path_roots()
|
||||
return roots[0] if roots else os.path.realpath(".")
|
||||
return _resolve_tool_path(raw)
|
||||
|
||||
|
||||
def _truncate(text: str, limit: int = MAX_OUTPUT_CHARS) -> str:
|
||||
if len(text) > limit:
|
||||
return text[:limit] + f"\n... (truncated, {len(text)} chars total)"
|
||||
@@ -396,11 +576,12 @@ async def _call_mcp_tool(
|
||||
tool: str,
|
||||
content: str,
|
||||
progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None,
|
||||
workspace: Optional[str] = None,
|
||||
) -> Dict:
|
||||
"""Route a legacy tool call through the MCP manager, with direct fallbacks."""
|
||||
mcp = get_mcp_manager()
|
||||
if not mcp:
|
||||
return await _direct_fallback(tool, content, progress_cb=progress_cb) or {"error": f"MCP manager not available for tool '{tool}'", "exit_code": 1}
|
||||
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}
|
||||
|
||||
server_id, tool_name = _MCP_TOOL_MAP[tool]
|
||||
qualified = f"mcp__{server_id}__{tool_name}"
|
||||
@@ -409,7 +590,7 @@ async def _call_mcp_tool(
|
||||
|
||||
# If MCP server not connected, try direct fallback
|
||||
if isinstance(result, dict) and result.get("exit_code") == 1 and "not connected" in result.get("error", ""):
|
||||
fallback = await _direct_fallback(tool, content, progress_cb=progress_cb)
|
||||
fallback = await _direct_fallback(tool, content, progress_cb=progress_cb, workspace=workspace)
|
||||
if fallback:
|
||||
return fallback
|
||||
|
||||
@@ -436,6 +617,7 @@ async def _direct_fallback(
|
||||
tool: str,
|
||||
content: str,
|
||||
progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None,
|
||||
workspace: Optional[str] = None,
|
||||
) -> Optional[Dict]:
|
||||
"""In-process execution path for the eight tools that used to live as
|
||||
stdio MCP servers under mcp_servers/. Those servers were deleted in
|
||||
@@ -461,6 +643,7 @@ async def _direct_fallback(
|
||||
"TERM": "xterm-256color",
|
||||
"COLUMNS": "120",
|
||||
"LINES": "40",
|
||||
"HOME": _AGENT_WORKDIR,
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -470,6 +653,7 @@ async def _direct_fallback(
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=_subproc_env,
|
||||
cwd=workspace or _AGENT_WORKDIR,
|
||||
)
|
||||
stdout, stderr, rc, timed_out = await _run_subprocess_streaming(
|
||||
proc,
|
||||
@@ -496,6 +680,7 @@ async def _direct_fallback(
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=_subproc_env,
|
||||
cwd=workspace or _AGENT_WORKDIR,
|
||||
)
|
||||
stdout, stderr, rc, timed_out = await _run_subprocess_streaming(
|
||||
proc,
|
||||
@@ -512,14 +697,43 @@ async def _direct_fallback(
|
||||
return {"output": output or "(no output)", "exit_code": rc or 0}
|
||||
|
||||
if tool == "read_file":
|
||||
raw_path = content.split("\n", 1)[0].strip()
|
||||
# Args: plain path on line 1 (back-compat) OR JSON
|
||||
# {path, offset?, limit?} where offset/limit are a 1-based line range.
|
||||
raw_path, offset, limit = content.split("\n", 1)[0].strip(), 0, 0
|
||||
_stripped = content.strip()
|
||||
if _stripped.startswith("{"):
|
||||
try:
|
||||
_a = _json.loads(_stripped)
|
||||
raw_path = str(_a.get("path", "")).strip()
|
||||
offset = int(_a.get("offset") or 0)
|
||||
limit = int(_a.get("limit") or 0)
|
||||
except (_json.JSONDecodeError, TypeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
path = _resolve_tool_path(raw_path)
|
||||
path = (_resolve_tool_path_in_workspace(workspace, raw_path)
|
||||
if workspace else _resolve_tool_path(raw_path))
|
||||
except ValueError as e:
|
||||
return {"error": f"read_file: {e}", "exit_code": 1}
|
||||
try:
|
||||
# Run blocking read in a thread to keep the loop responsive
|
||||
# Run blocking read in a thread to keep the loop responsive.
|
||||
def _read():
|
||||
if offset > 0 or limit > 0:
|
||||
# Line-range read: slice [offset, offset+limit).
|
||||
start = max(offset, 1)
|
||||
out, n, budget = [], 0, MAX_READ_CHARS
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||
for i, line in enumerate(f, 1):
|
||||
if i < start:
|
||||
continue
|
||||
if limit > 0 and n >= limit:
|
||||
break
|
||||
out.append(line)
|
||||
n += 1
|
||||
budget -= len(line)
|
||||
if budget <= 0:
|
||||
out.append(f"\n... [truncated at {MAX_READ_CHARS} chars]")
|
||||
break
|
||||
return "".join(out)
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||
return f.read(MAX_READ_CHARS + 1)
|
||||
data = await asyncio.to_thread(_read)
|
||||
@@ -527,10 +741,11 @@ async def _direct_fallback(
|
||||
return {"error": f"read_file: {path}: not found", "exit_code": 1}
|
||||
except PermissionError:
|
||||
return {"error": f"read_file: {path}: permission denied", "exit_code": 1}
|
||||
except IsADirectoryError:
|
||||
return {"error": f"read_file: {path}: is a directory (use ls)", "exit_code": 1}
|
||||
except OSError as e:
|
||||
return {"error": f"read_file: {path}: {e}", "exit_code": 1}
|
||||
truncated = len(data) > MAX_READ_CHARS
|
||||
if truncated:
|
||||
if not (offset > 0 or limit > 0) and len(data) > MAX_READ_CHARS:
|
||||
data = data[:MAX_READ_CHARS] + f"\n... [truncated at {MAX_READ_CHARS} chars]"
|
||||
return {"output": data, "exit_code": 0}
|
||||
|
||||
@@ -539,23 +754,226 @@ async def _direct_fallback(
|
||||
raw_path = lines[0].strip()
|
||||
body = lines[1] if len(lines) > 1 else ""
|
||||
try:
|
||||
path = _resolve_tool_path(raw_path)
|
||||
path = (_resolve_tool_path_in_workspace(workspace, raw_path)
|
||||
if workspace else _resolve_tool_path(raw_path))
|
||||
except ValueError as e:
|
||||
return {"error": f"write_file: {e}", "exit_code": 1}
|
||||
try:
|
||||
def _write():
|
||||
# Capture prior content (best-effort, text) so we can show a
|
||||
# before/after diff. Missing/binary file → treat as empty.
|
||||
old = ""
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
old = f.read()
|
||||
except (FileNotFoundError, IsADirectoryError, UnicodeDecodeError, OSError):
|
||||
old = ""
|
||||
d = os.path.dirname(path)
|
||||
if d:
|
||||
os.makedirs(d, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(body)
|
||||
return len(body)
|
||||
size = await asyncio.to_thread(_write)
|
||||
return old, len(body)
|
||||
old_content, size = await asyncio.to_thread(_write)
|
||||
except PermissionError:
|
||||
return {"error": f"write_file: {path}: permission denied", "exit_code": 1}
|
||||
except OSError as e:
|
||||
return {"error": f"write_file: {path}: {e}", "exit_code": 1}
|
||||
return {"output": f"Wrote {size} bytes to {path}", "exit_code": 0}
|
||||
diff = _unified_diff(old_content, body, path)
|
||||
result = {"output": f"Wrote {size} bytes to {path}", "exit_code": 0}
|
||||
if diff:
|
||||
result["diff"] = diff
|
||||
return result
|
||||
|
||||
if tool == "grep":
|
||||
# Args (JSON): {pattern, path?, glob?, ignore_case?, max_results?}.
|
||||
# Bare string → treated as the pattern.
|
||||
args: Dict[str, Any] = {}
|
||||
_s = (content or "").strip()
|
||||
if _s.startswith("{"):
|
||||
try:
|
||||
args = _json.loads(_s)
|
||||
except _json.JSONDecodeError:
|
||||
args = {}
|
||||
else:
|
||||
args = {"pattern": _s}
|
||||
pattern = str(args.get("pattern", "")).strip()
|
||||
if not pattern:
|
||||
return {"error": "grep: pattern is required", "exit_code": 1}
|
||||
ignore_case = bool(args.get("ignore_case"))
|
||||
glob_pat = str(args.get("glob", "") or "").strip()
|
||||
try:
|
||||
max_hits = int(args.get("max_results") or _CODENAV_MAX_HITS)
|
||||
except (TypeError, ValueError):
|
||||
max_hits = _CODENAV_MAX_HITS
|
||||
max_hits = max(1, min(max_hits, _CODENAV_MAX_HITS))
|
||||
try:
|
||||
root = _resolve_search_root(str(args.get("path", "")), workspace)
|
||||
except ValueError as e:
|
||||
return {"error": f"grep: {e}", "exit_code": 1}
|
||||
|
||||
def _grep():
|
||||
import re as _re
|
||||
import shutil
|
||||
rg = shutil.which("rg")
|
||||
if rg:
|
||||
cmd = [rg, "--line-number", "--no-heading", "--color=never",
|
||||
"--max-count", str(max_hits)]
|
||||
if ignore_case:
|
||||
cmd.append("--ignore-case")
|
||||
if glob_pat:
|
||||
cmd += ["--glob", glob_pat]
|
||||
# Exclude junk dirs even when the tree has no .gitignore, so
|
||||
# results match the Python fallback's skip set.
|
||||
for _d in _CODENAV_SKIP_DIRS:
|
||||
cmd += ["--glob", f"!**/{_d}/**"]
|
||||
cmd += ["--regexp", pattern, root]
|
||||
try:
|
||||
import subprocess
|
||||
p = subprocess.run(cmd, capture_output=True, text=True, timeout=20)
|
||||
lines = [ln for ln in (p.stdout or "").splitlines() if ln][:max_hits]
|
||||
return lines, None
|
||||
except subprocess.TimeoutExpired:
|
||||
return None, "grep: timed out"
|
||||
except Exception as _e:
|
||||
return None, f"grep: {_e}"
|
||||
# Python fallback (no ripgrep): walk + regex.
|
||||
try:
|
||||
rx = _re.compile(pattern, _re.IGNORECASE if ignore_case else 0)
|
||||
except _re.error as _e:
|
||||
return None, f"grep: bad pattern: {_e}"
|
||||
import fnmatch
|
||||
hits = []
|
||||
if os.path.isfile(root):
|
||||
file_iter = [root]
|
||||
else:
|
||||
file_iter = []
|
||||
for dp, dns, fns in os.walk(root):
|
||||
dns[:] = [d for d in dns if d not in _CODENAV_SKIP_DIRS]
|
||||
for fn in fns:
|
||||
if glob_pat and not fnmatch.fnmatch(fn, glob_pat):
|
||||
continue
|
||||
file_iter.append(os.path.join(dp, fn))
|
||||
for fp in file_iter:
|
||||
if len(hits) >= max_hits:
|
||||
break
|
||||
try:
|
||||
with open(fp, "r", encoding="utf-8", errors="strict") as f:
|
||||
for i, line in enumerate(f, 1):
|
||||
if rx.search(line):
|
||||
hits.append(f"{fp}:{i}:{line.rstrip()[:_CODENAV_MAX_LINE]}")
|
||||
if len(hits) >= max_hits:
|
||||
break
|
||||
except (UnicodeDecodeError, OSError):
|
||||
continue # skip binary / unreadable
|
||||
return hits, None
|
||||
|
||||
lines, err = await asyncio.to_thread(_grep)
|
||||
if err:
|
||||
return {"error": err, "exit_code": 1}
|
||||
if not lines:
|
||||
return {"output": f"No matches for {pattern!r} under {root}", "exit_code": 0}
|
||||
out = "\n".join(ln[:_CODENAV_MAX_LINE] for ln in lines)
|
||||
if len(lines) >= max_hits:
|
||||
out += f"\n... [capped at {max_hits} matches]"
|
||||
return {"output": _truncate(out), "exit_code": 0}
|
||||
|
||||
if tool == "glob":
|
||||
args = {}
|
||||
_s = (content or "").strip()
|
||||
if _s.startswith("{"):
|
||||
try:
|
||||
args = _json.loads(_s)
|
||||
except _json.JSONDecodeError:
|
||||
args = {}
|
||||
else:
|
||||
args = {"pattern": _s}
|
||||
pattern = str(args.get("pattern", "")).strip()
|
||||
if not pattern:
|
||||
return {"error": "glob: pattern is required", "exit_code": 1}
|
||||
try:
|
||||
root = _resolve_search_root(str(args.get("path", "")), workspace)
|
||||
except ValueError as e:
|
||||
return {"error": f"glob: {e}", "exit_code": 1}
|
||||
|
||||
def _glob():
|
||||
from pathlib import Path
|
||||
base = Path(root)
|
||||
if not base.is_dir():
|
||||
return None, f"glob: {root}: not a directory"
|
||||
matched = []
|
||||
try:
|
||||
for p in base.rglob(pattern):
|
||||
if set(p.relative_to(base).parts) & _CODENAV_SKIP_DIRS:
|
||||
continue
|
||||
try:
|
||||
mtime = p.stat().st_mtime
|
||||
except OSError:
|
||||
mtime = 0
|
||||
matched.append((mtime, str(p)))
|
||||
if len(matched) > _CODENAV_MAX_HITS * 5:
|
||||
break
|
||||
except (OSError, ValueError) as _e:
|
||||
return None, f"glob: {_e}"
|
||||
matched.sort(key=lambda t: t[0], reverse=True) # newest first
|
||||
return [pth for _, pth in matched[:_CODENAV_MAX_HITS]], None
|
||||
|
||||
paths, err = await asyncio.to_thread(_glob)
|
||||
if err:
|
||||
return {"error": err, "exit_code": 1}
|
||||
if not paths:
|
||||
return {"output": f"No files matching {pattern!r} under {root}", "exit_code": 0}
|
||||
out = "\n".join(paths)
|
||||
if len(paths) >= _CODENAV_MAX_HITS:
|
||||
out += f"\n... [capped at {_CODENAV_MAX_HITS} files]"
|
||||
return {"output": _truncate(out), "exit_code": 0}
|
||||
|
||||
if tool == "ls":
|
||||
raw_path = ""
|
||||
_s = (content or "").strip()
|
||||
if _s.startswith("{"):
|
||||
try:
|
||||
raw_path = str(_json.loads(_s).get("path", "")).strip()
|
||||
except _json.JSONDecodeError:
|
||||
raw_path = ""
|
||||
else:
|
||||
raw_path = _s.split("\n", 1)[0].strip()
|
||||
try:
|
||||
root = _resolve_search_root(raw_path, workspace)
|
||||
except ValueError as e:
|
||||
return {"error": f"ls: {e}", "exit_code": 1}
|
||||
|
||||
def _ls():
|
||||
if not os.path.isdir(root):
|
||||
return None, f"ls: {root}: not a directory"
|
||||
rows = []
|
||||
try:
|
||||
with os.scandir(root) as it:
|
||||
for entry in it:
|
||||
if entry.name.startswith("."):
|
||||
continue
|
||||
try:
|
||||
is_dir = entry.is_dir(follow_symlinks=False)
|
||||
size = entry.stat(follow_symlinks=False).st_size if not is_dir else 0
|
||||
except OSError:
|
||||
continue
|
||||
rows.append((is_dir, entry.name, size))
|
||||
except (PermissionError, OSError) as _e:
|
||||
return None, f"ls: {_e}"
|
||||
rows.sort(key=lambda r: (not r[0], r[1].lower())) # dirs first, then name
|
||||
lines = [f"{root}:"]
|
||||
for is_dir, name, size in rows[:_CODENAV_MAX_HITS]:
|
||||
lines.append(f" {name}/" if is_dir else f" {name} ({size} B)")
|
||||
if len(rows) > _CODENAV_MAX_HITS:
|
||||
lines.append(f" ... [{len(rows) - _CODENAV_MAX_HITS} more]")
|
||||
if not rows:
|
||||
lines.append(" (empty)")
|
||||
return "\n".join(lines), None
|
||||
|
||||
out, err = await asyncio.to_thread(_ls)
|
||||
if err:
|
||||
return {"error": err, "exit_code": 1}
|
||||
return {"output": _truncate(out), "exit_code": 0}
|
||||
|
||||
if tool == "web_search":
|
||||
from src.search import comprehensive_web_search
|
||||
@@ -685,6 +1103,7 @@ async def execute_tool_block(
|
||||
disabled_tools: Optional[set] = None,
|
||||
owner: Optional[str] = None,
|
||||
progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None,
|
||||
workspace: Optional[str] = None,
|
||||
) -> Tuple[str, Dict]:
|
||||
"""Execute a single tool block. Returns (description, result_dict).
|
||||
|
||||
@@ -700,6 +1119,7 @@ async def execute_tool_block(
|
||||
do_manage_documents, do_manage_settings, do_manage_notes,
|
||||
do_manage_calendar,
|
||||
do_download_model, do_serve_model, do_list_served_models, do_stop_served_model,
|
||||
do_tail_serve_output,
|
||||
do_list_downloads, do_cancel_download, do_search_hf_models, do_list_cached_models,
|
||||
do_list_serve_presets, do_serve_preset, do_adopt_served_model,
|
||||
do_list_cookbook_servers,
|
||||
@@ -772,7 +1192,7 @@ async def execute_tool_block(
|
||||
_is_bg, _bg_cmd = _split_bg_marker(content)
|
||||
if _is_bg and _bg_cmd:
|
||||
from src import bg_jobs
|
||||
rec = bg_jobs.launch(_bg_cmd, session_id=session_id)
|
||||
rec = bg_jobs.launch(_bg_cmd, session_id=session_id, cwd=workspace or _AGENT_WORKDIR)
|
||||
short = _bg_cmd.strip().split(chr(10))[0][:80]
|
||||
desc = f"bash (background): {short}"
|
||||
result = {
|
||||
@@ -794,7 +1214,14 @@ async def execute_tool_block(
|
||||
if tool in _MCP_TOOL_MAP:
|
||||
first_line = content.split(chr(10))[0][:80]
|
||||
desc = f"{tool}: {first_line}"
|
||||
result = await _call_mcp_tool(tool, content, progress_cb=progress_cb)
|
||||
result = await _call_mcp_tool(tool, content, progress_cb=progress_cb, workspace=workspace)
|
||||
elif tool in ("grep", "glob", "ls"):
|
||||
# Code-navigation tools — no MCP server; run the direct implementation.
|
||||
# Confined to the workspace when one is set (same policy as read_file).
|
||||
first_line = content.split(chr(10))[0][:80]
|
||||
desc = f"{tool}: {first_line}"
|
||||
result = await _direct_fallback(tool, content, progress_cb=progress_cb, workspace=workspace) \
|
||||
or {"error": f"{tool}: execution failed", "exit_code": 1}
|
||||
elif tool == "create_document":
|
||||
title = content.split("\n")[0].strip()[:60]
|
||||
desc = f"create_document: {title}"
|
||||
@@ -864,6 +1291,9 @@ async def execute_tool_block(
|
||||
elif tool == "stop_served_model":
|
||||
desc = "stop_served_model"
|
||||
result = await do_stop_served_model(content, owner=owner)
|
||||
elif tool == "tail_serve_output":
|
||||
desc = "tail_serve_output"
|
||||
result = await do_tail_serve_output(content, owner=owner)
|
||||
elif tool == "list_downloads":
|
||||
desc = "list_downloads"
|
||||
result = await do_list_downloads(content, owner=owner)
|
||||
@@ -894,6 +1324,9 @@ async def execute_tool_block(
|
||||
elif tool == "edit_image":
|
||||
desc = "edit_image"
|
||||
result = await do_edit_image(content, owner=owner)
|
||||
elif tool == "edit_file":
|
||||
result = await _do_edit_file(content, workspace=workspace)
|
||||
desc = result.get("output") or result.get("error") or "edit_file"
|
||||
elif tool == "trigger_research":
|
||||
desc = "trigger_research"
|
||||
result = await do_trigger_research(content, owner=owner)
|
||||
|
||||
+347
-34
@@ -5,6 +5,7 @@ Extracted tool implementation functions (do_* and helpers) from agent_tools.py.
|
||||
These handle the actual execution logic for each tool type.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -1956,7 +1957,19 @@ async def do_manage_notes(content: str, owner: Optional[str] = None) -> Dict:
|
||||
)
|
||||
db.add(note)
|
||||
db.commit()
|
||||
return {"response": f"Note created: \"{title or '(untitled)'}\" (id: {note.id[:8]})", "exit_code": 0}
|
||||
# Return note_id so the chat-side renderer can build a real
|
||||
# "View note" button that opens the notes modal at this id.
|
||||
# Previously the create response only included a prose
|
||||
# confirmation; the model would type "View note" as a markdown
|
||||
# link with no target, leaving the user with a click that
|
||||
# did nothing and uncertainty about whether the note was made.
|
||||
return {
|
||||
"response": f"Note created: \"{title or '(untitled)'}\" (id: {note.id[:8]})",
|
||||
"note_id": note.id,
|
||||
"note_title": title or "",
|
||||
"open_url": f"/#open=notes¬e={note.id}",
|
||||
"exit_code": 0,
|
||||
}
|
||||
|
||||
elif action == "update":
|
||||
note_id = args.get("id", "")
|
||||
@@ -2603,6 +2616,8 @@ async def _cookbook_env_for_host(host: str) -> Dict[str, Any]:
|
||||
|
||||
return {
|
||||
"env_prefix": env_prefix,
|
||||
"env_type": env_kind,
|
||||
"env_path": env_path,
|
||||
"gpus": env_root.get("gpus") or "",
|
||||
"platform": platform,
|
||||
"hf_token": env_root.get("hfToken") or "",
|
||||
@@ -3041,6 +3056,31 @@ async def do_serve_model(content: str, owner: Optional[str] = None) -> Dict:
|
||||
# the UI uses. Without env_prefix, `vllm serve …` lands in a shell
|
||||
# without the user's venv and fails 'command not found'.
|
||||
env_cfg = await _cookbook_env_for_host(host)
|
||||
# Rewrite bare `vllm` / `python3` leading tokens to the venv's absolute
|
||||
# binary path when the target host has a venv configured. SSH non-
|
||||
# interactive shells often leave ~/.local/bin ahead of the venv bin on
|
||||
# PATH even with the venv activated, so `vllm serve` finds the wrong
|
||||
# binary and crashes early (e.g. compute_89 torch ABI errors on an old
|
||||
# user-site torch). This mirrors what static/js/cookbook.js does in
|
||||
# _buildServeCmd for the UI launch path.
|
||||
env_path = (env_cfg.get("env_path") or "").rstrip("/")
|
||||
env_type = (env_cfg.get("env_type") or env_cfg.get("env") or "").lower()
|
||||
if env_type == "venv" and env_path:
|
||||
venv_bin = f"{env_path}/bin"
|
||||
# Match the FIRST shell-token: skip leading KEY=VAL env-var prefixes
|
||||
# (CUDA_VISIBLE_DEVICES=… VLLM_USE_FLASHINFER_SAMPLER=…) before the binary.
|
||||
import re as _re3
|
||||
tokens = cmd.split()
|
||||
idx = 0
|
||||
env_re = _re3.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
||||
while idx < len(tokens) and env_re.match(tokens[idx]):
|
||||
idx += 1
|
||||
if idx < len(tokens):
|
||||
head = tokens[idx]
|
||||
if head in ("vllm", "python3", "python"):
|
||||
tokens[idx] = f"{venv_bin}/{head}"
|
||||
cmd = " ".join(tokens)
|
||||
payload["cmd"] = cmd
|
||||
if env_cfg.get("env_prefix"): payload["env_prefix"] = env_cfg["env_prefix"]
|
||||
if env_cfg.get("gpus"): payload["gpus"] = env_cfg["gpus"]
|
||||
if env_cfg.get("hf_token"): payload["hf_token"] = env_cfg["hf_token"]
|
||||
@@ -3059,7 +3099,19 @@ async def do_serve_model(content: str, owner: Optional[str] = None) -> Dict:
|
||||
)
|
||||
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 {"error": data.get("error", "Serve failed"), "exit_code": 1}
|
||||
# FastAPI HTTPException puts the message under `detail`, not `error`.
|
||||
# Surface BOTH so the agent sees "Invalid characters in cmd" (from
|
||||
# _validate_serve_cmd rejecting `&&`/`source`/`cd`) instead of
|
||||
# the generic "Serve failed", which leaves it with nothing to act on.
|
||||
err_msg = data.get("error") or data.get("detail") or "Serve failed"
|
||||
hint = ""
|
||||
if isinstance(err_msg, str) and "cmd" in err_msg.lower():
|
||||
hint = (" — the cmd must START with an allowlisted binary "
|
||||
"(vllm, python3, llama-server, ollama, sglang, lmdeploy, node, npx). "
|
||||
"Do NOT prefix with `cd …`, `source …`, or chain with `&&`. "
|
||||
"env_prefix (e.g. `source ~/qwen35-env/bin/activate`) is added "
|
||||
"automatically from the host's saved venv settings.")
|
||||
return {"error": f"{err_msg}{hint}", "exit_code": 1}
|
||||
except Exception as e:
|
||||
return {"error": str(e), "exit_code": 1}
|
||||
|
||||
@@ -3103,13 +3155,31 @@ async def do_list_served_models(content: str, owner: Optional[str] = None) -> Di
|
||||
"exit_code": 0,
|
||||
}
|
||||
|
||||
# Sort so the agent sees what's actually LIVE first. Stopped/error/
|
||||
# completed tasks are mostly historical noise — they shouldn't lead
|
||||
# the list when something is genuinely serving.
|
||||
_ORDER = {
|
||||
"ready": 0, "running": 1, "loading": 1, "warming": 1,
|
||||
"queued": 2, "starting": 2,
|
||||
"error": 5, "crashed": 5, "failed": 5,
|
||||
"stopped": 6, "killed": 6, "cancelled": 6, "canceled": 6,
|
||||
"done": 7, "completed": 7, "finished": 7,
|
||||
}
|
||||
def _rank(t: Dict[str, Any]) -> int:
|
||||
phase = (t.get("phase") or t.get("status") or "unknown").lower()
|
||||
return _ORDER.get(phase, 3)
|
||||
merged.sort(key=_rank)
|
||||
|
||||
cb_n = len(cookbook_tasks)
|
||||
ext_n = len(external)
|
||||
live_n = sum(1 for t in merged if _rank(t) <= 2)
|
||||
header = []
|
||||
if cb_n:
|
||||
header.append(f"{cb_n} cookbook-tracked")
|
||||
if ext_n:
|
||||
header.append(f"{ext_n} external")
|
||||
if live_n:
|
||||
header.insert(0, f"{live_n} LIVE")
|
||||
lines = [f"Running: {', '.join(header)}."]
|
||||
for t in merged:
|
||||
phase = t.get("phase") or t.get("status", "unknown")
|
||||
@@ -3136,8 +3206,20 @@ async def do_list_served_models(content: str, owner: Optional[str] = None) -> Di
|
||||
if t.get("status") == "error" and t.get("output_tail"):
|
||||
tail = str(t.get("output_tail") or "").strip()
|
||||
if tail:
|
||||
# Prefer a window around a Python traceback if one exists,
|
||||
# falling back to the last 30 lines. The previous 6-line
|
||||
# tail showed only the post-crash bash prompt / neofetch
|
||||
# banner ("Locale: C / Ubuntu_Odysseus ❯") — useless for
|
||||
# diagnosis. The traceback we want is usually 50-200 lines
|
||||
# earlier in the buffer.
|
||||
_tail_lines = tail.splitlines()
|
||||
_shown = _tail_lines[-30:]
|
||||
for _i, _ln in enumerate(_tail_lines):
|
||||
if "Traceback (most recent call last)" in _ln or "ERROR" in _ln or "Error:" in _ln:
|
||||
_shown = _tail_lines[_i:_i + 40]
|
||||
break
|
||||
lines.append(" recent log:")
|
||||
for line in tail.splitlines()[-6:]:
|
||||
for line in _shown:
|
||||
lines.append(f" {line[:220]}")
|
||||
if t.get("external") and t.get("cmdline_preview"):
|
||||
lines.append(f" cmd: {t['cmdline_preview']}")
|
||||
@@ -3243,6 +3325,125 @@ async def do_stop_served_model(content: str, owner: Optional[str] = None) -> Dic
|
||||
)
|
||||
|
||||
|
||||
async def do_tail_serve_output(content: str, owner: Optional[str] = None) -> Dict:
|
||||
"""Capture the last N lines of a cookbook task's tmux pane — remote-aware.
|
||||
|
||||
Used by the agent to debug a failed/stuck serve: list_served_models tells
|
||||
you the task is `crashed`, this tool returns the actual stderr/traceback
|
||||
so the agent can match it against a known fix (compute_89 nvcc mismatch,
|
||||
flashinfer version mismatch, OOM, missing kernels, etc.) and decide
|
||||
whether to relaunch via serve_model with new flags.
|
||||
"""
|
||||
import httpx
|
||||
import shlex
|
||||
try:
|
||||
args = _parse_tool_args(content)
|
||||
except ValueError:
|
||||
return {"error": "Invalid JSON arguments", "exit_code": 1}
|
||||
session_id = (args.get("session_id") or "").strip()
|
||||
if not session_id:
|
||||
return {"error": "session_id is required (from list_served_models)", "exit_code": 1}
|
||||
import re as _re
|
||||
if not _re.fullmatch(r"[a-zA-Z0-9_-]+", session_id):
|
||||
return {"error": "Invalid session_id format", "exit_code": 1}
|
||||
try:
|
||||
tail = int(args.get("tail") or 400)
|
||||
except (TypeError, ValueError):
|
||||
tail = 400
|
||||
tail = max(20, min(tail, 4000))
|
||||
headers = _internal_headers()
|
||||
remote = (args.get("remote_host") or args.get("host") or "").strip()
|
||||
sport = (args.get("ssh_port") or "").strip()
|
||||
# Resolve host from cookbook state if caller didn't pass one — same
|
||||
# lookup _cookbook_kill_session uses.
|
||||
if not remote:
|
||||
state: Dict[str, Any] = {}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(f"{_COOKBOOK_BASE}/api/cookbook/state", headers=headers)
|
||||
state = resp.json() or {}
|
||||
except Exception as e:
|
||||
logger.debug(f"cookbook state lookup failed for {session_id}: {e}")
|
||||
if isinstance(state, dict):
|
||||
for t in (state.get("tasks") or []):
|
||||
if isinstance(t, dict) and (t.get("sessionId") == session_id or t.get("id") == session_id):
|
||||
remote = t.get("remoteHost") or ""
|
||||
if not sport:
|
||||
sport = t.get("sshPort") or ""
|
||||
break
|
||||
# Prefer the persisted /tmp/odysseus-tmux/SESSION.log file over the
|
||||
# live tmux pane. The pane is what the user would see scrolling on
|
||||
# their screen — including the post-crash neofetch banner and the
|
||||
# idle bash prompt that overwrites the actual traceback the moment
|
||||
# vllm exits. The log file is the raw stdout/stderr of the wrapped
|
||||
# process and survives the crash unchanged. We only fall back to
|
||||
# the pane when the log file doesn't exist (older sessions launched
|
||||
# before the tmux+tee wrapper was added).
|
||||
log_path = f"/tmp/odysseus-tmux/{session_id}.log"
|
||||
pane_inner = f"tmux capture-pane -t {shlex.quote(session_id)} -p -S -{tail} 2>/dev/null"
|
||||
file_inner = f"tail -n {tail} {shlex.quote(log_path)} 2>/dev/null"
|
||||
inner = (
|
||||
f"if [ -s {shlex.quote(log_path)} ]; then {file_inner}; "
|
||||
f"else {pane_inner}; fi"
|
||||
)
|
||||
if remote:
|
||||
_pf = f"-p {shlex.quote(str(sport))} " if sport and str(sport) != "22" else ""
|
||||
cmd = (
|
||||
f"ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no "
|
||||
f"{_pf}{shlex.quote(remote)} {shlex.quote(inner)}"
|
||||
)
|
||||
host_label = remote
|
||||
else:
|
||||
cmd = inner
|
||||
host_label = "local"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
resp = await client.post(f"{_COOKBOOK_BASE}/api/shell/exec",
|
||||
json={"command": cmd}, headers=headers)
|
||||
if resp.status_code >= 400:
|
||||
return {"error": f"shell/exec returned HTTP {resp.status_code}: {resp.text[:200]}", "exit_code": 1}
|
||||
data = resp.json() if resp.content else {}
|
||||
output_text = (data.get("stdout") or "").strip()
|
||||
stderr_text = (data.get("stderr") or "").strip()
|
||||
rc = data.get("exit_code")
|
||||
if rc not in (None, 0) and not output_text:
|
||||
already_gone = any(s in (stderr_text or "").lower() for s in ("no server running", "can't find session", "session not found"))
|
||||
if already_gone:
|
||||
return {"output": f"Tmux session {session_id} on {host_label} is gone (task already exited).", "exit_code": 0, "session_id": session_id, "host": host_label}
|
||||
return {"error": f"capture-pane failed on {host_label}: {stderr_text or f'exit {rc}'}", "exit_code": 1}
|
||||
# Dedupe download-progress noise. A 100-shard HF download produces
|
||||
# tens of thousands of `model-NN-of-MM.safetensors: 91%|...` lines
|
||||
# that all look the same to the agent and drown the actual error.
|
||||
# Keep only one sample per (file, decile-percent) bucket.
|
||||
import re as _re2
|
||||
lines = output_text.splitlines()
|
||||
dedup_lines = []
|
||||
seen_progress = set()
|
||||
progress_re = _re2.compile(r"^([\w./\-]+):\s+(\d+)%")
|
||||
for ln in lines:
|
||||
m = progress_re.match(ln.strip())
|
||||
if m:
|
||||
key = (m.group(1), int(m.group(2)) // 10) # bucket by 10%
|
||||
if key in seen_progress:
|
||||
continue
|
||||
seen_progress.add(key)
|
||||
dedup_lines.append(ln)
|
||||
output_text = "\n".join(dedup_lines)
|
||||
# Hard cap so the agent doesn't blow its token budget.
|
||||
MAX_CHARS = 8000
|
||||
if len(output_text) > MAX_CHARS:
|
||||
output_text = "…(earlier output truncated)…\n" + output_text[-MAX_CHARS:]
|
||||
return {
|
||||
"output": output_text or "(empty pane)",
|
||||
"session_id": session_id,
|
||||
"host": host_label,
|
||||
"tail_lines": tail,
|
||||
"exit_code": 0,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e), "exit_code": 1}
|
||||
|
||||
|
||||
async def do_list_downloads(content: str, owner: Optional[str] = None) -> Dict:
|
||||
"""List in-flight model downloads (filters /api/cookbook/tasks/status to type=download)."""
|
||||
import httpx
|
||||
@@ -3615,38 +3816,133 @@ async def do_serve_preset(content: str, owner: Optional[str] = None) -> Dict:
|
||||
|
||||
|
||||
async def do_list_cached_models(content: str, owner: Optional[str] = None) -> Dict:
|
||||
"""List models already cached locally (or on a remote host)."""
|
||||
"""List models already cached locally and/or on remote hosts.
|
||||
|
||||
With no `host` arg, scans EVERY configured Cookbook server (and local)
|
||||
and aggregates — so the agent sees the full inventory in one call
|
||||
instead of having to query each server individually.
|
||||
"""
|
||||
import httpx
|
||||
try:
|
||||
args = _parse_tool_args(content) if content.strip() else {}
|
||||
except ValueError:
|
||||
return {"error": "Invalid JSON arguments", "exit_code": 1}
|
||||
params: Dict[str, str] = {}
|
||||
raw_host = (args.get("host") or "").strip()
|
||||
host = await _resolve_cookbook_host(raw_host) if raw_host else ""
|
||||
if host:
|
||||
params["host"] = host
|
||||
if args.get("model_dir"):
|
||||
params["model_dir"] = args["model_dir"]
|
||||
if args.get("ssh_port"):
|
||||
params["ssh_port"] = str(args["ssh_port"])
|
||||
if args.get("platform"):
|
||||
params["platform"] = args["platform"]
|
||||
headers = _internal_headers()
|
||||
|
||||
async def _scan_one(host_label: str, host_val: str, ssh_port: str = "",
|
||||
platform: str = "", model_dir: str = "") -> list:
|
||||
"""Hit /api/model/cached for one host; tag each returned model with its source."""
|
||||
p: Dict[str, str] = {}
|
||||
if host_val:
|
||||
p["host"] = host_val
|
||||
# Caller-provided override beats per-server config beats nothing.
|
||||
if args.get("model_dir"):
|
||||
p["model_dir"] = args["model_dir"]
|
||||
elif model_dir:
|
||||
p["model_dir"] = model_dir
|
||||
if ssh_port:
|
||||
p["ssh_port"] = ssh_port
|
||||
elif args.get("ssh_port"):
|
||||
p["ssh_port"] = str(args["ssh_port"])
|
||||
if platform:
|
||||
p["platform"] = platform
|
||||
elif args.get("platform"):
|
||||
p["platform"] = args["platform"]
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
resp = await client.get(f"{_COOKBOOK_BASE}/api/model/cached",
|
||||
params=p, headers=headers)
|
||||
data = resp.json()
|
||||
ms = data.get("models", []) if isinstance(data, dict) else (data or [])
|
||||
for m in ms:
|
||||
m["host"] = host_label or "local"
|
||||
return ms or []
|
||||
except Exception as e:
|
||||
logger.debug(f"list_cached_models scan({host_label}) failed: {e}")
|
||||
return []
|
||||
|
||||
# When the caller specifies a host explicitly, scan only that one (old behaviour).
|
||||
# Otherwise iterate every configured server + local so the agent doesn't
|
||||
# have to repeat the call per server.
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
resp = await client.get(f"{_COOKBOOK_BASE}/api/model/cached",
|
||||
params=params, headers=_internal_headers())
|
||||
data = resp.json()
|
||||
models = data.get("models", []) if isinstance(data, dict) else data
|
||||
# Pull configured servers from cookbook state (used for resolving
|
||||
# modelDirs both when caller specifies a host and when we scan all).
|
||||
servers: list = []
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
st = await client.get(f"{_COOKBOOK_BASE}/api/cookbook/state", headers=headers)
|
||||
st_data = st.json() if st.headers.get("content-type", "").startswith("application/json") else {}
|
||||
servers = (st_data.get("env", {}) or {}).get("servers") or []
|
||||
except Exception as e:
|
||||
logger.debug(f"server list fetch failed: {e}")
|
||||
st_data = {}
|
||||
|
||||
def _dirs_for(server_record: Dict[str, Any]) -> str:
|
||||
"""Comma-joined modelDirs from a saved server record (Settings).
|
||||
|
||||
Filters out the HF cache (~/.cache/huggingface/hub) — the backend
|
||||
scan script always scans it by default, so re-passing it as an
|
||||
extra model_dir is redundant AND confuses some path-handling
|
||||
edge cases where the extra dir suppresses the deeper scan.
|
||||
We only need to forward the NON-default dirs (e.g. /mnt/HADES/models).
|
||||
"""
|
||||
mds = server_record.get("modelDirs") if isinstance(server_record, dict) else None
|
||||
HF_DEFAULTS = {"~/.cache/huggingface/hub", "~/.cache/huggingface"}
|
||||
if isinstance(mds, list):
|
||||
extras = [d for d in mds if isinstance(d, str) and d.strip() and d.strip() not in HF_DEFAULTS]
|
||||
return ",".join(extras)
|
||||
if isinstance(mds, str) and mds.strip() not in HF_DEFAULTS:
|
||||
return mds
|
||||
return ""
|
||||
|
||||
if raw_host:
|
||||
host = await _resolve_cookbook_host(raw_host)
|
||||
# Find this host's saved record so its modelDirs apply too.
|
||||
srv = next(
|
||||
(s for s in servers if isinstance(s, dict)
|
||||
and (s.get("name") == raw_host or s.get("host") == host or s.get("host") == raw_host)),
|
||||
{},
|
||||
)
|
||||
models = await _scan_one(raw_host, host, model_dir=_dirs_for(srv))
|
||||
else:
|
||||
# Always include local. Local's saved record is the one with no host.
|
||||
local_srv = next((s for s in servers if isinstance(s, dict) and not (s.get("host") or "").strip()), {})
|
||||
scans: list = [_scan_one("local", "", model_dir=_dirs_for(local_srv))]
|
||||
for s in servers:
|
||||
if not isinstance(s, dict):
|
||||
continue
|
||||
name = s.get("name") or s.get("host")
|
||||
host_val = s.get("host") or ""
|
||||
if not host_val:
|
||||
continue
|
||||
scans.append(_scan_one(
|
||||
name,
|
||||
host_val,
|
||||
ssh_port=str(s.get("port") or ""),
|
||||
platform=s.get("platform") or "",
|
||||
model_dir=_dirs_for(s),
|
||||
))
|
||||
results = await asyncio.gather(*scans, return_exceptions=False)
|
||||
# Dedupe by (host, repo_id) — same model could appear in both HF cache + Ollama list.
|
||||
seen = set()
|
||||
models: list = []
|
||||
for batch in results:
|
||||
for m in batch:
|
||||
key = (m.get("host", ""), m.get("repo_id", ""))
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
models.append(m)
|
||||
if not models:
|
||||
# Filesystem cache scans can miss models downloaded into the HF
|
||||
# default cache when the server has no explicit model_dir configured.
|
||||
# Still surface completed Cookbook downloads so the agent doesn't
|
||||
# incorrectly assume a model is absent and re-download it.
|
||||
# Cache scans can miss models downloaded into the HF default cache
|
||||
# when the server has no explicit model_dir configured. Surface
|
||||
# completed Cookbook download tasks so the agent doesn't conclude
|
||||
# a model is absent and re-download it.
|
||||
downloaded = []
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
st = await client.get(f"{_COOKBOOK_BASE}/api/cookbook/state", headers=_internal_headers())
|
||||
st = await client.get(f"{_COOKBOOK_BASE}/api/cookbook/state", headers=headers)
|
||||
state = st.json() if st.headers.get("content-type", "").startswith("application/json") else {}
|
||||
for t in (state.get("tasks") or []):
|
||||
if not isinstance(t, dict) or t.get("type") != "download":
|
||||
@@ -3654,27 +3950,44 @@ async def do_list_cached_models(content: str, owner: Optional[str] = None) -> Di
|
||||
if (t.get("status") or "").lower() not in {"done", "completed"}:
|
||||
continue
|
||||
task_host = t.get("remoteHost") or (t.get("payload") or {}).get("remote_host") or ""
|
||||
if host and task_host != host:
|
||||
if raw_host and task_host != raw_host:
|
||||
continue
|
||||
repo = t.get("modelId") or t.get("repoId") or (t.get("payload") or {}).get("repo_id") or t.get("name")
|
||||
if repo and repo not in downloaded:
|
||||
downloaded.append(repo)
|
||||
except Exception:
|
||||
downloaded = []
|
||||
host_str = f" on {raw_host}" if raw_host else ""
|
||||
if downloaded:
|
||||
host_str = f" on {raw_host or host}" if (raw_host or host) else ""
|
||||
lines = [f"No cache paths were detected{host_str}, but Cookbook has completed download task(s):"]
|
||||
lines.extend(f"- {repo} — downloaded via Cookbook task" for repo in downloaded)
|
||||
return {"output": "\n".join(lines), "models": [{"repo_id": repo, "source": "cookbook_task"} for repo in downloaded], "exit_code": 0}
|
||||
host_str = f" on {raw_host or host}" if (raw_host or host) else ""
|
||||
return {"output": f"No cached models found{host_str}.", "exit_code": 0}
|
||||
lines = [f"{len(models)} cached model(s):"]
|
||||
for m in models:
|
||||
name = m.get("repo_id", "?")
|
||||
sz = m.get("size") or (f"{m.get('size_bytes', 0) / (1024**3):.1f}GB" if m.get("size_bytes") else "")
|
||||
inc = " (incomplete)" if m.get("has_incomplete") else ""
|
||||
kind = " [diffusion]" if m.get("is_diffusion") else ""
|
||||
lines.append(f"- {name}{kind} — {sz}{inc}")
|
||||
# Multi-host scan: group by host so the agent sees inventory per server.
|
||||
# Single-host scan: flat list (matches old output shape).
|
||||
if raw_host:
|
||||
lines = [f"{len(models)} cached model(s) on {raw_host}:"]
|
||||
for m in models:
|
||||
name = m.get("repo_id", "?")
|
||||
sz = m.get("size") or (f"{m.get('size_bytes', 0) / (1024**3):.1f}GB" if m.get("size_bytes") else "")
|
||||
inc = " (incomplete)" if m.get("has_incomplete") else ""
|
||||
kind = " [diffusion]" if m.get("is_diffusion") else ""
|
||||
lines.append(f"- {name}{kind} — {sz}{inc}")
|
||||
else:
|
||||
from collections import defaultdict as _dd
|
||||
by_host = _dd(list)
|
||||
for m in models:
|
||||
by_host[m.get("host", "local")].append(m)
|
||||
lines = [f"{len(models)} cached model(s) across {len(by_host)} server(s):"]
|
||||
for host_name in sorted(by_host.keys()):
|
||||
lines.append(f"\n[{host_name}]")
|
||||
for m in by_host[host_name]:
|
||||
name = m.get("repo_id", "?")
|
||||
sz = m.get("size") or (f"{m.get('size_bytes', 0) / (1024**3):.1f}GB" if m.get("size_bytes") else "")
|
||||
inc = " (incomplete)" if m.get("has_incomplete") else ""
|
||||
kind = " [diffusion]" if m.get("is_diffusion") else ""
|
||||
backend = f" ({m.get('backend')})" if m.get("backend") else ""
|
||||
lines.append(f"- {name}{kind}{backend} — {sz}{inc}")
|
||||
return {"output": "\n".join(lines), "models": models, "exit_code": 0}
|
||||
except Exception as e:
|
||||
return {"error": str(e), "exit_code": 1}
|
||||
|
||||
+24
-5
@@ -22,7 +22,13 @@ logger = logging.getLogger(__name__)
|
||||
# Tools that are ALWAYS included regardless of retrieval results.
|
||||
# These are the most commonly needed and should never be missing.
|
||||
ALWAYS_AVAILABLE = frozenset({
|
||||
"bash", "python", "web_search", "web_fetch", "read_file",
|
||||
"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,
|
||||
@@ -31,7 +37,15 @@ ALWAYS_AVAILABLE = frozenset({
|
||||
# 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",
|
||||
"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",
|
||||
})
|
||||
@@ -63,8 +77,12 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
|
||||
"python": "Execute Python code for computation, data processing, math, scripting, parsing, API calls. Not for writing code for the user.",
|
||||
"web_search": "Quick single web lookup for a fact, current event, or doc mid-task. NOT for 'research X' / 'do research on X' requests — those are deep-research jobs (use trigger_research). web_search = one query; trigger_research = a full researched report in the sidebar.",
|
||||
"web_fetch": "Fetch and read the text content of a specific URL/website the user names (e.g. 'check example.com', 'open this link'). Use when you have a concrete URL; for open-ended lookups use web_search instead.",
|
||||
"read_file": "Read a file from disk and return its contents. View source code, config files, logs.",
|
||||
"write_file": "Write content to a file on disk. Create new files, save output, update configs.",
|
||||
"read_file": "Read a file from disk and return its contents. View source code, config files, logs. Supports an optional line range (offset/limit) for large files.",
|
||||
"grep": "Search file CONTENTS for a regex across a directory tree (ripgrep-backed, honours .gitignore). Returns file:line:match. Use to find where code/symbols/strings live — prefer over bash grep.",
|
||||
"glob": "Find FILES by glob pattern (e.g. '**/*.py'), newest first. Use to locate files by name/extension — prefer over bash find/ls.",
|
||||
"ls": "List a directory's entries (folders then files with sizes). Use to see what's in a folder — prefer over bash ls.",
|
||||
"write_file": "Write/create or fully rewrite a file ON DISK (source code, configs, project files). Use for new files or full rewrites — NOT create_document (editor panel) and NOT a bash heredoc.",
|
||||
"edit_file": "Edit an existing file ON DISK by exact string replacement (fix a bug, change a function). Shows a diff. The tool for changing files on disk — NOT edit_document (editor panel) and NOT bash sed/heredoc.",
|
||||
"create_document": "Create a new document in the editor panel. For code, articles, text content longer than 15 lines, unless an already-open document/email draft is the obvious target. If an email compose draft is open, edit that draft instead of creating another document.",
|
||||
"edit_document": "Preferred tool for editing an existing document — targeted find-and-replace. Use for any small change: add a function, fix a bug, tweak a section, rename things.",
|
||||
"update_document": "Replace the entire active document content. ONLY for full rewrites (>50% changed). Do not use for small edits — use edit_document instead.",
|
||||
@@ -104,9 +122,10 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
|
||||
"manage_notes": "Create and manage notes and checklists (Google Keep-style). ALWAYS use this for note/todo/checklist/reminder creation — NEVER hit /api/notes via app_api. Accepts natural-language `due_date` like 'tomorrow at 9am' or '11pm today' (parsed in the USER'S timezone). The due_date IS the reminder — it fires a notification at that time, so do NOT also create a calendar event for the same reminder. Set colors, labels, pin, archive. Do NOT use manage_memory for note content.",
|
||||
"manage_calendar": "Calendar event management: list, create, update, delete. Each event can carry a tag/category (event_type — work/personal/health/travel/meal/social/admin/other) and importance (low/normal/high/critical). Resolve today/tomorrow using the Current date and time context, then use ISO datetimes in the user's local wall time; supports all-day events. For event reminders/alarms, pass reminder_minutes; this creates the Notes reminder, so do not also call manage_notes for the same reminder.",
|
||||
"download_model": "Download a HuggingFace model to a local or remote server. Specify repo_id (e.g. 'Qwen/Qwen3-8B'), optional server host, and optional include filter for specific files.",
|
||||
"serve_model": "Start serving a model with vLLM, SGLang, llama.cpp, Ollama, or Diffusers. For image/inpainting/diffusion use python3 scripts/diffusion_server.py --model <repo> --port 8100. After launch, call list_served_models for readiness/errors and retry suggestions.",
|
||||
"serve_model": "Start serving a model with vLLM, SGLang, llama.cpp, Ollama, or Diffusers. cmd MUST start with the binary directly — e.g. `vllm serve /mnt/HADES/models/Qwen3.5-397B-A17B-AWQ --port 8003 --tensor-parallel-size 8 …`. NEVER prefix with `cd …`, `source …`, or chain with `&&`/`||` — those get rejected by the validator. The venv activation (env_prefix) and CUDA env are added automatically from the target host's saved settings. For image/inpainting/diffusion use python3 scripts/diffusion_server.py --model <repo> --port 8100. After launch, call list_served_models for readiness/errors and retry suggestions. If serve_model fails with 'Invalid characters in cmd', simplify to the bare binary + args.",
|
||||
"list_served_models": "List currently running model servers in the Cookbook — shows status (loading, ready, idle, error), model name, port, throughput, and serve failure diagnosis/retry suggestions. Use when the user asks 'what's running', 'show my cookbook', 'which models are up', 'what's serving'.",
|
||||
"stop_served_model": "Stop a running model server in the Cookbook by session ID or model name. Use when the user says 'kill my cookbook', 'stop the model', 'kill the serve', 'shut down vLLM', 'cancel the running model'.",
|
||||
"tail_serve_output": "Read the actual tmux stderr/traceback of a cookbook serve/download task. Use to debug WHY a task is `crashed`/`error` (compute_89 nvcc mismatch, OOM, missing kernels, wrong attention backend, etc.) so you can call serve_model with adjusted flags. Pass session_id from list_served_models; tail defaults to 300, bump if the error references 'see root cause above'.",
|
||||
"list_downloads": "List in-progress HuggingFace model downloads in the Cookbook. Shows model name, phase, percent, session ID. Use for 'what's downloading', 'show my downloads', 'check download progress'.",
|
||||
"cancel_download": "Cancel an in-progress model download by tmux session ID. Use for 'cancel the download', 'stop downloading X', 'kill the download'. Call list_downloads first to get the session_id.",
|
||||
"search_hf_models": "Search HuggingFace for models matching a query (e.g. 'qwen 8B', 'flux', 'llama-3 instruct'). Returns ranked repo IDs with sizes and download counts. Use for 'find a model', 'search huggingface for X', 'what models are there for Y'.",
|
||||
|
||||
+93
-4
@@ -82,16 +82,65 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"description": "Read a file from disk",
|
||||
"description": "Read a file from disk. Optionally read a line range with offset/limit for large files.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "File path to read"}
|
||||
"path": {"type": "string", "description": "File path to read"},
|
||||
"offset": {"type": "integer", "description": "1-based line to start reading from (optional)"},
|
||||
"limit": {"type": "integer", "description": "Max number of lines to read from offset (optional)"}
|
||||
},
|
||||
"required": ["path"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "grep",
|
||||
"description": "Search file contents for a regular expression across a directory tree (uses ripgrep when available, respecting .gitignore). Returns file:line:match. PREFER this over `bash grep/rg` for code search — confined to the allowed roots, structured output.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {"type": "string", "description": "Regular expression to search for"},
|
||||
"path": {"type": "string", "description": "Directory or file to search (optional; defaults to the project root)"},
|
||||
"glob": {"type": "string", "description": "Only search files matching this glob, e.g. '*.py' (optional)"},
|
||||
"ignore_case": {"type": "boolean", "description": "Case-insensitive match (optional)"},
|
||||
"max_results": {"type": "integer", "description": "Max matches to return (optional)"}
|
||||
},
|
||||
"required": ["pattern"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "glob",
|
||||
"description": "Find files by glob pattern (recursive), newest first. e.g. '**/*.py'. PREFER this over `bash find/ls` for locating files — confined to the allowed roots.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {"type": "string", "description": "Glob pattern, e.g. '**/*.ts' or 'src/**/test_*.py'"},
|
||||
"path": {"type": "string", "description": "Base directory (optional; defaults to the project root)"}
|
||||
},
|
||||
"required": ["pattern"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "ls",
|
||||
"description": "List the entries of a directory (folders first, then files with sizes). PREFER this over `bash ls` — confined to the allowed roots.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "Directory to list (optional; defaults to the project root)"}
|
||||
},
|
||||
"required": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -107,6 +156,23 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "edit_file",
|
||||
"description": "Edit a file ON DISK by exact string replacement (home folder, project files, any real path like ~/sweden.txt or /path/to/file). This is the right tool for files on disk — NOT edit_document (that's for editor-panel documents). PREFER this over bash (sed/echo) — it shows a diff. old_string must match the file exactly and be unique (or set replace_all). Use write_file to create a new file.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "File path to edit"},
|
||||
"old_string": {"type": "string", "description": "Exact text to replace (must match the file, including indentation)"},
|
||||
"new_string": {"type": "string", "description": "Replacement text"},
|
||||
"replace_all": {"type": "boolean", "description": "Replace all occurrences instead of requiring a unique match"}
|
||||
},
|
||||
"required": ["path", "old_string", "new_string"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -127,7 +193,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "edit_document",
|
||||
"description": "PREFERRED way to change an existing document. Targeted find-and-replace with multiple FIND/REPLACE pairs per call. Use this for any edit smaller than a full rewrite: adding a function, fixing a bug, tweaking a section, renaming things. Do NOT send the whole file back via update_document for small edits — it wastes tokens and is hard to review.",
|
||||
"description": "Edit a document OPEN IN THE EDITOR PANEL (created via create_document) — NOT a file on disk. For files on disk (home folder, project files, anything with a path like ~/x.txt or /path/to/file) use edit_file instead. Targeted find-and-replace with multiple FIND/REPLACE pairs per call; use for any edit smaller than a full rewrite. Do NOT send the whole file back via update_document for small edits.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -721,6 +787,21 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "tail_serve_output",
|
||||
"description": "Read the last N lines of a cookbook serve/download task's tmux pane. Use ONLY in this exact sequence: (1) the user asked to serve a model, (2) you launched it via serve_model, (3) list_served_models reports the NEW task as crashed/error, (4) call tail_serve_output on the new sessionId to find the root cause, (5) call serve_model again with adjusted flags. DO NOT call this on old stopped/completed download tasks — they are historical and won't tell you anything about the current attempt. DO NOT investigate past failures before launching; the environment may have changed since.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"session_id": {"type": "string", "description": "Tmux session id from list_served_models (e.g. 'serve-abc12345', 'cookbook-a1b2c3d4')."},
|
||||
"tail": {"type": "integer", "description": "How many lines of pane scrollback to fetch (default 300, max 4000). Bump this if the error in the visible tail references an earlier line ('see root cause above')."},
|
||||
},
|
||||
"required": ["session_id"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -1111,9 +1192,17 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock
|
||||
else:
|
||||
content = args.get("query", "")
|
||||
elif tool_type == "read_file":
|
||||
content = args.get("path", "")
|
||||
# Plain path (back-compat) unless a line range is requested → JSON.
|
||||
if args.get("offset") or args.get("limit"):
|
||||
content = json.dumps(args)
|
||||
else:
|
||||
content = args.get("path", "")
|
||||
elif tool_type in ("grep", "glob", "ls"):
|
||||
content = json.dumps(args) if args else "{}"
|
||||
elif tool_type == "write_file":
|
||||
content = args.get("path", "") + "\n" + args.get("content", "")
|
||||
elif tool_type == "edit_file":
|
||||
content = json.dumps(args)
|
||||
elif tool_type == "create_document":
|
||||
parts = [args.get("title", "Untitled")]
|
||||
if args.get("language"):
|
||||
|
||||
@@ -16,6 +16,10 @@ NON_ADMIN_BLOCKED_TOOLS = {
|
||||
"python",
|
||||
"read_file",
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"grep",
|
||||
"glob",
|
||||
"ls",
|
||||
"search_chats",
|
||||
"manage_memory",
|
||||
"manage_skills",
|
||||
|
||||
+34
-75
@@ -4,6 +4,7 @@
|
||||
// ============================================
|
||||
import Storage from './js/storage.js';
|
||||
import uiModule from './js/ui.js';
|
||||
import workspaceModule from './js/workspace.js';
|
||||
import fileHandlerModule from './js/fileHandler.js';
|
||||
import modelsModule from './js/models.js';
|
||||
import ragModule from './js/rag.js';
|
||||
@@ -13,6 +14,7 @@ import chatModule from './js/chat.js';
|
||||
import compareModule from './js/compare/index.js';
|
||||
import documentModule from './js/document.js';
|
||||
import searchChatModule from './js/search-chat.js';
|
||||
import { makeWindowDraggable } from './js/windowDrag.js';
|
||||
import markdownModule from './js/markdown.js';
|
||||
import chatRenderer from './js/chatRenderer.js';
|
||||
import sessionModule from './js/sessions.js';
|
||||
@@ -1686,6 +1688,7 @@ function initializeEventListeners() {
|
||||
}
|
||||
setupToggle('web-toggle-btn', 'web-toggle', 'web');
|
||||
setupToggle('bash-toggle-btn', 'bash-toggle', 'bash');
|
||||
try { workspaceModule.initWorkspace(); } catch (_) {}
|
||||
|
||||
// Document editor toggle (special: uses module panel, not a checkbox)
|
||||
const overflowDocBtn = el('overflow-doc-btn');
|
||||
@@ -2683,82 +2686,38 @@ function initializeEventListeners() {
|
||||
// Apply saved visibility on load
|
||||
applyUIVis(loadUIVis());
|
||||
|
||||
// Generic draggable for all .modal elements
|
||||
const _sharedDragModalIds = new Set(['settings-modal']);
|
||||
try { document.querySelectorAll('.modal').forEach(m => {
|
||||
if (_sharedDragModalIds.has(m.id)) return;
|
||||
const content = m.querySelector('.modal-content');
|
||||
const header = m.querySelector('.modal-header');
|
||||
if (!content || !header) return;
|
||||
let dragX, dragY, startLeft, startTop, dragging = false;
|
||||
|
||||
// Reset to flex-centered position each time modal opens
|
||||
new MutationObserver(() => {
|
||||
if (!m.classList.contains('hidden')) {
|
||||
content.style.position = '';
|
||||
content.style.left = '';
|
||||
content.style.top = '';
|
||||
content.style.right = '';
|
||||
content.style.bottom = '';
|
||||
content.style.margin = '';
|
||||
}
|
||||
}).observe(m, { attributes: true, attributeFilter: ['class'] });
|
||||
|
||||
function startDrag(clientX, clientY) {
|
||||
dragging = true;
|
||||
const rect = content.getBoundingClientRect();
|
||||
dragX = clientX; dragY = clientY;
|
||||
startLeft = rect.left; startTop = rect.top;
|
||||
// Switch to fixed so it can be freely positioned
|
||||
content.style.position = 'fixed';
|
||||
content.style.left = startLeft + 'px';
|
||||
content.style.top = startTop + 'px';
|
||||
content.style.margin = '0';
|
||||
}
|
||||
|
||||
header.addEventListener('mousedown', (e) => {
|
||||
if (e.target.closest('.close-btn')) return;
|
||||
e.preventDefault();
|
||||
startDrag(e.clientX, e.clientY);
|
||||
document.addEventListener('mousemove', onDrag);
|
||||
document.addEventListener('mouseup', stopDrag);
|
||||
});
|
||||
function onDrag(e) {
|
||||
if (!dragging) return;
|
||||
content.style.left = (startLeft + e.clientX - dragX) + 'px';
|
||||
content.style.top = (startTop + e.clientY - dragY) + 'px';
|
||||
}
|
||||
function stopDrag() {
|
||||
dragging = false;
|
||||
document.removeEventListener('mousemove', onDrag);
|
||||
document.removeEventListener('mouseup', stopDrag);
|
||||
}
|
||||
|
||||
// Touch drag is desktop-only — on mobile, modals are bottom sheets and
|
||||
// ui.js handles swipe-down-to-dismiss. Attaching this listener fights
|
||||
// the swipe-dismiss gesture.
|
||||
if (window.innerWidth > 768) {
|
||||
header.addEventListener('touchstart', (e) => {
|
||||
if (e.target.closest('.close-btn')) return;
|
||||
const t = e.touches[0];
|
||||
startDrag(t.clientX, t.clientY);
|
||||
document.addEventListener('touchmove', onTouchDrag, { passive: false });
|
||||
document.addEventListener('touchend', stopTouchDrag);
|
||||
// The only two modals without a per-module makeWindowDraggable call. Wire
|
||||
// them onto the shared helper, drag-only, to match their old behavior.
|
||||
try {
|
||||
['custom-preset-modal', 'rename-session-modal'].forEach((id) => {
|
||||
const m = document.getElementById(id);
|
||||
if (!m) return;
|
||||
const content = m.querySelector('.modal-content');
|
||||
const header = m.querySelector('.modal-header');
|
||||
if (!content || !header) return;
|
||||
makeWindowDraggable(m, {
|
||||
content, header,
|
||||
skipSelector: '.close-btn',
|
||||
enableDock: false,
|
||||
enableResize: false,
|
||||
});
|
||||
}
|
||||
function onTouchDrag(e) {
|
||||
if (!dragging) return;
|
||||
e.preventDefault();
|
||||
const t = e.touches[0];
|
||||
content.style.left = (startLeft + t.clientX - dragX) + 'px';
|
||||
content.style.top = (startTop + t.clientY - dragY) + 'px';
|
||||
}
|
||||
function stopTouchDrag() {
|
||||
dragging = false;
|
||||
document.removeEventListener('touchmove', onTouchDrag);
|
||||
document.removeEventListener('touchend', stopTouchDrag);
|
||||
}
|
||||
}); } catch(e) { console.error('Modal drag init error:', e); }
|
||||
// Re-center on open (these persist in the DOM). Guard on the
|
||||
// hidden→visible edge so it never fires mid-drag.
|
||||
let wasHidden = m.classList.contains('hidden');
|
||||
new MutationObserver(() => {
|
||||
const isHidden = m.classList.contains('hidden');
|
||||
if (wasHidden && !isHidden) {
|
||||
content.style.position = '';
|
||||
content.style.left = '';
|
||||
content.style.top = '';
|
||||
content.style.right = '';
|
||||
content.style.bottom = '';
|
||||
content.style.margin = '';
|
||||
}
|
||||
wasHidden = isHidden;
|
||||
}).observe(m, { attributes: true, attributeFilter: ['class'] });
|
||||
});
|
||||
} catch (e) { console.error('Dialog drag init error:', e); }
|
||||
})();
|
||||
|
||||
// ── Modal minimize → dock ──
|
||||
|
||||
+19
-1
@@ -1031,6 +1031,13 @@
|
||||
<span>RAG</span>
|
||||
<span class="overflow-active-dot"></span>
|
||||
</button>
|
||||
<button type="button" class="overflow-menu-item" id="overflow-workspace-btn">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||
</svg>
|
||||
<span>Workspace</span>
|
||||
<span class="overflow-active-dot"></span>
|
||||
</button>
|
||||
<!-- Inline "deep research mode" toggle removed (superseded by the
|
||||
Deep Research sidebar / trigger_research). The hidden
|
||||
#research-toggle checkbox is kept inert so existing JS refs
|
||||
@@ -1062,6 +1069,12 @@
|
||||
<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Workspace indicator (hidden until a folder is set) -->
|
||||
<button type="button" class="input-icon-btn tool-indicator" title="Workspace — click to clear" id="workspace-indicator-btn" aria-label="Clear workspace" style="display:none;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
|
||||
<span style="font-size:11px;margin-left:2px;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" id="workspace-indicator-name"></span>
|
||||
<svg class="tool-indicator-x" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
|
||||
</button>
|
||||
<!-- RAG toolbar indicator (hidden until active) -->
|
||||
<button type="button" class="input-icon-btn tool-indicator" title="RAG active — click to deactivate" id="rag-indicator-btn" style="display:none;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -1478,6 +1491,10 @@
|
||||
<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>
|
||||
@@ -2264,8 +2281,9 @@
|
||||
<script type="module" src="/static/js/chatRenderer.js"></script>
|
||||
<script type="module" src="/static/js/codeRunner.js"></script>
|
||||
<script type="module" src="/static/js/chatStream.js"></script>
|
||||
<script type="module" src="/static/js/chat.js?v=20260520m"></script>
|
||||
<script type="module" src="/static/js/chat.js?v=20260604s"></script>
|
||||
<script type="module" src="/static/js/cookbook.js"></script>
|
||||
<script src="/static/js/cookbookSchedule.js"></script>
|
||||
<script type="module" src="/static/js/search-chat.js"></script>
|
||||
<script type="module" src="/static/js/compare/index.js"></script>
|
||||
<script type="module" src="/static/js/theme.js"></script>
|
||||
|
||||
+78
-6
@@ -731,14 +731,14 @@ function initEndpointForm() {
|
||||
urlInput.addEventListener('input', () => {
|
||||
if (provider.value && urlInput.value.trim() !== provider.value) {
|
||||
provider.value = '';
|
||||
if (kindSel) kindSel.value = 'proxy';
|
||||
if (kindSel) kindSel.value = 'api';
|
||||
_renderPickerMenu();
|
||||
_syncPickerCurrent();
|
||||
}
|
||||
});
|
||||
if (kindSel) kindSel.value = provider.value ? 'api' : (kindSel.value || 'proxy');
|
||||
if (kindSel) kindSel.value = kindSel.value || 'api';
|
||||
function _apiEndpointKind() {
|
||||
return (kindSel && kindSel.value) ? kindSel.value : (provider.value ? 'api' : 'proxy');
|
||||
return (kindSel && kindSel.value) ? kindSel.value : 'api';
|
||||
}
|
||||
function _normalizeBaseUrl(raw) {
|
||||
let u = raw.trim();
|
||||
@@ -912,6 +912,78 @@ function initEndpointForm() {
|
||||
btn.disabled = false; btn.textContent = 'Add';
|
||||
});
|
||||
|
||||
// GitHub Copilot — device-flow login. Starts the flow, shows the user a
|
||||
// code + verification link, and polls until they authorise (or it expires).
|
||||
const copilotBtn = el('adm-copilotConnectBtn');
|
||||
if (copilotBtn) {
|
||||
let copilotPolling = false;
|
||||
copilotBtn.addEventListener('click', async () => {
|
||||
if (copilotPolling) return;
|
||||
const status = el('adm-copilotStatus');
|
||||
const reset = () => { copilotBtn.disabled = false; copilotBtn.textContent = 'Connect GitHub Copilot'; copilotPolling = false; };
|
||||
status.textContent = ''; status.className = 'adm-ep-inline-msg';
|
||||
copilotBtn.disabled = true; copilotBtn.textContent = 'Starting...';
|
||||
copilotPolling = true;
|
||||
let start;
|
||||
try {
|
||||
const res = await fetch('/api/copilot/device/start', { method: 'POST', body: new FormData(), credentials: 'same-origin' });
|
||||
start = await res.json();
|
||||
if (!res.ok) { status.textContent = start.detail || 'Failed to start login'; status.className = 'admin-error'; reset(); return; }
|
||||
} catch (e) { status.textContent = 'Request failed'; status.className = 'admin-error'; reset(); return; }
|
||||
|
||||
const { poll_id, user_code, verification_uri, verification_uri_complete, interval, expires_in } = start;
|
||||
// Prefer the "complete" URL — it embeds the code so the user only has to
|
||||
// click "Authorize" (no manual code entry).
|
||||
const authUrl = verification_uri_complete || verification_uri || '';
|
||||
const esc = (s) => String(s || '').replace(/[<>&"]/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"' }[c]));
|
||||
copilotBtn.textContent = 'Waiting…';
|
||||
|
||||
// Cohesive waiting panel: spinner + status line, the device code as a
|
||||
// copyable chip, and a primary "Authorize on GitHub" action.
|
||||
status.className = '';
|
||||
status.innerHTML =
|
||||
'<div class="adm-copilot-panel">' +
|
||||
'<div class="adm-copilot-wait"><span class="admin-spinner"></span>' +
|
||||
'<span>Waiting for GitHub authorization…</span></div>' +
|
||||
'<div class="adm-copilot-coderow">' +
|
||||
'<span class="adm-copilot-code-label">Code</span>' +
|
||||
'<code class="adm-copilot-code">' + esc(user_code) + '</code>' +
|
||||
'<button type="button" class="admin-btn-sm adm-copilot-copy">Copy</button>' +
|
||||
'</div>' +
|
||||
'<a class="admin-btn-add adm-copilot-auth" href="' + encodeURI(authUrl) + '" target="_blank" rel="noopener">Authorize on GitHub ↗</a>' +
|
||||
'<div class="adm-copilot-hint">A new tab opened on GitHub — approve there to finish. Didn\'t open? Use the button above.</div>' +
|
||||
'</div>';
|
||||
const copyBtn = status.querySelector('.adm-copilot-copy');
|
||||
if (copyBtn) copyBtn.addEventListener('click', async () => {
|
||||
try { await navigator.clipboard.writeText(user_code || ''); copyBtn.textContent = 'Copied'; setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500); } catch (e) {}
|
||||
});
|
||||
try { if (authUrl) window.open(authUrl, '_blank', 'noopener'); } catch (e) {}
|
||||
|
||||
const deadline = Date.now() + (expires_in || 900) * 1000;
|
||||
const stepMs = Math.max((interval || 5), 2) * 1000;
|
||||
const done = (cls, text) => { status.className = cls; status.textContent = text; reset(); };
|
||||
const poll = async () => {
|
||||
if (Date.now() > deadline) { done('admin-error', 'Authorization expired — try again.'); return; }
|
||||
try {
|
||||
const fd = new FormData(); fd.append('poll_id', poll_id);
|
||||
const r = await fetch('/api/copilot/device/poll', { method: 'POST', body: fd, credentials: 'same-origin' });
|
||||
const d = await r.json();
|
||||
if (d.status === 'authorized') {
|
||||
const n = ((d.endpoint && d.endpoint.models) || []).length;
|
||||
done('admin-success', '✓ Connected — ' + n + ' Copilot model' + (n !== 1 ? 's' : '') + ' available.');
|
||||
if (d.endpoint && d.endpoint.id) _recentlyAddedEpId = String(d.endpoint.id);
|
||||
await loadEndpoints();
|
||||
await _selectAddedModelInChat(d.endpoint || {});
|
||||
return;
|
||||
}
|
||||
if (d.status === 'failed') { done('admin-error', 'Authorization failed (' + (d.error || 'denied') + ').'); return; }
|
||||
} catch (e) { /* transient — keep polling */ }
|
||||
setTimeout(poll, stepMs);
|
||||
};
|
||||
setTimeout(poll, stepMs);
|
||||
});
|
||||
}
|
||||
|
||||
// Local "Add" button — sibling form for self-hosted base URLs.
|
||||
const localAddBtn = el('adm-epLocalAddBtn');
|
||||
const localTestBtn = el('adm-epLocalTestBtn');
|
||||
@@ -1133,11 +1205,11 @@ const _GOOGLE_OAUTH_HELP = `To get Google OAuth credentials:
|
||||
|
||||
const MCP_PRESETS = [
|
||||
{ name: "Gmail", command: "npx", args: ["-y", "@gongrzhe/server-gmail-autoauth-mcp"], env: { GOOGLE_CLIENT_ID: "", GOOGLE_CLIENT_SECRET: "" },
|
||||
oauthFile: { dir: "~/.gmail-mcp", filename: "gcp-oauth.keys.json" },
|
||||
oauthFile: { dir: "gmail", filename: "gcp-oauth.keys.json" },
|
||||
oauth: {
|
||||
provider: "google",
|
||||
keys_file: "~/.gmail-mcp/gcp-oauth.keys.json",
|
||||
token_file: "~/.gmail-mcp/credentials.json",
|
||||
keys_file: "gmail/gcp-oauth.keys.json",
|
||||
token_file: "gmail/credentials.json",
|
||||
scopes: ["https://www.googleapis.com/auth/gmail.modify", "https://www.googleapis.com/auth/gmail.settings.basic"],
|
||||
},
|
||||
help: `Setup:
|
||||
|
||||
+141
-5
@@ -299,13 +299,40 @@ async function _updateEvent(uid, data) {
|
||||
}
|
||||
|
||||
async function _deleteEvent(uid) {
|
||||
const backup = _allEvents[uid];
|
||||
delete _allEvents[uid];
|
||||
// Multiple "sibling" UIDs may need to vanish optimistically:
|
||||
// 1. The exact uid the user clicked.
|
||||
// 2. If the user clicked a RECURRING occurrence (uid contains "::"),
|
||||
// the server deletes the master + every occurrence — so we strip
|
||||
// the master uid AND every "master::*" expansion from the
|
||||
// client-side caches too. Without this, deleting one day of a
|
||||
// multi-day recurring task only removed THAT day visually; the
|
||||
// other days kept rendering until the next full refresh.
|
||||
// 3. If the user clicked the master, strip every "master::*"
|
||||
// expansion (same prefix scan).
|
||||
const masterUid = uid.includes('::') ? uid.split('::')[0] : uid;
|
||||
const backups = {};
|
||||
const _matches = (k) => k === uid || k === masterUid || k.startsWith(masterUid + '::');
|
||||
|
||||
for (const k of Object.keys(_allEvents)) {
|
||||
if (_matches(k)) {
|
||||
backups[k] = _allEvents[k];
|
||||
delete _allEvents[k];
|
||||
}
|
||||
}
|
||||
if (Array.isArray(_events)) {
|
||||
_events = _events.filter(e => !(e && _matches(e.uid || '')));
|
||||
}
|
||||
if (_open) _render();
|
||||
_updateBadge && _updateBadge();
|
||||
const isRecurring = uid.includes('::');
|
||||
fetch(`${API_BASE}/api/calendar/events/${encodeURIComponent(uid)}`, {
|
||||
method: 'DELETE', credentials: 'same-origin',
|
||||
}).then(r => {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
// 404 = the event was already deleted by another session/device. That's
|
||||
// exactly the state we want, so treat it as success — don't restore the
|
||||
// row, otherwise the user can never clear stale cached events that were
|
||||
// deleted from desktop while mobile was open (and vice versa).
|
||||
if (!r.ok && r.status !== 404) throw new Error('HTTP ' + r.status);
|
||||
if (isRecurring) {
|
||||
_fetchedRanges = [];
|
||||
localStorage.removeItem(LS_KEY);
|
||||
@@ -313,7 +340,11 @@ async function _deleteEvent(uid) {
|
||||
_saveCache && _saveCache();
|
||||
}
|
||||
}).catch((e) => {
|
||||
if (backup) _allEvents[uid] = backup;
|
||||
// Server rejected — restore every uid we optimistically stripped.
|
||||
for (const [k, ev] of Object.entries(backups)) {
|
||||
_allEvents[k] = ev;
|
||||
if (Array.isArray(_events)) _events.push(ev);
|
||||
}
|
||||
if (window.uiModule) window.uiModule.showError('Failed to delete event: ' + (e?.message || 'unknown'));
|
||||
if (_open) _render();
|
||||
});
|
||||
@@ -980,7 +1011,39 @@ async function _renderMonth() {
|
||||
const startColInt = Math.round(startCol);
|
||||
const endColInt = Math.round(endCol);
|
||||
const span = endColInt - startColInt + 1;
|
||||
h += `<div class="cal-multiday" style="--col:${startColInt};--span:${span};--slot:${barSlot};background:${_calColor(md)};--cal-event-fg:${_calEventFg(md)}" draggable="true" data-uid="${_e(md.uid)}" title="${_e(md.summary)}">${_e(md.summary)}</div>`;
|
||||
// Proportional offsets for timed events that span across midnight
|
||||
// (e.g. 8 PM Mon → 5 AM Tue). Without this, an overnight serve
|
||||
// window visually fills the ENTIRE next day even when it only
|
||||
// covers a few hours. All-day events keep the full-day shape.
|
||||
// Bar visually spans from column (col+startFrac) to (col+span-1+endFrac),
|
||||
// so a 8 PM→5 AM run shows ~17% of day 1 + ~21% of day 2, not 200%.
|
||||
let startFrac = 0;
|
||||
let endFrac = 1;
|
||||
if (!md.all_day) {
|
||||
try {
|
||||
const sIso = md.dtstart || '';
|
||||
const eIso = md.dtend || '';
|
||||
const sDate = sIso ? new Date(sIso) : null;
|
||||
const eDate = eIso ? new Date(eIso) : null;
|
||||
// First-visible-day fraction (0 = midnight start). Clamp to 0
|
||||
// when the event started before this row, so the bar still
|
||||
// starts at the row's left edge.
|
||||
if (sDate && !isNaN(sDate) && mdStart >= rowStart) {
|
||||
const midnight = new Date(sDate); midnight.setHours(0, 0, 0, 0);
|
||||
startFrac = Math.max(0, Math.min(1, (sDate - midnight) / 86400000));
|
||||
}
|
||||
if (eDate && !isNaN(eDate) && mdEnd <= rowEnd) {
|
||||
const midnight = new Date(eDate); midnight.setHours(0, 0, 0, 0);
|
||||
endFrac = Math.max(0, Math.min(1, (eDate - midnight) / 86400000));
|
||||
// CalDAV end-times are exclusive: an event ending at exactly
|
||||
// 00:00 on day N really ended at end-of-day N-1, so endFrac=0
|
||||
// would visually paint a zero-width slice. Snap to a small
|
||||
// visible minimum (5% of a day) so the bar still registers.
|
||||
if (endFrac === 0) endFrac = 1;
|
||||
}
|
||||
} catch (_) { startFrac = 0; endFrac = 1; }
|
||||
}
|
||||
h += `<div class="cal-multiday" style="--col:${startColInt};--span:${span};--slot:${barSlot};--start-frac:${startFrac.toFixed(4)};--end-frac:${endFrac.toFixed(4)};background:${_calColor(md)};--cal-event-fg:${_calEventFg(md)}" draggable="true" data-uid="${_e(md.uid)}" title="${_e(md.summary)}">${_e(md.summary)}</div>`;
|
||||
barSlot++;
|
||||
}
|
||||
h += '</div>';
|
||||
@@ -2688,6 +2751,28 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
|
||||
<option value="FREQ=YEARLY" ${existing?.rrule === 'FREQ=YEARLY' ? 'selected' : ''}>Yearly</option>
|
||||
</select>
|
||||
<textarea id="cal-f-desc" placeholder="Description" class="cal-input" rows="2">${_e(existing?.description || '')}</textarea>
|
||||
${(() => {
|
||||
// Cookbook-task back-link. When the description carries a
|
||||
// "cookbook_task_id: <id>" marker (set by cookbookSchedule.js
|
||||
// when the user ticks "Create event in calendar"), render an
|
||||
// Open-task button so the user can jump straight to the
|
||||
// source task in the Tasks tab.
|
||||
const _ct = (existing?.description || '').match(/cookbook_task_id:\s*([A-Za-z0-9_-]+)/);
|
||||
if (!_ct) return '';
|
||||
return `<div class="cal-form-row cal-form-cookbook-link" style="align-items:center;gap:8px;">
|
||||
<button type="button" id="cal-f-open-task" data-task-id="${_e(_ct[1])}"
|
||||
style="display:inline-flex;align-items:center;gap:6px;background:transparent;
|
||||
color:var(--accent,var(--red));border:1px solid var(--border);
|
||||
border-radius:6px;padding:5px 10px;font:inherit;font-size:12px;cursor:pointer;">
|
||||
<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="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>Open in Tasks</span>
|
||||
</button>
|
||||
<span style="font-size:11px;opacity:0.5;">Linked to a Cookbook scheduled task</span>
|
||||
</div>`;
|
||||
})()}
|
||||
<div class="cal-form-row" style="align-items:center;gap:8px;">
|
||||
<label style="font-size:11px;display:flex;align-items:center;gap:4px;"><svg class="cal-remind-bell" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--accent, var(--red))" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg><span style="opacity:0.5;">Reminder</span></label>
|
||||
<select id="cal-f-remind" class="cal-input" style="flex:1;">
|
||||
@@ -2737,6 +2822,19 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
|
||||
document.getElementById('cal-f-allday')?.addEventListener('change', (e) => {
|
||||
document.getElementById('cal-time-row').style.display = e.target.checked ? 'none' : '';
|
||||
});
|
||||
// Open-task back-link button — dynamically imports the tasks module
|
||||
// so the linkage works even if the user is opening the calendar
|
||||
// before they've touched the Tasks tab in this session.
|
||||
document.getElementById('cal-f-open-task')?.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
const taskId = e.currentTarget?.dataset?.taskId || '';
|
||||
try {
|
||||
const m = await import('/static/js/tasks.js');
|
||||
const openTasks = m.openTasks || m.default?.openTasks;
|
||||
if (typeof openTasks === 'function') { openTasks(taskId); return; }
|
||||
} catch (_) {}
|
||||
document.getElementById('tool-tasks-btn')?.click();
|
||||
});
|
||||
// Keep end date >= start date
|
||||
document.getElementById('cal-f-date')?.addEventListener('change', () => {
|
||||
const s = document.getElementById('cal-f-date').value;
|
||||
@@ -3341,6 +3439,44 @@ window.addEventListener('calendar-refresh', () => {
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
// Cross-session catch-up: when the tab/app becomes visible again (you alt-tab
|
||||
// back, the mobile app comes to the foreground, or you switch back from
|
||||
// another browser session), drop the range cache and re-fetch. Without this,
|
||||
// a delete or add on desktop never propagates to the still-open mobile tab
|
||||
// until the user does a full reload — so stale events sit there undeletable
|
||||
// (they 404 on the server). Triggers on every visibility change but the
|
||||
// fetch is cheap and already de-duped by _fetchPromise on line ~120.
|
||||
let _lastVisRefetchAt = 0;
|
||||
const _VIS_REFETCH_MIN_MS = 10 * 1000; // throttle if user is rapidly tab-flipping
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
const now = Date.now();
|
||||
if (now - _lastVisRefetchAt < _VIS_REFETCH_MIN_MS) return;
|
||||
_lastVisRefetchAt = now;
|
||||
_fetchedRanges = [];
|
||||
const range = (_view === 'year')
|
||||
? [`${_currentDate.getFullYear()}-01-01`, `${_currentDate.getFullYear() + 1}-01-01`]
|
||||
: (_view === 'week') ? _weekRange(_currentDate) : _monthRange(_currentDate);
|
||||
_fetchEvents(range[0], range[1], /*force*/ true)
|
||||
.then(() => { if (_open) _render(); _updateBadge(); })
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
// Same idea for window-level focus — covers desktop alt-tabbing back to a
|
||||
// browser that already had the tab visible (visibilitychange won't fire).
|
||||
window.addEventListener('focus', () => {
|
||||
const now = Date.now();
|
||||
if (now - _lastVisRefetchAt < _VIS_REFETCH_MIN_MS) return;
|
||||
_lastVisRefetchAt = now;
|
||||
_fetchedRanges = [];
|
||||
const range = (_view === 'year')
|
||||
? [`${_currentDate.getFullYear()}-01-01`, `${_currentDate.getFullYear() + 1}-01-01`]
|
||||
: (_view === 'week') ? _weekRange(_currentDate) : _monthRange(_currentDate);
|
||||
_fetchEvents(range[0], range[1], /*force*/ true)
|
||||
.then(() => { if (_open) _render(); _updateBadge(); })
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
// Calendar reminders are stored as Notes. The Notes reminder loop owns
|
||||
// notification dispatch so calendar reminders do not fire twice.
|
||||
|
||||
|
||||
+265
-31
@@ -82,13 +82,15 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
|
||||
// Background streaming support
|
||||
const _backgroundStreams = new Map(); // sessionId -> { status, accumulated, sourcesHtml, abortCtrl, query, metrics }
|
||||
const _resumingStreams = new Set(); // sessionId -> a resumeStream() reader is live (re-attach lock)
|
||||
let _streamSessionId = null; // Session ID for the currently active reader loop
|
||||
let _lastReaderActivity = 0; // Timestamp of last reader.read() success — used to detect frozen streams
|
||||
let _webLockRelease = null; // Function to release the Web Lock held during streaming
|
||||
|
||||
/** Check if an SSE reader is still actively connected for a session. */
|
||||
function hasActiveStream(sessionId) {
|
||||
return _streamSessionId === sessionId || _backgroundStreams.has(sessionId);
|
||||
return _streamSessionId === sessionId || _backgroundStreams.has(sessionId) ||
|
||||
_resumingStreams.has(sessionId);
|
||||
}
|
||||
|
||||
// Sources box builder and toggleSources are now in chatRenderer.js
|
||||
@@ -779,6 +781,10 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
if (incognitoChk && incognitoChk.checked) {
|
||||
fd.append('incognito', 'true');
|
||||
}
|
||||
const _ws = (Storage.KEYS && Storage.get(Storage.KEYS.WORKSPACE, '')) || '';
|
||||
if (_ws) {
|
||||
fd.append('workspace', _ws);
|
||||
}
|
||||
if (presetsModule.getSelectedPreset()) {
|
||||
fd.append('preset_id', presetsModule.getSelectedPreset());
|
||||
}
|
||||
@@ -842,7 +848,7 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
var _charNameInit = presetsModule.getCharacterName ? presetsModule.getCharacterName() : '';
|
||||
if (_charNameInit) roleLabel = _charNameInit;
|
||||
const roleTs = new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
|
||||
holder.innerHTML = `<div class="role">${roleLabel} <span class="role-timestamp">${roleTs}</span></div><div class="body"></div>`;
|
||||
holder.innerHTML = `<div class="role">${uiModule.esc(roleLabel)} <span class="role-timestamp">${roleTs}</span></div><div class="body"></div>`;
|
||||
_applyModelColor(holder.querySelector('.role'), modelName);
|
||||
holder.style.position = 'relative';
|
||||
|
||||
@@ -1118,7 +1124,7 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
let _measureDiv = null;
|
||||
|
||||
function _replyAfterClosedThinking(text) {
|
||||
const closeRe = /<\/think(?:ing)?>/gi;
|
||||
const closeRe = /<\/(?:think(?:ing)?|thought)>|<channel\|>/gi;
|
||||
let match = null;
|
||||
let last = null;
|
||||
while ((match = closeRe.exec(text || '')) !== null) last = match;
|
||||
@@ -1145,7 +1151,7 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
replyTrimmed = (replyText || '').trim();
|
||||
} else {
|
||||
// Non-tag: check for garbled <think> (reasoning\n<think>reply)
|
||||
const _gm = dt.match(/^[\s\S]+?<think(?:ing)?>\s*([\s\S]*?)(?:<\/think(?:ing)?>)?\s*$/i);
|
||||
const _gm = dt.match(/^[\s\S]+?<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>\s*([\s\S]*?)(?:<\/(?:think(?:ing)?|thought)>)?\s*$/i);
|
||||
if (_gm && _gm[1].trim()) {
|
||||
replyTrimmed = _gm[1].trim();
|
||||
} else {
|
||||
@@ -1186,8 +1192,11 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
const prevLen = contentEl._prevTextLen || 0;
|
||||
// If thinking is still streaming (unclosed <think>), show indicator instead of raw text
|
||||
if (markdownModule.hasUnclosedThinkTag && markdownModule.hasUnclosedThinkTag(dt)) {
|
||||
const thinkStart = dt.search(/<think(?:ing)?>/i);
|
||||
const thinkContent = dt.substring(thinkStart).replace(/<think(?:ing)?>/i, '').trim();
|
||||
const thinkStart = dt.search(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>|<\|channel>thought/i);
|
||||
const thinkContent = dt.substring(Math.max(thinkStart, 0))
|
||||
.replace(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>|<\|channel>thought\s*\n?/i, '')
|
||||
.replace(/<channel\|>/gi, '')
|
||||
.trim();
|
||||
const lines = thinkContent.split('\n').length;
|
||||
// Don't show beforeThink text during streaming — it'll appear in the final render
|
||||
// This prevents the "split into two" duplication
|
||||
@@ -1447,7 +1456,7 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
// Detect non-tag thinking patterns: "Thinking:", "Thinking Process:", Gemma-style reasoning
|
||||
// These patterns don't use <think> tags, so we simulate unclosed thinking during streaming
|
||||
const _replyPrefixes = ['Hey', 'Hi ', 'Hi!', 'Hello', 'Sure', 'Yes', 'No ', 'No,', 'Yo', 'OK', 'Here', 'Absolutely', 'Of course', 'Great', 'Alright', 'Thanks', 'Welcome', 'Good ', "I'm happy", "I'd be"];
|
||||
if (!hasUnclosedThink && !roundText.includes('<think')) {
|
||||
if (!hasUnclosedThink && !/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>|<\|channel>thought/i.test(roundText)) {
|
||||
const _trimmedRT = roundText.trimStart();
|
||||
const _isReasoning = markdownModule.startsWithReasoningPrefix(_trimmedRT);
|
||||
if (_isReasoning) {
|
||||
@@ -1473,10 +1482,10 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!hasUnclosedThink && /^<think(?:ing)?>\s*<\/think(?:ing)?>/i.test(roundText)) {
|
||||
if (!hasUnclosedThink && /^<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>\s*<\/(?:think(?:ing)?|thought)>/i.test(roundText)) {
|
||||
// Empty <think></think> — the model likely put thinking outside the tags
|
||||
const afterEmpty = roundText.replace(/^<think(?:ing)?>\s*<\/think(?:ing)?>/i, '').trim();
|
||||
const closeTags = (afterEmpty.match(/<\/think(?:ing)?>/gi) || []).length;
|
||||
const afterEmpty = roundText.replace(/^<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>\s*<\/(?:think(?:ing)?|thought)>/i, '').trim();
|
||||
const closeTags = (afterEmpty.match(/<\/(?:think(?:ing)?|thought)>/gi) || []).length;
|
||||
if (closeTags === 0 && afterEmpty.length > 0) {
|
||||
hasUnclosedThink = true; // still waiting for real closing tag
|
||||
}
|
||||
@@ -1485,13 +1494,13 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
// Only applies when there's a second </think> later (model leaked thinking outside tags)
|
||||
// Do NOT trigger if the text after </think> contains tool calls (that's real content)
|
||||
if (!hasUnclosedThink && isThinking) {
|
||||
const _thinkMatch = roundText.match(/<think(?:ing)?>([\s\S]*?)<\/think(?:ing)?>/i);
|
||||
const _thinkMatch = roundText.match(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>([\s\S]*?)<\/(?:think(?:ing)?|thought)>/i);
|
||||
const _thinkLen = _thinkMatch ? _thinkMatch[1].trim().length : 0;
|
||||
if (_thinkLen < 20) {
|
||||
const _afterClose = roundText.replace(/<think(?:ing)?>([\s\S]*?)<\/think(?:ing)?>/i, '').trim();
|
||||
const _afterClose = roundText.replace(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>([\s\S]*?)<\/(?:think(?:ing)?|thought)>/i, '').trim();
|
||||
// Only keep waiting if there's trailing text that looks like thinking (not tool calls)
|
||||
const _hasToolCall = /```(?:bash|python|web_search|read_file|write_file|create_document|edit_document|manage_|generate_image)/i.test(_afterClose);
|
||||
const _hasOrphanClose = /<\/think(?:ing)?>/i.test(_afterClose);
|
||||
const _hasOrphanClose = /<\/(?:think(?:ing)?|thought)>/i.test(_afterClose);
|
||||
if (!_hasToolCall && (_hasOrphanClose || (Date.now() - thinkingStartTime) < 500)) {
|
||||
hasUnclosedThink = true; // keep waiting for real </think>
|
||||
}
|
||||
@@ -1548,8 +1557,12 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
}
|
||||
} else if (hasUnclosedThink && isThinking) {
|
||||
if (_liveThinkInner) {
|
||||
// Extract raw thinking text (strip all <think>/<thinking> open/close tags and prefixes)
|
||||
var thinkText = roundText.replace(/<\/?think(?:ing)?>/gi, '');
|
||||
// Extract raw thinking text (strip known thinking wrappers and prefixes)
|
||||
var thinkText = roundText
|
||||
.replace(/<\/?(?:think(?:ing)?|thought)(?:\s+[^>]*)?>/gi, '')
|
||||
.replace(/<\|channel>thought\s*\n?/gi, '')
|
||||
.replace(/<\|channel>response\s*\n?/gi, '')
|
||||
.replace(/<channel\|>/gi, '');
|
||||
thinkText = thinkText.replace(/^\s*Thinking(?:\s+Process)?:\s*/i, '');
|
||||
_liveThinkInner.innerHTML = markdownModule.mdToHtml(thinkText);
|
||||
// Keep thinking box scrolled to bottom
|
||||
@@ -1827,6 +1840,44 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (json.type === 'rounds_exhausted') {
|
||||
// The agent hit the per-turn step limit while still working.
|
||||
// Offer a Continue button instead of stalling silently.
|
||||
// NOTE: append to the chat-history container (bottom), NOT the
|
||||
// message body — the body innerHTML is re-rendered at stream
|
||||
// finalize, which would wipe a note placed inside it.
|
||||
const _chatBox = document.getElementById('chat-history');
|
||||
if (!_isBg && _chatBox) {
|
||||
// Drop any prior box so repeated cap-hits each get a fresh
|
||||
// Continue at the bottom (multiple continues in a row).
|
||||
const _old = _chatBox.querySelector('.rounds-exhausted');
|
||||
if (_old) _old.remove();
|
||||
const note = document.createElement('div');
|
||||
note.className = 'stopped-indicator rounds-exhausted';
|
||||
const label = document.createElement('span');
|
||||
label.className = 'rounds-exhausted-label';
|
||||
label.textContent = `Reached the ${json.rounds || ''}-step limit — not finished.`;
|
||||
note.appendChild(label);
|
||||
const contBtn = document.createElement('button');
|
||||
contBtn.className = 'continue-btn';
|
||||
contBtn.title = 'Continue the task';
|
||||
contBtn.textContent = 'Continue ▸';
|
||||
const _holder = currentHolder;
|
||||
contBtn.addEventListener('click', () => {
|
||||
note.remove();
|
||||
_hideUserBubble = true;
|
||||
_pendingContinue = _holder;
|
||||
const msgInput = uiModule.el('message');
|
||||
if (msgInput) {
|
||||
msgInput.value = 'You hit the step limit before finishing — the task is not complete. Continue from exactly where you left off and keep going until it is done. Do NOT repeat work already done.';
|
||||
const sb = document.querySelector('.send-btn');
|
||||
if (sb) sb.click();
|
||||
}
|
||||
});
|
||||
note.appendChild(contBtn);
|
||||
_chatBox.appendChild(note);
|
||||
try { note.scrollIntoView({ block: 'end', behavior: 'smooth' }); } catch (_) { uiModule.scrollHistory && uiModule.scrollHistory(); }
|
||||
}
|
||||
} else if (json.type === 'attachments') {
|
||||
if (_isBg) continue;
|
||||
// Update user bubble — replace file chips with image previews
|
||||
@@ -1993,7 +2044,7 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
const node = document.createElement('div')
|
||||
node.className = 'agent-thread-node running';
|
||||
const cmdHtml = cmd ? `<pre class="agent-thread-cmd">${esc(cmd)}</pre>` : '';
|
||||
node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">\u25B6</span><span class="agent-thread-tool">${toolLabel}</span><span class="agent-thread-wave">▁▂▃</span></div><div class="agent-thread-content">${cmdHtml}</div>`;
|
||||
node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">\u25B6</span><span class="agent-thread-tool">${esc(toolLabel)}</span><span class="agent-thread-wave">▁▂▃</span></div><div class="agent-thread-content">${cmdHtml}</div>`;
|
||||
// Expand/collapse via delegated click handler (init at module bottom).
|
||||
threadWrap.appendChild(node);
|
||||
currentToolBubble = node;
|
||||
@@ -2072,7 +2123,33 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
if (json.output && json.output.trim()) {
|
||||
outHtml = `<details class="agent-tool-output"><summary>Output</summary><pre>${esc(json.output)}</pre></details>`;
|
||||
}
|
||||
const cmdHtml2 = cmd ? `<pre class="agent-thread-cmd">${esc(cmd)}</pre>` : '';
|
||||
// File-write diff (write_file): show a before/after unified diff.
|
||||
let diffHtml = '';
|
||||
if (json.diff && json.diff.text) {
|
||||
const d = json.diff;
|
||||
// Collapsed summary: filename + +adds (green) / −dels (red).
|
||||
const stat = [
|
||||
d.new_file ? '<span class="diff-stat-new">new</span>' : '',
|
||||
d.added ? `<span class="diff-stat-add">+${d.added}</span>` : '',
|
||||
d.removed ? `<span class="diff-stat-del">−${d.removed}</span>` : '',
|
||||
].filter(Boolean).join(' ');
|
||||
const rows = d.text.split('\n').map(line => {
|
||||
let cls = 'diff-ctx', text = line;
|
||||
if (line.startsWith('+++') || line.startsWith('---')) cls = 'diff-meta';
|
||||
else if (line.startsWith('@@')) cls = 'diff-hunk';
|
||||
// Drop the leading diff marker (+/-/space) — the row colour
|
||||
// already encodes add/del, and keeping it doubles up with
|
||||
// markdown "- " bullets (reads as "+-"/"--").
|
||||
else if (line.startsWith('+')) { cls = 'diff-add'; text = line.slice(1); }
|
||||
else if (line.startsWith('-')) { cls = 'diff-del'; text = line.slice(1); }
|
||||
else if (line.startsWith(' ')) { text = line.slice(1); }
|
||||
return `<span class="${cls}">${esc(text) || ' '}</span>`;
|
||||
}).join(''); // spans are display:block — a literal \n here would double-space the diff
|
||||
diffHtml = `<details class="agent-tool-output agent-tool-diff"><summary><span class="diff-file">${esc(d.file || 'diff')}</span> <span class="diff-summary-stats">${stat}</span></summary><pre class="diff-pre">${rows}</pre></details>`;
|
||||
}
|
||||
// For file edits the "command" is the raw JSON args — redundant
|
||||
// next to the diff, so hide it when we have a diff to show.
|
||||
const cmdHtml2 = (cmd && !(json.diff && json.diff.text)) ? `<pre class="agent-thread-cmd">${esc(cmd)}</pre>` : '';
|
||||
// Preserve the user's .open choice across the innerHTML
|
||||
// rewrite \u2014 otherwise expanding a running tool collapses
|
||||
// it as soon as the result lands, forcing the user to
|
||||
@@ -2080,7 +2157,7 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
// bottom of file) so no per-node listener needed.
|
||||
const _wasOpen = currentToolBubble.classList.contains('open');
|
||||
currentToolBubble.className = 'agent-thread-node' + (ok ? '' : ' error') + (_wasOpen ? ' open' : '');
|
||||
currentToolBubble.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">${ok ? '\u2713' : '\u2717'}</span><span class="agent-thread-tool">${esc(json.tool)}</span><span class="agent-thread-status">${ok ? 'done' : 'failed'}</span><span class="agent-thread-chevron">\u25B6</span></div><div class="agent-thread-content">${cmdHtml2}${outHtml}</div>`;
|
||||
currentToolBubble.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">${ok ? '\u2713' : '\u2717'}</span><span class="agent-thread-tool">${esc(json.tool)}</span><span class="agent-thread-status">${ok ? 'done' : 'failed'}</span><span class="agent-thread-chevron">\u25B6</span></div><div class="agent-thread-content">${cmdHtml2}${outHtml}${diffHtml}</div>`;
|
||||
// Reset so thinking spinner between tools says "Thinking" not the old tool's label
|
||||
_lastToolName = '';
|
||||
uiModule.scrollHistory();
|
||||
@@ -2097,10 +2174,19 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
if (json.screenshot && currentToolBubble) {
|
||||
const contentEl = currentToolBubble.querySelector('.agent-thread-content');
|
||||
if (contentEl) {
|
||||
const details = document.createElement('details');
|
||||
details.className = 'agent-tool-output';
|
||||
details.innerHTML = `<summary>Screenshot</summary><img src="${json.screenshot}" style="max-width:100%;border-radius:6px;margin-top:6px;border:1px solid var(--border)" />`;
|
||||
contentEl.appendChild(details);
|
||||
const screenshotSrc = chatRenderer.safeToolScreenshotSrc(json.screenshot);
|
||||
if (screenshotSrc) {
|
||||
const details = document.createElement('details');
|
||||
details.className = 'agent-tool-output';
|
||||
const summary = document.createElement('summary');
|
||||
summary.textContent = 'Screenshot';
|
||||
const img = document.createElement('img');
|
||||
img.src = screenshotSrc;
|
||||
img.style.cssText = 'max-width:100%;border-radius:6px;margin-top:6px;border:1px solid var(--border)';
|
||||
details.appendChild(summary);
|
||||
details.appendChild(img);
|
||||
contentEl.appendChild(details);
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- Reload sessions after manage_session tool (delete, rename, etc.) ---
|
||||
@@ -2374,8 +2460,8 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
_finalReply = (_extracted.content || '').trim();
|
||||
} else {
|
||||
// Non-tag thinking: extract reply from raw text
|
||||
// Handle garbled <think> tag: "Thinking: reasoning\n<think>reply"
|
||||
const _garbledMatch = finalDisplay.match(/^[\s\S]+?<think(?:ing)?>\s*([\s\S]*?)(?:<\/think(?:ing)?>)?\s*$/i);
|
||||
// Handle garbled thinking tag: "Thinking: reasoning\n<think>reply"
|
||||
const _garbledMatch = finalDisplay.match(/^[\s\S]+?<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>\s*([\s\S]*?)(?:<\/(?:think(?:ing)?|thought)>)?\s*$/i);
|
||||
if (_garbledMatch && _garbledMatch[1].trim()) {
|
||||
_finalReply = _garbledMatch[1].trim();
|
||||
} else {
|
||||
@@ -2424,8 +2510,8 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
_body4b.innerHTML = _sourcesData ? _buildSourcesBox(_sourcesData, _sourcesType, _wasExpanded2) : _sourcesHtml;
|
||||
} else if (roundHolder !== holder) {
|
||||
// Check if there's thinking content worth showing
|
||||
const _thinkMatch = roundText.match(/<think(?:ing)?>([\s\S]*?)<\/think(?:ing)?>/i);
|
||||
if (_thinkMatch && _thinkMatch[1].trim()) {
|
||||
const _thinkingOnly = markdownModule.extractThinkingBlocks(roundText);
|
||||
if (_thinkingOnly.thinkingBlocks?.length && !_thinkingOnly.content) {
|
||||
// Show thinking in a collapsed section even if no visible reply text
|
||||
const _body4c = roundHolder.querySelector('.body');
|
||||
if (_body4c) _body4c.innerHTML = markdownModule.processWithThinking(roundText);
|
||||
@@ -3045,6 +3131,152 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
var _notifyStreamComplete = chatStream.notifyStreamComplete;
|
||||
var _insertStreamDoneToast = chatStream.insertStreamDoneToast;
|
||||
|
||||
/**
|
||||
* Live-resume a chat run still streaming detached on the server (#2539).
|
||||
*
|
||||
* On session re-entry, GET /api/chat/resume/{id} replays the run's buffer then
|
||||
* streams live; reply tokens render as they arrive. On completion a plain text
|
||||
* reply is finalized in place (canonical bubble via chatRenderer.addMessage, no
|
||||
* reload); a "rich" reply (tool calls, sources, doc streaming, multi-round) is
|
||||
* reloaded from the DB so its full render stays faithful. Returns true if it
|
||||
* attached, false to let the caller fall back to spinner+poll.
|
||||
*/
|
||||
export async function resumeStream(sessionId) {
|
||||
if (!sessionId) return false;
|
||||
if (hasActiveStream(sessionId)) return false;
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${API_BASE}/api/chat/resume/${sessionId}`);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
if (!res.ok || !res.body) return false;
|
||||
|
||||
const box = document.getElementById('chat-history');
|
||||
if (!box) return false;
|
||||
|
||||
// Block duplicate re-attach attempts while this reader is live. A dedicated
|
||||
// set (not _backgroundStreams) so checkBackgroundStream doesn't mistake this
|
||||
// for a same-tab POST stream and spawn its own spinner+poll on re-entry.
|
||||
_resumingStreams.add(sessionId);
|
||||
|
||||
const holder = document.createElement('div');
|
||||
holder.className = 'msg msg-ai';
|
||||
const meta = sessionModule.getSessions().find(s => s.id === sessionId);
|
||||
const roleLabel = _shortModel(meta && meta.model);
|
||||
const roleTs = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
holder.innerHTML = '<div class="role">' + uiModule.esc(roleLabel) +
|
||||
' <span class="role-timestamp">' + roleTs + '</span></div>' +
|
||||
'<div class="body"><div class="stream-content"></div></div>';
|
||||
_applyModelColor(holder.querySelector('.role'), meta && meta.model);
|
||||
const contentDiv = holder.querySelector('.stream-content');
|
||||
box.appendChild(holder);
|
||||
|
||||
const spinner = spinnerModule.create('Generating response...', 'right');
|
||||
holder.querySelector('.body').appendChild(spinner.createElement());
|
||||
spinner.start();
|
||||
uiModule.scrollHistory();
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let roundText = '';
|
||||
let gotDelta = false;
|
||||
let leftSession = false;
|
||||
let metricsData = null;
|
||||
// "Rich" responses (tool calls, sources, doc streaming, multi-round) need the
|
||||
// full canonical render, which is rebuilt from the saved DB record on reload.
|
||||
// Plain text replies can be finalized in place without a reload.
|
||||
let rich = false;
|
||||
|
||||
const cleanup = () => {
|
||||
try { spinner.destroy(); } catch (_) {}
|
||||
_resumingStreams.delete(sessionId);
|
||||
};
|
||||
|
||||
const renderDelta = () => {
|
||||
const dt = stripToolBlocks(roundText);
|
||||
contentDiv.innerHTML = markdownModule.mdToHtml(markdownModule.squashOutsideCode(dt));
|
||||
uiModule.scrollHistory();
|
||||
};
|
||||
|
||||
try {
|
||||
readLoop:
|
||||
while (true) {
|
||||
// User left this session: stop rendering, the run continues server-side.
|
||||
if (sessionModule.getCurrentSessionId &&
|
||||
sessionModule.getCurrentSessionId() !== sessionId) {
|
||||
leftSession = true;
|
||||
try { await reader.cancel(); } catch (_) {}
|
||||
break;
|
||||
}
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const parts = buffer.split('\n\n');
|
||||
buffer = parts.pop();
|
||||
for (const part of parts) {
|
||||
const line = part.split('\n').find(l => l.startsWith('data: '));
|
||||
if (!line) continue;
|
||||
const payload = line.slice(6);
|
||||
if (payload === '[DONE]') {
|
||||
try { await reader.cancel(); } catch (_) {}
|
||||
break readLoop;
|
||||
}
|
||||
let json;
|
||||
try { json = JSON.parse(payload); } catch (_) { continue; }
|
||||
if (json.delta) {
|
||||
roundText += json.delta;
|
||||
if (!gotDelta) { gotDelta = true; try { spinner.destroy(); } catch (_) {} }
|
||||
renderDelta();
|
||||
} else if (json.type === 'doc_stream_open') {
|
||||
rich = true;
|
||||
if (documentModule) documentModule.streamDocOpen(json.title || '', json.lang || '');
|
||||
} else if (json.type === 'doc_stream_delta') {
|
||||
rich = true;
|
||||
if (documentModule && json.delta) documentModule.streamDocDelta(json.delta);
|
||||
} else if (json.type === 'metrics') {
|
||||
metricsData = json.data || metricsData;
|
||||
} else if (json.type === 'tool_start' || json.type === 'tool_output' ||
|
||||
json.type === 'tool_progress' || json.type === 'agent_step' ||
|
||||
json.type === 'web_sources' || json.type === 'rag_sources' ||
|
||||
json.type === 'research_progress' || json.type === 'research_sources' ||
|
||||
json.type === 'research_findings' || json.type === 'research_done') {
|
||||
rich = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Network drop or parse failure: fall through to the reload below.
|
||||
}
|
||||
|
||||
cleanup();
|
||||
if (leftSession) { if (holder.parentNode) holder.remove(); return true; }
|
||||
|
||||
const onThisSession = sessionModule.getCurrentSessionId &&
|
||||
sessionModule.getCurrentSessionId() === sessionId;
|
||||
|
||||
// Plain text reply: finalize in place. Replace the live bubble with a
|
||||
// canonical single message (markdown + footer actions + metrics) using the
|
||||
// same renderer history does. No history refetch, no end-of-stream flicker.
|
||||
if (onThisSession && !rich && roundText.trim()) {
|
||||
if (holder.parentNode) holder.remove();
|
||||
const model = meta && meta.model;
|
||||
const meta_ = metricsData ? Object.assign({ model }, metricsData) : { model };
|
||||
chatRenderer.addMessage('assistant', roundText, model, meta_);
|
||||
uiModule.scrollHistory();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Rich response (tools, sources, docs, multi-round) or user moved on:
|
||||
// reload from the DB for the full canonical render.
|
||||
if (holder.parentNode) holder.remove();
|
||||
if (onThisSession) sessionModule.selectSession(sessionId);
|
||||
else sessionModule.loadSessions();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for background streams when switching to a session.
|
||||
* Called after history loads on session switch.
|
||||
@@ -3090,7 +3322,7 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
var meta = sessionModule.getSessions().find(function(s) { return s.id === sessionId; });
|
||||
var roleLabel = _shortModel(meta && meta.model);
|
||||
var roleTs = new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
|
||||
holder.innerHTML = '<div class="role">' + roleLabel + ' <span class="role-timestamp">' + roleTs + '</span></div><div class="body"></div>';
|
||||
holder.innerHTML = '<div class="role">' + uiModule.esc(roleLabel) + ' <span class="role-timestamp">' + roleTs + '</span></div><div class="body"></div>';
|
||||
_applyModelColor(holder.querySelector('.role'), meta && meta.model);
|
||||
|
||||
var bodyDiv = holder.querySelector('.body');
|
||||
@@ -3892,7 +4124,7 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
const roleTs = new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
|
||||
const agentMeta = sessionModule.getSessions().find(s => s.id === sessionModule.getCurrentSessionId());
|
||||
const agentModelLabel = _shortModel(agentMeta?.model);
|
||||
holder.innerHTML = `<div class="role">${agentModelLabel} <span class="role-timestamp">${roleTs}</span></div><div class="body"></div>`;
|
||||
holder.innerHTML = `<div class="role">${uiModule.esc(agentModelLabel)} <span class="role-timestamp">${roleTs}</span></div><div class="body"></div>`;
|
||||
_applyModelColor(holder.querySelector('.role'), agentMeta?.model);
|
||||
box.appendChild(holder);
|
||||
|
||||
@@ -4360,9 +4592,10 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
// never closes (so it would otherwise hide the whole answer). Peel all of
|
||||
// those off so what's left is just the rewritten text.
|
||||
const _stripThink = (t) => {
|
||||
t = t.replace(/<think>[\s\S]*?<\/think>/gi, ''); // complete blocks
|
||||
if (/<\/think>/i.test(t)) t = t.replace(/^[\s\S]*?<\/think>/i, ''); // reasoning w/o opener
|
||||
return t.replace(/<\/?think>/gi, '').trim(); // any orphan tag
|
||||
t = markdownModule.normalizeThinkingMarkup(t || '');
|
||||
t = t.replace(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>[\s\S]*?<\/(?:think(?:ing)?|thought)>/gi, ''); // complete blocks
|
||||
if (/<\/(?:think(?:ing)?|thought)>/i.test(t)) t = t.replace(/^[\s\S]*?<\/(?:think(?:ing)?|thought)>/i, ''); // reasoning w/o opener
|
||||
return t.replace(/<\/?(?:think(?:ing)?|thought)(?:\s+[^>]*)?>/gi, '').trim(); // any orphan tag
|
||||
};
|
||||
newText = _stripThink(newText);
|
||||
|
||||
@@ -4528,6 +4761,7 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
abortCurrentRequest,
|
||||
detachCurrentStream,
|
||||
checkBackgroundStream,
|
||||
resumeStream,
|
||||
hideWelcomeScreen: chatRenderer.hideWelcomeScreen,
|
||||
showWelcomeScreen: chatRenderer.showWelcomeScreen,
|
||||
checkPendingResearch,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import uiModule from './ui.js';
|
||||
import markdownModule from './markdown.js';
|
||||
import { addAITTSButton } from './tts-ai.js';
|
||||
import { providerLogo } from './providers.js';
|
||||
import { providerLogo, providerLabel } from './providers.js';
|
||||
import settingsModule from './settings.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import { bindMenuDismiss } from './escMenuStack.js';
|
||||
@@ -26,6 +26,29 @@ function _safeHref(url) {
|
||||
return '#';
|
||||
}
|
||||
|
||||
export function safeToolScreenshotSrc(raw) {
|
||||
const src = String(raw || '').trim();
|
||||
if (/^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(src)) {
|
||||
return src;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function safeDisplayImageSrc(raw) {
|
||||
const src = String(raw || '').trim();
|
||||
if (!src) return '';
|
||||
if (/^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(src)) {
|
||||
return src;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(src, window.location.origin);
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||
return parsed.href;
|
||||
}
|
||||
} catch (_) {}
|
||||
return '';
|
||||
}
|
||||
|
||||
function _makeActionBtn(className, title, text, handler) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = className;
|
||||
@@ -577,6 +600,12 @@ export function applyModelColor(roleEl, modelName) {
|
||||
if (logoHtml) html += '<span class="role-provider-logo" style="opacity:0.7">' + logoHtml + '</span>';
|
||||
html += short + '</div>';
|
||||
html += '<div><span class="ctx-label">Model</span> ' + modelName.split('/').pop() + '</div>';
|
||||
// Provider = the serving endpoint, distinct from the model vendor/logo
|
||||
// (e.g. the same model via OpenRouter vs Copilot vs Anthropic direct).
|
||||
const _epUrl = (window.sessionModule && window.sessionModule.getCurrentEndpointUrl)
|
||||
? window.sessionModule.getCurrentEndpointUrl() : null;
|
||||
const _provLabel = providerLabel(_epUrl);
|
||||
if (_provLabel) html += '<div><span class="ctx-label">Provider</span> ' + uiModule.esc(_provLabel) + '</div>';
|
||||
// Show static context initially, then fetch real from server
|
||||
const _realCtx = window._realContextLengths && window._realContextLengths[modelName];
|
||||
if (_realCtx) {
|
||||
@@ -1052,12 +1081,19 @@ export function buildImageBubble(imageUrl, prompt, model, size, quality, imageId
|
||||
const body = document.createElement('div');
|
||||
body.className = 'body';
|
||||
|
||||
const safeImageUrl = safeDisplayImageSrc(imageUrl);
|
||||
if (!safeImageUrl) {
|
||||
body.textContent = '[Image unavailable]';
|
||||
wrap.appendChild(body);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.className = 'generated-image';
|
||||
img.alt = prompt || 'Generated image';
|
||||
img.title = prompt || 'Generated image';
|
||||
img.src = imageUrl;
|
||||
img.addEventListener('click', () => { window.open(img.src, '_blank'); });
|
||||
img.src = safeImageUrl;
|
||||
img.addEventListener('click', () => { window.open(safeImageUrl, '_blank', 'noopener,noreferrer'); });
|
||||
body.appendChild(img);
|
||||
|
||||
if (prompt) {
|
||||
@@ -1947,13 +1983,37 @@ export function addMessage(role, content, modelName, metadata) {
|
||||
if (ev.output && ev.output.trim()) {
|
||||
outHtml = `<details class="agent-tool-output"><summary>Output</summary><pre>${esc(ev.output)}</pre></details>`;
|
||||
}
|
||||
if (ev.screenshot) {
|
||||
outHtml += `<details class="agent-tool-output"><summary>Screenshot</summary><img src="${esc(ev.screenshot)}" style="max-width:100%;border-radius:6px;margin-top:6px;border:1px solid var(--border)" /></details>`;
|
||||
const screenshotSrc = safeToolScreenshotSrc(ev.screenshot);
|
||||
if (screenshotSrc) {
|
||||
outHtml += `<details class="agent-tool-output"><summary>Screenshot</summary><img src="${esc(screenshotSrc)}" style="max-width:100%;border-radius:6px;margin-top:6px;border:1px solid var(--border)" /></details>`;
|
||||
}
|
||||
// File-write/edit diff (persisted in the tool event) \u2014 re-render it
|
||||
// so it survives reload, matching the live stream.
|
||||
let evDiffHtml = '';
|
||||
if (ev.diff && ev.diff.text) {
|
||||
const d = ev.diff;
|
||||
const stat = [
|
||||
d.new_file ? '<span class="diff-stat-new">new</span>' : '',
|
||||
d.added ? `<span class="diff-stat-add">+${d.added}</span>` : '',
|
||||
d.removed ? `<span class="diff-stat-del">\u2212${d.removed}</span>` : '',
|
||||
].filter(Boolean).join(' ');
|
||||
const rows = d.text.split('\n').map(line => {
|
||||
let cls = 'diff-ctx', text = line;
|
||||
if (line.startsWith('+++') || line.startsWith('---')) cls = 'diff-meta';
|
||||
else if (line.startsWith('@@')) cls = 'diff-hunk';
|
||||
// Drop the leading diff marker (+/-/space) — colour encodes add/del.
|
||||
else if (line.startsWith('+')) { cls = 'diff-add'; text = line.slice(1); }
|
||||
else if (line.startsWith('-')) { cls = 'diff-del'; text = line.slice(1); }
|
||||
else if (line.startsWith(' ')) { text = line.slice(1); }
|
||||
return `<span class="${cls}">${esc(text) || ' '}</span>`;
|
||||
}).join(''); // spans are display:block \u2014 a literal \n would double-space
|
||||
evDiffHtml = `<details class="agent-tool-output agent-tool-diff"><summary><span class="diff-file">${esc(d.file || 'diff')}</span> <span class="diff-summary-stats">${stat}</span></summary><pre class="diff-pre">${rows}</pre></details>`;
|
||||
}
|
||||
const node = document.createElement('div');
|
||||
node.className = 'agent-thread-node' + (ok ? '' : ' error');
|
||||
const evCmdHtml = ev.command ? `<pre class="agent-thread-cmd">${esc(ev.command)}</pre>` : '';
|
||||
node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">${ok ? '\u2713' : '\u2717'}</span><span class="agent-thread-tool">${esc(ev.tool)}</span><span class="agent-thread-status">${ok ? 'done' : 'failed'}</span><span class="agent-thread-chevron">\u25B6</span></div><div class="agent-thread-content">${evCmdHtml}${outHtml}</div>`;
|
||||
// Hide the raw JSON command when a diff says it better (same as live).
|
||||
const evCmdHtml = (ev.command && !(ev.diff && ev.diff.text)) ? `<pre class="agent-thread-cmd">${esc(ev.command)}</pre>` : '';
|
||||
node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">${ok ? '\u2713' : '\u2717'}</span><span class="agent-thread-tool">${esc(ev.tool)}</span><span class="agent-thread-status">${ok ? 'done' : 'failed'}</span><span class="agent-thread-chevron">\u25B6</span></div><div class="agent-thread-content">${evCmdHtml}${outHtml}${evDiffHtml}</div>`;
|
||||
// Click handling is delegated globally \u2014 see chat.js init.
|
||||
threadWrap.appendChild(node);
|
||||
}
|
||||
@@ -2279,6 +2339,8 @@ const chatRenderer = {
|
||||
updateSessionCostUI,
|
||||
roleTimestamp,
|
||||
stripToolBlocks,
|
||||
safeToolScreenshotSrc,
|
||||
safeDisplayImageSrc,
|
||||
buildSourcesBox,
|
||||
buildFindingsBox,
|
||||
appendReportButton,
|
||||
|
||||
@@ -362,6 +362,7 @@ export function runHTML(code, panel) {
|
||||
addCloseBtn(panel);
|
||||
return;
|
||||
}
|
||||
try { win.opener = null; } catch (_) {}
|
||||
win.document.open();
|
||||
win.document.write(code);
|
||||
win.document.close();
|
||||
|
||||
@@ -1090,6 +1090,7 @@ function _exportPrint() {
|
||||
// the system print dialog — user can pick "Save as PDF" from there.
|
||||
const w = window.open('', '_blank');
|
||||
if (!w) return;
|
||||
try { w.opener = null; } catch (_) {}
|
||||
const escape = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
const html = '<!doctype html><meta charset="utf-8"><title>Compare export</title>' +
|
||||
'<style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;max-width:780px;margin:32px auto;padding:0 24px;line-height:1.55;color:#222}' +
|
||||
|
||||
@@ -1195,7 +1195,7 @@ async function showModelSelector() {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'compare-probe-row';
|
||||
row.dataset.idx = 'p' + i;
|
||||
row.innerHTML = `<span class="compare-probe-spinner">▁▂▃</span><span class="compare-probe-name">${p.label || p.id}</span><span class="compare-probe-status"></span>`;
|
||||
row.innerHTML = `<span class="compare-probe-spinner">▁▂▃</span><span class="compare-probe-name">${escapeHtml(p.label || p.id)}</span><span class="compare-probe-status"></span>`;
|
||||
const waveEl = row.querySelector('.compare-probe-spinner');
|
||||
const waveFrames = WAVE_FRAMES;
|
||||
let wIdx = 0;
|
||||
|
||||
+43
-25
@@ -1,7 +1,7 @@
|
||||
// compare/stream.js — SSE streaming to panes
|
||||
import state from './state.js';
|
||||
import { addFinishBadge } from './vote.js';
|
||||
import { getModelCost } from '../chatRenderer.js';
|
||||
import { getModelCost, safeDisplayImageSrc } from '../chatRenderer.js';
|
||||
import markdownModule from '../markdown.js';
|
||||
import spinnerModule from '../spinner.js';
|
||||
import uiModule from '../ui.js';
|
||||
@@ -11,6 +11,16 @@ var escapeHtml = uiModule.esc;
|
||||
|
||||
const WAVE_FRAMES = ['▁▂▃', '▂▃▄', '▃▄▅', '▄▅▆', '▅▆▇', '▆▅▄', '▅▄▃', '▄▃▂'];
|
||||
|
||||
function _safeHttpHref(raw) {
|
||||
try {
|
||||
const parsed = new URL(String(raw || '').trim(), window.location.origin);
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||
return parsed.href;
|
||||
}
|
||||
} catch (_) {}
|
||||
return '';
|
||||
}
|
||||
|
||||
// ── Lazy-registered functions from compare.js (avoids circular deps) ──
|
||||
let _rerollPane = null;
|
||||
let _autoPreviewHtml = null;
|
||||
@@ -36,9 +46,12 @@ function _renderSearchResults(data) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'compare-search-result';
|
||||
const titleLink = document.createElement('a');
|
||||
titleLink.href = r.url || '#';
|
||||
titleLink.target = '_blank';
|
||||
titleLink.rel = 'noopener';
|
||||
const safeUrl = _safeHttpHref(r.url);
|
||||
if (safeUrl) {
|
||||
titleLink.href = safeUrl;
|
||||
titleLink.target = '_blank';
|
||||
titleLink.rel = 'noopener noreferrer';
|
||||
}
|
||||
titleLink.className = 'search-result-title';
|
||||
titleLink.textContent = r.title || 'Untitled';
|
||||
card.appendChild(titleLink);
|
||||
@@ -344,7 +357,7 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
const cmdHtml = cmd ? `<pre class="agent-thread-cmd">${escapeHtml(cmd)}</pre>` : '';
|
||||
const node = document.createElement('div');
|
||||
node.className = 'agent-thread-node running';
|
||||
node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">\u25B6</span><span class="agent-thread-tool">${toolLabel}</span><span class="agent-thread-wave">▁▂▃</span></div><div class="agent-thread-content">${cmdHtml}</div>`;
|
||||
node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">\u25B6</span><span class="agent-thread-tool">${escapeHtml(toolLabel)}</span><span class="agent-thread-wave">▁▂▃</span></div><div class="agent-thread-content">${cmdHtml}</div>`;
|
||||
node.querySelector('.agent-thread-header').addEventListener('click', () => node.classList.toggle('open'));
|
||||
// Animate wave
|
||||
const waveEl = node.querySelector('.agent-thread-wave');
|
||||
@@ -363,28 +376,33 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
if (json.image_url) {
|
||||
// Stop image spinner and render generated image in pane
|
||||
if (aiMsgEl._imgSpinner) { aiMsgEl._imgSpinner.destroy(); aiMsgEl._imgSpinner = null; }
|
||||
const safeImageUrl = safeDisplayImageSrc(json.image_url);
|
||||
aiBody.innerHTML = '';
|
||||
const img = document.createElement('img');
|
||||
img.className = 'compare-gen-image';
|
||||
img.src = json.image_url;
|
||||
img.alt = json.image_prompt || '';
|
||||
img.title = json.image_prompt || '';
|
||||
img.addEventListener('click', () => window.open(img.src, '_blank'));
|
||||
aiBody.appendChild(img);
|
||||
if (json.image_prompt) {
|
||||
const caption = document.createElement('div');
|
||||
caption.style.cssText = 'font-size:0.82em;color:color-mix(in srgb, var(--fg) 55%, transparent);margin-top:6px;line-height:1.4;';
|
||||
caption.textContent = json.image_prompt;
|
||||
aiBody.appendChild(caption);
|
||||
if (!safeImageUrl) {
|
||||
aiBody.textContent = '[Image unavailable]';
|
||||
} else {
|
||||
const img = document.createElement('img');
|
||||
img.className = 'compare-gen-image';
|
||||
img.src = safeImageUrl;
|
||||
img.alt = json.image_prompt || '';
|
||||
img.title = json.image_prompt || '';
|
||||
img.addEventListener('click', () => window.open(safeImageUrl, '_blank', 'noopener,noreferrer'));
|
||||
aiBody.appendChild(img);
|
||||
if (json.image_prompt) {
|
||||
const caption = document.createElement('div');
|
||||
caption.style.cssText = 'font-size:0.82em;color:color-mix(in srgb, var(--fg) 55%, transparent);margin-top:6px;line-height:1.4;';
|
||||
caption.textContent = json.image_prompt;
|
||||
aiBody.appendChild(caption);
|
||||
}
|
||||
// Show model name below image (hidden in blind mode until vote)
|
||||
if (json.image_model && !state._blindMode) {
|
||||
const modelLabel = document.createElement('div');
|
||||
modelLabel.style.cssText = 'font-size:0.75em;color:color-mix(in srgb, var(--fg) 40%, transparent);margin-top:4px;';
|
||||
modelLabel.textContent = json.image_model;
|
||||
aiBody.appendChild(modelLabel);
|
||||
}
|
||||
aiMsgEl._imageData = { url: safeImageUrl, prompt: json.image_prompt, model: json.image_model, size: json.image_size, quality: json.image_quality };
|
||||
}
|
||||
// Show model name below image (hidden in blind mode until vote)
|
||||
if (json.image_model && !state._blindMode) {
|
||||
const modelLabel = document.createElement('div');
|
||||
modelLabel.style.cssText = 'font-size:0.75em;color:color-mix(in srgb, var(--fg) 40%, transparent);margin-top:4px;';
|
||||
modelLabel.textContent = json.image_model;
|
||||
aiBody.appendChild(modelLabel);
|
||||
}
|
||||
aiMsgEl._imageData = { url: json.image_url, prompt: json.image_prompt, model: json.image_model, size: json.image_size, quality: json.image_quality };
|
||||
} else if (currentToolBlock) {
|
||||
// Stop wave animation
|
||||
if (currentToolBlock._waveInterval) { clearInterval(currentToolBlock._waveInterval); currentToolBlock._waveInterval = null; }
|
||||
|
||||
@@ -378,16 +378,12 @@ export const ERROR_PATTERNS = [
|
||||
message: 'Model architecture too new for installed vLLM/transformers.',
|
||||
fixes: [
|
||||
{ label: 'Try --trust-remote-code', action: (panel) => _serveAutoRetry(panel, '--trust-remote-code'), autofix: true },
|
||||
{ label: 'Update vLLM on server', action: (panel) => {
|
||||
const taskEl = panel.closest('.cookbook-task');
|
||||
const task = taskEl ? _loadTasks().find(t => t.sessionId === taskEl.dataset.taskId) : null;
|
||||
const host = task?.remoteHost || '';
|
||||
const prefix = _buildEnvPrefix();
|
||||
const pipCmd = prefix ? prefix + ' pip install -U vllm transformers' : 'pip install -U vllm transformers';
|
||||
const cmd = host ? _sshCmd(host, pipCmd) : pipCmd;
|
||||
// Run in tmux so it doesn't timeout
|
||||
const name = 'update-vllm';
|
||||
_launchServeTask(name, 'pip-update', cmd);
|
||||
{ label: 'Update vLLM on server', action: () => {
|
||||
// Use the venv's python3 by absolute path when configured (SSH non-
|
||||
// interactive sessions often pick user-site Python over the venv).
|
||||
const _vp = (_envState.env === 'venv' && _envState.envPath)
|
||||
? `${_envState.envPath.replace(/\/+$/, '')}/bin/python3` : 'python3';
|
||||
_launchServeTask('update-vllm', 'pip-update', `${_vp} -m pip install -U vllm transformers`);
|
||||
}},
|
||||
],
|
||||
},
|
||||
@@ -395,16 +391,10 @@ export const ERROR_PATTERNS = [
|
||||
pattern: /Either a revision or a version must be specified|transformers\.integrations\.hub_kernels|kernels\/layer/i,
|
||||
message: 'Transformers/kernels package mismatch.',
|
||||
fixes: [
|
||||
{ label: 'Repair kernel package', action: (panel) => {
|
||||
const taskEl = panel.closest('.cookbook-task');
|
||||
const task = taskEl ? _loadTasks().find(t => t.sessionId === taskEl.dataset.taskId) : null;
|
||||
const host = task?.remoteHost || '';
|
||||
const prefix = _buildEnvPrefix();
|
||||
const pipCmd = prefix
|
||||
? prefix + ' python3 -m pip install --user --break-system-packages "kernels<0.15"'
|
||||
: 'python3 -m pip install --user --break-system-packages "kernels<0.15"';
|
||||
const cmd = host ? _sshCmd(host, pipCmd) : pipCmd;
|
||||
_launchServeTask('repair-kernels', 'pip-update', cmd);
|
||||
{ label: 'Repair kernel package', action: () => {
|
||||
const _vp = (_envState.env === 'venv' && _envState.envPath)
|
||||
? `${_envState.envPath.replace(/\/+$/, '')}/bin/python3` : 'python3';
|
||||
_launchServeTask('repair-kernels', 'pip-update', `${_vp} -m pip install --user --break-system-packages kernels<0.15`);
|
||||
}},
|
||||
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('sglang') },
|
||||
],
|
||||
@@ -445,14 +435,10 @@ export const ERROR_PATTERNS = [
|
||||
pattern: /Triton kernels.*Failed to import|cannot import name '\w+' from 'triton_kernels/i,
|
||||
message: 'Triton kernels version mismatch. Non-fatal warning — model will still run, just without optimized MoE kernels.',
|
||||
fixes: [
|
||||
{ label: 'Update triton on server', action: (panel) => {
|
||||
const taskEl = panel.closest('.cookbook-task');
|
||||
const task = taskEl ? _loadTasks().find(t => t.sessionId === taskEl.dataset.taskId) : null;
|
||||
const host = task?.remoteHost || '';
|
||||
const prefix = _buildEnvPrefix();
|
||||
const pipCmd = prefix ? prefix + ' pip install -U triton triton-kernels' : 'pip install -U triton triton-kernels';
|
||||
const cmd = host ? _sshCmd(host, pipCmd) : pipCmd;
|
||||
_launchServeTask('update-triton', 'pip-update', cmd);
|
||||
{ label: 'Update triton on server', action: () => {
|
||||
const _vp = (_envState.env === 'venv' && _envState.envPath)
|
||||
? `${_envState.envPath.replace(/\/+$/, '')}/bin/python3` : 'python3';
|
||||
_launchServeTask('update-triton', 'pip-update', `${_vp} -m pip install -U triton triton-kernels`);
|
||||
}},
|
||||
],
|
||||
},
|
||||
@@ -474,14 +460,56 @@ export const ERROR_PATTERNS = [
|
||||
pattern: /attention_sink|sliding.window.*not supported|sliding_window.*incompatible/i,
|
||||
message: 'Model uses attention features unsupported in this vLLM version.',
|
||||
fixes: [
|
||||
{ label: 'Update vLLM on server', action: (panel) => {
|
||||
const taskEl = panel.closest('.cookbook-task');
|
||||
const task = taskEl ? _loadTasks().find(t => t.sessionId === taskEl.dataset.taskId) : null;
|
||||
const host = task?.remoteHost || '';
|
||||
const prefix = _buildEnvPrefix();
|
||||
const pipCmd = prefix ? prefix + ' pip install -U vllm' : 'pip install -U vllm';
|
||||
const cmd = host ? _sshCmd(host, pipCmd) : pipCmd;
|
||||
_launchServeTask('update-vllm', 'pip-update', cmd);
|
||||
{ label: 'Update vLLM on server', action: () => {
|
||||
const _vp = (_envState.env === 'venv' && _envState.envPath)
|
||||
? `${_envState.envPath.replace(/\/+$/, '')}/bin/python3` : 'python3';
|
||||
_launchServeTask('update-vllm', 'pip-update', `${_vp} -m pip install -U vllm`);
|
||||
}},
|
||||
],
|
||||
},
|
||||
{
|
||||
// FlashInfer JIT-compiles attention kernels for the host GPU on first
|
||||
// use. If the system /usr/bin/nvcc is older than CUDA 11.8 it can't
|
||||
// target sm_89/sm_90 (Ada/Hopper), and the engine workers die before
|
||||
// they can report a useful traceback. Two quick paths out: pick a
|
||||
// non-flashinfer attention backend, or set CUDACXX to a newer nvcc
|
||||
// (vLLM installs nvidia-cuda-nvcc into the venv — point at that).
|
||||
pattern: /nvcc fatal\s+:\s+Unsupported gpu architecture 'compute_\d+'/i,
|
||||
message: 'FlashInfer is JIT-compiling sampling kernels with an nvcc too old for this GPU (no sm_89 / sm_90 support — pre-CUDA 11.8). Changing the attention backend does not help — flashinfer JITs the SAMPLER too. The clean fix is to set VLLM_USE_FLASHINFER_SAMPLER=0 so vLLM uses its native sampler instead.',
|
||||
suggestion: 'Suggested action: relaunch with VLLM_USE_FLASHINFER_SAMPLER=0 prepended. (Confirmed on the QuantTrio/Qwen3.5 model card as the canonical workaround.)',
|
||||
fixes: [
|
||||
{ label: 'Retry with VLLM_USE_FLASHINFER_SAMPLER=0', action: (panel) => _serveAutoRetryReplace(panel, '', 'VLLM_USE_FLASHINFER_SAMPLER=0 ', { prepend: true }) },
|
||||
{ label: 'Uninstall flashinfer-python', action: () => {
|
||||
// Hard fallback: vLLM 0.22 reaches into flashinfer for sampling kernels
|
||||
// even with VLLM_USE_FLASHINFER_SAMPLER=0 in some configs. Removing
|
||||
// the package forces it onto the native sampler.
|
||||
const _vp = (_envState.env === 'venv' && _envState.envPath)
|
||||
? `${_envState.envPath.replace(/\/+$/, '')}/bin/python3` : 'python3';
|
||||
_launchServeTask('uninstall-flashinfer', 'pip-update', `${_vp} -m pip uninstall flashinfer-python -y`);
|
||||
}},
|
||||
{ label: 'Edit serve', action: (panel) => _openServeEditFromDiagnosis(panel) },
|
||||
],
|
||||
},
|
||||
{
|
||||
// vLLM <-> torch ABI mismatch: vLLM imports torch.library helpers
|
||||
// (`infer_schema`, `register_fake`, etc.) that only exist on newer torch
|
||||
// versions. When the installed torch is older, the import fails before
|
||||
// any server code runs. Fix is to reinstall vllm (which pulls a matching
|
||||
// torch) or upgrade torch directly.
|
||||
pattern: /ImportError: cannot import name '[^']+' from 'torch(\.\w+)+'/i,
|
||||
message: 'vLLM was built against a newer torch than what is installed. Reinstall vLLM so pip pulls a compatible torch (or upgrade torch directly).',
|
||||
fixes: [
|
||||
{ label: 'Reinstall vLLM (pulls matching torch)', action: () => {
|
||||
// Absolute path to the venv's python3 — bare `python3` lands in the
|
||||
// wrong site-packages over SSH when ~/.local/bin precedes the venv.
|
||||
const _vp = (_envState.env === 'venv' && _envState.envPath)
|
||||
? `${_envState.envPath.replace(/\/+$/, '')}/bin/python3` : 'python3';
|
||||
_launchServeTask('reinstall-vllm', 'pip-reinstall', `${_vp} -m pip install --force-reinstall vllm`);
|
||||
}},
|
||||
{ label: 'Upgrade torch only', action: () => {
|
||||
const _vp = (_envState.env === 'venv' && _envState.envPath)
|
||||
? `${_envState.envPath.replace(/\/+$/, '')}/bin/python3` : 'python3';
|
||||
_launchServeTask('upgrade-torch', 'pip-update', `${_vp} -m pip install -U torch`);
|
||||
}},
|
||||
],
|
||||
},
|
||||
@@ -607,59 +635,24 @@ export function _showDiagnosis(panel, diagnosis, sourceText) {
|
||||
};
|
||||
|
||||
if (fixes.length) {
|
||||
// Always render fixes as inline buttons. The old "Actions ▾" dropdown
|
||||
// (for >3 fixes) was broken — the menu wouldn't open in some panels and
|
||||
// hid useful actions behind a non-working affordance. Inline buttons wrap
|
||||
// naturally in `.cookbook-diag-fixes` (flex-wrap) so a long list reflows
|
||||
// onto multiple rows instead of getting collapsed.
|
||||
const row = document.createElement('div');
|
||||
row.className = 'cookbook-diag-fixes';
|
||||
|
||||
if (fixes.length <= 3) {
|
||||
for (const fix of fixes) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'cookbook-btn cookbook-diag-btn';
|
||||
btn.type = 'button';
|
||||
btn.innerHTML = _diagFixIcon(fix.label) + '<span class="cookbook-diag-btn-label">' + _diagEsc(fix.label) + '</span>';
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
runFix(fix, btn);
|
||||
});
|
||||
row.appendChild(btn);
|
||||
}
|
||||
body.appendChild(row);
|
||||
return;
|
||||
}
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'cookbook-diag-actions';
|
||||
|
||||
const trigger = document.createElement('button');
|
||||
trigger.className = 'cookbook-btn cookbook-diag-action-trigger';
|
||||
trigger.type = 'button';
|
||||
trigger.textContent = 'Actions';
|
||||
trigger.appendChild(document.createTextNode(' ▾'));
|
||||
wrap.appendChild(trigger);
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'dropdown cookbook-diag-menu hidden';
|
||||
for (const fix of fixes) {
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.innerHTML = _diagFixIcon(fix.label) + '<span class="cookbook-diag-btn-label">' + _diagEsc(fix.label) + '</span>';
|
||||
item.addEventListener('click', async (e) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'cookbook-btn cookbook-diag-btn';
|
||||
btn.type = 'button';
|
||||
btn.innerHTML = _diagFixIcon(fix.label) + '<span class="cookbook-diag-btn-label">' + _diagEsc(fix.label) + '</span>';
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (item.dataset.busy || trigger.dataset.busy) return;
|
||||
item.dataset.busy = '1';
|
||||
await runFix(fix, trigger, fix.label, () => menu.classList.add('hidden'), () => delete item.dataset.busy);
|
||||
runFix(fix, btn);
|
||||
});
|
||||
menu.appendChild(item);
|
||||
row.appendChild(btn);
|
||||
}
|
||||
wrap.appendChild(menu);
|
||||
trigger.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (trigger.dataset.busy) return;
|
||||
document.querySelectorAll('.cookbook-diag-menu').forEach(m => {
|
||||
if (m !== menu) m.classList.add('hidden');
|
||||
});
|
||||
menu.classList.toggle('hidden');
|
||||
});
|
||||
row.appendChild(wrap);
|
||||
body.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
+73
-10
@@ -353,6 +353,15 @@ function _buildEnvPrefixWindows() {
|
||||
}
|
||||
|
||||
export function _buildServeCmd(f, modelName, backend) {
|
||||
// When a venv is configured on the chosen server, use the venv's binaries
|
||||
// by absolute path. Bare `vllm` / `python3` relies on PATH, and SSH non-
|
||||
// interactive sessions often leave a user-site install (~/.local/bin/vllm)
|
||||
// ahead of the venv's bin, so the WRONG vllm gets launched even with the
|
||||
// venv activated. Absolute path sidesteps the whole PATH question.
|
||||
const _isVenv = _envState.env === 'venv' && _envState.envPath;
|
||||
const _venvBin = _isVenv ? (_envState.envPath.replace(/\/+$/, '') + '/bin/') : '';
|
||||
const _vllmBin = _venvBin ? `${_venvBin}vllm` : 'vllm';
|
||||
const _py3Bin = _venvBin ? `${_venvBin}python3` : 'python3';
|
||||
let cmd = '';
|
||||
if (backend === 'vllm') {
|
||||
const gpuId = f.gpu_id?.trim() || '';
|
||||
@@ -361,7 +370,15 @@ export function _buildServeCmd(f, modelName, backend) {
|
||||
const _opts = _detectModelOptimizations(modelName);
|
||||
if (_opts.envVars.length) cmd += _opts.envVars.join(' ') + ' ';
|
||||
}
|
||||
cmd += `vllm serve ${modelName} --host 0.0.0.0 --port ${f.port || '8000'}`;
|
||||
// Pinned attention backend (Attention field). Empty = let vLLM pick.
|
||||
const _attn = (f.vllm_attn_backend ?? '').toString().trim();
|
||||
if (_attn) cmd += `VLLM_ATTENTION_BACKEND=${_attn} `;
|
||||
// Free-text "Env" field — verbatim KEY=VAL pairs (space-separated).
|
||||
// Collapse any pasted newlines/tabs so the backend allowlist (which
|
||||
// rejects \n / \r) doesn't trip on a multi-line paste from a model card.
|
||||
const _extraEnv = (f.extra_env ?? '').toString().replace(/\s+/g, ' ').trim();
|
||||
if (_extraEnv) cmd += _extraEnv + ' ';
|
||||
cmd += `${_vllmBin} serve ${modelName} --host 0.0.0.0 --port ${f.port || '8000'}`;
|
||||
cmd += ` --tensor-parallel-size ${f.tp || '1'}`;
|
||||
cmd += ` --max-model-len ${f.ctx || '8192'}`;
|
||||
cmd += ` --gpu-memory-utilization ${f.gpu_mem || '0.90'}`;
|
||||
@@ -389,7 +406,9 @@ export function _buildServeCmd(f, modelName, backend) {
|
||||
} else if (backend === 'sglang') {
|
||||
const gpuId = f.gpu_id?.trim() || '';
|
||||
if (gpuId) cmd += `CUDA_VISIBLE_DEVICES=${gpuId} `;
|
||||
cmd += `python3 -m sglang.launch_server --model-path ${modelName} --host 0.0.0.0 --port ${f.port || '30000'}`;
|
||||
const _extraEnv = (f.extra_env ?? '').toString().replace(/\s+/g, ' ').trim();
|
||||
if (_extraEnv) cmd += _extraEnv + ' ';
|
||||
cmd += `${_py3Bin} -m sglang.launch_server --model-path ${modelName} --host 0.0.0.0 --port ${f.port || '30000'}`;
|
||||
if (f.tp && f.tp !== '1') cmd += ` --tp ${f.tp}`;
|
||||
if (f.ctx) cmd += ` --context-length ${f.ctx}`;
|
||||
if (f.gpu_mem && f.gpu_mem !== '0.90') cmd += ` --mem-fraction-static ${f.gpu_mem}`;
|
||||
@@ -642,13 +661,20 @@ async function _fetchDependencies() {
|
||||
const winBlocked = !isLocal && _isWindows() && _winUnsupported.has(pkg.name);
|
||||
const note = pkg.status_note ? `<div class="memory-item-meta" style="font-size:10px;opacity:0.65;margin-top:3px;">${esc(pkg.status_note)}</div>` : '';
|
||||
const updateNote = pkg.installed && pkg.pip_update_available === false && pkg.update_note ? `<div class="memory-item-meta" style="font-size:10px;opacity:0.55;margin-top:3px;">${esc(pkg.update_note)}</div>` : '';
|
||||
// Inline "Rebuild" tag for the llama_cpp row only. Styled as a
|
||||
// .cookbook-dep-tag so it matches the LLM category tag's pill look,
|
||||
// and lives to the LEFT of the category tag (clear affordance before
|
||||
// the row "value").
|
||||
const _rebuildBtn = (pkg.name === 'llama_cpp')
|
||||
? `<button type="button" class="cookbook-dep-tag cookbook-dep-rebuild" id="cookbook-rebuild-engine" title="Clear the cached llama.cpp build so the next serve recompiles from source (use after installing a CUDA/ROCm toolkit to turn a CPU-only build into a GPU build).">Rebuild</button>`
|
||||
: '';
|
||||
// Inline rebuild/reinstall tag. Styled as a .cookbook-dep-tag so it
|
||||
// matches the LLM category tag's pill look, and lives to the LEFT of the
|
||||
// category tag. llama_cpp uses the /api/cookbook/rebuild-engine flow
|
||||
// (clear cached binary so next serve recompiles); vllm/sglang use the
|
||||
// diagnosis-style `_launchServeTask` with `pip install --force-reinstall`
|
||||
// so the user can watch the pip install in the Running tab.
|
||||
let _rebuildBtn = '';
|
||||
if (pkg.name === 'llama_cpp') {
|
||||
_rebuildBtn = `<button type="button" class="cookbook-dep-tag cookbook-dep-rebuild" id="cookbook-rebuild-engine" title="Clear the cached llama.cpp build so the next serve recompiles from source (use after installing a CUDA/ROCm toolkit to turn a CPU-only build into a GPU build).">Rebuild</button>`;
|
||||
} else if (pkg.name === 'vllm' && pkg.installed) {
|
||||
_rebuildBtn = `<button type="button" class="cookbook-dep-tag cookbook-dep-rebuild cookbook-dep-reinstall" data-reinstall-pkg="vllm" title="Force-reinstall vLLM (pulls a matching torch). Runs as a tmux task in the Running tab.">Reinstall</button>`;
|
||||
} else if (pkg.name === 'sglang' && pkg.installed) {
|
||||
_rebuildBtn = `<button type="button" class="cookbook-dep-tag cookbook-dep-rebuild cookbook-dep-reinstall" data-reinstall-pkg="sglang" title="Force-reinstall SGLang (pulls a matching torch). Runs as a tmux task in the Running tab.">Reinstall</button>`;
|
||||
}
|
||||
return `<div class="cookbook-dep-row${winBlocked ? ' cookbook-dep-blocked' : ''}" data-pkg-name="${esc(pkg.name)}" data-dep-pip="${esc(pkg.pip || '')}" data-dep-target="${isLocal ? 'local' : 'remote'}" data-dep-kind="${esc(pkg.kind || 'python')}">`
|
||||
+ `<div class="cookbook-dep-info">`
|
||||
+ `<div class="memory-item-title">${esc(pkg.name)}</div>`
|
||||
@@ -696,7 +722,18 @@ async function _fetchDependencies() {
|
||||
// for PEP-668-locked system pythons (Arch, newer Debian).
|
||||
const _inEnv = _envState.env === 'venv' || _envState.env === 'conda';
|
||||
const _pipFlags = (!_isWindows() && !_inEnv) ? ' --user --break-system-packages' : '';
|
||||
const _py = _isWindows() ? 'python' : 'python3';
|
||||
// Use the venv's python3 by absolute path when configured. Even with the
|
||||
// env_prefix sourcing activate, SSH non-interactive sessions sometimes
|
||||
// pick a `python3` ahead of the venv's bin on PATH, so the install
|
||||
// silently lands in the wrong site-packages.
|
||||
let _py;
|
||||
if (_isWindows()) {
|
||||
_py = 'python';
|
||||
} else if (_envState.env === 'venv' && _envState.envPath) {
|
||||
_py = `${_envState.envPath.replace(/\/+$/, '')}/bin/python3`;
|
||||
} else {
|
||||
_py = 'python3';
|
||||
}
|
||||
const cmd = `${_py} -m pip install${upgrade ? ' -U' : ''}${_pipFlags} "${pipName}"`;
|
||||
let envPrefix = '';
|
||||
if (_isWindows()) {
|
||||
@@ -1072,6 +1109,32 @@ function _wireTabEvents(body) {
|
||||
});
|
||||
}
|
||||
|
||||
// "Reinstall" buttons for pip-based serving stacks (vllm, sglang). The
|
||||
// deps list renders ASYNCHRONOUSLY after _fetchDependencies resolves, so
|
||||
// attaching listeners directly here would miss buttons that don't exist
|
||||
// yet. Use document-level delegation instead — the click always finds the
|
||||
// right .cookbook-dep-reinstall button no matter when it was painted.
|
||||
if (!document._cookbookReinstallWired) {
|
||||
document._cookbookReinstallWired = true;
|
||||
document.addEventListener('click', async (ev) => {
|
||||
const btn = ev.target.closest?.('.cookbook-dep-reinstall');
|
||||
if (!btn) return;
|
||||
const pkg = btn.dataset.reinstallPkg || '';
|
||||
if (!pkg) return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const sel = document.getElementById('hwfit-deps-server');
|
||||
if (sel) _applyServerSelection(sel.value);
|
||||
const host = _envState.remoteHost || '';
|
||||
const where = host || 'this server';
|
||||
if (!confirm(`Reinstall ${pkg} on ${where}?\n\nRuns "pip install --force-reinstall --no-deps ${pkg}" as a tmux task. Watch progress in the Running tab.`)) return;
|
||||
const _venvPy = (_envState.env === 'venv' && _envState.envPath)
|
||||
? `${_envState.envPath.replace(/\/+$/, '')}/bin/python3`
|
||||
: 'python3';
|
||||
_launchServeTask(`reinstall-${pkg}`, 'pip-reinstall', `${_venvPy} -m pip install --force-reinstall --no-deps ${pkg}`);
|
||||
}, true);
|
||||
}
|
||||
|
||||
// Serve sort
|
||||
const serveSort = document.getElementById('serve-sort');
|
||||
if (serveSort) {
|
||||
|
||||
+189
-30
@@ -141,6 +141,14 @@ async function _openDownloadForGgufTask(task) {
|
||||
function _terminalServeDiagnosis(task, outputText) {
|
||||
const out = String(outputText || task?.output || '');
|
||||
if (!task || task.type !== 'serve' || !['stopped', 'error', 'crashed', 'failed'].includes(task.status) || !out.trim()) return null;
|
||||
// Pip tasks (Reinstall vLLM, Upgrade torch, etc.) ride on the serve task
|
||||
// type so they get a tmux session + show up in Running tab — but they are
|
||||
// NOT serve invocations. Their output is pip's own; the generic
|
||||
// "Serve stopped before the model became reachable" message + Edit-serve
|
||||
// fix make no sense. Bail so the panel just shows pip's output.
|
||||
const _isPipTask = ((task.payload?.repo_id || '').startsWith('pip-'))
|
||||
|| /python3? -m pip\b/.test(task.payload?._cmd || '');
|
||||
if (_isPipTask) return null;
|
||||
if (_serveTaskLooksAwqOnLocalBackend(task, out)) {
|
||||
return {
|
||||
message: 'AWQ/GPTQ/FP8 cannot be served through llama.cpp/Ollama unified-memory mode.',
|
||||
@@ -266,7 +274,7 @@ const SERVE_STATE_KEY = 'cookbook-serve-state';
|
||||
|
||||
// Polling / timeout intervals
|
||||
const TASK_POLL_INTERVAL_MS = 3000; // delay between reconnect-loop iterations
|
||||
const BG_MONITOR_INTERVAL_MS = 10000; // background task status poll
|
||||
const BG_MONITOR_INTERVAL_MS = 5000; // background task status poll
|
||||
const STALE_PROGRESS_MS = 5 * 60 * 1000; // download with no progress this long = stale
|
||||
const STARTUP_STALE_PROGRESS_MS = 45 * 1000; // 0%-forever startup stall: retry much sooner
|
||||
|
||||
@@ -540,6 +548,26 @@ function _serveOutputLooksReady(task) {
|
||||
|
||||
function _normalizeTaskForDisplay(task) {
|
||||
if (!task || typeof task !== 'object') return task;
|
||||
// Pip tasks (Reinstall vLLM / Upgrade torch / etc.) ride on the serve task
|
||||
// type so they get tmux + the Running tab. They are NOT serves — their
|
||||
// "ready" markers are pip's `Successfully installed` / `Requirement already
|
||||
// satisfied`, not "Application startup complete".
|
||||
const _isPipTask = ((task.payload?.repo_id || '').startsWith('pip-'))
|
||||
|| /python3? -m pip\b/.test(task.payload?._cmd || '');
|
||||
if (_isPipTask) {
|
||||
// Override stale status: any pip task whose output carries pip's own
|
||||
// success markers gets displayed as `done` regardless of what's in
|
||||
// localStorage. Old pre-fix runs landed in error/stopped state and
|
||||
// stuck there even after we taught the rest of the flow about pip
|
||||
// tasks — this is the catch-all that flips them to Finished on render.
|
||||
const out = String(task.output || '');
|
||||
const ranOk = /Successfully installed|Requirement already (?:satisfied|up-to-date)/i.test(out)
|
||||
&& !/error:|ERROR:/.test(out.slice(-1024));
|
||||
if (ranOk && task.status !== 'done' && task.status !== 'running') {
|
||||
return { ...task, status: 'done' };
|
||||
}
|
||||
return task;
|
||||
}
|
||||
if (task.type === 'serve' && task.status === 'done' && !_serveOutputLooksReady(task)) {
|
||||
return { ...task, status: 'error' };
|
||||
}
|
||||
@@ -2066,7 +2094,7 @@ export function _renderRunningTab() {
|
||||
// Edit serve — open the full serve panel (same as the edit icon),
|
||||
// switching to this task's server first so the model is found.
|
||||
if (task.type === 'serve' && task.payload?.repo_id) {
|
||||
items.push({ label: 'Edit serve', action: 'edit-panel', custom: () => _openEdit() });
|
||||
items.push({ label: 'Edit in serve panel', action: 'edit-panel', tooltip: 'Open the full Serve config panel pre-filled with this task — pick a different backend, change GPUs, edit env vars, then Launch from there', custom: () => _openEdit() });
|
||||
}
|
||||
// Save serve — save current launch config as a preset.
|
||||
if (task.type === 'serve' && task.payload?._cmd) {
|
||||
@@ -2079,7 +2107,7 @@ export function _renderRunningTab() {
|
||||
// Edit command — only meaningful for serve tasks that aren't running.
|
||||
// Lets the user tweak flags after a crash/error and relaunch.
|
||||
if (task.type === 'serve' && task.status !== 'running' && task.payload?._cmd) {
|
||||
items.push({ label: 'Edit command', action: 'edit', custom: async () => {
|
||||
items.push({ label: 'Edit cmd & relaunch', action: 'edit', tooltip: 'Edit the raw vllm/llama-server cmd string in a dialog and relaunch immediately on the same host', custom: async () => {
|
||||
const newCmd = await _promptEditServeCmd(task.payload._cmd);
|
||||
if (newCmd == null) return; // cancelled
|
||||
try {
|
||||
@@ -2173,7 +2201,19 @@ export function _renderRunningTab() {
|
||||
_copyText(last);
|
||||
uiModule.showToast('Copied last 50 lines');
|
||||
}});
|
||||
items.push({ label: 'Remove', action: 'kill', danger: true });
|
||||
// Label matches behavior — the kill handler ALWAYS first kills
|
||||
// the live tmux session and (for serve tasks) deletes the
|
||||
// matching model-endpoint, THEN animates the task card out.
|
||||
// Just "Remove" hid that it stops the live serve too.
|
||||
const _isLive = task.type === 'serve' && ['running', 'ready', 'loading', 'warming', 'starting'].includes(task.status || '');
|
||||
items.push({
|
||||
label: _isLive ? 'Stop and remove' : 'Remove',
|
||||
action: 'kill',
|
||||
tooltip: _isLive
|
||||
? 'Kill the live tmux session, deregister the chat endpoint, and remove this row'
|
||||
: 'Remove this row',
|
||||
danger: true,
|
||||
});
|
||||
// Cancel = mobile-only dismiss item. Same pattern as the email kebab:
|
||||
// the `dropdown-cancel-mobile` class is hidden on desktop and styled
|
||||
// as a separated bottom row on mobile (border-top + extra padding).
|
||||
@@ -2200,6 +2240,7 @@ export function _renderRunningTab() {
|
||||
+ (item.danger ? ' cookbook-dropdown-danger' : '')
|
||||
+ (item.mobileOnly ? ' dropdown-cancel-mobile' : '');
|
||||
div.style.cssText = 'display:flex;align-items:center;gap:8px;';
|
||||
if (item.tooltip) div.title = item.tooltip;
|
||||
const ic = _MENU_ICONS[item.action] || '';
|
||||
div.innerHTML = `<span style="display:inline-flex;flex-shrink:0;opacity:0.7;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${ic}</svg></span><span>${item.label}</span>`;
|
||||
div.addEventListener('click', () => {
|
||||
@@ -2319,22 +2360,57 @@ export function _renderRunningTab() {
|
||||
_animateOutThenRemove(el, task.sessionId);
|
||||
});
|
||||
|
||||
// Wire kill
|
||||
el.querySelector('.cookbook-task-action-kill').addEventListener('click', () => {
|
||||
// Wire kill — awaits the SSH/tmux kill and verifies the session is
|
||||
// actually gone before removing the row. Previously fire-and-forget,
|
||||
// which meant a failed kill (wrong remoteHost, SSH error, tmux server
|
||||
// already exited) silently left the live serve running while the
|
||||
// row disappeared from the UI.
|
||||
el.querySelector('.cookbook-task-action-kill').addEventListener('click', async () => {
|
||||
const outputText = el.querySelector('.cookbook-output-pre')?.textContent || task.output || '';
|
||||
const isLive = task.type === 'serve' && ['running', 'ready', 'loading', 'warming', 'starting'].includes(task.status || '');
|
||||
const ollamaUnload = _ollamaUnloadCommand(task, outputText);
|
||||
if (ollamaUnload) {
|
||||
fetch('/api/shell/exec', {
|
||||
try {
|
||||
await fetch('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: ollamaUnload }),
|
||||
});
|
||||
} catch (_) { /* unload best-effort */ }
|
||||
}
|
||||
let killOk = true;
|
||||
try {
|
||||
const r = await fetch('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: ollamaUnload }),
|
||||
}).catch(() => {});
|
||||
body: JSON.stringify({ command: _tmuxGracefulKill(task) }),
|
||||
});
|
||||
if (r.ok) {
|
||||
const out = await r.json();
|
||||
// Don't trust exit_code alone — tmux kill returns 0 even when
|
||||
// there was nothing to kill. Verify the session is actually gone.
|
||||
if (task.sessionId && isLive) {
|
||||
try {
|
||||
const probe = await fetch('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: _tmuxCmd(task, `has-session -t ${task.sessionId}`) }),
|
||||
});
|
||||
if (probe.ok) {
|
||||
const pj = await probe.json();
|
||||
// has-session exits 0 when session STILL exists; non-zero = gone.
|
||||
if ((pj.exit_code || 0) === 0) killOk = false;
|
||||
}
|
||||
} catch (_) { /* probe best-effort; trust kill */ }
|
||||
}
|
||||
} else {
|
||||
killOk = false;
|
||||
}
|
||||
} catch (_) { killOk = false; }
|
||||
if (!killOk) {
|
||||
try { uiModule.showToast('Kill failed — session may still be running. Check `tmux ls` on the server.', 'error'); } catch (_) {}
|
||||
return; // leave the row so the user can retry
|
||||
}
|
||||
fetch('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: _tmuxGracefulKill(task) }),
|
||||
}).catch(() => {});
|
||||
if (task.type === 'serve' && task.payload) {
|
||||
const endpointUrl = _endpointUrlForTask(task, outputText);
|
||||
_removeEndpointByUrl(endpointUrl);
|
||||
@@ -2373,7 +2449,13 @@ export function _renderRunningTab() {
|
||||
if (targetBody) targetBody.appendChild(el);
|
||||
else group.appendChild(el);
|
||||
|
||||
if (task.status === 'running') {
|
||||
// Auto-attach the tmux output stream for any task whose underlying
|
||||
// session could still be alive — not just 'running'. Scheduler-
|
||||
// launched serves transition to 'ready' as soon as /v1/models
|
||||
// responds; without this, the user opens the Running tab and sees
|
||||
// only the placeholder ("Launched by scheduled task …") because
|
||||
// _reconnectTask never fires for status 'ready'/'loading'/'warming'.
|
||||
if (['running', 'ready', 'loading', 'warming', 'starting'].includes(task.status)) {
|
||||
_reconnectTask(el, task);
|
||||
}
|
||||
}
|
||||
@@ -2426,7 +2508,7 @@ async function _reconnectTask(el, task) {
|
||||
if (data.exit_code !== 0) {
|
||||
failCount++;
|
||||
if (failCount < 5) {
|
||||
await new Promise(r => setTimeout(r, 5000));
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
@@ -2447,7 +2529,15 @@ async function _reconnectTask(el, task) {
|
||||
}
|
||||
|
||||
const lastOutput = output.textContent || '';
|
||||
const diag = _diagnose(lastOutput);
|
||||
// Pip tasks (Reinstall vLLM / Upgrade torch / etc.) must skip the
|
||||
// generic serve `_diagnose` step. Their output is pip's own and the
|
||||
// error patterns there (torch ABI traceback, "No module named torch",
|
||||
// etc.) are routinely matched against the previous tmux scrollback,
|
||||
// tagging a clean pip success as a crashed serve. Detection is the
|
||||
// same shape as the looksSuccessful branch below.
|
||||
const _isPipTaskDiag = ((task.payload?.repo_id || '').startsWith('pip-'))
|
||||
|| /python3? -m pip\b/.test(task.payload?._cmd || '');
|
||||
const diag = _isPipTaskDiag ? null : _diagnose(lastOutput);
|
||||
if (diag) {
|
||||
let diagEl = el.querySelector('.cookbook-diagnosis');
|
||||
if (!diagEl) {
|
||||
@@ -2464,17 +2554,46 @@ async function _reconnectTask(el, task) {
|
||||
} else {
|
||||
const downloadLooksSuccessful = !lastOutput.includes('DOWNLOAD_FAILED')
|
||||
&& (lastOutput.includes('DONE') || lastOutput.includes('100%') || lastOutput.includes('/snapshots/') || lastOutput.includes('Download complete') || lastOutput.includes('DOWNLOAD_OK'));
|
||||
// Pip install / reinstall tasks are launched via _launchServeTask (so
|
||||
// they show up in the Running tab + use tmux) but they aren't real
|
||||
// serves — the cmd is `python3 -m pip ...` and the success markers
|
||||
// are pip's own. Without this branch, a successful reinstall ends
|
||||
// with no "Uvicorn running on" line and gets mis-flagged as a crashed
|
||||
// serve.
|
||||
const _isPipTask = ((task.payload?.repo_id || '').startsWith('pip-'))
|
||||
|| /python3? -m pip\b/.test(task.payload?._cmd || '');
|
||||
const pipLooksSuccessful = _isPipTask
|
||||
&& /Successfully installed|Requirement already (?:satisfied|up-to-date)/i.test(lastOutput)
|
||||
&& !/error:|ERROR:/.test(lastOutput.slice(-1024));
|
||||
const serveLooksReady = task.type === 'serve' && _serveOutputLooksReady({ ...task, output: lastOutput });
|
||||
// Dependency installs are tracked as download tasks but finish with a
|
||||
// pip exit-0 sentinel, not HF download markers — so check that too.
|
||||
// pip exit-0 sentinel, not HF download markers — check that too.
|
||||
// Standalone pip-* serves finish with pip's own success line, not
|
||||
// HF or "Uvicorn running on".
|
||||
const depInstallSucceeded = !!task.payload?._dep && _depInstallSucceeded(lastOutput);
|
||||
const looksSuccessful = depInstallSucceeded || (task.type === 'download' ? downloadLooksSuccessful : serveLooksReady);
|
||||
const looksSuccessful = depInstallSucceeded
|
||||
|| (task.type === 'download'
|
||||
? downloadLooksSuccessful
|
||||
: (_isPipTask ? pipLooksSuccessful : serveLooksReady));
|
||||
if (!lastOutput.trim() || !looksSuccessful) {
|
||||
_updateTask(task.sessionId, { status: 'crashed' });
|
||||
el.dataset.status = 'crashed';
|
||||
const badge = el.querySelector('.cookbook-task-status');
|
||||
if (badge) { badge.textContent = _statusLabel('crashed', task.type); badge.className = 'cookbook-task-status cookbook-task-crashed'; }
|
||||
if (task.type === 'serve') {
|
||||
if (_isPipTask) {
|
||||
// Pip tasks: don't run the serve diagnosis (which would yell
|
||||
// "Serve stopped before the model became reachable"). Show a
|
||||
// pip-tailored message; the user can read pip's own error output
|
||||
// directly above.
|
||||
const _ranOk = /Successfully installed|Requirement already (?:satisfied|up-to-date)/i.test(lastOutput);
|
||||
if (!_ranOk) {
|
||||
_showDiagnosis(el, {
|
||||
message: 'Pip install did not finish with a success marker. Check the output for the underlying error.',
|
||||
suggestion: 'Suggested action: copy the troubleshooting bundle. Common causes: missing build deps, network blip, mismatched torch ABI.',
|
||||
fixes: [],
|
||||
}, lastOutput);
|
||||
}
|
||||
} else if (task.type === 'serve') {
|
||||
const diag = _diagnose(lastOutput) || {
|
||||
message: _serveTaskLooksAwqOnLocalBackend(task, lastOutput)
|
||||
? 'AWQ/GPTQ/FP8 cannot be served through llama.cpp/Ollama unified-memory mode.'
|
||||
@@ -2553,6 +2672,28 @@ async function _reconnectTask(el, task) {
|
||||
}
|
||||
_showCookbookNotif(true);
|
||||
} else {
|
||||
// Strong completion markers — `DOWNLOAD_OK` is emitted by our
|
||||
// downloader wrapper AFTER the model snapshot is on disk, and
|
||||
// `/snapshots/` only appears once HF has resolved the cached
|
||||
// tree. Either is conclusive. Finalize as done immediately, skip
|
||||
// the 30s debounce — the debounce only exists to guard against
|
||||
// ambiguous markers (bare "100%" / "Download complete") which can
|
||||
// appear mid-stream during multi-file downloads.
|
||||
const _strongDone = task.type === 'download'
|
||||
&& (lastOutput.includes('DOWNLOAD_OK') || lastOutput.includes('/snapshots/'));
|
||||
if (_strongDone) {
|
||||
_updateTask(task.sessionId, { status: 'done', _doneConfirmAt: null, _lastStatusFlipAt: Date.now() });
|
||||
el.dataset.status = 'done';
|
||||
const badge = el.querySelector('.cookbook-task-status');
|
||||
if (badge) { badge.textContent = _statusLabel('done', task.type); badge.className = 'cookbook-task-status cookbook-task-done'; }
|
||||
const _chk = el.querySelector('.cookbook-task-check'); if (_chk) _chk.style.display = '';
|
||||
const _sb = el.querySelector('.cookbook-task-serve-btn'); if (_sb) _sb.style.display = '';
|
||||
_showCookbookNotif();
|
||||
_refreshDepsAfterInstall(task);
|
||||
_renderRunningTab();
|
||||
_processQueue();
|
||||
break;
|
||||
}
|
||||
// Debounce the done flip. Tmux capture-pane can fail transiently
|
||||
// (network blip, ssh reconnect), and the verify has-session right
|
||||
// above can briefly report dead even when the session is in the
|
||||
@@ -2579,7 +2720,7 @@ async function _reconnectTask(el, task) {
|
||||
stillAlive = pData.exit_code === 0;
|
||||
} catch { /* network blip — treat as inconclusive, prefer running */ stillAlive = true; }
|
||||
if (stillAlive) {
|
||||
_updateTask(task.sessionId, { status: 'running', _doneConfirmAt: null });
|
||||
_updateTask(task.sessionId, { status: 'running', _doneConfirmAt: null, _lastStatusFlipAt: Date.now() });
|
||||
const _el = document.querySelector(`.cookbook-task[data-task-id="${task.sessionId}"]`);
|
||||
if (_el) {
|
||||
_el.dataset.status = 'running';
|
||||
@@ -2591,7 +2732,7 @@ async function _reconnectTask(el, task) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
_updateTask(task.sessionId, { status: 'done', _doneConfirmAt: null });
|
||||
_updateTask(task.sessionId, { status: 'done', _doneConfirmAt: null, _lastStatusFlipAt: Date.now() });
|
||||
const _el = document.querySelector(`.cookbook-task[data-task-id="${task.sessionId}"]`);
|
||||
if (_el) {
|
||||
_el.dataset.status = 'done';
|
||||
@@ -2616,8 +2757,14 @@ async function _reconnectTask(el, task) {
|
||||
|
||||
const snapshot = (data.stdout || '').trim();
|
||||
if (snapshot) {
|
||||
// Only auto-scroll to bottom if the user was already there. When
|
||||
// they've scrolled up to read earlier output, leave their position
|
||||
// alone so a fresh snapshot doesn't yank them back to the tail.
|
||||
// 40px tolerance covers sub-pixel rounding + the moment between
|
||||
// releasing the scrollbar and the next poll arriving.
|
||||
const _atBottom = (output.scrollHeight - output.scrollTop - output.clientHeight) < 40;
|
||||
output.textContent = snapshot;
|
||||
output.scrollTop = output.scrollHeight;
|
||||
if (_atBottom) output.scrollTop = output.scrollHeight;
|
||||
|
||||
// Live status parsing for download tasks
|
||||
if (task.type === 'download') {
|
||||
@@ -3173,16 +3320,27 @@ export async function _selfHealStaleTasks(opts = {}) {
|
||||
// itself fires every 10s, so this almost always fires too, but the
|
||||
// guard keeps a fast manual call from doubling up).
|
||||
const now = Date.now();
|
||||
if (now - _selfHealLastTs < 8000) return;
|
||||
if (now - _selfHealLastTs < 4000) return;
|
||||
_selfHealLastTs = now;
|
||||
}
|
||||
const tasks = _loadTasks();
|
||||
const candidates = tasks.filter(t =>
|
||||
t.type === 'download'
|
||||
&& ['done', 'error', 'crashed', 'stopped'].includes(t.status)
|
||||
&& t.sessionId
|
||||
&& !String(t.sessionId).startsWith('queue-')
|
||||
);
|
||||
const candidates = tasks.filter(t => {
|
||||
if (t.type !== 'download') return false;
|
||||
if (!['done', 'error', 'crashed', 'stopped'].includes(t.status)) return false;
|
||||
if (!t.sessionId || String(t.sessionId).startsWith('queue-')) return false;
|
||||
// Finished downloads with strong completion markers (DOWNLOAD_OK or HF
|
||||
// /snapshots/ resolution) are demonstrably done — do not flip them back
|
||||
// to running just because the tmux session is still alive (e.g., a
|
||||
// long-lived shell that hosted the download or a flapping SSH that
|
||||
// reports the session as up). This was the main source of finished↔
|
||||
// downloading oscillation on a flaky connection.
|
||||
if (t.status === 'done' && /DOWNLOAD_OK|\/snapshots\//.test(t.output || '')) return false;
|
||||
// Cooldown: never flip the same task more than once every 45s. A flapping
|
||||
// SSH connection used to drive the badge back-and-forth on every probe
|
||||
// cycle; this enforces a stable view between flaps.
|
||||
if (t._lastStatusFlipAt && (Date.now() - t._lastStatusFlipAt < 45000)) return false;
|
||||
return true;
|
||||
});
|
||||
if (!candidates.length) return;
|
||||
let flipped = 0;
|
||||
for (const t of candidates) {
|
||||
@@ -3200,6 +3358,7 @@ export async function _selfHealStaleTasks(opts = {}) {
|
||||
if (ft && ft.status !== 'running') {
|
||||
ft.status = 'running';
|
||||
ft._selfHealed = true;
|
||||
ft._lastStatusFlipAt = Date.now();
|
||||
_saveTasks(fresh);
|
||||
flipped++;
|
||||
const _el = document.querySelector(`.cookbook-task[data-task-id="${t.sessionId}"]`);
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
// Cookbook Schedule — opens a small inline form (styled with the app's
|
||||
// existing .cookbook-* classes) that creates a ScheduledTask with
|
||||
// action=cookbook_serve. Mounted from two places:
|
||||
//
|
||||
// 1. The ^ button next to Launch in a serve panel.
|
||||
// 2. The "Schedule…" entry in the cached-model ⋯ dropdown menu (which
|
||||
// programmatically clicks the ^ button so this module owns the
|
||||
// single source of truth).
|
||||
//
|
||||
// Feedback uses uiModule.showToast() — the same toast the rest of the
|
||||
// app uses for "Saved", "Favorited", etc. — so the success message
|
||||
// doesn't introduce a parallel notification style.
|
||||
//
|
||||
// To remove: delete this file + the <script> tag in index.html + the
|
||||
// ^ button in cookbookServe.js + the "cookbook_serve" entry in
|
||||
// BUILTIN_ACTIONS + src/cookbook_serve_lifecycle.py + its
|
||||
// registration line in app.py.
|
||||
|
||||
try { (function () {
|
||||
function _safe(fn) {
|
||||
return function () {
|
||||
try { return fn.apply(this, arguments); }
|
||||
catch (e) { try { console.warn("[cookbookSchedule]", e); } catch (_) {} }
|
||||
};
|
||||
}
|
||||
function esc(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// Cached handle to the ui.js showToast function. Bound lazily on
|
||||
// first use because ui.js is an ES module — it's not on `window`
|
||||
// unless something else has explicitly exposed it.
|
||||
let _toastFn = null;
|
||||
async function _getToast() {
|
||||
if (_toastFn) return _toastFn;
|
||||
try {
|
||||
const m = await import("/static/js/ui.js");
|
||||
_toastFn = m.default?.showToast || m.showToast || null;
|
||||
} catch (_) { _toastFn = null; }
|
||||
return _toastFn;
|
||||
}
|
||||
// Optional opts: {action, onAction, duration, leadingIcon}
|
||||
async function toast(msg, opts) {
|
||||
const fn = await _getToast();
|
||||
if (fn) {
|
||||
try { fn(msg, opts); return; } catch (_) {}
|
||||
}
|
||||
try { console.log("[toast]", msg); } catch (_) {}
|
||||
}
|
||||
|
||||
// Cached handle to the tasks module so the success toast's "Open"
|
||||
// action can jump straight to the new task in the Tasks tab.
|
||||
let _tasksMod = null;
|
||||
async function _getTasksMod() {
|
||||
if (_tasksMod) return _tasksMod;
|
||||
try { _tasksMod = await import("/static/js/tasks.js"); } catch (_) {}
|
||||
return _tasksMod;
|
||||
}
|
||||
async function openTaskInTasksTab(taskId) {
|
||||
const m = await _getTasksMod();
|
||||
if (m && typeof m.openTasks === "function") {
|
||||
try { m.openTasks(taskId); return; } catch (_) {}
|
||||
}
|
||||
// Last-resort fallback: click the sidebar Tasks button.
|
||||
document.getElementById("tool-tasks-btn")?.click();
|
||||
}
|
||||
|
||||
const DAYS = [
|
||||
{ k: "MO", l: "Mon", idx: 0 },
|
||||
{ k: "TU", l: "Tue", idx: 1 },
|
||||
{ k: "WE", l: "Wed", idx: 2 },
|
||||
{ k: "TH", l: "Thu", idx: 3 },
|
||||
{ k: "FR", l: "Fri", idx: 4 },
|
||||
{ k: "SA", l: "Sat", idx: 5 },
|
||||
{ k: "SU", l: "Sun", idx: 6 },
|
||||
];
|
||||
const WEEKDAYS = new Set(["MO","TU","WE","TH","FR"]);
|
||||
|
||||
// Resolve the model identity from the closest .memory-item card —
|
||||
// that's the canonical container the cookbook serve UI uses, with
|
||||
// the model repo on data-repo. We do NOT grab the title via
|
||||
// textContent, because the title row also contains inline status
|
||||
// pills ("running", "downloading") and an "HF ↗" link — pulling all
|
||||
// of it in turns a clean preset name like "Qwen3.5-397B-A17B-AWQ"
|
||||
// into "Qwen3.5-397B-A17B-AWQ running HF ↗", which then fails the
|
||||
// preset lookup in action_cookbook_serve.
|
||||
function readPanelConfig(arrowBtn) {
|
||||
const item = arrowBtn.closest(".memory-item") || arrowBtn.closest(".hwfit-cached-item");
|
||||
const panel = arrowBtn.closest(".hwfit-serve-panel");
|
||||
const repo = item?.dataset?.repo
|
||||
|| arrowBtn.closest(".hwfit-serve-panel")?.dataset?.repo
|
||||
|| "";
|
||||
// Title = last segment of the repo (after the final /), which is
|
||||
// exactly what the cookbook UI renders in the card title and what
|
||||
// the preset registry uses as its short name. e.g.
|
||||
// cyankiwi/Qwen3.5-397B-A17B-AWQ → Qwen3.5-397B-A17B-AWQ
|
||||
// Falls back to data-modelName or the bare repo for ollama-style
|
||||
// entries that don't have a slash.
|
||||
let title = "";
|
||||
if (repo) {
|
||||
title = repo.includes("/") ? repo.split("/").pop() : repo;
|
||||
}
|
||||
if (!title) {
|
||||
title = item?.dataset?.modelName || "model";
|
||||
}
|
||||
return { panel, item, title, repo_id: repo, host: item?.dataset?.host || "" };
|
||||
}
|
||||
|
||||
function buildFormHtml(cfg) {
|
||||
return `
|
||||
<div class="hwfit-schedule-form cookbook-panel">
|
||||
<div class="hwfit-schedule-title">
|
||||
<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="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 class="hwfit-schedule-title-text">Schedule serve: <strong>${esc(cfg.title)}</strong></span>
|
||||
<span class="hwfit-schedule-title-spacer"></span>
|
||||
<label class="hwfit-schedule-mirror-toggle" title="Also create a calendar event on the Cookbook calendar">
|
||||
<span class="hwfit-schedule-mirror-label">Create event in calendar</span>
|
||||
<span class="admin-switch hwfit-schedule-mirror-switch">
|
||||
<input type="checkbox" class="hwfit-sched-calendar-mirror" />
|
||||
<span class="admin-slider"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="hwfit-schedule-row">
|
||||
<label class="hwfit-schedule-field">
|
||||
<span>From</span>
|
||||
<input type="time" class="hwfit-sched-start cookbook-field-input" value="09:00" />
|
||||
</label>
|
||||
<label class="hwfit-schedule-field">
|
||||
<span>Until</span>
|
||||
<input type="time" class="hwfit-sched-end cookbook-field-input" value="17:00" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="hwfit-schedule-row hwfit-schedule-days-row">
|
||||
<span class="hwfit-schedule-label">Days</span>
|
||||
<div class="hwfit-sched-days">
|
||||
${DAYS.map(d => `
|
||||
<button type="button" class="hwfit-sched-day-chip${WEEKDAYS.has(d.k) ? " is-on" : ""}" data-day="${d.k}">${d.l}</button>
|
||||
`).join("")}
|
||||
</div>
|
||||
<span class="hwfit-schedule-actions-spacer"></span>
|
||||
<button type="button" class="cookbook-btn hwfit-sched-cancel" title="Cancel">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:5px;flex-shrink:0;"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
<span>Cancel</span>
|
||||
</button>
|
||||
<button type="button" class="cookbook-btn hwfit-sched-save" title="Save schedule" aria-label="Save schedule">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:5px;flex-shrink:0;"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
<span>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="hwfit-sched-err"></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function openForm(arrowBtn) {
|
||||
const cfg = readPanelConfig(arrowBtn);
|
||||
const anchor = cfg.panel
|
||||
|| cfg.item
|
||||
|| arrowBtn.closest(".cookbook-saved-item")
|
||||
|| arrowBtn.parentElement?.parentElement
|
||||
|| arrowBtn.parentElement;
|
||||
if (!anchor) {
|
||||
toast("Couldn't find a panel to mount the schedule form");
|
||||
return;
|
||||
}
|
||||
// Toggle.
|
||||
const existing = anchor.querySelector(".hwfit-schedule-form");
|
||||
if (existing) { existing.remove(); return; }
|
||||
const tmp = document.createElement("div");
|
||||
tmp.innerHTML = buildFormHtml(cfg);
|
||||
const form = tmp.firstElementChild;
|
||||
anchor.appendChild(form);
|
||||
setTimeout(() => {
|
||||
try { form.scrollIntoView({ behavior: "smooth", block: "nearest" }); } catch (_) {}
|
||||
}, 50);
|
||||
wireForm(form, cfg);
|
||||
}
|
||||
|
||||
function wireForm(form, cfg) {
|
||||
form.querySelectorAll(".hwfit-sched-day-chip").forEach(chip => {
|
||||
chip.addEventListener("click", () => chip.classList.toggle("is-on"));
|
||||
});
|
||||
form.querySelector(".hwfit-sched-cancel").addEventListener("click", () => form.remove());
|
||||
form.querySelector(".hwfit-sched-save").addEventListener("click", _safe(async () => {
|
||||
const startTime = form.querySelector(".hwfit-sched-start").value;
|
||||
const endTime = form.querySelector(".hwfit-sched-end").value;
|
||||
const days = Array.from(form.querySelectorAll(".hwfit-sched-day-chip.is-on")).map(c => c.dataset.day);
|
||||
const mirrorToCalendar = !!form.querySelector(".hwfit-sched-calendar-mirror")?.checked;
|
||||
const errEl = form.querySelector(".hwfit-sched-err");
|
||||
errEl.textContent = "";
|
||||
errEl.classList.remove("is-visible");
|
||||
|
||||
function fail(msg) {
|
||||
errEl.textContent = msg;
|
||||
errEl.classList.add("is-visible");
|
||||
}
|
||||
if (!/^\d\d:\d\d$/.test(startTime) || !/^\d\d:\d\d$/.test(endTime)) {
|
||||
return fail("Start and end must be HH:MM");
|
||||
}
|
||||
if (!days.length) {
|
||||
return fail("Pick at least one day");
|
||||
}
|
||||
|
||||
const [sh, sm] = startTime.split(":").map(Number);
|
||||
const [eh, em] = endTime.split(":").map(Number);
|
||||
let dur = (eh * 60 + em) - (sh * 60 + sm);
|
||||
if (dur <= 0) dur += 24 * 60;
|
||||
|
||||
// The backend stores scheduled_time as UTC. The user picks
|
||||
// wall-clock LOCAL time. Without converting, "09:55" in a UTC+9
|
||||
// timezone gets stored as 09:55 UTC = 18:55 local → next-run
|
||||
// shows ~9 hours later instead of "in 5 min". Mirror what
|
||||
// tasks.js does via its _localTimeToUtc helper.
|
||||
const _localHHMMToUtc = (hhmm) => {
|
||||
const [h, m] = hhmm.split(":").map(Number);
|
||||
const d = new Date();
|
||||
d.setHours(h, m, 0, 0);
|
||||
return `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`;
|
||||
};
|
||||
const startUtc = _localHHMMToUtc(startTime);
|
||||
const [shUtc, smUtc] = startUtc.split(":").map(Number);
|
||||
|
||||
const allDays = days.length === 7;
|
||||
const weekdaysOnly = days.length === 5 && ["MO","TU","WE","TH","FR"].every(d => days.includes(d));
|
||||
const sched = {};
|
||||
if (allDays) {
|
||||
sched.schedule = "daily";
|
||||
sched.scheduled_time = startUtc;
|
||||
} else if (weekdaysOnly) {
|
||||
sched.schedule = "cron";
|
||||
sched.cron_expression = `${smUtc} ${shUtc} * * 1-5`;
|
||||
} else if (days.length === 1) {
|
||||
const dayIdx = DAYS.find(d => d.k === days[0]).idx;
|
||||
sched.schedule = "weekly";
|
||||
sched.scheduled_time = startUtc;
|
||||
sched.scheduled_day = dayIdx;
|
||||
} else {
|
||||
const dayNum = days.map(k => {
|
||||
const i = DAYS.find(d => d.k === k).idx;
|
||||
return i === 6 ? 0 : i + 1;
|
||||
});
|
||||
sched.schedule = "cron";
|
||||
sched.cron_expression = `${smUtc} ${shUtc} * * ${dayNum.join(",")}`;
|
||||
}
|
||||
|
||||
// Name: "Serve: <full model name>" — pulled from .memory-item-title
|
||||
// so it's the user's display name (e.g. "Qwen3.5-397B-A17B-AWQ")
|
||||
// not a placeholder like "model".
|
||||
const fullName = (cfg.title || cfg.repo_id || "").trim() || "model";
|
||||
const payload = {
|
||||
name: `Serve: ${fullName}`,
|
||||
task_type: "action",
|
||||
action: "cookbook_serve",
|
||||
trigger_type: "schedule",
|
||||
prompt: JSON.stringify({
|
||||
preset: fullName,
|
||||
repo_id: cfg.repo_id || "",
|
||||
host: cfg.host || "",
|
||||
end_after_min: dur,
|
||||
}),
|
||||
...sched,
|
||||
};
|
||||
const saveBtn = form.querySelector(".hwfit-sched-save");
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = "Saving…";
|
||||
try {
|
||||
const r = await fetch("/api/tasks", {
|
||||
method: "POST", credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok || data.error) {
|
||||
fail(data.error || data.detail || `HTTP ${r.status}`);
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = "Save schedule";
|
||||
toast(`Schedule save failed: ${data.error || data.detail || r.status}`);
|
||||
return;
|
||||
}
|
||||
if (mirrorToCalendar) {
|
||||
// Mirror onto a dedicated "Cookbook" calendar so the user can
|
||||
// toggle the whole set on/off as a unit in the calendar UI.
|
||||
// Best-effort: if anything here fails, we still consider the
|
||||
// task creation a success (the task itself works regardless).
|
||||
try {
|
||||
const calsRes = await fetch("/api/calendar/calendars", { credentials: "same-origin" });
|
||||
const calsBody = calsRes.ok ? await calsRes.json() : {};
|
||||
let cookbookCal = (calsBody.calendars || []).find(c => (c.name || "").toLowerCase() === "cookbook");
|
||||
if (!cookbookCal) {
|
||||
const mk = await fetch("/api/calendar/calendars?name=Cookbook&color=%233b82f6", {
|
||||
method: "POST", credentials: "same-origin",
|
||||
});
|
||||
if (mk.ok) {
|
||||
const mkData = await mk.json();
|
||||
// The create endpoint returns {ok, id, name, color}; the
|
||||
// list endpoint returns {href, name, color}. The two map
|
||||
// 1:1 (href === id) so we synthesize the same shape.
|
||||
cookbookCal = { href: mkData.id, name: mkData.name, color: mkData.color };
|
||||
}
|
||||
}
|
||||
// The `cookbook_task_id:` marker on its own line lets
|
||||
// calendar.js's event-form code detect that this event was
|
||||
// created from a Cookbook schedule and render an
|
||||
// "Open task" button alongside the description, so the user
|
||||
// can jump straight to the source task from the calendar UI.
|
||||
const evBody = {
|
||||
summary: payload.name,
|
||||
dtstart: new Date().toISOString(),
|
||||
dtend: new Date(Date.now() + dur * 60 * 1000).toISOString(),
|
||||
all_day: false,
|
||||
description: `Auto-mirrored from Cookbook schedule task ${data.id || ""}.\n`
|
||||
+ `Edit/delete the task in the Tasks tab — this event will follow.\n`
|
||||
+ `cookbook_task_id: ${data.id || ""}`,
|
||||
rrule: weekdaysOnly
|
||||
? "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"
|
||||
: (sched.schedule === "weekly" ? `FREQ=WEEKLY;BYDAY=${days.join(",")}`
|
||||
: (sched.schedule === "daily" ? "FREQ=DAILY" : "FREQ=WEEKLY")),
|
||||
color: "#3b82f6",
|
||||
};
|
||||
if (cookbookCal?.href) evBody.calendar_href = cookbookCal.href;
|
||||
const evRes = await fetch("/api/calendar/events", {
|
||||
method: "POST", credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(evBody),
|
||||
});
|
||||
const evData = evRes.ok ? await evRes.json() : null;
|
||||
// Stash the event uid + calendar href on the task's prompt
|
||||
// JSON so the task-delete hook can cascade the calendar
|
||||
// cleanup. PATCH the task with an updated prompt.
|
||||
if (evData && (evData.uid || evData.id)) {
|
||||
const eventUid = evData.uid || evData.id;
|
||||
try {
|
||||
const updatedPrompt = JSON.stringify({
|
||||
...JSON.parse(payload.prompt),
|
||||
cookbook_event_uid: eventUid,
|
||||
cookbook_event_calendar: cookbookCal?.href || "",
|
||||
});
|
||||
// /api/tasks/{id} accepts PUT, not PATCH — sending PATCH
|
||||
// here silently failed (no such method on that route), so
|
||||
// the task never got the cookbook_event_uid marker and the
|
||||
// server-side delete-cascade had nothing to follow when the
|
||||
// user later deleted the task.
|
||||
await fetch(`/api/tasks/${encodeURIComponent(data.id)}`, {
|
||||
method: "PUT", credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ prompt: updatedPrompt }),
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
form.remove();
|
||||
const newTaskId = data.id || data.task_id || "";
|
||||
toast(`Created task: Serve: ${fullName}`, {
|
||||
leadingIcon: "check",
|
||||
action: "Open",
|
||||
duration: 5000,
|
||||
onAction: () => openTaskInTasksTab(newTaskId),
|
||||
});
|
||||
} catch (e) {
|
||||
fail(String(e));
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = "Save schedule";
|
||||
toast(`Schedule save failed: ${e}`);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
document.addEventListener("click", _safe((e) => {
|
||||
const arrow = e.target.closest && e.target.closest(".hwfit-serve-schedule-arrow");
|
||||
if (!arrow) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openForm(arrow);
|
||||
}));
|
||||
})(); } catch (e) { try { console.warn("[cookbookSchedule] top-level error:", e); } catch (_) {} }
|
||||
+176
-10
@@ -294,19 +294,23 @@ function _rerenderCachedModels() {
|
||||
}
|
||||
const ggufCount = _runnableGgufFiles(m).length;
|
||||
if (ggufCount > 1) metaParts.push(`${ggufCount} GGUFs`);
|
||||
if (m.status === 'downloading') {
|
||||
const _active = _isActivelyDownloading(m.repo_id);
|
||||
metaParts.push(`<span class="cookbook-dl-status" style="color:var(--accent,var(--red));">${_active ? 'downloading' : 'download stalled'}</span>`);
|
||||
}
|
||||
// "downloading" status now renders as a title-row pill instead of
|
||||
// a meta-row text label, matching the "running" pill style and
|
||||
// living on the same line as the model name.
|
||||
const _isDownloading = m.status === 'downloading';
|
||||
const _isDlActive = _isDownloading ? _isActivelyDownloading(m.repo_id) : false;
|
||||
const isSelectMode = document.getElementById('hwfit-cache-select')?.classList.contains('active');
|
||||
html += `<div class="doclib-card memory-item" data-repo="${esc(m.repo_id)}" data-tag="${m._tag || ''}" data-family="${m._family || ''}" style="cursor:pointer;">`;
|
||||
html += `<span class="serve-select-cb memory-select-dot" style="display:${isSelectMode ? 'inline-block' : 'none'};cursor:pointer;"></span>`;
|
||||
html += `<div style="flex:1;min-width:0;">`;
|
||||
const _mc = modelColor(m.repo_id) || '';
|
||||
const _runningPill = _isActivelyServing(m.repo_id)
|
||||
? ' <span class="cookbook-serve-running-pill" title="This model is currently being served">running</span>'
|
||||
? ` <span class="cookbook-serve-running-pill is-clickable" title="This model is currently being served — click to open in Running" data-repo="${esc(m.repo_id)}" role="button" tabindex="0">running</span>`
|
||||
: '';
|
||||
html += `<div class="memory-item-title"${_mc ? ` style="color:${_mc}"` : ''}>${modelLogo(m.repo_id)}${esc(shortName)}${hfLink ? ` <a href="${esc(hfLink)}" target="_blank" rel="noopener" class="cookbook-hf-link">HF ↗</a>` : ''}${_runningPill}</div>`;
|
||||
const _downloadingPill = _isDownloading
|
||||
? ` <span class="cookbook-serve-downloading-pill${_isDlActive ? '' : ' is-stalled'}" title="${_isDlActive ? 'Download in progress' : 'Download stalled — retry to resume'}">${_isDlActive ? 'downloading' : 'stalled'}</span>`
|
||||
: '';
|
||||
html += `<div class="memory-item-title"${_mc ? ` style="color:${_mc}"` : ''}>${modelLogo(m.repo_id)}${esc(shortName)}${hfLink ? ` <a href="${esc(hfLink)}" target="_blank" rel="noopener" class="cookbook-hf-link">HF ↗</a>` : ''}${_runningPill}${_downloadingPill}</div>`;
|
||||
html += `<div class="memory-item-meta" style="font-size:10px;opacity:0.4;margin-top:2px;">${metaParts.join(' \u00b7 ')}</div>`;
|
||||
html += `</div>`;
|
||||
const _bk = _detectBackend(m).backend;
|
||||
@@ -388,9 +392,11 @@ function _rerenderCachedModels() {
|
||||
const _retryIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>';
|
||||
const _deleteIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>';
|
||||
const _selectIco = '<span style="font-size:16px;line-height:1;position:relative;top:-2px;">●</span>';
|
||||
const _schedIco = '<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="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>';
|
||||
const items = [];
|
||||
if (m && m.status === 'ready') items.push({ label: 'Serve', icon: _serveIco, action: 'serve' });
|
||||
if (m && m.status === 'downloading') items.push({ label: 'Retry', icon: _retryIco, action: 'retry' });
|
||||
if (m && m.status === 'ready') items.push({ label: 'Schedule…', icon: _schedIco, action: 'schedule' });
|
||||
items.push({ label: 'Select', icon: _selectIco, action: 'select' });
|
||||
items.push({ label: 'Delete', icon: _deleteIco, action: 'delete', danger: true });
|
||||
for (const opt of items) {
|
||||
@@ -402,6 +408,16 @@ function _rerenderCachedModels() {
|
||||
if (opt.action === 'serve') item.click();
|
||||
else if (opt.action === 'delete') _deleteCachedModel(repo, item, false, m);
|
||||
else if (opt.action === 'retry') _retryCachedModel(repo, m);
|
||||
else if (opt.action === 'schedule') {
|
||||
// Same entry point as the ^ button next to Launch — let
|
||||
// cookbookSchedule.js handle it. Expand the panel first
|
||||
// so the form has somewhere to mount.
|
||||
if (!item.querySelector('.hwfit-serve-panel')) item.click();
|
||||
setTimeout(() => {
|
||||
const arrow = item.querySelector('.hwfit-serve-schedule-arrow');
|
||||
if (arrow) arrow.click();
|
||||
}, 120);
|
||||
}
|
||||
else if (opt.action === 'select') {
|
||||
const selectBtn = document.getElementById('hwfit-cache-select');
|
||||
const bulkBar = document.getElementById('serve-bulk-bar');
|
||||
@@ -613,6 +629,20 @@ function _rerenderCachedModels() {
|
||||
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 class="hwfit-backend-vllm">${_l('KV Cache','vLLM --kv-cache-dtype. auto uses the model/runtime default; fp8 reduces KV memory for long context.')}<select class="hwfit-sf" data-field="vllm_kv_cache_dtype" style="height:32px;">${vllmKvCacheOpts}</select></label>`;
|
||||
// Attention backend selector — pin the kernel impl. Default `auto` lets
|
||||
// vLLM pick FlashInfer (which JITs on first use and breaks on older
|
||||
// system nvcc) → FlashAttention → xformers. Forcing FLASH_ATTN skips
|
||||
// the JIT entirely, fixing the `nvcc fatal: Unsupported gpu
|
||||
// architecture 'compute_89'` failure mode on Ada / Hopper hosts.
|
||||
const vllmAttnBackendOpts = ['auto', 'FLASH_ATTN', 'XFORMERS', 'FLASHINFER', 'TORCH_SDPA']
|
||||
.map(b => `<option value="${b === 'auto' ? '' : b}"${(sv('vllm_attn_backend','') === (b === 'auto' ? '' : b)) ? ' selected' : ''}>${b}</option>`).join('');
|
||||
panelHtml += `<label class="hwfit-backend-vllm">${_l('Attention','vLLM VLLM_ATTENTION_BACKEND. auto = vLLM picks (often FLASHINFER, which JITs and can fail on old nvcc). FLASH_ATTN skips the JIT entirely.')}<select class="hwfit-sf" data-field="vllm_attn_backend" style="height:32px;">${vllmAttnBackendOpts}</select></label>`;
|
||||
// Free-text env-vars field. Anything pasted here is prepended to the
|
||||
// launch command verbatim. Use for CUDACXX, PATH overrides, NCCL_*
|
||||
// tuning, or any other KEY=VALUE pair that doesn't have a dedicated
|
||||
// field. After the venv activate runs, $VIRTUAL_ENV / $PATH / etc. are
|
||||
// already exported so they expand correctly here.
|
||||
panelHtml += `<label class="hwfit-backend-vllm hwfit-backend-sglang" style="flex:1 1 100%;">${_l('Env','Extra KEY=VALUE env-var pairs prepended to the launch (space-separated). Example: CUDACXX=$VIRTUAL_ENV/lib/python3.10/site-packages/nvidia/cuda_nvcc/bin/nvcc — points flashinfer at the venv-bundled nvcc when the system one is too old for your GPU.')}<input type="text" class="hwfit-sf" data-field="extra_env" value="${esc(sv('extra_env',''))}" placeholder="CUDACXX=/path/to/nvcc NCCL_P2P_DISABLE=1" style="width:100%;" /></label>`;
|
||||
panelHtml += `</div>`;
|
||||
// Row 2b: Diffusers settings
|
||||
const diffDtypeOpts = ['bfloat16','float16','float32'].map(d => `<option value="${d}"${sv('diff_dtype','bfloat16')===d?' selected':''}>${d}</option>`).join('');
|
||||
@@ -729,8 +759,16 @@ function _rerenderCachedModels() {
|
||||
// Copy moved inside the command textarea (top-right). Spacer then
|
||||
// pushes Cancel + Launch to the right.
|
||||
panelHtml += `<span class="hwfit-serve-actions-spacer"></span>`;
|
||||
panelHtml += `<button class="cookbook-btn hwfit-serve-cancel" type="button" title="Close this configuration panel">Cancel</button>`;
|
||||
panelHtml += `<button class="cookbook-btn hwfit-serve-cancel" type="button" title="Close this configuration panel"><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>Cancel</button>`;
|
||||
// Launch + a small ^ that opens an inline schedule form. The form
|
||||
// creates a ScheduledTask (action=cookbook_serve), so the schedule
|
||||
// ends up in the existing Tasks UI for edit/delete/pause.
|
||||
panelHtml += `<span class="hwfit-serve-launch-group">`;
|
||||
panelHtml += `<button class="cookbook-btn hwfit-serve-launch"><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:4px;flex-shrink:0;"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>Launch</button>`;
|
||||
// Chevron points DOWN because the schedule form opens beneath the
|
||||
// panel — the arrow signals the direction of motion, not menu state.
|
||||
panelHtml += `<button class="cookbook-btn hwfit-serve-schedule-arrow" type="button" aria-haspopup="true" aria-label="Schedule this serve on a recurring window" title="Schedule this serve as a recurring task"><svg width="10" height="10" 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>`;
|
||||
panelHtml += `</span>`;
|
||||
panelHtml += `</div>`;
|
||||
panelHtml += `</div>`;
|
||||
|
||||
@@ -1643,6 +1681,35 @@ function _rerenderCachedModels() {
|
||||
// Launch button
|
||||
panel.querySelector('.hwfit-serve-launch').addEventListener('click', async (ev) => {
|
||||
const _launchBtn = ev.currentTarget;
|
||||
// Immediate visual feedback. The GPU probe + backend-warning prompt
|
||||
// below can take ~1-2s before the task UI shows up, leaving the
|
||||
// button looking dead. Drop in the same whirlpool spinner the rest of
|
||||
// the cookbook uses (Probe GPUs, dependency installs, etc.) right
|
||||
// away; restored on any early-return / failure path below.
|
||||
const _origBtnHtml = _launchBtn.innerHTML;
|
||||
const _origBtnDisabled = _launchBtn.disabled;
|
||||
let _launchingWp = null;
|
||||
const _restoreLaunchBtn = () => {
|
||||
try { _launchingWp?.destroy?.(); } catch {}
|
||||
_launchingWp = null;
|
||||
_launchBtn.innerHTML = _origBtnHtml;
|
||||
_launchBtn.disabled = _origBtnDisabled;
|
||||
};
|
||||
_launchBtn.disabled = true;
|
||||
_launchBtn.innerHTML = '';
|
||||
const _launchingWrap = document.createElement('span');
|
||||
_launchingWrap.className = 'hwfit-serve-launching';
|
||||
_launchingWrap.style.cssText = 'display:inline-flex;align-items:center;gap:6px;';
|
||||
_launchingWp = spinnerModule.createWhirlpool(18);
|
||||
if (_launchingWp?.element) {
|
||||
_launchingWp.element.style.margin = '0';
|
||||
_launchingWp.element.style.transform = 'translateY(-2px)';
|
||||
_launchingWrap.appendChild(_launchingWp.element);
|
||||
}
|
||||
const _launchingLabel = document.createElement('span');
|
||||
_launchingLabel.textContent = 'Launching…';
|
||||
_launchingWrap.appendChild(_launchingLabel);
|
||||
_launchBtn.appendChild(_launchingWrap);
|
||||
// Final safety net: never launch with ctx beyond the model's trained
|
||||
// limit (or the absolute sanity ceiling when the limit is unknown). A
|
||||
// stale preset or typo (e.g. 16000000) overflows and, with a quantized
|
||||
@@ -1650,7 +1717,14 @@ function _rerenderCachedModels() {
|
||||
// command (then we respect their literal text).
|
||||
if (!_cmdManuallyEdited) _clampCtx(true);
|
||||
if (!_cmdManuallyEdited) updateCmd();
|
||||
const launchCmd = _cmdTextarea ? _cmdTextarea.value.trim() : panel._cmd;
|
||||
// Pasted commands often carry hidden newlines / CRs / tabs from copies
|
||||
// out of model cards or wrapped help text. The backend cmd allowlist
|
||||
// rejects \n / \r outright (`Invalid characters in cmd`), so collapse
|
||||
// all whitespace to single spaces before launch — same effect as the
|
||||
// user manually re-flowing the textarea, no behavior change.
|
||||
const _rawLaunchCmd = _cmdTextarea ? _cmdTextarea.value : panel._cmd;
|
||||
const launchCmd = String(_rawLaunchCmd || '').replace(/\s+/g, ' ').trim();
|
||||
if (_cmdTextarea && _cmdTextarea.value !== launchCmd) _cmdTextarea.value = launchCmd;
|
||||
const serveState = {};
|
||||
panel.querySelectorAll('.hwfit-sf').forEach(el => {
|
||||
if (el.type === 'checkbox') serveState[el.dataset.field] = el.checked;
|
||||
@@ -1659,6 +1733,7 @@ function _rerenderCachedModels() {
|
||||
serveState.backend = serveState.backend || (_detectBackend(m).backend) || 'vllm';
|
||||
const backendWarning = _serveBackendWarning(m, repo, serveState.backend, serveState);
|
||||
if (backendWarning) {
|
||||
_restoreLaunchBtn();
|
||||
await window.styledConfirm(backendWarning.body, {
|
||||
title: backendWarning.title,
|
||||
confirmText: 'Edit settings',
|
||||
@@ -1689,7 +1764,7 @@ function _rerenderCachedModels() {
|
||||
`No GPU detected on ${_probeHost ? _probeHost : 'this host'}. ${serveState.backend.toUpperCase()} needs a visible CUDA/ROCm accelerator to start — launching now will most likely crash early.\n\nLaunch anyway?`,
|
||||
{ title: 'No GPU detected', confirmText: 'Launch anyway', cancelText: 'Cancel', danger: true },
|
||||
);
|
||||
if (!_proceed) return;
|
||||
if (!_proceed) { _restoreLaunchBtn(); return; }
|
||||
}
|
||||
} catch {
|
||||
// Network / probe failure — don't block. Better to let the launch
|
||||
@@ -1697,6 +1772,56 @@ function _rerenderCachedModels() {
|
||||
// hiccuped (the user can read the real error in the task output).
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-launch PORT probe — second most common failure pattern is
|
||||
// collision with an already-running server (vllm crashing with
|
||||
// "Address already in use" because Ollama owns 11434, or a
|
||||
// previous vllm on the same port wasn't killed). The post-mortem
|
||||
// "Suggested action: Kill existing vLLM" came AFTER the failed
|
||||
// launch — user wants to know BEFORE clicking Launch. Parse the
|
||||
// port out of the cmd, ssh-check who owns it on the target host,
|
||||
// and offer to abort or proceed.
|
||||
try {
|
||||
const _portMatch = launchCmd.match(/(?:^|\s)(?:--port|-p|--host\s+\S+\s+--port)\s+(\d{2,5})\b/)
|
||||
|| launchCmd.match(/(?:^|\s)--port=(\d{2,5})\b/)
|
||||
|| launchCmd.match(/OLLAMA_HOST=[^:\s]+:(\d{2,5})\b/);
|
||||
const _port = _portMatch ? _portMatch[1] : '';
|
||||
if (_port) {
|
||||
const _portHost = (_envState.remoteHost || '').trim();
|
||||
const _checkInner = `ss -tlnp 2>/dev/null | awk '$4 ~ /:${_port}$/ {print; exit}' || netstat -tlnp 2>/dev/null | awk '$4 ~ /:${_port}$/ {print; exit}'`;
|
||||
const _cmd = _portHost
|
||||
? `ss h ${_portHost} <<<"" 2>/dev/null; ssh -o ConnectTimeout=4 -o StrictHostKeyChecking=no ${_portHost} ${JSON.stringify(_checkInner)}`
|
||||
: _checkInner;
|
||||
const _res = await fetch('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: _cmd }),
|
||||
});
|
||||
const _data = await _res.json().catch(() => ({}));
|
||||
const _stdout = (_data.stdout || '').trim();
|
||||
if (_stdout) {
|
||||
// Try to surface the process name from `users:(("name",pid=...,...))`.
|
||||
const _procMatch = _stdout.match(/users:\(\("([^"]+)",pid=(\d+)/);
|
||||
const _procDesc = _procMatch
|
||||
? `${_procMatch[1]} (PID ${_procMatch[2]})`
|
||||
: 'another process';
|
||||
const _hostLabel = _portHost ? _portHost : 'this host';
|
||||
const _proceed = await window.styledConfirm(
|
||||
`Port ${_port} on ${_hostLabel} is already in use by ${_procDesc}. Launching ${serveState.backend.toUpperCase()} now will fail with "Address already in use".\n\nStop the existing process first, OR change the --port in the command above, OR launch anyway and watch it crash.`,
|
||||
{
|
||||
title: `Port ${_port} taken`,
|
||||
confirmText: 'Launch anyway',
|
||||
cancelText: 'Cancel',
|
||||
danger: true,
|
||||
},
|
||||
);
|
||||
if (!_proceed) { _restoreLaunchBtn(); return; }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Probe failure — don't block. If the port check can't run we'd
|
||||
// rather let the launch try than silently refuse.
|
||||
}
|
||||
// Save in the { _byRepo, _lastUsed } schema — no legacy flat keys at
|
||||
// the root so per-model state doesn't leak between models.
|
||||
try {
|
||||
@@ -1750,7 +1875,12 @@ function _rerenderCachedModels() {
|
||||
|
||||
// Copy button — now icon-only, so flash a green checkmark on success
|
||||
// instead of swapping to text (which would also break the width).
|
||||
panel.querySelector('.hwfit-serve-copy').addEventListener('click', () => {
|
||||
panel.querySelector('.hwfit-serve-copy').addEventListener('click', (e) => {
|
||||
// Without stopPropagation the click bubbles up to the
|
||||
// .doclib-card click handler that toggles the expand state →
|
||||
// copying collapses the whole serve panel mid-flight.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const cmd = panel.querySelector('.hwfit-serve-cmd').value;
|
||||
_copyText(cmd).then(() => {
|
||||
const btn = panel.querySelector('.hwfit-serve-copy');
|
||||
@@ -2126,3 +2256,39 @@ export function initServe(shared) {
|
||||
}
|
||||
|
||||
export { _cachedAllModels, _filterCachedList, _rerenderCachedModels, _deleteCachedModel };
|
||||
|
||||
// Click the "running" pill on a serve-card → switch to Cookbook → Running
|
||||
// tab and scroll the matching task into view, with a brief flash so the
|
||||
// user can find it among a long list. Tracks the click via event
|
||||
// delegation so it survives every _rerenderCachedModels() pass.
|
||||
function _openRunningTabForRepo(repo) {
|
||||
const body = document.querySelector('#cookbook-modal .cookbook-body');
|
||||
if (!body) return;
|
||||
const runTab = body.querySelector('.cookbook-tab[data-backend="Running"]');
|
||||
if (runTab) runTab.click();
|
||||
// The Running tab needs a tick to mount/render before we can find
|
||||
// task cards inside it.
|
||||
setTimeout(() => {
|
||||
const candidates = Array.from(body.querySelectorAll('.cookbook-task'));
|
||||
const match = candidates.find(c => {
|
||||
// task cards expose modelId or name via dataset / inner title
|
||||
const dsRepo = c.dataset?.modelId || c.dataset?.repoId || '';
|
||||
if (dsRepo === repo) return true;
|
||||
const title = c.querySelector('.cookbook-task-title, .memory-item-title')?.textContent?.trim() || '';
|
||||
return title === repo || title === (repo.split('/').pop() || '');
|
||||
});
|
||||
if (match) {
|
||||
try { match.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (_) {}
|
||||
match.classList.add('cookbook-task-flash');
|
||||
setTimeout(() => match.classList.remove('cookbook-task-flash'), 1600);
|
||||
}
|
||||
}, 180);
|
||||
}
|
||||
document.addEventListener('click', (e) => {
|
||||
const pill = e.target.closest && e.target.closest('.cookbook-serve-running-pill.is-clickable');
|
||||
if (!pill) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const repo = pill.dataset.repo || '';
|
||||
if (repo) _openRunningTabForRepo(repo);
|
||||
});
|
||||
|
||||
@@ -2246,7 +2246,9 @@ import * as Modals from './modalManager.js';
|
||||
// WYSIWYG body — use it verbatim. (Checking a leading '<' isn't enough: a
|
||||
// rich body often starts with plain text, e.g. "Hi <b>there</b>".)
|
||||
if (/<\/?(b|i|u|s|strong|em|del|strike|a|p|div|br|ul|ol|li|h[1-3]|blockquote|span|code|pre)\b[^>]*>/i.test(t)) return t;
|
||||
try { return markdownModule.mdToHtml(text); }
|
||||
// Email body: keep author-typed `:shortcode:` text literal. Issue #345
|
||||
// (shortcode → emoji) is scoped to chat; do not rewrite colons in mail.
|
||||
try { return markdownModule.mdToHtml(text, { shortcodes: false }); }
|
||||
catch (_) {
|
||||
const d = document.createElement('div'); d.textContent = text;
|
||||
return d.innerHTML.replace(/\n/g, '<br>');
|
||||
@@ -8386,7 +8388,7 @@ import * as Modals from './modalManager.js';
|
||||
const text = textarea.value || '';
|
||||
let body;
|
||||
if (lang === 'markdown' && markdownModule?.mdToHtml) {
|
||||
body = markdownModule.mdToHtml(text);
|
||||
body = markdownModule.mdToHtml(text, { shortcodes: false }); // export: keep :shortcodes: literal
|
||||
} else {
|
||||
body = '<pre style="white-space:pre-wrap;font-size:12px;font-family:monospace;">' +
|
||||
text.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>') + '</pre>';
|
||||
@@ -8417,7 +8419,7 @@ import * as Modals from './modalManager.js';
|
||||
// Render content as HTML for PDF
|
||||
let html;
|
||||
if (lang === 'markdown' && markdownModule?.mdToHtml) {
|
||||
html = markdownModule.mdToHtml(text);
|
||||
html = markdownModule.mdToHtml(text, { shortcodes: false }); // export: keep :shortcodes: literal
|
||||
} else {
|
||||
html = '<pre style="white-space:pre-wrap;font-size:11px;font-family:monospace;color:#000;background:#fff;">' +
|
||||
text.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>') + '</pre>';
|
||||
@@ -8547,7 +8549,7 @@ import * as Modals from './modalManager.js';
|
||||
if (active) {
|
||||
const md = textarea.value || '';
|
||||
if (markdownModule && markdownModule.mdToHtml) {
|
||||
preview.innerHTML = markdownModule.mdToHtml(md);
|
||||
preview.innerHTML = markdownModule.mdToHtml(md, { shortcodes: false }); // doc preview: keep :shortcodes: literal
|
||||
} else {
|
||||
preview.innerHTML = md.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
@@ -76,6 +76,15 @@ function _hlSearch(text) {
|
||||
'<mark class="doclib-search-hl">$1</mark>');
|
||||
} catch { return esc; }
|
||||
}
|
||||
|
||||
function _safeResearchHref(raw) {
|
||||
try {
|
||||
const parsed = new URL(String(raw || '').trim(), window.location.origin);
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return _esc(parsed.href);
|
||||
} catch {}
|
||||
return '';
|
||||
}
|
||||
|
||||
let _libraryEscHandler = null;
|
||||
let _librarySelectMode = false;
|
||||
let _librarySelectedIds = new Set();
|
||||
@@ -1589,7 +1598,11 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content doclib-modal-content" style="width:min(640px, 92vw);max-height:85vh;background:var(--bg);">
|
||||
<div class="modal-header">
|
||||
<h4><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/><line x1="8" y1="7" x2="16" y2="7"/><line x1="8" y1="11" x2="14" y2="11"/></svg>Library</h4>
|
||||
<!-- Header title + icon mirror the currently-active sub-tab (Chats /
|
||||
Documents / Research / Archive) so the user sees ONE icon at
|
||||
the top representing the section they're in, with the tab
|
||||
strip below as sub-navigation. _switchLibTab() updates this. -->
|
||||
<h4 id="doclib-header-title"><span id="doclib-header-icon" style="vertical-align:-2px;margin-right:4px;display:inline-flex;"></span><span id="doclib-header-text">Library</span></h4>
|
||||
<button class="close-btn" id="doclib-close">\u2716</button>
|
||||
</div>
|
||||
<div class="lib-tabs" id="doclib-lib-tabs" style="padding:0 10px;">
|
||||
@@ -1822,6 +1835,27 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
grid.parentElement.appendChild(btn);
|
||||
}
|
||||
|
||||
// SVG markup + label for each tab — used to keep the modal header
|
||||
// in sync with whichever sub-tab the user is on.
|
||||
const _TAB_HEADERS = {
|
||||
chats: {
|
||||
label: 'Chats',
|
||||
svg: '<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="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>',
|
||||
},
|
||||
documents: {
|
||||
label: 'Documents',
|
||||
svg: '<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="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="13" y2="17"/></svg>',
|
||||
},
|
||||
research: {
|
||||
label: 'Research',
|
||||
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>',
|
||||
},
|
||||
archive: {
|
||||
label: 'Archive',
|
||||
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>',
|
||||
},
|
||||
};
|
||||
|
||||
function _switchLibTab(tab) {
|
||||
_activeLibTab = tab;
|
||||
_tabBtns.forEach(b => b.classList.toggle('active', b.dataset.doclibTab === tab));
|
||||
@@ -1832,6 +1866,14 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
p.style.display = 'none';
|
||||
}
|
||||
});
|
||||
// Sync the modal header icon + label to match the active sub-tab.
|
||||
const hdr = _TAB_HEADERS[tab];
|
||||
if (hdr) {
|
||||
const ico = document.getElementById('doclib-header-icon');
|
||||
const txt = document.getElementById('doclib-header-text');
|
||||
if (ico) ico.innerHTML = hdr.svg;
|
||||
if (txt) txt.textContent = hdr.label;
|
||||
}
|
||||
if (tab === 'chats') _renderLibChats();
|
||||
else if (tab === 'archive') _renderLibArchive();
|
||||
else if (tab === 'research') _renderLibResearch();
|
||||
@@ -2649,7 +2691,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
const data = await res.json();
|
||||
_researchItems = data.research || data || [];
|
||||
} catch (e) {
|
||||
grid.innerHTML = `<div class="hwfit-loading">Failed to load: ${e.message}</div>`;
|
||||
grid.innerHTML = `<div class="hwfit-loading">Failed to load: ${_esc(e.message)}</div>`;
|
||||
return;
|
||||
}
|
||||
_renderResearchGrid();
|
||||
@@ -2691,9 +2733,9 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
const sources = Array.isArray(detail.sources) ? detail.sources : [];
|
||||
const sourcesList = sources.slice(0, 12).map((src, i) => {
|
||||
const title = _esc(src.title || src.url || `Source ${i + 1}`);
|
||||
const url = src.url || '';
|
||||
const url = _safeResearchHref(src.url);
|
||||
return url
|
||||
? `<li><a href="${_esc(url)}" target="_blank" rel="noopener">${title}</a></li>`
|
||||
? `<li><a href="${url}" target="_blank" rel="noopener">${title}</a></li>`
|
||||
: `<li>${title}</li>`;
|
||||
}).join('');
|
||||
const sourcesHtml = sources.length
|
||||
@@ -3112,8 +3154,10 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
// Switch to initial tab if not documents
|
||||
if (_activeLibTab !== 'documents') _switchLibTab(_activeLibTab);
|
||||
// Switch to the initial tab. Always call this — even when the
|
||||
// default ('documents') matches — so the modal header's icon + label
|
||||
// sync from "Library" to the active sub-tab on first open.
|
||||
_switchLibTab(_activeLibTab);
|
||||
|
||||
const searchInput = document.getElementById('doclib-search');
|
||||
searchInput.addEventListener('input', () => {
|
||||
|
||||
@@ -30,6 +30,28 @@ export function _esc(text) {
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function _attrEsc(text) {
|
||||
return String(text ?? '')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/`/g, '`');
|
||||
}
|
||||
|
||||
function _compactUrlSchemeValue(value) {
|
||||
return String(value || '').replace(/[\u0000-\u0020\u007f-\u009f]+/g, '').toLowerCase();
|
||||
}
|
||||
|
||||
function _isDangerousUrl(value) {
|
||||
const compact = _compactUrlSchemeValue(value);
|
||||
return compact.startsWith('javascript:') || compact.startsWith('vbscript:') || compact.startsWith('data:');
|
||||
}
|
||||
|
||||
function _isDangerousSrcset(value) {
|
||||
return String(value || '').split(',').some(candidate => _isDangerousUrl(candidate));
|
||||
}
|
||||
|
||||
// Escape + linkify URLs and email addresses. Returns innerHTML-safe markup.
|
||||
export function _escLinkify(text) {
|
||||
const escaped = _esc(text);
|
||||
@@ -39,9 +61,9 @@ export function _escLinkify(text) {
|
||||
return escaped
|
||||
.replace(urlRe, (m) => {
|
||||
const href = m.startsWith('www.') ? `https://${m}` : m;
|
||||
return `<a href="${href}" target="_blank" rel="noopener noreferrer">${m}</a>`;
|
||||
return `<a href="${_attrEsc(href)}" target="_blank" rel="noopener noreferrer">${m}</a>`;
|
||||
})
|
||||
.replace(mailRe, (m) => `<a href="mailto:${m}">${m}</a>`);
|
||||
.replace(mailRe, (m) => `<a href="${_attrEsc(`mailto:${m}`)}">${m}</a>`);
|
||||
}
|
||||
|
||||
// Pull display name out of "Name <email@x>"; fallback to local-part of
|
||||
@@ -133,19 +155,14 @@ export function _initials(s) {
|
||||
// `data:` URLs on every known URL attribute, scrubs inline colour/font/
|
||||
// position styles so the theme can take over, and wraps highlight-bearing
|
||||
// inline tags in <mark> so they render legibly across themes.
|
||||
export function _sanitizeHtml(html) {
|
||||
function _sanitizeHtmlOnce(html) {
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
doc.querySelectorAll(
|
||||
'script, iframe, object, embed, form, style, link, ' +
|
||||
'svg, math, base, meta, noscript, frame, frameset, applet, portal'
|
||||
).forEach(el => el.remove());
|
||||
|
||||
const URL_ATTRS = ['href', 'src', 'srcset', 'action', 'formaction', 'background', 'poster', 'data'];
|
||||
const isDangerousUrl = (val) => {
|
||||
if (!val) return false;
|
||||
const v = val.trim().toLowerCase();
|
||||
return v.startsWith('javascript:') || v.startsWith('vbscript:') || v.startsWith('data:');
|
||||
};
|
||||
const URL_ATTRS = ['href', 'src', 'xlink:href', 'srcset', 'action', 'formaction', 'background', 'poster', 'data'];
|
||||
|
||||
const STRIP_CSS_PROPS = ['color', 'background', 'background-color',
|
||||
'font-family', 'font', '-webkit-text-fill-color',
|
||||
@@ -160,7 +177,7 @@ export function _sanitizeHtml(html) {
|
||||
const name = attr.name.toLowerCase();
|
||||
if (name.startsWith('on')) { el.removeAttribute(attr.name); continue; }
|
||||
if (name === 'srcdoc') { el.removeAttribute(attr.name); continue; }
|
||||
if (URL_ATTRS.includes(name) && isDangerousUrl(attr.value)) {
|
||||
if (URL_ATTRS.includes(name) && (name === 'srcset' ? _isDangerousSrcset(attr.value) : _isDangerousUrl(attr.value))) {
|
||||
el.removeAttribute(attr.name);
|
||||
continue;
|
||||
}
|
||||
@@ -177,8 +194,8 @@ export function _sanitizeHtml(html) {
|
||||
if (style) {
|
||||
const kept = style.split(';').map(s => s.trim()).filter(decl => {
|
||||
if (!decl) return false;
|
||||
const lower = decl.toLowerCase();
|
||||
if (lower.includes('javascript:') || lower.includes('expression(')) return false;
|
||||
const lower = _compactUrlSchemeValue(decl);
|
||||
if (lower.includes('javascript:') || lower.includes('vbscript:') || lower.includes('data:') || lower.includes('expression(')) return false;
|
||||
const prop = decl.split(':', 1)[0].trim().toLowerCase();
|
||||
return !STRIP_CSS_PROPS.includes(prop);
|
||||
});
|
||||
@@ -200,3 +217,13 @@ export function _sanitizeHtml(html) {
|
||||
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
|
||||
export function _sanitizeHtml(html) {
|
||||
let out = String(html ?? '');
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const next = _sanitizeHtmlOnce(out);
|
||||
if (next === out) break;
|
||||
out = next;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
// static/js/emojiShortcodes.js
|
||||
//
|
||||
// Emoji shortcode → Unicode conversion (issue #345).
|
||||
//
|
||||
// Chat models frequently emit GitHub/Slack-style `:shortcode:` text — e.g.
|
||||
// `:blush:`, `:fire:`, `:microphone:` — instead of the actual emoji character.
|
||||
// Nothing in the render pipeline used to translate these, so they showed up as
|
||||
// literal `:blush:` text in the chat bubble.
|
||||
//
|
||||
// This module turns the common shortcode set into the real Unicode emoji. The
|
||||
// chat renderer (markdown.js → svgifyEmoji) runs this BEFORE its existing
|
||||
// Unicode-emoji → monochrome-SVG pass, so a converted `:blush:` renders as the
|
||||
// same theme-tinted single-color line icon as any other emoji (project rule:
|
||||
// never colorful emoji), not as a colored system glyph.
|
||||
//
|
||||
// Pure and browser-free on purpose: no DOM, no imports, so it can be unit
|
||||
// tested with plain `node` (see tests/test_emoji_shortcodes_js.py).
|
||||
|
||||
// Canonical map of common shortcode → Unicode emoji. Names follow the GitHub
|
||||
// convention (lowercase, underscore-separated). A handful of well-known aliases
|
||||
// (`+1`, `thumbsup`, `grinning_face`, …) point at the same glyph so the most
|
||||
// frequent model spellings all resolve.
|
||||
export const EMOJI_SHORTCODES = {
|
||||
// ── Smileys & emotion ──
|
||||
grinning: '😀', grinning_face: '😀',
|
||||
smiley: '😃', smiley_face: '😃',
|
||||
smile: '😄',
|
||||
grin: '😁',
|
||||
laughing: '😆', satisfied: '😆',
|
||||
sweat_smile: '😅',
|
||||
rofl: '🤣', rolling_on_the_floor_laughing: '🤣',
|
||||
joy: '😂',
|
||||
slightly_smiling_face: '🙂', slight_smile: '🙂',
|
||||
upside_down_face: '🙃', upside_down: '🙃',
|
||||
wink: '😉', winking_face: '😉',
|
||||
blush: '😊', smiling_face_with_smiling_eyes: '😊',
|
||||
innocent: '😇',
|
||||
smiling_face_with_three_hearts: '🥰',
|
||||
heart_eyes: '😍', heart_eyes_face: '😍',
|
||||
star_struck: '🤩',
|
||||
kissing_heart: '😘',
|
||||
kissing: '😗',
|
||||
kissing_closed_eyes: '😚',
|
||||
kissing_smiling_eyes: '😙',
|
||||
yum: '😋',
|
||||
stuck_out_tongue: '😛',
|
||||
stuck_out_tongue_winking_eye: '😜',
|
||||
zany_face: '🤪',
|
||||
stuck_out_tongue_closed_eyes: '😝',
|
||||
money_mouth_face: '🤑',
|
||||
hugs: '🤗', hugging_face: '🤗',
|
||||
hand_over_mouth: '🤭',
|
||||
shushing_face: '🤫',
|
||||
thinking: '🤔', thinking_face: '🤔',
|
||||
zipper_mouth_face: '🤐',
|
||||
raised_eyebrow: '🤨',
|
||||
neutral_face: '😐',
|
||||
expressionless: '😑',
|
||||
no_mouth: '😶',
|
||||
smirk: '😏', smirk_face: '😏',
|
||||
unamused: '😒',
|
||||
roll_eyes: '🙄', face_with_rolling_eyes: '🙄',
|
||||
grimacing: '😬',
|
||||
lying_face: '🤥',
|
||||
relieved: '😌',
|
||||
pensive: '😔',
|
||||
sleepy: '😪',
|
||||
drooling_face: '🤤',
|
||||
sleeping: '😴',
|
||||
mask: '😷',
|
||||
face_with_thermometer: '🤒',
|
||||
face_with_head_bandage: '🤕',
|
||||
nauseated_face: '🤢',
|
||||
vomiting_face: '🤮',
|
||||
sneezing_face: '🤧',
|
||||
hot_face: '🥵',
|
||||
cold_face: '🥶',
|
||||
woozy_face: '🥴',
|
||||
dizzy_face: '😵',
|
||||
exploding_head: '🤯',
|
||||
cowboy_hat_face: '🤠',
|
||||
partying_face: '🥳',
|
||||
sunglasses: '😎',
|
||||
nerd_face: '🤓',
|
||||
monocle_face: '🧐',
|
||||
confused: '😕',
|
||||
worried: '😟',
|
||||
slightly_frowning_face: '🙁',
|
||||
frowning_face: '☹️',
|
||||
open_mouth: '😮',
|
||||
hushed: '😯',
|
||||
astonished: '😲',
|
||||
flushed: '😳',
|
||||
pleading_face: '🥺',
|
||||
frowning: '😦',
|
||||
anguished: '😧',
|
||||
fearful: '😨',
|
||||
cold_sweat: '😰',
|
||||
disappointed_relieved: '😥',
|
||||
cry: '😢',
|
||||
sob: '😭',
|
||||
scream: '😱',
|
||||
confounded: '😖',
|
||||
persevere: '😣',
|
||||
disappointed: '😞',
|
||||
sweat: '😓',
|
||||
weary: '😩',
|
||||
tired_face: '😫',
|
||||
yawning_face: '🥱',
|
||||
triumph: '😤',
|
||||
rage: '😡', pout: '😡', pouting_face: '😡',
|
||||
angry: '😠',
|
||||
cursing_face: '🤬',
|
||||
smiling_imp: '😈',
|
||||
imp: '👿',
|
||||
skull: '💀',
|
||||
skull_and_crossbones: '☠️',
|
||||
hankey: '💩', poop: '💩', shit: '💩',
|
||||
clown_face: '🤡',
|
||||
japanese_ogre: '👹',
|
||||
japanese_goblin: '👺',
|
||||
ghost: '👻',
|
||||
alien: '👽',
|
||||
space_invader: '👾',
|
||||
robot: '🤖', robot_face: '🤖',
|
||||
// ── Cats ──
|
||||
smiley_cat: '😺',
|
||||
smile_cat: '😸',
|
||||
joy_cat: '😹',
|
||||
heart_eyes_cat: '😻',
|
||||
smirk_cat: '😼',
|
||||
kissing_cat: '😽',
|
||||
scream_cat: '🙀',
|
||||
crying_cat_face: '😿',
|
||||
pouting_cat: '😾',
|
||||
see_no_evil: '🙈',
|
||||
hear_no_evil: '🙉',
|
||||
speak_no_evil: '🙊',
|
||||
// ── Hands & body ──
|
||||
wave: '👋', wave_hand: '👋',
|
||||
raised_back_of_hand: '🤚',
|
||||
raised_hand_with_fingers_splayed: '🖐️',
|
||||
hand: '✋', raised_hand: '✋',
|
||||
vulcan_salute: '🖖',
|
||||
ok_hand: '👌',
|
||||
pinched_fingers: '🤌',
|
||||
pinching_hand: '🤏',
|
||||
v: '✌️', victory_hand: '✌️',
|
||||
crossed_fingers: '🤞',
|
||||
love_you_gesture: '🤟',
|
||||
metal: '🤘',
|
||||
call_me_hand: '🤙',
|
||||
point_left: '👈',
|
||||
point_right: '👉',
|
||||
point_up_2: '👆',
|
||||
middle_finger: '🖕', fu: '🖕',
|
||||
point_down: '👇',
|
||||
point_up: '☝️',
|
||||
'+1': '👍', thumbsup: '👍', thumbup: '👍', thumbs_up: '👍',
|
||||
'-1': '👎', thumbsdown: '👎', thumbdown: '👎', thumbs_down: '👎',
|
||||
fist_raised: '✊', fist: '✊',
|
||||
fist_oncoming: '👊', facepunch: '👊', punch: '👊',
|
||||
fist_left: '🤛',
|
||||
fist_right: '🤜',
|
||||
clap: '👏', clapping_hands: '👏',
|
||||
raised_hands: '🙌',
|
||||
open_hands: '👐',
|
||||
palms_up_together: '🤲',
|
||||
handshake: '🤝',
|
||||
pray: '🙏', folded_hands: '🙏',
|
||||
writing_hand: '✍️',
|
||||
nail_care: '💅',
|
||||
selfie: '🤳',
|
||||
muscle: '💪', flexed_biceps: '💪',
|
||||
// ── Hearts & symbols of feeling ──
|
||||
heart: '❤️', red_heart: '❤️',
|
||||
orange_heart: '🧡',
|
||||
yellow_heart: '💛',
|
||||
green_heart: '💚',
|
||||
blue_heart: '💙',
|
||||
purple_heart: '💜',
|
||||
black_heart: '🖤',
|
||||
white_heart: '🤍',
|
||||
brown_heart: '🤎',
|
||||
broken_heart: '💔',
|
||||
heart_on_fire: '❤️🔥',
|
||||
two_hearts: '💕',
|
||||
revolving_hearts: '💞',
|
||||
heartbeat: '💓',
|
||||
heartpulse: '💗',
|
||||
sparkling_heart: '💖',
|
||||
cupid: '💘',
|
||||
gift_heart: '💝',
|
||||
heart_decoration: '💟',
|
||||
heavy_heart_exclamation: '❣️',
|
||||
// ── Celebration & misc objects ──
|
||||
fire: '🔥', flame: '🔥',
|
||||
'100': '💯', hundred: '💯',
|
||||
sparkles: '✨',
|
||||
star: '⭐',
|
||||
star2: '🌟', glowing_star: '🌟',
|
||||
dizzy: '💫',
|
||||
boom: '💥', collision: '💥',
|
||||
anger: '💢',
|
||||
sweat_drops: '💦',
|
||||
dash: '💨',
|
||||
zzz: '💤',
|
||||
tada: '🎉', party_popper: '🎉',
|
||||
confetti_ball: '🎊',
|
||||
balloon: '🎈',
|
||||
gift: '🎁',
|
||||
trophy: '🏆',
|
||||
'1st_place_medal': '🥇',
|
||||
'2nd_place_medal': '🥈',
|
||||
'3rd_place_medal': '🥉',
|
||||
medal_sports: '🏅',
|
||||
zap: '⚡', lightning: '⚡',
|
||||
bulb: '💡', light_bulb: '💡',
|
||||
key: '🔑',
|
||||
lock: '🔒',
|
||||
unlock: '🔓',
|
||||
bell: '🔔',
|
||||
no_bell: '🔕',
|
||||
loudspeaker: '📢',
|
||||
mega: '📣', megaphone: '📣',
|
||||
speech_balloon: '💬',
|
||||
thought_balloon: '💭',
|
||||
white_check_mark: '✅',
|
||||
heavy_check_mark: '✔️', check_mark: '✔️',
|
||||
ballot_box_with_check: '☑️',
|
||||
x: '❌', cross_mark: '❌',
|
||||
negative_squared_cross_mark: '❎',
|
||||
question: '❓',
|
||||
grey_question: '❔',
|
||||
exclamation: '❗', heavy_exclamation_mark: '❗',
|
||||
grey_exclamation: '❕',
|
||||
warning: '⚠️',
|
||||
no_entry: '⛔',
|
||||
no_entry_sign: '🚫',
|
||||
red_circle: '🔴',
|
||||
green_circle: '🟢',
|
||||
large_blue_circle: '🔵',
|
||||
yellow_circle: '🟡',
|
||||
white_circle: '⚪',
|
||||
black_circle: '⚫',
|
||||
orange_circle: '🟠',
|
||||
purple_circle: '🟣',
|
||||
brown_circle: '🟤',
|
||||
// ── Tech, work, study ──
|
||||
rocket: '🚀',
|
||||
eyes: '👀',
|
||||
eye: '👁️',
|
||||
brain: '🧠',
|
||||
books: '📚',
|
||||
book: '📖', open_book: '📖',
|
||||
memo: '📝', pencil: '📝',
|
||||
pencil2: '✏️',
|
||||
page_facing_up: '📄',
|
||||
paperclip: '📎',
|
||||
pushpin: '📌',
|
||||
round_pushpin: '📍',
|
||||
link: '🔗',
|
||||
bar_chart: '📊',
|
||||
chart_with_upwards_trend: '📈',
|
||||
chart_with_downwards_trend: '📉',
|
||||
mag: '🔍',
|
||||
mag_right: '🔎',
|
||||
globe_with_meridians: '🌐',
|
||||
earth_africa: '🌍',
|
||||
earth_americas: '🌎',
|
||||
earth_asia: '🌏',
|
||||
alarm_clock: '⏰',
|
||||
hourglass_flowing_sand: '⏳',
|
||||
hourglass: '⌛',
|
||||
microphone: '🎤', mic: '🎤',
|
||||
musical_note: '🎵',
|
||||
notes: '🎶', musical_notes: '🎶',
|
||||
headphones: '🎧',
|
||||
camera: '📷',
|
||||
camera_flash: '📸',
|
||||
clapper: '🎬',
|
||||
tv: '📺',
|
||||
computer: '💻', laptop: '💻',
|
||||
desktop_computer: '🖥️',
|
||||
iphone: '📱', mobile_phone: '📱',
|
||||
telephone: '☎️',
|
||||
wrench: '🔧',
|
||||
hammer: '🔨',
|
||||
gear: '⚙️',
|
||||
nut_and_bolt: '🔩',
|
||||
magnet: '🧲',
|
||||
test_tube: '🧪',
|
||||
microscope: '🔬',
|
||||
dart: '🎯', bullseye: '🎯',
|
||||
game_die: '🎲',
|
||||
jigsaw: '🧩',
|
||||
// ── Food & drink ──
|
||||
pizza: '🍕',
|
||||
hamburger: '🍔',
|
||||
fries: '🍟',
|
||||
taco: '🌮',
|
||||
sushi: '🍣',
|
||||
doughnut: '🍩', donut: '🍩',
|
||||
coffee: '☕',
|
||||
beer: '🍺',
|
||||
wine_glass: '🍷',
|
||||
// ── Animals & nature ──
|
||||
dog: '🐶',
|
||||
cat: '🐱',
|
||||
mouse: '🐭',
|
||||
hamster: '🐹',
|
||||
rabbit: '🐰',
|
||||
fox_face: '🦊',
|
||||
bear: '🐻',
|
||||
panda_face: '🐼',
|
||||
koala: '🐨',
|
||||
tiger: '🐯',
|
||||
lion: '🦁',
|
||||
cow: '🐮',
|
||||
pig: '🐷',
|
||||
frog: '🐸',
|
||||
monkey_face: '🐵',
|
||||
chicken: '🐔',
|
||||
penguin: '🐧',
|
||||
bird: '🐦',
|
||||
eagle: '🦅',
|
||||
duck: '🦆',
|
||||
owl: '🦉',
|
||||
wolf: '🐺',
|
||||
horse: '🐴',
|
||||
unicorn: '🦄',
|
||||
bee: '🐝', honeybee: '🐝',
|
||||
bug: '🐛',
|
||||
butterfly: '🦋',
|
||||
snail: '🐌',
|
||||
lady_beetle: '🐞',
|
||||
snake: '🐍',
|
||||
turtle: '🐢',
|
||||
octopus: '🐙',
|
||||
crab: '🦀',
|
||||
tropical_fish: '🐠',
|
||||
whale: '🐳',
|
||||
shark: '🦈',
|
||||
cherry_blossom: '🌸',
|
||||
rose: '🌹',
|
||||
sunflower: '🌻',
|
||||
hibiscus: '🌺',
|
||||
tulip: '🌷',
|
||||
seedling: '🌱',
|
||||
evergreen_tree: '🌲',
|
||||
deciduous_tree: '🌳',
|
||||
four_leaf_clover: '🍀',
|
||||
apple: '🍎',
|
||||
green_apple: '🍏',
|
||||
pear: '🍐',
|
||||
tangerine: '🍊',
|
||||
lemon: '🍋',
|
||||
banana: '🍌',
|
||||
watermelon: '🍉',
|
||||
grapes: '🍇',
|
||||
strawberry: '🍓',
|
||||
blueberries: '🫐',
|
||||
peach: '🍑',
|
||||
rainbow: '🌈',
|
||||
sunny: '☀️', sun: '☀️',
|
||||
partly_sunny: '⛅',
|
||||
cloud: '☁️',
|
||||
snowflake: '❄️',
|
||||
ocean: '🌊',
|
||||
// ── Arrows & signs ──
|
||||
arrow_right: '➡️',
|
||||
arrow_left: '⬅️',
|
||||
arrow_up: '⬆️',
|
||||
arrow_down: '⬇️',
|
||||
arrow_upper_right: '↗️',
|
||||
arrow_lower_right: '↘️',
|
||||
arrow_lower_left: '↙️',
|
||||
arrow_upper_left: '↖️',
|
||||
leftwards_arrow_with_hook: '↩️',
|
||||
arrow_right_hook: '↪️',
|
||||
arrows_counterclockwise: '🔄',
|
||||
arrows_clockwise: '🔃',
|
||||
heavy_plus_sign: '➕',
|
||||
heavy_minus_sign: '➖',
|
||||
heavy_division_sign: '➗',
|
||||
heavy_multiplication_x: '✖️',
|
||||
infinity: '♾️',
|
||||
copyright: '©️',
|
||||
registered: '®️',
|
||||
tm: '™️',
|
||||
recycle: '♻️',
|
||||
checkered_flag: '🏁',
|
||||
triangular_flag_on_post: '🚩',
|
||||
white_flag: '🏳️',
|
||||
black_flag: '🏴',
|
||||
// ── People & wearables ──
|
||||
baby: '👶',
|
||||
boy: '👦',
|
||||
girl: '👧',
|
||||
man: '👨',
|
||||
woman: '👩',
|
||||
older_man: '👴',
|
||||
older_woman: '👵',
|
||||
crown: '👑',
|
||||
gem: '💎',
|
||||
graduation_cap: '🎓', mortar_board: '🎓',
|
||||
};
|
||||
|
||||
// `:name:` where name is letters/digits/`_`/`+`/`-`. Length ≥1 so `:+1:` and
|
||||
// `:-1:` match. Global + case-insensitive for replace; a separate non-global
|
||||
// literal is used for the cheap presence check so there's no shared lastIndex
|
||||
// state to reset.
|
||||
const SHORTCODE_RE = /:([a-z0-9_+-]{1,40}):/gi;
|
||||
|
||||
/**
|
||||
* Cheap test for whether `text` could contain any emoji shortcode at all.
|
||||
* Lets callers skip the replace pass entirely on the common no-shortcode path.
|
||||
*/
|
||||
export function hasEmojiShortcode(text) {
|
||||
return !!text && text.indexOf(':') !== -1 && /:[a-z0-9_+-]{1,40}:/i.test(text);
|
||||
}
|
||||
|
||||
// A shortcode must stand on its own — flanked by whitespace, punctuation, a
|
||||
// string edge, or markup, never glued to an ASCII word character. Without this
|
||||
// guard, real `:name:` shortcodes that happen to sit inside a longer run of
|
||||
// digits/letters get converted by mistake and mangle perfectly literal text:
|
||||
// "1:100:2" → the `:100:` would become 💯 ("1💯2")
|
||||
// "host:fire:port", URL authorities, `key:value:` pairs, etc.
|
||||
// Chat models always emit shortcodes delimited by spaces/punctuation (":fire:",
|
||||
// "**:microphone:**", "nice :tada:!"), so requiring a boundary keeps every real
|
||||
// shortcode working while leaving embedded colon runs untouched. `_` counts as a
|
||||
// word char too (identifier-like), but `+`/`-` do not, so "C++ :fire:" still works.
|
||||
const _WORDISH = /[A-Za-z0-9_]/;
|
||||
function _boundedOnBothSides(str, start, end) {
|
||||
const before = start > 0 ? str[start - 1] : '';
|
||||
const after = end < str.length ? str[end] : '';
|
||||
return !_WORDISH.test(before) && !_WORDISH.test(after);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace every known `:shortcode:` in `text` with its Unicode emoji. Unknown
|
||||
* shortcodes (`:definitely_not_emoji:`), colon runs that don't form a shortcode
|
||||
* (`10:30:45`, `16:9`), and known shortcodes embedded mid-token (`1:100:2`) are
|
||||
* all left exactly as-is.
|
||||
*/
|
||||
export function replaceEmojiShortcodes(text) {
|
||||
if (!text || text.indexOf(':') === -1) return text;
|
||||
return text.replace(SHORTCODE_RE, (whole, name, offset, str) => {
|
||||
const key = name.toLowerCase();
|
||||
if (!Object.prototype.hasOwnProperty.call(EMOJI_SHORTCODES, key)) return whole;
|
||||
// Only convert when the `:shortcode:` is a standalone token, not glued to a
|
||||
// surrounding word/number (which would mean it's literal text, not an emoji).
|
||||
if (!_boundedOnBothSides(str, offset, offset + whole.length)) return whole;
|
||||
return EMOJI_SHORTCODES[key];
|
||||
});
|
||||
}
|
||||
|
||||
export default { EMOJI_SHORTCODES, replaceEmojiShortcodes, hasEmojiShortcode };
|
||||
+9
-6
@@ -676,7 +676,7 @@ function _createGroupBubble(model, box) {
|
||||
// Role label — use character name if assigned, otherwise model name
|
||||
const roleLabel = model._groupName || (model.character ? model.character.characterName : chatRenderer.shortModel(model.mid));
|
||||
const roleTs = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
wrap.innerHTML = `<div class="role">${roleLabel} <span class="role-timestamp">${roleTs}</span></div><div class="body"></div>`;
|
||||
wrap.innerHTML = `<div class="role">${uiModule.esc(roleLabel)} <span class="role-timestamp">${roleTs}</span></div><div class="body"></div>`;
|
||||
chatRenderer.applyModelColor(wrap.querySelector('.role'), model.mid);
|
||||
|
||||
// Spinner — identical to chat.js line 3062
|
||||
@@ -860,11 +860,14 @@ async function _streamToHolder(modelIdx, sessionId, msg, holderEl, abortCtrl) {
|
||||
}
|
||||
// Generated image
|
||||
else if (json.type === 'generated_image' && json.url) {
|
||||
const img = document.createElement('img');
|
||||
img.src = json.url;
|
||||
img.style.cssText = 'max-width:100%;border-radius:8px;margin:8px 0;';
|
||||
img.loading = 'lazy';
|
||||
bodyEl.appendChild(img);
|
||||
const safeImageUrl = chatRenderer.safeDisplayImageSrc(json.url);
|
||||
if (safeImageUrl) {
|
||||
const img = document.createElement('img');
|
||||
img.src = safeImageUrl;
|
||||
img.style.cssText = 'max-width:100%;border-radius:8px;margin:8px 0;';
|
||||
img.loading = 'lazy';
|
||||
bodyEl.appendChild(img);
|
||||
}
|
||||
}
|
||||
// Error
|
||||
else if (json.error) {
|
||||
|
||||
+88
-17
@@ -6,6 +6,7 @@
|
||||
|
||||
import uiModule from './ui.js';
|
||||
import { splitTableRow } from './markdown/tableRow.js';
|
||||
import { replaceEmojiShortcodes, hasEmojiShortcode } from './emojiShortcodes.js';
|
||||
|
||||
var escapeHtml = uiModule.esc;
|
||||
|
||||
@@ -60,9 +61,21 @@ const _ALLOWED_HTML_BAD_TAGS = new Set([
|
||||
'SVG', 'MATH',
|
||||
]);
|
||||
const _ALLOWED_HTML_URL_ATTRS = new Set([
|
||||
'href', 'src', 'xlink:href', 'action', 'formaction', 'background', 'poster',
|
||||
'href', 'src', 'srcset', 'xlink:href', 'action', 'formaction', 'background', 'poster',
|
||||
]);
|
||||
|
||||
function _compactUrlSchemeValue(value) {
|
||||
return String(value || '').replace(/[\u0000-\u0020\u007f-\u009f]+/g, '').toLowerCase();
|
||||
}
|
||||
|
||||
function _isDangerousUrl(value) {
|
||||
return /^(javascript|vbscript|data):/.test(_compactUrlSchemeValue(value));
|
||||
}
|
||||
|
||||
function _isDangerousSrcset(value) {
|
||||
return String(value || '').split(',').some(candidate => _isDangerousUrl(candidate));
|
||||
}
|
||||
|
||||
function _cleanAllowedHtmlOnce(htmlString) {
|
||||
const tpl = document.createElement('template');
|
||||
tpl.innerHTML = htmlString;
|
||||
@@ -82,11 +95,17 @@ function _cleanAllowedHtmlOnce(htmlString) {
|
||||
el.removeAttribute(attr.name);
|
||||
continue;
|
||||
}
|
||||
if (name === 'style') {
|
||||
const value = _compactUrlSchemeValue(attr.value);
|
||||
if (/javascript:|vbscript:|data:|expression\(/.test(value)) {
|
||||
el.removeAttribute(attr.name);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Neutralize javascript:/vbscript:/data: in URL-bearing attributes.
|
||||
// Strip control/space chars first so e.g. "java\tscript:" can't slip by.
|
||||
if (_ALLOWED_HTML_URL_ATTRS.has(name)) {
|
||||
const value = (attr.value || '').replace(/[\x00-\x20]+/g, '').toLowerCase();
|
||||
if (/^(javascript|vbscript|data):/.test(value)) {
|
||||
if (name === 'srcset' ? _isDangerousSrcset(attr.value) : _isDangerousUrl(attr.value)) {
|
||||
el.removeAttribute(attr.name);
|
||||
}
|
||||
}
|
||||
@@ -116,8 +135,13 @@ function sanitizeAllowedHtml(html) {
|
||||
* Check if text has unclosed think tag
|
||||
*/
|
||||
export function hasUnclosedThinkTag(text) {
|
||||
const openCount = (text.match(/<think(?:ing)?>/gi) || []).length;
|
||||
const closeCount = (text.match(/<\/think(?:ing)?>/gi) || []).length;
|
||||
text = text || '';
|
||||
const openCount =
|
||||
(text.match(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>/gi) || []).length
|
||||
+ (text.match(/<\|channel>thought/gi) || []).length;
|
||||
const closeCount =
|
||||
(text.match(/<\/(?:think(?:ing)?|thought)>/gi) || []).length
|
||||
+ (text.match(/<channel\|>/gi) || []).length;
|
||||
return openCount > closeCount;
|
||||
}
|
||||
|
||||
@@ -125,8 +149,25 @@ export function startsWithReasoningPrefix(text) {
|
||||
return /^\s*(?:thinking(?:\s+process)?\s*:|the user |i need |i should |i will |they are |the question |i can )/i.test(text || '');
|
||||
}
|
||||
|
||||
export function normalizeThinkingMarkup(text) {
|
||||
if (!text) return text;
|
||||
let normalized = text;
|
||||
normalized = normalized.replace(/<thought(\s+[^>]*)?>/gi, (_m, attrs = '') => `<think${attrs || ''}>`);
|
||||
normalized = normalized.replace(/<\/thought>/gi, '</think>');
|
||||
normalized = normalized.replace(/<\|channel>thought\s*\n?([\s\S]*?)<channel\|>\s*/gi, (_m, content = '') => {
|
||||
const thought = String(content || '').trim();
|
||||
return thought ? `<think>${thought}</think>\n` : '';
|
||||
});
|
||||
normalized = normalized.replace(/<\|channel>response\s*\n?([\s\S]*?)<channel\|>/gi, (_m, content = '') => content || '');
|
||||
normalized = normalized.replace(/<\|channel>response\s*\n?/gi, '');
|
||||
normalized = normalized.replace(/<channel\|>/gi, '');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizePlainThinking(text) {
|
||||
if (!text || /<think/i.test(text)) return text;
|
||||
if (!text) return text;
|
||||
text = normalizeThinkingMarkup(text);
|
||||
if (/<think/i.test(text)) return text;
|
||||
|
||||
const trimmed = text.trimStart();
|
||||
if (!startsWithReasoningPrefix(trimmed)) return text;
|
||||
@@ -220,11 +261,21 @@ export function extractThinkingBlocks(text) {
|
||||
// (b) Cut-off mid-generation — there's already real reply text before the
|
||||
// opener. Drop from the tag onward as before (it's truncated thinking).
|
||||
if (hasUnclosedThinkTag(normalized)) {
|
||||
const strayOpener = cleanContent.match(/^\s*<think(?:ing)?(?:\s+[^>]*)?>([\s\S]*)$/i);
|
||||
if (strayOpener) {
|
||||
cleanContent = strayOpener[1];
|
||||
const gemmaThoughtStart = cleanContent.search(/<\|channel>thought/i);
|
||||
if (gemmaThoughtStart >= 0) {
|
||||
const leakedThought = cleanContent
|
||||
.slice(gemmaThoughtStart)
|
||||
.replace(/^<\|channel>thought\s*\n?/i, '')
|
||||
.trim();
|
||||
if (gemmaThoughtStart === 0 && leakedThought) thinkingBlocks.push(leakedThought);
|
||||
cleanContent = cleanContent.slice(0, gemmaThoughtStart);
|
||||
} else {
|
||||
cleanContent = cleanContent.replace(/<think(?:ing)?(?:\s+[^>]*)?>[\s\S]*$/gi, '');
|
||||
const strayOpener = cleanContent.match(/^\s*<think(?:ing)?(?:\s+[^>]*)?>([\s\S]*)$/i);
|
||||
if (strayOpener) {
|
||||
cleanContent = strayOpener[1];
|
||||
} else {
|
||||
cleanContent = cleanContent.replace(/<think(?:ing)?(?:\s+[^>]*)?>[\s\S]*$/gi, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,8 +367,19 @@ function _useSvgEmoji() {
|
||||
return typeof document === 'undefined' || !document.body?.classList.contains('text-emojis');
|
||||
}
|
||||
|
||||
export function svgifyEmoji(html) {
|
||||
if (!_useSvgEmoji() || !html || !_EMOJI_RE.test(html)) return html;
|
||||
// `opts.shortcodes` (default true) controls the issue-#345 `:name:` → emoji
|
||||
// expansion. Chat passes it through as true; document/email body renderers pass
|
||||
// false so author-typed `:shortcode:` text stays literal (see mdToHtml callers).
|
||||
// The Unicode-emoji → monochrome-SVG pass always runs regardless, so a real 😀
|
||||
// in a document still renders as the themed line icon as it always has.
|
||||
export function svgifyEmoji(html, opts) {
|
||||
if (!_useSvgEmoji() || !html) return html;
|
||||
const allowShortcodes = !opts || opts.shortcodes !== false;
|
||||
// Two reasons to walk the HTML: real Unicode emoji to turn into SVG icons,
|
||||
// or `:shortcode:` text the model emitted instead of an emoji (issue #345).
|
||||
const hasUnicode = _EMOJI_RE.test(html);
|
||||
const hasShortcode = allowShortcodes && hasEmojiShortcode(html);
|
||||
if (!hasUnicode && !hasShortcode) return html;
|
||||
const parts = html.split(/(<[^>]*>)/); // odd indices = tags
|
||||
let codeDepth = 0;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
@@ -327,7 +389,13 @@ export function svgifyEmoji(html) {
|
||||
else if (/^<\/(pre|code)\s*>/.test(t)) codeDepth = Math.max(0, codeDepth - 1);
|
||||
continue;
|
||||
}
|
||||
if (codeDepth === 0 && _EMOJI_RE.test(parts[i])) parts[i] = _svgifyText(parts[i]);
|
||||
if (codeDepth !== 0) continue;
|
||||
let seg = parts[i];
|
||||
// Expand shortcodes to Unicode first, then both they and any pre-existing
|
||||
// Unicode emoji get rendered as the same monochrome line icons below.
|
||||
if (hasShortcode) seg = replaceEmojiShortcodes(seg);
|
||||
if (_EMOJI_RE.test(seg)) seg = _svgifyText(seg);
|
||||
parts[i] = seg;
|
||||
}
|
||||
return parts.join('');
|
||||
}
|
||||
@@ -371,7 +439,7 @@ export function processWithThinking(text) {
|
||||
/**
|
||||
* Convert markdown to HTML
|
||||
*/
|
||||
export function mdToHtml(src) {
|
||||
export function mdToHtml(src, opts) {
|
||||
const allowedHtmlBlocks = [];
|
||||
const codeBlocks = [];
|
||||
const mermaidBlocks = [];
|
||||
@@ -456,9 +524,11 @@ export function mdToHtml(src) {
|
||||
// allowlist keeps it from matching file names / versions ("package.json",
|
||||
// "node.js", "v1.2.3"); the required start/[\s(<] prefix means domains
|
||||
// already inside an http link (preceded by "//") or an email ("@") are
|
||||
// skipped. Trailing sentence punctuation is kept outside the link.
|
||||
// skipped. Require the TLD to end at a real domain boundary so dotted code
|
||||
// identifiers like `sklearn.metrics` do not link `sklearn.me` and leave
|
||||
// placeholder fragments in the remaining text.
|
||||
s = s.replace(
|
||||
/(^|[\s(<])((?:www\.)?[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9-]+)*\.(?:com|org|net|io|ai|co|dev|app|gov|edu|news|info|tech|xyz|me)(?:\/[^\s<>"'`\])]*)?)/gi,
|
||||
/(^|[\s(<])((?:www\.)?[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9-]+)*\.(?:com|org|net|io|ai|co|dev|app|gov|edu|news|info|tech|xyz|me)(?=$|[\/\s<>"'`\]).,;:!?])(?:\/[^\s<>"'`\])]*)?)/gi,
|
||||
(match, prefix, domain) => {
|
||||
const trail = (domain.match(/[.,;:!?)]+$/) || [''])[0];
|
||||
const core = trail ? domain.slice(0, -trail.length) : domain;
|
||||
@@ -628,7 +698,7 @@ export function mdToHtml(src) {
|
||||
s = s.replace(`___CODE_BLOCK_${index}___`, block);
|
||||
});
|
||||
|
||||
return _useSvgEmoji() ? svgifyEmoji(s) : s;
|
||||
return _useSvgEmoji() ? svgifyEmoji(s, opts) : s;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -686,6 +756,7 @@ const markdownModule = {
|
||||
createCollapsible,
|
||||
hasUnclosedThinkTag,
|
||||
extractThinkingBlocks,
|
||||
normalizeThinkingMarkup,
|
||||
startsWithReasoningPrefix,
|
||||
renderMermaid
|
||||
};
|
||||
|
||||
+39
-14
@@ -179,14 +179,23 @@ function _initModelPickerDropdown() {
|
||||
const result = [];
|
||||
const seen = new Set();
|
||||
items.forEach(item => {
|
||||
if (item.offline) return;
|
||||
// Previously: offline endpoints were skipped entirely, so a server
|
||||
// that briefly went down disappeared from the picker — confusing
|
||||
// when the user can still see it (offline-tagged) in Settings.
|
||||
// Now: include offline-endpoint models too but flag them
|
||||
// `stale: true` so the row renderer dims them + shows the offline
|
||||
// pill. The user can still click and try anyway (matches the
|
||||
// existing "local server appears offline" path on line 301).
|
||||
const epOffline = !!item.offline;
|
||||
const allModels = (item.models || []).concat(item.models_extra || []);
|
||||
const allDisplay = (item.models_display || []).concat(item.models_extra_display || []);
|
||||
// Mark local endpoints whose live probe failed.
|
||||
const probeResult = item.endpoint_id ? _localProbe[item.endpoint_id] : null;
|
||||
const isLocalDead = !!(probeResult && probeResult.alive === false);
|
||||
allModels.forEach((mid, i) => {
|
||||
// Deduplicate by model ID — prefer DB endpoints over env-discovered
|
||||
// Deduplicate by model ID — prefer ONLINE endpoint entries over
|
||||
// offline duplicates so the user gets a working endpoint first
|
||||
// when the same model is exposed by both.
|
||||
if (seen.has(mid)) return;
|
||||
seen.add(mid);
|
||||
result.push({
|
||||
@@ -201,8 +210,11 @@ function _initModelPickerDropdown() {
|
||||
item.host || '',
|
||||
item.url || '',
|
||||
].filter(Boolean).join(' '),
|
||||
stale: isLocalDead,
|
||||
staleReason: isLocalDead ? (probeResult.error || 'not responding') : '',
|
||||
stale: isLocalDead || epOffline,
|
||||
staleReason: epOffline
|
||||
? (item.ping_error || 'endpoint offline')
|
||||
: (isLocalDead ? (probeResult.error || 'not responding') : ''),
|
||||
offline: epOffline,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -377,22 +389,35 @@ function _initModelPickerDropdown() {
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Browse mode: Recent (auto) + Favorites (manual). No flat "All" dump. ──
|
||||
// ── Browse mode: Favorites (manual) + Recent (auto), with dedupe. ──
|
||||
// Rules:
|
||||
// 1. Never list the same model twice in the dropdown. Favorites
|
||||
// win over Recent (if you favorited it, that's where it
|
||||
// belongs — Recent shouldn't show it again as duplicate).
|
||||
// 2. Small catalogs (≤ BROWSE_ALL_LIMIT total) skip the Recent
|
||||
// section entirely — when there's only ~10 models, the whole
|
||||
// list fits below as "All models" and a separate Recent
|
||||
// section just duplicates rows.
|
||||
const shown = new Set();
|
||||
const recentModels = _loadRecent()
|
||||
.map(id => byId.get(id))
|
||||
.filter(Boolean)
|
||||
.slice(0, RECENT_MAX);
|
||||
const favModels = favs.map(id => byId.get(id)).filter(Boolean);
|
||||
|
||||
if (recentModels.length) {
|
||||
_addSection('Recent');
|
||||
recentModels.forEach(m => { shown.add(m.mid); _addRow(m); });
|
||||
}
|
||||
if (favModels.length) {
|
||||
_addSection('Favorites');
|
||||
favModels.forEach(m => { shown.add(m.mid); _addRow(m); });
|
||||
}
|
||||
// Recent: only render when the catalog is big enough that surfacing
|
||||
// a recency shortlist is actually useful, AND only models that
|
||||
// aren't already in Favorites (dedupe).
|
||||
if (all.length > BROWSE_ALL_LIMIT) {
|
||||
const recentModels = _loadRecent()
|
||||
.map(id => byId.get(id))
|
||||
.filter(Boolean)
|
||||
.filter(m => !shown.has(m.mid))
|
||||
.slice(0, RECENT_MAX);
|
||||
if (recentModels.length) {
|
||||
_addSection('Recent');
|
||||
recentModels.forEach(m => { shown.add(m.mid); _addRow(m); });
|
||||
}
|
||||
}
|
||||
|
||||
// Small catalogs: still list everything so users aren't forced to search.
|
||||
if (all.length <= BROWSE_ALL_LIMIT) {
|
||||
|
||||
+67
-12
@@ -438,13 +438,22 @@ async function _patchNote(id, patch) {
|
||||
// ---- Helpers ----
|
||||
|
||||
function _esc(s) { return uiModule.esc ? uiModule.esc(s || '') : (s || '').replace(/</g, '<').replace(/>/g, '>'); }
|
||||
// Image src guard — reject anything that isn't a relative path or http(s)/data URL
|
||||
// so an AI-saved note can't slip a `javascript:` URL into the rendered <img>.
|
||||
function _attrEsc(s) {
|
||||
return String(s || '')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/`/g, '`');
|
||||
}
|
||||
// Image src guard — reject anything that isn't a relative path, http(s), or
|
||||
// raster data URL so an AI-saved note can't slip script-capable media into the
|
||||
// rendered <img>.
|
||||
function _safeImgSrc(s) {
|
||||
const v = (s || '').trim();
|
||||
if (!v) return '';
|
||||
if (v.startsWith('/') || v.startsWith('./') || v.startsWith('../')) return v;
|
||||
if (/^https?:\/\//i.test(v) || /^data:image\//i.test(v)) return v;
|
||||
if (/^https?:\/\//i.test(v) || /^data:image\/(?:png|jpe?g|gif|webp);base64,/i.test(v)) return v;
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -461,7 +470,7 @@ function _linkify(s) {
|
||||
url = url.slice(0, -1);
|
||||
}
|
||||
const href = url.startsWith('www.') ? `https://${url}` : url;
|
||||
return `<a href="${href}" class="note-link" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">${url}</a>` + (url !== m ? m.slice(url.length) : '');
|
||||
return `<a href="${_attrEsc(href)}" class="note-link" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">${url}</a>` + (url !== m ? m.slice(url.length) : '');
|
||||
});
|
||||
}
|
||||
function _uid() { return Math.random().toString(36).slice(2, 10); }
|
||||
@@ -2779,7 +2788,7 @@ function _buildForm(note = null) {
|
||||
form.className = 'note-form';
|
||||
if (color && !_isBgImage(color)) form.classList.add('note-color-' + color);
|
||||
if (_isBgImage(color)) form.setAttribute('style', _customColorStyle(color));
|
||||
let currentImageUrl = note?.image_url || '';
|
||||
let currentImageUrl = _safeImgSrc(note?.image_url || '');
|
||||
form.innerHTML = `
|
||||
<div class="note-form-header">
|
||||
<input type="text" class="note-form-title" placeholder="Title" value="${_esc(note?.title || '')}" />
|
||||
@@ -2861,7 +2870,7 @@ function _buildForm(note = null) {
|
||||
let _stashedGoalItems = (type === 'goal' && Array.isArray(note?.items)) ? note.items.slice() : null;
|
||||
|
||||
// Drawing also stashes the saved image URL so it survives Note↔Draw flips.
|
||||
let _stashedDrawUrl = (type === 'draw') ? (note?.image_url || null) : null;
|
||||
let _stashedDrawUrl = (type === 'draw') ? (_safeImgSrc(note?.image_url) || null) : null;
|
||||
const _refreshFormLayout = () => {
|
||||
const body = form.closest('.notes-pane-body');
|
||||
if (!body) return;
|
||||
@@ -2913,7 +2922,7 @@ function _buildForm(note = null) {
|
||||
// toggled to Draw, paint that photo onto the canvas so they can draw
|
||||
// on top of it. _stashedDrawUrl wins if they were drawing earlier in
|
||||
// the same edit session.
|
||||
_wireCanvas(bodyEl, _stashedDrawUrl || currentImageUrl || note?.image_url || null);
|
||||
_wireCanvas(bodyEl, _stashedDrawUrl || currentImageUrl || _safeImgSrc(note?.image_url) || null);
|
||||
} else {
|
||||
const text = (_stashedNoteText !== null && _stashedNoteText !== undefined && _stashedNoteText !== '')
|
||||
? _stashedNoteText
|
||||
@@ -3003,7 +3012,7 @@ function _buildForm(note = null) {
|
||||
if (currentType === 'todo') _wireChecklist(form.querySelector('.note-form-body'));
|
||||
if (currentType === 'goal') _wireGoalForm(form, form.querySelector('.note-form-body'));
|
||||
if (currentType === 'draw') {
|
||||
_wireCanvas(form.querySelector('.note-form-body'), note?.image_url || null);
|
||||
_wireCanvas(form.querySelector('.note-form-body'), _safeImgSrc(note?.image_url) || null);
|
||||
// Same hides we apply on type-switch — keep them consistent on initial open.
|
||||
const _ip = form.querySelector('.note-form-image-wrap'); if (_ip) _ip.style.display = 'none';
|
||||
const _cp = form.querySelector('.note-color-picker'); if (_cp) _cp.style.display = 'none';
|
||||
@@ -3894,11 +3903,12 @@ function _wireCanvas(container, initialImageUrl) {
|
||||
ctx.lineJoin = 'round';
|
||||
|
||||
// Load prior drawing as starting point so consecutive edits compose.
|
||||
if (initialImageUrl) {
|
||||
const safeInitialImageUrl = _safeImgSrc(initialImageUrl);
|
||||
if (safeInitialImageUrl) {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => { try { ctx.drawImage(img, 0, 0, cssW, cssH); } catch {} };
|
||||
img.src = initialImageUrl;
|
||||
img.src = safeInitialImageUrl;
|
||||
// Float an X over the canvas so the user can blank it out and go back to
|
||||
// a clean draw surface. Removes itself once clicked.
|
||||
const wrap = container.querySelector('.note-form-draw-wrap');
|
||||
@@ -5043,9 +5053,54 @@ async function _initReminders() {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const notesModule = { openPanel, closePanel, togglePanel, isPanelOpen, openNotes: openPanel, closeNotes: closePanel, isNotesOpen: isPanelOpen, refreshDueBadge };
|
||||
// Open the notes panel and scroll/flash the matching note card. Used
|
||||
// by chatRenderer.js when the user clicks a [View note](#note-<id>)
|
||||
// link the agent emits after a manage_notes create. Falls back to
|
||||
// just opening the panel when the card isn't found (panel still
|
||||
// loading, note in a different filter, etc.).
|
||||
async function openNote(noteId) {
|
||||
// If the panel is already open, openPanel() short-circuits and does
|
||||
// nothing — including no re-fetch — so a freshly-created note added
|
||||
// server-side never shows up. Force a refresh by closing first when
|
||||
// open, then re-opening. Clicking the sidebar Notes button as a
|
||||
// last resort keeps this working even if the module state got out
|
||||
// of sync (rare but seen during HMR or after a stuck modal).
|
||||
try {
|
||||
if (isPanelOpen && isPanelOpen()) {
|
||||
closePanel();
|
||||
// give the close animation a frame to settle
|
||||
await new Promise(r => setTimeout(r, 30));
|
||||
}
|
||||
} catch (_) {}
|
||||
openPanel();
|
||||
// openPanel() kicks off _fetchNotes() asynchronously, so the cards
|
||||
// for newly-created notes may not be in the DOM yet. Also poll the
|
||||
// _notes module array directly — if the note IS loaded but the
|
||||
// active filter (e.g. archive view) is hiding it, we can still
|
||||
// surface a confirmation toast.
|
||||
if (!noteId) return;
|
||||
let tries = 0;
|
||||
const findAndFlash = () => {
|
||||
const card = document.querySelector(`.note-card[data-note-id="${noteId}"]`)
|
||||
|| document.querySelector(`.note-card[data-note-id^="${noteId.slice(0, 8)}"]`);
|
||||
if (card) {
|
||||
try { card.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (_) {}
|
||||
card.classList.add('note-card-flash');
|
||||
setTimeout(() => card.classList.remove('note-card-flash'), 1600);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const tryNext = () => {
|
||||
if (findAndFlash()) return;
|
||||
if (++tries < 20) setTimeout(tryNext, 200);
|
||||
};
|
||||
setTimeout(tryNext, 120);
|
||||
}
|
||||
|
||||
const notesModule = { openPanel, closePanel, togglePanel, isPanelOpen, openNote, openNotes: openPanel, closeNotes: closePanel, isNotesOpen: isPanelOpen, refreshDueBadge };
|
||||
export default notesModule;
|
||||
export { openPanel as openNotes, closePanel as closeNotes, isPanelOpen as isNotesOpen };
|
||||
export { openPanel as openNotes, closePanel as closeNotes, isPanelOpen as isNotesOpen, openNote };
|
||||
window.notesModule = notesModule;
|
||||
|
||||
// Start reminder loop on module load (after a short delay so app loads first)
|
||||
|
||||
+48
-1
@@ -90,4 +90,51 @@ export function providerLogo(modelId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export default { providerLogo };
|
||||
// Host suffix → friendly provider label. The model-info card shows this so the
|
||||
// SAME model name served by DIFFERENT routes is distinguishable (e.g.
|
||||
// `claude-haiku` via OpenRouter vs GitHub Copilot vs Anthropic direct); the logo
|
||||
// only reflects the model vendor, not the actual endpoint. Patterns are anchored
|
||||
// to the end of the hostname (^|.)domain$ so a host like `max.airlines.com`
|
||||
// doesn't match `x.ai`.
|
||||
const _ENDPOINT_LABELS = [
|
||||
[/(^|\.)githubcopilot\.com$/i, "GitHub Copilot"],
|
||||
[/(^|\.)openrouter\.ai$/i, "OpenRouter"],
|
||||
[/(^|\.)anthropic\.com$/i, "Anthropic"],
|
||||
[/(^|\.)openai\.com$/i, "OpenAI"],
|
||||
[/(^|\.)(generativelanguage|aiplatform)\.googleapis\.com$/i, "Google"],
|
||||
[/(^|\.)bedrock[\w.-]*\.amazonaws\.com$/i, "AWS Bedrock"],
|
||||
[/(^|\.)deepseek\.com$/i, "DeepSeek"],
|
||||
[/(^|\.)mistral\.ai$/i, "Mistral"],
|
||||
[/(^|\.)groq\.com$/i, "Groq"],
|
||||
[/(^|\.)together\.(ai|xyz)$/i, "Together"],
|
||||
[/(^|\.)fireworks\.ai$/i, "Fireworks"],
|
||||
[/(^|\.)perplexity\.ai$/i, "Perplexity"],
|
||||
[/(^|\.)x\.ai$/i, "xAI"],
|
||||
];
|
||||
|
||||
/**
|
||||
* Friendly label for the endpoint that served a model, from its URL.
|
||||
* Returns "Local" for loopback/LAN hosts, a known provider name when matched,
|
||||
* else the bare host. Null when no URL is available.
|
||||
*/
|
||||
export function providerLabel(endpointUrl) {
|
||||
if (!endpointUrl || typeof endpointUrl !== "string") return null;
|
||||
let host;
|
||||
try {
|
||||
host = new URL(endpointUrl).hostname;
|
||||
} catch (_) {
|
||||
// Not a full URL (e.g. bare host[:port]) — strip scheme/path/port best-effort.
|
||||
host = endpointUrl.replace(/^[a-z]+:\/\//i, "").split("/")[0].split(":")[0];
|
||||
}
|
||||
if (!host) return null;
|
||||
if (/^(localhost|127\.|0\.0\.0\.0|::1|192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/i.test(host)) {
|
||||
return "Local";
|
||||
}
|
||||
for (const [re, label] of _ENDPOINT_LABELS) {
|
||||
if (re.test(host)) return label;
|
||||
}
|
||||
// Unknown host → drop a leading "api." for a cleaner readout.
|
||||
return host.replace(/^api\./i, "");
|
||||
}
|
||||
|
||||
export default { providerLogo, providerLabel };
|
||||
|
||||
@@ -1103,8 +1103,10 @@ function _renderResult(job) {
|
||||
html += '<div class="research-job-sources">';
|
||||
for (const s of job.sources.slice(0, 10)) {
|
||||
const title = _esc(s.title || s.url || '');
|
||||
const url = _esc(s.url || '');
|
||||
html += `<a href="${url}" target="_blank" rel="noopener" class="research-source-link">${title}</a>`;
|
||||
const url = _safeSourceHref(s.url);
|
||||
html += url
|
||||
? `<a href="${url}" target="_blank" rel="noopener" class="research-source-link">${title}</a>`
|
||||
: `<span class="research-source-link">${title}</span>`;
|
||||
}
|
||||
if (job.sources.length > 10) html += `<span class="research-source-more">+${job.sources.length - 10} more</span>`;
|
||||
html += '</div>';
|
||||
@@ -1231,3 +1233,11 @@ function _esc(s) {
|
||||
d.textContent = s || '';
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function _safeSourceHref(raw) {
|
||||
try {
|
||||
const parsed = new URL(String(raw || '').trim(), window.location.origin);
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return _esc(parsed.href);
|
||||
} catch {}
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -2157,7 +2157,14 @@ async function _checkServerStream(sessionId) {
|
||||
// Skip if this is a research stream — research has its own progress UI
|
||||
if (info.mode === 'research' || info.is_research) return;
|
||||
|
||||
// Server is still streaming — show spinner and poll
|
||||
// Live-resume the detached run: replay its buffer then stream live tokens
|
||||
// (#2539). Falls back to the spinner+poll path below if unavailable.
|
||||
if (window.chatModule && window.chatModule.resumeStream) {
|
||||
const attached = await window.chatModule.resumeStream(sessionId);
|
||||
if (attached) return;
|
||||
}
|
||||
|
||||
// Fallback: server is still streaming, show spinner and poll.
|
||||
const box = document.getElementById('chat-history');
|
||||
if (!box) return;
|
||||
|
||||
|
||||
+114
-11
@@ -13,6 +13,10 @@ let modalEl = null;
|
||||
|
||||
function el(id) { return document.getElementById(id); }
|
||||
function esc(s) { return uiModule.esc(s); }
|
||||
function safeRasterDataUrl(raw) {
|
||||
const value = String(raw || '').trim();
|
||||
return /^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(value) ? value : '';
|
||||
}
|
||||
|
||||
/* ── Tab switching ── */
|
||||
const ADMIN_TABS = new Set(['services', 'integrations', 'tools', 'users', 'system']);
|
||||
@@ -1554,6 +1558,7 @@ async function initResearchSearchSettings() {
|
||||
/* ── Agent Settings (AI tab) ── */
|
||||
async function initAgentSettings() {
|
||||
var toolsInput = el('set-agentMaxTools');
|
||||
var roundsInput = el('set-agentMaxRounds');
|
||||
var msg = el('set-agentMsg');
|
||||
if (!toolsInput) return;
|
||||
|
||||
@@ -1561,23 +1566,41 @@ async function initAgentSettings() {
|
||||
var res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||
var settings = await res.json();
|
||||
if (settings.agent_max_tool_calls) toolsInput.value = settings.agent_max_tool_calls;
|
||||
if (roundsInput && settings.agent_max_rounds) roundsInput.value = settings.agent_max_rounds;
|
||||
} catch (e) {}
|
||||
|
||||
// Clamp + coerce a raw input to an int in [lo, hi]; falls back to `dflt`
|
||||
// when blank/non-numeric. Mirrors the server-side validation.
|
||||
function clampInt(raw, lo, hi, dflt) {
|
||||
var n = parseInt(raw, 10);
|
||||
if (isNaN(n)) return dflt;
|
||||
return Math.max(lo, Math.min(n, hi));
|
||||
}
|
||||
|
||||
async function save() {
|
||||
var val = parseInt(toolsInput.value, 10) || 0;
|
||||
var tools = clampInt(toolsInput.value, 0, 1000, 0);
|
||||
var rounds = roundsInput ? clampInt(roundsInput.value, 1, 200, 20) : null;
|
||||
toolsInput.value = tools; // reflect the clamped value
|
||||
if (roundsInput) roundsInput.value = rounds;
|
||||
var payload = { agent_max_tool_calls: tools };
|
||||
if (rounds != null) payload.agent_max_rounds = rounds;
|
||||
try {
|
||||
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ agent_max_tool_calls: val })
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
msg.textContent = val > 0 ? 'Limit: ' + val + ' tool calls per message' : 'Unlimited';
|
||||
msg.textContent = (tools > 0 ? 'Limit: ' + tools + ' tool calls' : 'Unlimited tool calls') +
|
||||
(rounds != null ? ' · ' + rounds + ' steps/message' : '');
|
||||
msg.style.color = 'var(--fg)';
|
||||
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||
}
|
||||
|
||||
toolsInput.addEventListener('change', save);
|
||||
if (roundsInput) roundsInput.addEventListener('change', save);
|
||||
var cur = parseInt(toolsInput.value, 10) || 0;
|
||||
msg.textContent = cur > 0 ? 'Limit: ' + cur + ' tool calls per message' : 'Unlimited';
|
||||
var curR = roundsInput ? (parseInt(roundsInput.value, 10) || 20) : null;
|
||||
msg.textContent = (cur > 0 ? 'Limit: ' + cur + ' tool calls' : 'Unlimited tool calls') +
|
||||
(curR != null ? ' · ' + curR + ' steps/message' : '');
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
@@ -2069,15 +2092,16 @@ function initAccount() {
|
||||
const r = await fetch('/api/auth/2fa/setup', { method: 'POST', credentials: 'same-origin' });
|
||||
if (!r.ok) { const d = await r.json(); throw new Error(d.detail || 'Failed'); }
|
||||
const setup = await r.json();
|
||||
const qrCode = safeRasterDataUrl(setup.qr_code);
|
||||
// Show QR code + manual secret + verify input
|
||||
tfaContent.innerHTML = `
|
||||
<div style="text-align:center;margin-bottom:12px;">
|
||||
<img src="${setup.qr_code}" alt="QR Code" style="border-radius:8px;max-width:200px;">
|
||||
${qrCode ? `<img src="${esc(qrCode)}" alt="QR Code" style="border-radius:8px;max-width:200px;">` : ''}
|
||||
</div>
|
||||
<div style="font-size:11px;opacity:0.5;text-align:center;margin-bottom:8px;">
|
||||
Scan with your authenticator app, or enter manually:
|
||||
</div>
|
||||
<div style="font-family:monospace;font-size:12px;text-align:center;padding:6px;background:var(--bg);border:1px solid var(--border);border-radius:4px;margin-bottom:12px;word-break:break-all;user-select:all;cursor:text;">${setup.secret}</div>
|
||||
<div style="font-family:monospace;font-size:12px;text-align:center;padding:6px;background:var(--bg);border:1px solid var(--border);border-radius:4px;margin-bottom:12px;word-break:break-all;user-select:all;cursor:text;">${esc(setup.secret)}</div>
|
||||
<input id="tfa-verify-code" type="text" placeholder="Enter 6-digit code to verify" autocomplete="one-time-code" inputmode="numeric" maxlength="8" style="width:100%;padding:8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:13px;box-sizing:border-box;text-align:center;letter-spacing:3px;margin-bottom:6px;">
|
||||
<div class="settings-row" style="justify-content:flex-end;">
|
||||
<span id="tfa-msg" style="font-size:11px;margin-right:auto;"></span>
|
||||
@@ -4424,6 +4448,68 @@ async function initUnifiedIntegrations() {
|
||||
|
||||
// ── MCP form — full management view ──
|
||||
async function showMcpForm(editId) {
|
||||
// Toggle an in-flight loading state on a button (disabled + dimmed + label).
|
||||
function _setBtnLoading(btn, loading, label) {
|
||||
if (!btn) return;
|
||||
btn.disabled = loading;
|
||||
btn.style.opacity = loading ? '0.6' : '';
|
||||
btn.style.cursor = loading ? 'progress' : '';
|
||||
if (label != null) btn.textContent = label;
|
||||
}
|
||||
function _showMcpPasteback(id) {
|
||||
const msg = el('uf-mcp-msg'); if (!msg) return;
|
||||
if (el('uf-mcp-pasteback')) return; // already shown
|
||||
msg.innerHTML =
|
||||
'Authorize in the opened tab. If the redirect fails (remote access), paste the resulting URL here: ' +
|
||||
'<input id="uf-mcp-pasteback" class="settings-input" placeholder="http://localhost:7000/api/mcp/oauth/callback?code=..." style="margin-top:4px">' +
|
||||
'<button class="admin-btn-sm" id="uf-mcp-paste-go" style="margin-top:4px">Submit</button>';
|
||||
const pasteGo = el('uf-mcp-paste-go');
|
||||
if (pasteGo) pasteGo.addEventListener('click', async () => {
|
||||
const cb = el('uf-mcp-pasteback').value.trim();
|
||||
if (!cb) return;
|
||||
const pf = new FormData(); pf.append('callback_url', cb);
|
||||
_setBtnLoading(pasteGo, true, 'Submitting…');
|
||||
try {
|
||||
await fetch(`/api/mcp/oauth/exchange/${id}`, { method: 'POST', credentials: 'same-origin', body: pf });
|
||||
} finally {
|
||||
_setBtnLoading(pasteGo, false, 'Submit');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Drives the OAuth flow: waits for the auth_url (discovery+DCR may lag),
|
||||
// opens it once, then resolves on connected/error.
|
||||
async function _handleMcpAuth(id, initialAuthUrl, tries = 90) {
|
||||
let opened = false;
|
||||
const openAuth = (u) => { if (!opened && u) { opened = true; window.open(u, '_blank', 'noopener'); _showMcpPasteback(id); } };
|
||||
openAuth(initialAuthUrl);
|
||||
const msg = el('uf-mcp-msg');
|
||||
let fails = 0;
|
||||
for (let i = 0; i < tries; i++) {
|
||||
await new Promise(res => setTimeout(res, 2000));
|
||||
try {
|
||||
const r = await fetch('/api/mcp/servers', { credentials: 'same-origin' });
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
const list = await r.json();
|
||||
fails = 0;
|
||||
const s = Array.isArray(list) ? list.find(x => x.id === id) : null;
|
||||
if (!s) continue;
|
||||
if (s.auth_url) openAuth(s.auth_url);
|
||||
if (s.status === 'connected') {
|
||||
if (msg) msg.textContent = `Connected (${s.tool_count || 0} tools)`;
|
||||
await renderList(); return;
|
||||
}
|
||||
if (s.status === 'error') {
|
||||
if (msg) msg.textContent = `Failed: ${s.error || 'unknown'}`; return;
|
||||
}
|
||||
} catch (e) {
|
||||
// Tolerate a single blip, but surface persistent failures instead of
|
||||
// silently polling until timeout.
|
||||
if (++fails >= 5 && msg) msg.textContent = `Status check failing (${e.message || 'network error'}) — still retrying…`;
|
||||
}
|
||||
}
|
||||
if (msg) msg.textContent = 'Authorization timed out. Reconnect from the server list to retry.';
|
||||
}
|
||||
if (editId && editId !== 'new') {
|
||||
// Show management view for existing server
|
||||
formEl.innerHTML = '<div class="admin-card" style="margin-top:8px"><span style="opacity:0.5;font-size:11px">Loading...</span></div>';
|
||||
@@ -4501,7 +4587,7 @@ async function initUnifiedIntegrations() {
|
||||
<h2 style="font-size:13px">Add MCP Server</h2>
|
||||
<div class="settings-col">
|
||||
<div class="settings-row"><label class="settings-label">Name</label><input id="uf-mcp-name" class="settings-input" placeholder="Server name"></div>
|
||||
<div class="settings-row"><label class="settings-label">Transport</label><select id="uf-mcp-transport" class="settings-input"><option value="stdio">stdio</option><option value="sse">SSE</option></select></div>
|
||||
<div class="settings-row"><label class="settings-label">Transport</label><select id="uf-mcp-transport" class="settings-input"><option value="stdio">stdio</option><option value="sse">SSE</option><option value="http">Streamable HTTP</option></select></div>
|
||||
<div id="uf-mcp-stdio-fields" style="display:flex;flex-direction:column;gap:6px;">
|
||||
<div class="settings-row"><label class="settings-label">Command</label><input id="uf-mcp-cmd" class="settings-input" placeholder="npx"></div>
|
||||
<div class="settings-row"><label class="settings-label">Args</label><input id="uf-mcp-args" class="settings-input" placeholder='["-y", "@modelcontextprotocol/server-filesystem"]'></div>
|
||||
@@ -4514,9 +4600,12 @@ async function initUnifiedIntegrations() {
|
||||
</div>
|
||||
</div>`;
|
||||
el('uf-mcp-transport').addEventListener('change', () => {
|
||||
const sse = el('uf-mcp-transport').value === 'sse';
|
||||
el('uf-mcp-stdio-fields').style.display = sse ? 'none' : 'flex';
|
||||
el('uf-mcp-sse-fields').style.display = sse ? 'flex' : 'none';
|
||||
const v = el('uf-mcp-transport').value;
|
||||
const isUrl = (v === 'sse' || v === 'http');
|
||||
el('uf-mcp-stdio-fields').style.display = isUrl ? 'none' : 'flex';
|
||||
el('uf-mcp-sse-fields').style.display = isUrl ? 'flex' : 'none';
|
||||
const urlInput = el('uf-mcp-url');
|
||||
if (urlInput) urlInput.placeholder = (v === 'http') ? 'https://mcp.example.com/mcp' : 'http://localhost:3001/sse';
|
||||
});
|
||||
el('uf-mcp-cancel').addEventListener('click', () => { formEl.style.display = 'none'; });
|
||||
el('uf-mcp-save').addEventListener('click', async () => {
|
||||
@@ -4534,14 +4623,25 @@ async function initUnifiedIntegrations() {
|
||||
} else {
|
||||
fd.append('url', el('uf-mcp-url').value);
|
||||
}
|
||||
const saveBtn = el('uf-mcp-save'), cancelBtn = el('uf-mcp-cancel');
|
||||
const _origLabel = saveBtn.textContent;
|
||||
_setBtnLoading(saveBtn, true, 'Saving…'); if (cancelBtn) cancelBtn.disabled = true;
|
||||
try {
|
||||
const r = await fetch('/api/mcp/servers', { method: 'POST', credentials: 'same-origin', body: fd });
|
||||
if (r.ok) {
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (r.ok && data.needs_auth) {
|
||||
el('uf-mcp-msg').textContent = 'Preparing authorization…';
|
||||
_handleMcpAuth(data.id, data.auth_url);
|
||||
} else if (r.ok && (data.connected || data.status === 'connected')) {
|
||||
el('uf-mcp-msg').textContent = `Connected (${data.tool_count || 0} tools)`;
|
||||
formEl.style.display = 'none'; await renderList();
|
||||
} else if (r.ok) {
|
||||
el('uf-mcp-msg').textContent = 'Saved'; formEl.style.display = 'none'; await renderList();
|
||||
} else {
|
||||
el('uf-mcp-msg').textContent = `Failed (${r.status})`;
|
||||
}
|
||||
} catch (_) { el('uf-mcp-msg').textContent = 'Failed'; }
|
||||
finally { _setBtnLoading(saveBtn, false, _origLabel); if (cancelBtn) cancelBtn.disabled = false; }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4566,6 +4666,8 @@ async function initUnifiedIntegrations() {
|
||||
{ key: 'calendar:write', label: 'Calendar write', detail: 'Create and update calendar events' },
|
||||
{ key: 'memory:read', label: 'Memory', detail: 'Read memory when enabled' },
|
||||
{ key: 'memory:write', label: 'Memory write', detail: 'Write memory when enabled' },
|
||||
{ key: 'cookbook:read', label: 'Cookbook', detail: 'List cookbook tasks + tail their tmux output (debug a model serve from outside the UI)' },
|
||||
{ key: 'cookbook:launch', label: 'Cookbook launch', detail: 'Launch and stop cookbook serve tasks. Powerful: runs SSH commands on your configured servers, bounded by the same allowlist the UI uses (vllm/python3/sglang/llama-server/...)' },
|
||||
];
|
||||
// Strict name-prefix match keeps Codex and Claude tokens in their own forms.
|
||||
const agentTokens = (Array.isArray(tokens) ? tokens : []).filter(tok =>
|
||||
@@ -4578,6 +4680,7 @@ async function initUnifiedIntegrations() {
|
||||
email: '<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="2" y="4" width="20" height="16" rx="2"/><polyline points="2 6 12 13 22 6"/></svg>',
|
||||
calendar: '<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="3" y="4" width="18" height="18" rx="2" ry="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>',
|
||||
memory: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M9.5 2a2.5 2.5 0 0 0-2.5 2.5 2.5 2.5 0 0 0-2.5 2.5A2.5 2.5 0 0 0 2 9.5v3A2.5 2.5 0 0 0 4.5 15a2.5 2.5 0 0 0 2.5 2.5A2.5 2.5 0 0 0 9.5 20H10V2z"/><path d="M14.5 2a2.5 2.5 0 0 1 2.5 2.5 2.5 2.5 0 0 1 2.5 2.5A2.5 2.5 0 0 1 22 9.5v3A2.5 2.5 0 0 1 19.5 15a2.5 2.5 0 0 1-2.5 2.5A2.5 2.5 0 0 1 14.5 20H14V2z"/></svg>',
|
||||
cookbook: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>',
|
||||
};
|
||||
const _scopeNiceLabel = (label) => label.replace(/\s+(write|drafts?|send)$/i, '');
|
||||
const _scopeAction = (key) => (key.split(':')[1] || '').toLowerCase();
|
||||
|
||||
+27
-7
@@ -14,6 +14,20 @@
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
|
||||
function _esc(s) {
|
||||
return String(s ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function _safeSignatureDataUrl(raw) {
|
||||
const value = String(raw || '').trim();
|
||||
return /^data:image\/(?:png|jpe?g);base64,[a-z0-9+/=\s]+$/i.test(value) ? value : '';
|
||||
}
|
||||
|
||||
// Last signature the user picked or created in this session. Lets the export
|
||||
// modal pre-fill subsequent signature fields with the same one — sign once,
|
||||
// applies everywhere.
|
||||
@@ -446,13 +460,17 @@ export function capture(opts = {}) {
|
||||
export function pick(opts = {}) {
|
||||
return new Promise(async (resolve) => {
|
||||
const sigs = await _listSignatures();
|
||||
const tiles = sigs.map((s) => `
|
||||
<div class="sig-tile" data-id="${s.id}">
|
||||
<img src="${s.data_url}"/>
|
||||
<div style="margin-top:4px;font-size:0.72rem;color:var(--fg);opacity:0.85;text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${(s.name || '').replace(/[<>&]/g, '')}</div>
|
||||
<button class="sig-tile-del" data-id="${s.id}" title="Delete">×</button>
|
||||
const tiles = sigs.map((s) => {
|
||||
const dataUrl = _safeSignatureDataUrl(s.data_url);
|
||||
if (!dataUrl) return '';
|
||||
return `
|
||||
<div class="sig-tile" data-id="${_esc(s.id)}">
|
||||
<img src="${_esc(dataUrl)}"/>
|
||||
<div style="margin-top:4px;font-size:0.72rem;color:var(--fg);opacity:0.85;text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${_esc(s.name || '')}</div>
|
||||
<button class="sig-tile-del" data-id="${_esc(s.id)}" title="Delete">×</button>
|
||||
</div>
|
||||
`).join('');
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const overlay = _modal(`
|
||||
<div class="modal-content" style="width:min(560px,94vw);">
|
||||
@@ -477,7 +495,9 @@ export function pick(opts = {}) {
|
||||
const id = tile.dataset.id;
|
||||
const s = sigs.find((x) => x.id === id);
|
||||
if (s) {
|
||||
const out = { id: s.id, dataUrl: s.data_url, width: s.width, height: s.height, name: s.name };
|
||||
const dataUrl = _safeSignatureDataUrl(s.data_url);
|
||||
if (!dataUrl) return;
|
||||
const out = { id: s.id, dataUrl, width: s.width, height: s.height, name: s.name };
|
||||
setLastUsed(out);
|
||||
close(out);
|
||||
}
|
||||
|
||||
+111
-3
@@ -17,6 +17,7 @@ import chatRenderer from './chatRenderer.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import themeModule from './theme.js';
|
||||
import documentModule from './document.js';
|
||||
import workspaceModule from './workspace.js';
|
||||
import settingsModule from './settings.js';
|
||||
import cookbookModule from './cookbook.js';
|
||||
import { EVAL_PROMPTS } from './compare/index.js';
|
||||
@@ -1141,6 +1142,35 @@ async function _cmdToggleDoc(args, ctx) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Workspace: confine the agent's file/shell tools to a folder. Not a boolean —
|
||||
// show / set <path> / clear / pick (open the directory browser).
|
||||
async function _cmdWorkspace(args, ctx) {
|
||||
const sub = (args[0] || '').toLowerCase();
|
||||
const rest = args.slice(1).join(' ').trim();
|
||||
const cur = workspaceModule.getWorkspace();
|
||||
if (!sub || sub === 'show' || sub === 'status' || sub === 'info') {
|
||||
slashReply(cur ? `Workspace: <code>${uiModule.esc(cur)}</code>` : 'No workspace set. <code>/workspace pick</code> or <code>/workspace set /path</code>.');
|
||||
return true;
|
||||
}
|
||||
if (sub === 'set' || sub === 'cd' || sub === 'use') {
|
||||
if (!rest) { slashReply('Usage: <code>/workspace set /absolute/path</code>'); return true; }
|
||||
workspaceModule.setWorkspace(rest);
|
||||
slashReply(`Workspace set: <code>${uiModule.esc(rest)}</code>`);
|
||||
return true;
|
||||
}
|
||||
if (sub === 'clear' || sub === 'off' || sub === 'none' || sub === 'unset') {
|
||||
workspaceModule.clearWorkspace();
|
||||
slashReply('Workspace cleared.');
|
||||
return true;
|
||||
}
|
||||
if (sub === 'pick' || sub === 'browse' || sub === 'open') {
|
||||
workspaceModule.openWorkspaceBrowser();
|
||||
return true;
|
||||
}
|
||||
slashReply('Usage: <code>/workspace</code> · <code>set /path</code> · <code>clear</code> · <code>pick</code>');
|
||||
return true;
|
||||
}
|
||||
|
||||
async function _cmdToggleShow(args, ctx) {
|
||||
const name = (args[0] || '').toLowerCase();
|
||||
const val = (args[1] || '').toLowerCase();
|
||||
@@ -4735,11 +4765,47 @@ function _clearSetupCommandInput() {
|
||||
}
|
||||
}
|
||||
|
||||
// GitHub Copilot device-flow sign-in, driven from chat (mirrors the Settings
|
||||
// "Connect GitHub Copilot" button). Replies via the setup guide messages.
|
||||
async function _setupCopilot() {
|
||||
_clearSetupGuideMessages();
|
||||
await _setupReply('Starting GitHub Copilot sign-in…');
|
||||
let start;
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/api/copilot/device/start`, { method: 'POST', body: new FormData(), credentials: 'same-origin' });
|
||||
start = await r.json();
|
||||
if (!r.ok) { await _setupReply(start.detail || 'Failed to start Copilot sign-in.'); return; }
|
||||
} catch (e) { await _setupReply('Request failed.'); return; }
|
||||
const authUrl = start.verification_uri_complete || start.verification_uri || '';
|
||||
await _setupReply(`Opening GitHub — approve the request (code ${start.user_code}). Waiting…`);
|
||||
try { if (authUrl) window.open(authUrl, '_blank', 'noopener'); } catch (e) {}
|
||||
const deadline = Date.now() + (start.expires_in || 900) * 1000;
|
||||
const stepMs = Math.max((start.interval || 5), 2) * 1000;
|
||||
const poll = async () => {
|
||||
if (Date.now() > deadline) { await _setupReply('Copilot sign-in expired — run /setup copilot again.'); return; }
|
||||
try {
|
||||
const fd = new FormData(); fd.append('poll_id', start.poll_id);
|
||||
const r = await fetch(`${API_BASE}/api/copilot/device/poll`, { method: 'POST', body: fd, credentials: 'same-origin' });
|
||||
const d = await r.json();
|
||||
if (d.status === 'authorized') {
|
||||
const n = ((d.endpoint && d.endpoint.models) || []).length;
|
||||
await _setupReply(`Connected — ${n} Copilot model${n !== 1 ? 's' : ''} available.`);
|
||||
if (modelsModule) modelsModule.refreshModels(true);
|
||||
return;
|
||||
}
|
||||
if (d.status === 'failed') { await _setupReply('Copilot sign-in failed (' + (d.error || 'denied') + ').'); return; }
|
||||
} catch (e) { /* transient — keep polling */ }
|
||||
setTimeout(poll, stepMs);
|
||||
};
|
||||
setTimeout(poll, stepMs);
|
||||
}
|
||||
|
||||
async function _cmdSetup(args, ctx) {
|
||||
_hideWelcomeScreen();
|
||||
_clearSetupCommandInput();
|
||||
const topic = (args[0] || '').trim().toLowerCase();
|
||||
const topicArgs = args.slice(1);
|
||||
if (topic === 'copilot' || topic === 'github') { await _setupCopilot(); return true; }
|
||||
const provider = _setupProviderFromInput(topic);
|
||||
if (provider) {
|
||||
_clearSetupGuideMessages();
|
||||
@@ -4749,7 +4815,15 @@ async function _cmdSetup(args, ctx) {
|
||||
} else {
|
||||
pendingSetupProvider = provider;
|
||||
setupMode = 'endpoint-key-for-provider';
|
||||
await _setupReply(`Paste your ${provider.name} API key.`);
|
||||
// Show the canonical "/setup <provider> <key>" usage so the user
|
||||
// learns the one-shot form instead of relying on the pasted-key
|
||||
// mode that always greets them with a generic prompt.
|
||||
// _setupReply renders as plain text (no HTML) — use markdown
|
||||
// backticks for the inline code instead of <code> + <>.
|
||||
const _slug = (topic || '').toLowerCase();
|
||||
await _setupReply(
|
||||
`Paste your ${provider.name} API key, or run \`/setup ${_slug} <api-key>\` to set it in one step.`
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -5419,6 +5493,14 @@ const COMMANDS = {
|
||||
'_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]',
|
||||
},
|
||||
memory: {
|
||||
alias: ['m'],
|
||||
category: 'Memory',
|
||||
@@ -5464,7 +5546,31 @@ const COMMANDS = {
|
||||
category: 'Getting started',
|
||||
help: 'Add local or API model endpoints',
|
||||
handler: _cmdSetup,
|
||||
usage: '/setup local URL · /setup groq KEY · /setup endpoint'
|
||||
usage: '/setup local URL · /setup groq KEY · /setup copilot · /setup endpoint',
|
||||
// Provider subs so the autocomplete popup surfaces "/setup deepseek",
|
||||
// "/setup openai", etc. when the user types "/setup de". Each sub's
|
||||
// handler is a thin wrapper that re-prepends the sub name and
|
||||
// re-dispatches into _cmdSetup, which already knows how to handle
|
||||
// bare-provider (prompts for the key) AND provider-with-key (saves it).
|
||||
// Without the explicit handler, the slash-dispatcher errors with
|
||||
// "subDef.handler is not a function".
|
||||
subs: {
|
||||
deepseek: { help: 'DeepSeek', usage: '/setup deepseek sk-...', handler: (a, c) => _cmdSetup(['deepseek', ...a], c) },
|
||||
openai: { help: 'OpenAI', usage: '/setup openai sk-proj-...', handler: (a, c) => _cmdSetup(['openai', ...a], c) },
|
||||
anthropic: { help: 'Anthropic', usage: '/setup anthropic sk-ant-...',handler: (a, c) => _cmdSetup(['anthropic', ...a], c) },
|
||||
openrouter: { help: 'OpenRouter', usage: '/setup openrouter sk-or-...',handler: (a, c) => _cmdSetup(['openrouter', ...a], c) },
|
||||
groq: { help: 'Groq', usage: '/setup groq gsk_...', handler: (a, c) => _cmdSetup(['groq', ...a], c) },
|
||||
gemini: { help: 'Google Gemini', alias: ['google'], usage: '/setup gemini AIza...', handler: (a, c) => _cmdSetup(['gemini', ...a], c) },
|
||||
xai: { help: 'xAI (Grok)', alias: ['grok'], usage: '/setup xai xai-...', handler: (a, c) => _cmdSetup(['xai', ...a], c) },
|
||||
ollama: { help: 'Ollama Cloud', usage: '/setup ollama KEY', handler: (a, c) => _cmdSetup(['ollama', ...a], c) },
|
||||
copilot: { help: 'GitHub Copilot', usage: '/setup copilot', handler: (a, c) => _cmdSetup(['copilot', ...a], c) },
|
||||
local: { help: 'Local model server (vLLM / LM Studio / llama.cpp / Ollama)',
|
||||
usage: '/setup local http://localhost:8000/v1',
|
||||
handler: (a, c) => _cmdSetup(['local', ...a], c) },
|
||||
endpoint: { help: 'Open the endpoint manager in Settings',
|
||||
usage: '/setup endpoint',
|
||||
handler: (a, c) => _cmdSetup(['endpoint', ...a], c) },
|
||||
},
|
||||
},
|
||||
demo: {
|
||||
alias: ['tour'],
|
||||
@@ -5844,7 +5950,9 @@ async function handleSlashCommand(input) {
|
||||
let args = parts.slice(1);
|
||||
const ctx = _makeCtx();
|
||||
let _userShown = false;
|
||||
function _showUser() { if (!_userShown) { _userShown = true; _addMessage('user', input); _persistMsg('user', input); } }
|
||||
// Tag the echoed command with source:'slash' so it renders in the transcript
|
||||
// but is excluded from LLM context (get_context_messages), like the replies.
|
||||
function _showUser() { if (!_userShown) { _userShown = true; _addMessage('user', input); _persistMsg('user', input, { source: 'slash' }); } }
|
||||
|
||||
try {
|
||||
// --- Check for --help / -h on any command ---
|
||||
|
||||
@@ -23,7 +23,8 @@ export const KEYS = {
|
||||
MCP_ACTIVE: 'odysseus-mcp-active',
|
||||
SECTION_ORDER: 'sidebar-section-order',
|
||||
ADMIN_LAST_TAB: 'admin-last-tab',
|
||||
DENSITY: 'odysseus-density'
|
||||
DENSITY: 'odysseus-density',
|
||||
WORKSPACE: 'odysseus-workspace'
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+15
-4
@@ -504,8 +504,13 @@ const _CATEGORY_MAP = {
|
||||
ssh_command: 'System',
|
||||
run_script: 'System',
|
||||
run_local: 'System',
|
||||
cookbook_serve: 'Cookbook',
|
||||
};
|
||||
const _CATEGORY_ORDER = ['Other', 'Calendar', 'Email', 'Chats', 'Documents', 'Memory', 'Research', 'Skills', 'Assistant', 'System'];
|
||||
// Cookbook serves listed FIRST so a just-saved schedule shows at the
|
||||
// top instead of scrolling off the bottom of the list. The remaining
|
||||
// order is preserved for backwards-compatibility with users who've
|
||||
// learned where things are.
|
||||
const _CATEGORY_ORDER = ['Cookbook', 'Other', 'Calendar', 'Email', 'Chats', 'Documents', 'Memory', 'Research', 'Skills', 'Assistant', 'System'];
|
||||
const _CATEGORY_ICONS = {
|
||||
Calendar: '<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"/>',
|
||||
Email: '<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"/>',
|
||||
@@ -516,6 +521,8 @@ const _CATEGORY_ICONS = {
|
||||
Skills: '<path d="M9 11l3 3L22 4"/><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M4 4.5A2.5 2.5 0 0 1 6.5 2H20v15H6.5A2.5 2.5 0 0 0 4 19.5z"/>',
|
||||
Assistant: '<circle cx="12" cy="12" r="10"/><circle cx="12" cy="10" r="3"/><path d="M7 18a5 5 0 0 1 10 0"/>',
|
||||
System: '<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"/>',
|
||||
// Cookbook icon — matches the recipe-book glyph used on the sidebar.
|
||||
Cookbook: '<path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/>',
|
||||
Other: '<circle cx="12" cy="12" r="3"/>',
|
||||
};
|
||||
|
||||
@@ -955,9 +962,13 @@ function _showPresetPicker() {
|
||||
let html = '<div class="admin-card" style="flex:1;display:flex;flex-direction:column;overflow:hidden;">';
|
||||
html += '<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;"><h2 style="margin:0;padding:0;line-height:1;">Add Task</h2></div>';
|
||||
html += '<p class="memory-desc" style="position:relative;top:4px;">Describe a task for the AI to draft, or pick a type below to set one up manually.</p>';
|
||||
html += '<div class="task-ai-compose" style="display:flex;gap:6px;margin:6px 0 10px;">'
|
||||
+ '<input type="text" id="task-ai-input" class="memory-search-input" style="flex:1;" placeholder="Describe a task — e.g. "every weekday 7am summarize my unread email"" />'
|
||||
+ '<button class="memory-toolbar-btn active" id="task-ai-btn" title="Draft a task with AI" style="white-space:nowrap;height:28px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:3px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>Draft with AI</button>'
|
||||
// flex-wrap + min-width:0 on the input lets the row collapse cleanly
|
||||
// on narrow modal widths instead of pushing the AI button past the
|
||||
// right edge. margin-left:-4px nudges the compose row 4px into the
|
||||
// description bar above so the input lines up with it visually.
|
||||
html += '<div class="task-ai-compose" style="display:flex;gap:6px;margin:6px 0 10px -4px;flex-wrap:wrap;align-items:center;">'
|
||||
+ '<input type="text" id="task-ai-input" class="memory-search-input" style="flex:1 1 220px;min-width:0;" placeholder="Describe a task — e.g. "every weekday 7am summarize my unread email"" />'
|
||||
+ '<button class="memory-toolbar-btn active" id="task-ai-btn" title="Draft a task with AI" style="white-space:nowrap;height:28px;flex:0 0 auto;"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:3px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>Draft with AI</button>'
|
||||
+ '</div>';
|
||||
html += '<div class="memory-list" style="max-height:none;flex:1;gap:0px;margin-top:2px;padding-right:8px;">';
|
||||
_TASK_PRESETS.forEach((p, i) => {
|
||||
|
||||
@@ -149,6 +149,13 @@ export function makeWindowDraggable(modal, options = {}) {
|
||||
const _startDrag = (cx, cy) => {
|
||||
dragging = true;
|
||||
if (modal) modal.classList.add('modal-dragging');
|
||||
// Cancel any in-flight open animation so we don't pin a mid-animation
|
||||
// rect and then jump once the animation settles.
|
||||
try {
|
||||
content.getAnimations()
|
||||
.filter(a => a.playState !== 'finished')
|
||||
.forEach(a => a.cancel());
|
||||
} catch (_) {}
|
||||
const rect = content.getBoundingClientRect();
|
||||
if (onDragStart) {
|
||||
try { onDragStart({ rect, cx, cy }); } catch (_) {}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// 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 };
|
||||
@@ -1,824 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="Odysseus — a self-hosted AI workspace: chat, agents, tools, model serving, email, research, and more. Your models, your hardware, your data.">
|
||||
<title>Odysseus — A Self-Hosted AI Workspace</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpath d='M16 4L16 22L6 22Z' fill='%23e06c75'/%3E%3Cpath d='M16 8L16 22L24 22Z' fill='%23e06c75' opacity='0.6'/%3E%3Cpath d='M4 24Q10 20 16 24Q22 28 28 24' stroke='%23e06c75' stroke-width='2.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E">
|
||||
<style>
|
||||
:root {
|
||||
/* Odysseus default theme — exact app tokens */
|
||||
--bg: #282c34;
|
||||
--bg2: #1e2228; /* app code/hl background */
|
||||
--panel: #111; /* app panel surface */
|
||||
--panel2: #1e2228;
|
||||
--fg: #9cdef2; /* signature cyan text */
|
||||
--heading: #9cdef2;
|
||||
--muted: #6b8a94; /* app subheader */
|
||||
--border: #355a66; /* teal border */
|
||||
--accent: #e06c75; /* app accent (the send-button coral) */
|
||||
--accent2: #f0989e; /* lighter coral for gradients */
|
||||
--green: #50fa7b;
|
||||
--gold: #f0ad4e; /* app --warn */
|
||||
--red: #e06c75;
|
||||
--radius: 8px;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; scroll-snap-type: y mandatory; scroll-padding-top: 60px; }
|
||||
/* Each section is a full-viewport "page" with its content centered, so only
|
||||
one shows at a time and the snap is obvious. */
|
||||
.hero, section {
|
||||
scroll-snap-align: start; min-height: 100vh;
|
||||
display: flex; flex-direction: column; justify-content: center;
|
||||
}
|
||||
/* Alternate the page backgrounds: slate (the body) ↔ black, to make each
|
||||
page boundary obvious. */
|
||||
section:nth-of-type(odd) { background: #111111; }
|
||||
section:nth-of-type(even) { background: var(--bg); }
|
||||
/* Domino reveal — each section fades/slides up as it scrolls into view. */
|
||||
.hero, section { opacity: 0; transform: translateY(24px); transition: opacity .6s cubic-bezier(.2,.7,.2,1), transform .6s cubic-bezier(.2,.7,.2,1); }
|
||||
.hero.in, section.in { opacity: 1; transform: none; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html { scroll-snap-type: none; }
|
||||
.hero, section { opacity: 1 !important; transform: none !important; transition: none; }
|
||||
}
|
||||
/* Capabilities cards cascade in like the app's domino expand. */
|
||||
#features .feature { opacity: 0; transform: translateY(16px); }
|
||||
#features.in .feature { animation: domino-in .5s cubic-bezier(.2,.7,.2,1) forwards; }
|
||||
#features.in .feature:nth-child(1) { animation-delay: .04s; }
|
||||
#features.in .feature:nth-child(2) { animation-delay: .09s; }
|
||||
#features.in .feature:nth-child(3) { animation-delay: .14s; }
|
||||
#features.in .feature:nth-child(4) { animation-delay: .19s; }
|
||||
#features.in .feature:nth-child(5) { animation-delay: .24s; }
|
||||
#features.in .feature:nth-child(6) { animation-delay: .29s; }
|
||||
#features.in .feature:nth-child(7) { animation-delay: .34s; }
|
||||
#features.in .feature:nth-child(8) { animation-delay: .39s; }
|
||||
#features.in .feature:nth-child(9) { animation-delay: .44s; }
|
||||
@keyframes domino-in { to { opacity: 1; transform: none; } }
|
||||
body {
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(1100px 520px at 82% -10%, rgba(224,108,117,0.12), transparent 60%),
|
||||
radial-gradient(900px 520px at 0% 0%, rgba(53,90,102,0.30), transparent 55%),
|
||||
var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: 'Fira Code', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
.wrap { max-width: 1080px; margin: 0 auto; padding: 0 22px; }
|
||||
|
||||
/* Nav */
|
||||
nav {
|
||||
position: sticky; top: 0; z-index: 50;
|
||||
backdrop-filter: blur(10px);
|
||||
background: rgba(17,17,17,0.88);
|
||||
border-bottom: 1px solid #9cdef2;
|
||||
}
|
||||
nav .wrap { display: flex; align-items: center; justify-content: space-between; height: 60px; }
|
||||
.brand { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 17px; letter-spacing: 0.2px; color: var(--heading); }
|
||||
.brand .boat { color: var(--accent); flex-shrink: 0; }
|
||||
.nav-links { display: flex; align-items: center; gap: 22px; }
|
||||
.nav-links a { color: var(--muted); font-size: 14px; font-weight: 500; }
|
||||
.nav-links a:hover { color: var(--fg); }
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 9px 16px; border-radius: 10px; font-weight: 600; font-size: 14px;
|
||||
border: 1px solid var(--border); color: var(--fg); background: var(--panel);
|
||||
transition: transform .12s ease, border-color .12s ease, background .12s ease;
|
||||
}
|
||||
.btn:hover { transform: translateY(-1px); border-color: var(--accent); }
|
||||
.btn.primary {
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||||
color: #fff; border: none;
|
||||
}
|
||||
.btn.primary:hover { filter: brightness(1.07); }
|
||||
.nav-links-hamburger {
|
||||
display: none;
|
||||
position: relative;
|
||||
}
|
||||
.hamburger-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
color: var(--fg);
|
||||
cursor: pointer;
|
||||
}
|
||||
.hamburger-btn:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.hamburger-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
right: 0;
|
||||
min-width: 220px;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
padding: 12px;
|
||||
background: rgba(17,17,17,0.98);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,0.4);
|
||||
}
|
||||
.hamburger-menu a {
|
||||
color: var(--muted);
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.hamburger-menu a:hover {
|
||||
color: var(--fg);
|
||||
}
|
||||
.nav-links-hamburger.open .hamburger-menu {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Hero */
|
||||
.hero { padding: 86px 0 40px; text-align: center; }
|
||||
.badge {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
font-size: 12.5px; color: var(--muted); border: 1px solid var(--border);
|
||||
background: var(--panel); padding: 5px 12px; border-radius: 999px; margin-bottom: 22px;
|
||||
}
|
||||
.badge .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); box-shadow: 0 0 8px var(--green); }
|
||||
.hero-logo { display: flex; align-items: center; justify-content: center; gap: 14px; color: var(--accent); margin-bottom: 4px; }
|
||||
.hero-logo svg { filter: drop-shadow(0 4px 18px rgba(224,108,117,0.35)); }
|
||||
.hero-logo .wordmark { font-size: clamp(30px, 6vw, 44px); font-weight: 700; color: var(--heading); letter-spacing: -0.01em; line-height: 1; }
|
||||
.hero h1 {
|
||||
font-size: clamp(32px, 5.4vw, 52px); line-height: 1.12; margin: 0 0 18px;
|
||||
letter-spacing: -0.01em; font-weight: 700; color: var(--heading);
|
||||
}
|
||||
.hero h1 .grad {
|
||||
background: linear-gradient(120deg, var(--accent), var(--accent2));
|
||||
-webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;
|
||||
}
|
||||
.hero .slogan { font-style: italic; color: var(--accent); font-size: 12px; margin: 0 0 24px; letter-spacing: 0.3px; opacity: 0.9; }
|
||||
.hero p.lede { font-size: clamp(16px, 2.4vw, 20px); color: var(--muted); max-width: 680px; margin: 0 auto 30px; }
|
||||
.hero-cta { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; }
|
||||
|
||||
/* terminal origin card */
|
||||
.term-intro { color: var(--fg); font-size: clamp(13px, 1.8vw, 15px); margin: 34px auto 0; max-width: 560px; }
|
||||
.term {
|
||||
max-width: 620px; margin: 12px auto 0; text-align: left;
|
||||
background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
overflow: hidden; box-shadow: 0 24px 60px rgba(0,0,0,0.4);
|
||||
}
|
||||
.term-bar { display: flex; align-items: center; justify-content: space-between; padding: 5px 6px 5px 12px; border-bottom: 1px solid var(--border); background: #20242c; }
|
||||
.term-bar .ttl { color: var(--muted); font-size: 12px; font-family: 'Fira Code', ui-monospace, monospace; }
|
||||
.term-bar .winbtns { display: flex; gap: 1px; }
|
||||
.term-bar .winbtns span { cursor: pointer; }
|
||||
.term { transition: opacity .18s ease, transform .18s ease; }
|
||||
/* Minimized = a rounded "pill", like the app's tab-down dock chip. */
|
||||
.term.term-min { max-width: max-content; border-radius: 999px; box-shadow: 0 6px 22px rgba(0,0,0,0.4); }
|
||||
.term.term-min .term-bar { border-bottom: none; border-radius: 999px; padding: 7px 10px 7px 16px; gap: 12px; background: var(--panel); }
|
||||
.term.term-min pre { display: none; }
|
||||
.term.term-closed { opacity: 0; transform: scale(0.96); pointer-events: none; height: 0; margin: 0 auto; border: 0; overflow: hidden; }
|
||||
.term-reopen {
|
||||
display: none; margin: 14px auto 0; font-family: 'Fira Code', monospace; font-size: 12px;
|
||||
color: var(--muted); background: none; border: 1px dashed var(--border); border-radius: 6px;
|
||||
padding: 5px 12px; cursor: pointer;
|
||||
}
|
||||
.term-reopen:hover { color: var(--accent); border-color: var(--accent); }
|
||||
.term-reopen.show { display: inline-block; }
|
||||
.term-bar .winbtns span {
|
||||
width: 28px; height: 20px; display: inline-flex; align-items: center; justify-content: center;
|
||||
border-radius: 4px; color: var(--muted); font-size: 12px; line-height: 1;
|
||||
}
|
||||
.term-bar .winbtns span:hover { background: rgba(156,222,242,0.12); color: var(--fg); }
|
||||
.term-bar .winbtns span.x:hover { background: #c0392b; color: #fff; }
|
||||
.term pre {
|
||||
margin: 0; padding: 18px 16px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 13.5px; color: var(--fg); line-height: 1.7; white-space: pre-wrap;
|
||||
}
|
||||
.term .cs { color: var(--green); } .term .cm { color: #828997; }
|
||||
.term-cursor { display: inline-block; color: var(--fg); font-weight: 400; animation: term-blink 1.05s steps(1) infinite; }
|
||||
@keyframes term-blink { 50% { opacity: 0; } }
|
||||
|
||||
/* Sections */
|
||||
section { padding: 60px 0; }
|
||||
.eyebrow { color: var(--accent); font-weight: 700; font-size: 13px; letter-spacing: 0.12em; text-transform: uppercase; }
|
||||
h2.h { font-size: clamp(24px, 3.6vw, 32px); margin: 8px 0 12px; letter-spacing: -0.01em; color: var(--heading); font-weight: 700; }
|
||||
.sub { color: var(--muted); max-width: 620px; }
|
||||
.center { text-align: center; }
|
||||
.center .sub { margin: 0 auto; }
|
||||
|
||||
/* Testimonial gag — single featured testimonial, click/swipe to cycle (all sizes) */
|
||||
.tcarousel-wrap { position: relative; max-width: 820px; margin: 36px auto 0; }
|
||||
.tarrow {
|
||||
position: absolute; top: 50%; transform: translateY(-50%); z-index: 4;
|
||||
width: 38px; height: 38px; border-radius: 50%;
|
||||
background: rgba(17,17,17,0.85); border: 1px solid var(--border); color: var(--fg);
|
||||
font-size: 20px; line-height: 1; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: border-color .12s ease, color .12s ease;
|
||||
}
|
||||
.tarrow:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.tarrow.prev { left: 0; }
|
||||
.tarrow.next { right: 0; }
|
||||
.tgrid {
|
||||
display: block; position: relative; overflow: hidden; cursor: pointer;
|
||||
margin: 0 auto; max-width: 740px;
|
||||
}
|
||||
.tgrid .tcard {
|
||||
display: none;
|
||||
flex-direction: row-reverse; align-items: center; gap: 24px; text-align: left;
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 28px;
|
||||
}
|
||||
.tgrid .tcard.active { display: flex; animation: tslide .25s ease both; }
|
||||
.tgrid .tcard.active.shake { animation: tshake .5s ease-in-out 2 both; }
|
||||
.tcard .av {
|
||||
width: 84px; height: 84px; border-radius: 50%; overflow: hidden;
|
||||
border: 1px solid var(--border); background: var(--panel2); flex: 0 0 auto;
|
||||
}
|
||||
.tcard .av img, .tcard .av svg { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.tcard .tmeta { flex: 1 1 auto; }
|
||||
.tcard .q { font-size: 18px; color: var(--fg); margin: 0 0 12px; }
|
||||
.tcard .stars { font-size: 15px; letter-spacing: 3px; margin: 0 0 8px; color: var(--gold); }
|
||||
.tcard .stars.zero { color: var(--muted); opacity: 0.5; }
|
||||
.tcard .nm { font-weight: 700; font-size: 14.5px; }
|
||||
.tcard .rl { color: var(--muted); font-size: 12.5px; }
|
||||
.tcard.cyclops { border-color: rgba(255,90,90,0.45); background: linear-gradient(180deg, rgba(255,80,80,0.06), var(--panel)); }
|
||||
.tcard.cyclops .q { color: #ff8a8a; font-weight: 700; letter-spacing: 0.4px; word-break: break-word; }
|
||||
.tnav { display: block; text-align: center; margin-top: 18px; }
|
||||
.tdot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; background: #39414d; margin: 0 4px; cursor: pointer; }
|
||||
.tdot.on { background: var(--accent); }
|
||||
.thint { font-size: 12px; color: var(--muted); margin-top: 8px; }
|
||||
@keyframes tshake {
|
||||
0%,100% { transform: translateX(0) rotate(0); }
|
||||
10% { transform: translateX(-9px) rotate(-1.5deg); }
|
||||
20% { transform: translateX(9px) rotate(1.5deg); }
|
||||
35% { transform: translateX(-7px) rotate(-1deg); }
|
||||
50% { transform: translateX(7px) rotate(1deg); }
|
||||
65% { transform: translateX(-5px); } 80% { transform: translateX(4px); } 92% { transform: translateX(-2px); }
|
||||
}
|
||||
@keyframes tslide { from { opacity: 0; transform: translateX(24px); } to { opacity: 1; transform: none; } }
|
||||
|
||||
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 36px; }
|
||||
.feature {
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 22px; transition: transform .14s ease, border-color .14s ease;
|
||||
}
|
||||
.feature:hover { transform: translateY(-3px); border-color: var(--accent); }
|
||||
.feature .ico {
|
||||
width: 40px; height: 40px; border-radius: 10px; display: inline-flex; align-items: center; justify-content: center;
|
||||
background: linear-gradient(135deg, rgba(224,108,117,0.18), rgba(53,90,102,0.28));
|
||||
border: 1px solid var(--border); color: var(--accent); margin-bottom: 14px;
|
||||
}
|
||||
.feature h3 { margin: 0 0 6px; font-size: 16.5px; }
|
||||
.feature p { margin: 0; color: var(--muted); font-size: 14px; }
|
||||
|
||||
/* Screenshot strip */
|
||||
.shotrow { display: grid; grid-template-columns: 1.4fr 1fr 1fr; gap: 16px; margin-top: 8px; }
|
||||
.shot {
|
||||
border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden;
|
||||
background: linear-gradient(180deg, var(--panel), var(--panel2));
|
||||
aspect-ratio: 16/10; display: flex; align-items: center; justify-content: center;
|
||||
color: var(--muted); font-size: 13px; position: relative;
|
||||
}
|
||||
.shot .ph { display: flex; flex-direction: column; align-items: center; gap: 8px; opacity: 0.7; }
|
||||
.shot .frame-dots { position: absolute; top: 10px; left: 12px; display: flex; gap: 5px; }
|
||||
.shot .frame-dots i { width: 8px; height: 8px; border-radius: 50%; background: #39414d; display: inline-block; }
|
||||
|
||||
/* Previews — expanding hover carousel that plays a video on hover */
|
||||
.previews { display: flex; align-items: center; gap: 12px; height: 480px; max-width: 1000px; margin: 36px auto 0; }
|
||||
.preview-panel {
|
||||
position: relative; flex: 1 1 0; min-width: 0; height: 360px; overflow: hidden;
|
||||
border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer;
|
||||
background: linear-gradient(180deg, var(--panel), var(--panel2));
|
||||
transition: flex-grow .5s cubic-bezier(.2,.7,.2,1), height .5s cubic-bezier(.2,.7,.2,1), border-color .25s ease;
|
||||
}
|
||||
.previews:hover .preview-panel { flex-grow: 0.55; height: 300px; }
|
||||
.preview-panel:hover, .preview-panel:focus-visible { flex-grow: 3.4 !important; height: 480px !important; border-color: var(--accent); }
|
||||
.preview-panel .ph {
|
||||
position: absolute; inset: 0; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center; gap: 10px;
|
||||
color: var(--muted); font-size: 12.5px; opacity: 0.7; text-align: center; padding: 8px;
|
||||
}
|
||||
.preview-panel video {
|
||||
position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover;
|
||||
z-index: 1; opacity: 0; transition: opacity .3s ease; background: transparent;
|
||||
}
|
||||
.preview-panel.has-video video { opacity: 1; }
|
||||
.preview-panel .label {
|
||||
position: absolute; z-index: 2; left: 0; right: 0; bottom: 0; padding: 14px 16px;
|
||||
background: linear-gradient(0deg, rgba(0,0,0,0.8), transparent);
|
||||
color: var(--heading); font-weight: 700; font-size: 14px;
|
||||
display: flex; align-items: center; gap: 8px; white-space: nowrap;
|
||||
}
|
||||
.preview-panel .label .ico { color: var(--accent); flex-shrink: 0; }
|
||||
@media (max-width: 760px) {
|
||||
.previews { flex-direction: column; height: auto; }
|
||||
.preview-panel { height: 200px; flex: none; }
|
||||
.previews:hover .preview-panel, .preview-panel:hover { flex: none !important; }
|
||||
}
|
||||
|
||||
/* Get started */
|
||||
.start {
|
||||
background: linear-gradient(180deg, var(--panel), var(--bg2));
|
||||
border: 1px solid var(--border); border-radius: 18px; padding: 40px; text-align: center;
|
||||
}
|
||||
.codeblock {
|
||||
display: inline-flex; align-items: center; gap: 14px; margin: 18px auto 8px;
|
||||
background: var(--bg2); border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 12px 16px; font-family: ui-monospace, monospace; font-size: 14px; color: var(--fg);
|
||||
}
|
||||
.codeblock .prompt { color: var(--accent); }
|
||||
.pill-row { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; margin-top: 18px; }
|
||||
.pill { font-size: 12.5px; color: var(--muted); border: 1px solid var(--border); border-radius: 999px; padding: 5px 12px; background: var(--panel); }
|
||||
|
||||
footer { border-top: 1px solid var(--border); padding: 30px 0; color: var(--muted); font-size: 13px; scroll-snap-align: end; }
|
||||
footer .wrap { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; }
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.shotrow { grid-template-columns: 1fr; }
|
||||
.nav-links { display: none; }
|
||||
.nav-links-hamburger { display: block; }
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
.tgrid .tcard { padding: 20px; gap: 16px; }
|
||||
.tcard .av { width: 64px; height: 64px; }
|
||||
.tcard .q { font-size: 15px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav>
|
||||
<div class="wrap">
|
||||
<div class="brand">
|
||||
<svg class="boat" viewBox="0 0 32 32" width="24" height="24" aria-hidden="true"><path d="M16 4L16 22L6 22Z" fill="currentColor"/><path d="M16 8L16 22L24 22Z" fill="currentColor" opacity="0.6"/><path d="M4 24Q10 20 16 24Q22 28 28 24" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round"/></svg>
|
||||
Odysseus
|
||||
</div>
|
||||
<div class="nav-links">
|
||||
<a href="#features">Features</a>
|
||||
<a href="#testimonials">Testimonials</a>
|
||||
<a href="#how">How it started</a>
|
||||
<a href="#start">Get started</a>
|
||||
<a class="btn" href="https://github.com/pewdiepie-archdaemon/odysseus" target="_blank">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.2.8-.6v-2c-3.2.7-3.9-1.5-3.9-1.5-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.3 1.2a11.5 11.5 0 0 1 6 0C17.3 4.7 18.3 5 18.3 5c.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.3c0 .4.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="nav-links-hamburger">
|
||||
<button class="hamburger-btn" aria-label="Open menu">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3.5,7 C3.22385763,7 3,6.77614237 3,6.5 C3,6.22385763 3.22385763,6 3.5,6 L20.5,6 C20.7761424,6 21,6.22385763 21,6.5 C21,6.77614237 20.7761424,7 20.5,7 L3.5,7 Z M3.5,12 C3.22385763,12 3,11.7761424 3,11.5 C3,11.2238576 3.22385763,11 3.5,11 L20.5,11 C20.7761424,11 21,11.2238576 21,11.5 C21,11.7761424 20.7761424,12 20.5,12 L3.5,12 Z M3.5,17 C3.22385763,17 3,16.7761424 3,16.5 C3,16.2238576 3.22385763,16 3.5,16 L20.5,16 C20.7761424,16 21,16.2238576 21,16.5 C21,16.7761424 20.7761424,17 20.5,17 L3.5,17 Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="hamburger-menu">
|
||||
<a href="#features">Features</a>
|
||||
<a href="#testimonials">Testimonials</a>
|
||||
<a href="#how">How it started</a>
|
||||
<a href="#start">Get started</a>
|
||||
|
||||
<a class="btn" href="https://github.com/pewdiepie-archdaemon/odysseus" target="_blank">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.2.8-.6v-2c-3.2.7-3.9-1.5-3.9-1.5-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.3 1.2a11.5 11.5 0 0 1 6 0C17.3 4.7 18.3 5 18.3 5c.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.3c0 .4.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"/>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- HERO -->
|
||||
<header class="hero">
|
||||
<div class="wrap">
|
||||
<div class="hero-logo">
|
||||
<svg viewBox="0 0 32 32" width="48" height="48" aria-hidden="true"><path d="M16 4L16 22L6 22Z" fill="currentColor"/><path d="M16 8L16 22L24 22Z" fill="currentColor" opacity="0.6"/><path d="M4 24Q10 20 16 24Q22 28 28 24" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round"/></svg>
|
||||
<span class="wordmark">Odysseus</span>
|
||||
</div>
|
||||
<p class="slogan">Yours for the voyage.</p>
|
||||
<h1>Your own <span class="grad">AI workspace</span>,<br>running on your hardware.</h1>
|
||||
<p class="lede">
|
||||
Odysseus is a self-hosted interface for talking to language models — chat,
|
||||
autonomous agents, tools, model serving, email, research, and more. Local-first,
|
||||
privacy-first, and no telemetry. Just you and your models.
|
||||
</p>
|
||||
<p style="font-size:11.5px; color:var(--muted); opacity:0.7; max-width:560px; margin:-18px auto 30px;">
|
||||
(if you want to add an API that's cool too — I'm not here to tell you how to live your life…)
|
||||
</p>
|
||||
<div class="hero-cta">
|
||||
<a class="btn primary" href="#start">Get started</a>
|
||||
<a class="btn" href="https://github.com/pewdiepie-archdaemon/odysseus" target="_blank">View on GitHub</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- TESTIMONIALS (gag) -->
|
||||
<section id="testimonials" style="padding-top:30px;">
|
||||
<div class="wrap">
|
||||
<div class="center">
|
||||
<div class="eyebrow">Loved by enterprises</div>
|
||||
<h2 class="h">What our customers are saying</h2>
|
||||
</div>
|
||||
|
||||
<div class="tcarousel-wrap">
|
||||
<button class="tarrow prev" type="button" aria-label="Previous testimonial">‹</button>
|
||||
<div class="tgrid" id="tcarousel">
|
||||
|
||||
<!-- Coder guy -->
|
||||
<figure class="tcard">
|
||||
<span class="av"><img src="https://cdn.prod.website-files.com/66708f90d7e407423093fa76/66708f91d7e407423093fd21_john-carter-testimonial-image-dentistry-x-webflow-template.png" alt="Generic Coder Guy" loading="lazy"></span>
|
||||
<div class="tmeta">
|
||||
<p class="q">"Odysseus helped us ship more ships while shipping ships. Truly best-in-class shipping."</p>
|
||||
<div class="stars">★★★★★</div>
|
||||
<div class="nm">Generic Coder Guy</div>
|
||||
<div class="rl">Sr. Engineer, ShipShip Inc.</div>
|
||||
</div>
|
||||
</figure>
|
||||
|
||||
<!-- Woman -->
|
||||
<figure class="tcard">
|
||||
<span class="av"><img src="https://images.pexels.com/photos/5876695/pexels-photo-5876695.jpeg?auto=compress&cs=tinysrgb&w=160&h=160&fit=crop" alt="A real woman" loading="lazy"></span>
|
||||
<div class="tmeta">
|
||||
<p class="q">"I'm a real person. This is a real testimonial. By a real woman."</p>
|
||||
<div class="stars">★★★★★</div>
|
||||
<div class="nm">Generic Corporate Woman</div>
|
||||
<div class="rl">VP of Verticals, Things LLC</div>
|
||||
</div>
|
||||
</figure>
|
||||
|
||||
<!-- Cyclops -->
|
||||
<figure class="tcard cyclops" data-shake="1">
|
||||
<span class="av" style="border-color:rgba(255,90,90,0.6);">
|
||||
<svg viewBox="0 0 72 72" width="54" height="54" fill="none" stroke="#cbd5e1" stroke-width="2">
|
||||
<rect x="0" y="0" width="72" height="72" fill="#16241a"/>
|
||||
<circle cx="36" cy="32" r="18" fill="#7fae7f" stroke="#5a7a5a"/>
|
||||
<line x1="29" y1="22" x2="43" y2="34" stroke="#ff5a5a" stroke-width="3"/>
|
||||
<line x1="43" y1="22" x2="29" y2="34" stroke="#ff5a5a" stroke-width="3"/>
|
||||
<ellipse cx="36" cy="45" rx="7" ry="9" fill="#3a0a0a" stroke="#200"/>
|
||||
<path d="M31 51 l-1 4" stroke="#fff" stroke-width="2"/><path d="M41 51 l1 4" stroke="#fff" stroke-width="2"/>
|
||||
</svg>
|
||||
</span>
|
||||
<div class="tmeta">
|
||||
<p class="q">"AHHHHHHHHHHHHHHHHHHHHHHHHHHHHH"</p>
|
||||
<div class="stars zero">☆☆☆☆☆</div>
|
||||
<div class="nm">Polyphemus</div>
|
||||
<div class="rl">Cyclops, Cave Solutions (on leave)</div>
|
||||
</div>
|
||||
</figure>
|
||||
|
||||
<!-- Corporate -->
|
||||
<figure class="tcard">
|
||||
<span class="av">
|
||||
<svg viewBox="0 0 80 80" aria-hidden="true">
|
||||
<rect width="80" height="80" rx="18" fill="#111827"/>
|
||||
<circle cx="40" cy="29" r="14" fill="#d1d5db"/>
|
||||
<path d="M18 70c4-18 15-27 22-27s18 9 22 27" fill="#374151"/>
|
||||
<path d="M28 58h24l-5 12H33z" fill="#e06c75"/>
|
||||
<path d="M32 14h16l6 11H26z" fill="#f8fafc"/>
|
||||
</svg>
|
||||
</span>
|
||||
<div class="tmeta">
|
||||
<p class="q">"Anyway, as I was saying — best-in-class."</p>
|
||||
<div class="stars">★★★★★</div>
|
||||
<div class="nm">Chad Corporate</div>
|
||||
<div class="rl">Chief Executive Officer</div>
|
||||
</div>
|
||||
</figure>
|
||||
|
||||
</div>
|
||||
<button class="tarrow next" type="button" aria-label="Next testimonial">›</button>
|
||||
</div>
|
||||
<div class="tnav" id="tnav"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FEATURES -->
|
||||
<section id="features">
|
||||
<div class="wrap">
|
||||
<div class="center">
|
||||
<div class="eyebrow">Everything, self-hosted</div>
|
||||
<h2 class="h">One app, a lot of capabilities</h2>
|
||||
<p class="sub">Started as an AI chat. Became a workspace. Each piece runs locally against
|
||||
whatever endpoints you point it at.</p>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div class="feature">
|
||||
<span class="ico"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></span>
|
||||
<h3>Chat & Agents</h3>
|
||||
<p>Multi-turn chat plus autonomous agents that plan, call tools, and work through tasks.</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<span class="ico"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.8-3.8a6 6 0 0 1-7.9 7.9l-6.9 6.9a2.1 2.1 0 0 1-3-3l6.9-6.9a6 6 0 0 1 7.9-7.9z"/></svg></span>
|
||||
<h3>Tools & MCP</h3>
|
||||
<p>Built-in tools (bash, files, web, memory) plus any MCP server you connect. Toggle per tool.</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<span class="ico"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg></span>
|
||||
<h3>Cookbook</h3>
|
||||
<p>Hardware-aware model recommendations and one-click serving across 270+ catalogued models.</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<span class="ico"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-10 5L2 7"/></svg></span>
|
||||
<h3>Email Assistant</h3>
|
||||
<p>AI summaries, style-matched draft replies, auto-tagging and spam triage over IMAP/SMTP.</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<span class="ico"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg></span>
|
||||
<h3>Deep Research</h3>
|
||||
<p>Multi-step research runs that gather, read, and synthesize sources into a written report.</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<span class="ico"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="8" height="18" rx="1"/><rect x="14" y="3" width="8" height="18" rx="1"/></svg></span>
|
||||
<h3>Compare</h3>
|
||||
<p>Send one prompt to several models at once and compare their answers side-by-side.</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<span class="ico"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14c0 1.7 4 3 9 3s9-1.3 9-3V5"/><path d="M3 12c0 1.7 4 3 9 3s9-1.3 9-3"/></svg></span>
|
||||
<h3>Memory</h3>
|
||||
<p>Persistent memory the assistant builds up and recalls across all your conversations.</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<span class="ico"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/></svg></span>
|
||||
<h3>Skills <span style="font-size:10.5px;font-weight:700;color:var(--accent);border:1px solid var(--border);border-radius:999px;padding:1px 7px;margin-left:4px;vertical-align:middle;">self-evolving</span></h3>
|
||||
<p>The assistant writes, refines, and reuses its own skills — getting more capable over time.</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<span class="ico"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
|
||||
<h3>Private by default</h3>
|
||||
<p>Runs on your machine against your own endpoints. No telemetry, with optional external integrations when you choose them.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- The one-shot prompt it started from (gag) -->
|
||||
<section style="padding-top:0;">
|
||||
<div class="wrap" style="text-align:center;">
|
||||
<p class="term-intro">Odysseus was created by a carefully crafted one-shot AI prompt:</p>
|
||||
<div class="term">
|
||||
<div class="term-bar">
|
||||
<span class="ttl">user@odysseus: ~</span>
|
||||
<span class="winbtns"><span data-term="min" title="Minimize">–</span><span class="x" data-term="close" title="Close">✕</span></span>
|
||||
</div>
|
||||
<pre id="term-pre"><span class="cs">></span> idk what to make can you write it for me?
|
||||
actually make an ai chat, but make it good
|
||||
and also make it better</pre>
|
||||
</div>
|
||||
<button class="term-reopen" type="button">✕ reopen terminal</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- PREVIEWS — hover to expand + play -->
|
||||
<section id="previews">
|
||||
<div class="wrap">
|
||||
<div class="center">
|
||||
<div class="eyebrow">See it in action</div>
|
||||
<h2 class="h">Hover to take a closer look</h2>
|
||||
<p class="sub center">Each panel expands and plays its preview when you hover it.</p>
|
||||
</div>
|
||||
<div class="previews">
|
||||
<div class="preview-panel" tabindex="0">
|
||||
<div class="ph"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg><span>[ Chat & Agents ]</span></div>
|
||||
<video muted loop playsinline preload="none"><source src="chat.webm" type="video/webm"><source src="chat.mp4" type="video/mp4"></video>
|
||||
<div class="label"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>Chat & Agents</div>
|
||||
</div>
|
||||
<div class="preview-panel" tabindex="0">
|
||||
<div class="ph"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg><span>[ Cookbook ]</span></div>
|
||||
<div class="label"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>Cookbook</div>
|
||||
</div>
|
||||
<div class="preview-panel" tabindex="0">
|
||||
<div class="ph"><svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-10 5L2 7"/></svg><span>[ Email Assistant ]</span></div>
|
||||
<video muted loop playsinline preload="none"><source src="email.webm" type="video/webm"><source src="email.mp4" type="video/mp4"></video>
|
||||
<div class="label"><svg class="ico" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-10 5L2 7"/></svg>Email Assistant</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- HOW IT STARTED -->
|
||||
<section id="how">
|
||||
<div class="wrap">
|
||||
<div class="eyebrow">How it actually started</div>
|
||||
<h2 class="h">Odysseus is everything I hate, just making it tolerable.</h2>
|
||||
<p class="sub" style="max-width:760px;">
|
||||
I started working on the Odysseus project because running local AI felt fun — a step into the future.
|
||||
But the options to actually engage with LLMs felt like taking steps back. Where were
|
||||
features like Memory, Deep Research, Agents, and just basic integrations?!
|
||||
</p>
|
||||
<p class="sub" style="max-width:760px; margin-top:14px;">
|
||||
So I started building my own, for fun — and eventually figured it might be fun to
|
||||
share what I built for myself with others. Doesn't work for you? Well… it runs
|
||||
great on my hardware.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- GET STARTED -->
|
||||
<section id="start">
|
||||
<div class="wrap">
|
||||
<div class="start">
|
||||
<div class="eyebrow">Get started</div>
|
||||
<h2 class="h" style="margin-bottom:6px;">Clone it and run</h2>
|
||||
<p class="sub center" style="margin:0 auto;">It's open source and free. No sales team, no demo request — just clone the repo.</p>
|
||||
<div class="codeblock"><span class="prompt">$</span> git clone https://github.com/pewdiepie-archdaemon/odysseus.git && cd odysseus</div>
|
||||
<div>
|
||||
<a class="btn primary" href="https://github.com/pewdiepie-archdaemon/odysseus" target="_blank" style="margin-top:14px;">View on GitHub</a>
|
||||
</div>
|
||||
<div class="pill-row">
|
||||
<span class="pill">Self-hosted</span>
|
||||
<span class="pill">Bring your own models</span>
|
||||
<span class="pill">Local-first</span>
|
||||
<span class="pill">MCP-ready</span>
|
||||
<span class="pill">No telemetry</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<div class="wrap">
|
||||
<div>© 2026 Odysseus · Built from one prompt that refused to stop.</div>
|
||||
<div>No cyclopes were harmed in production.<sup>*</sup></div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Typewriter for the origin terminal: type line 1, pause 2s, line 2, pause
|
||||
// 2s, line 3, hold 4s, then reset and loop. Blinking "|" cursor throughout.
|
||||
(function () {
|
||||
var pre = document.getElementById('term-pre');
|
||||
if (!pre) return;
|
||||
var lines = [
|
||||
{ p: '<span class="cs">></span> ', t: 'idk what to make can you write it for me?' },
|
||||
{ p: ' ', t: 'actually make an ai chat, but make it good' },
|
||||
{ p: ' ', t: 'and also make it better' }
|
||||
];
|
||||
var CURSOR = '<span class="term-cursor">|</span>';
|
||||
var TYPE_MS = 40;
|
||||
var done = [], li = 0, timer = null;
|
||||
|
||||
function render(partial) {
|
||||
pre.innerHTML = done.join('\n') + (done.length ? '\n' : '') + partial + CURSOR;
|
||||
}
|
||||
function typeLine() {
|
||||
var ln = lines[li], i = 0;
|
||||
(function step() {
|
||||
if (i <= ln.t.length) {
|
||||
render(ln.p + ln.t.slice(0, i));
|
||||
i++; timer = setTimeout(step, TYPE_MS);
|
||||
} else {
|
||||
done.push(ln.p + ln.t);
|
||||
li++;
|
||||
if (li >= lines.length) timer = setTimeout(reset, 4000); // hold last line 4s
|
||||
else timer = setTimeout(typeLine, 2000); // pause 2s before next
|
||||
}
|
||||
})();
|
||||
}
|
||||
function reset() { clearTimeout(timer); done = []; li = 0; typeLine(); }
|
||||
|
||||
// Start typing only when the terminal scrolls into view (and replay each
|
||||
// time you return to it).
|
||||
if ('IntersectionObserver' in window) {
|
||||
var io2 = new IntersectionObserver(function (entries) {
|
||||
entries.forEach(function (e) { if (e.isIntersecting) reset(); });
|
||||
}, { threshold: 0.45 });
|
||||
io2.observe(pre);
|
||||
} else {
|
||||
reset();
|
||||
}
|
||||
})();
|
||||
|
||||
// Previews: hovering a panel expands it (CSS) and plays its video; the
|
||||
// video only becomes visible once it actually starts playing, so missing
|
||||
// files just leave the labeled placeholder.
|
||||
(function () {
|
||||
document.querySelectorAll('.preview-panel').forEach(function (p) {
|
||||
var v = p.querySelector('video');
|
||||
if (!v) return;
|
||||
v.addEventListener('playing', function () { p.classList.add('has-video'); });
|
||||
v.addEventListener('pause', function () { /* keep last frame */ });
|
||||
var play = function () { var pr = v.play(); if (pr && pr.catch) pr.catch(function () {}); };
|
||||
p.addEventListener('mouseenter', play);
|
||||
p.addEventListener('focus', play);
|
||||
p.addEventListener('mouseleave', function () { v.pause(); });
|
||||
p.addEventListener('blur', function () { v.pause(); });
|
||||
p.addEventListener('click', function () { if (v.paused) play(); else v.pause(); });
|
||||
});
|
||||
})();
|
||||
|
||||
// Domino reveal: fade/slide each section in as it scrolls into view.
|
||||
(function () {
|
||||
var els = document.querySelectorAll('.hero, section');
|
||||
if (!('IntersectionObserver' in window)) {
|
||||
els.forEach(function (e) { e.classList.add('in'); });
|
||||
return;
|
||||
}
|
||||
var io = new IntersectionObserver(function (entries) {
|
||||
entries.forEach(function (e) {
|
||||
if (e.isIntersecting) { e.target.classList.add('in'); io.unobserve(e.target); }
|
||||
});
|
||||
}, { threshold: 0.12, rootMargin: '0px 0px -8% 0px' });
|
||||
els.forEach(function (e) { io.observe(e); });
|
||||
})();
|
||||
|
||||
// Fake terminal window buttons — minimize, maximize, close (and reopen).
|
||||
(function () {
|
||||
var term = document.querySelector('.term');
|
||||
var reopen = document.querySelector('.term-reopen');
|
||||
if (!term) return;
|
||||
term.querySelectorAll('.winbtns [data-term]').forEach(function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
var act = b.getAttribute('data-term');
|
||||
if (act === 'min') term.classList.toggle('term-min');
|
||||
else if (act === 'close') {
|
||||
term.classList.add('term-closed');
|
||||
if (reopen) reopen.classList.add('show');
|
||||
}
|
||||
});
|
||||
});
|
||||
if (reopen) reopen.addEventListener('click', function () {
|
||||
term.classList.remove('term-closed', 'term-min');
|
||||
reopen.classList.remove('show');
|
||||
});
|
||||
})();
|
||||
|
||||
// Mobile testimonial carousel: tap or swipe to advance; Polyphemus shakes ~1s.
|
||||
(function () {
|
||||
var carousel = document.getElementById('tcarousel');
|
||||
var nav = document.getElementById('tnav');
|
||||
if (!carousel || !nav) return;
|
||||
var cards = [].slice.call(carousel.querySelectorAll('.tcard'));
|
||||
if (!cards.length) return;
|
||||
var idx = 0;
|
||||
|
||||
var dots = cards.map(function (_, k) {
|
||||
var d = document.createElement('span');
|
||||
d.className = 'tdot';
|
||||
d.addEventListener('click', function (e) { e.stopPropagation(); show(k); });
|
||||
nav.appendChild(d);
|
||||
return d;
|
||||
});
|
||||
var hint = document.createElement('div');
|
||||
hint.className = 'thint';
|
||||
hint.textContent = 'tap or swipe for the next satisfied customer →';
|
||||
nav.appendChild(hint);
|
||||
|
||||
function show(i) {
|
||||
idx = (i + cards.length) % cards.length;
|
||||
cards.forEach(function (c, k) { c.classList.toggle('active', k === idx); c.classList.remove('shake'); });
|
||||
dots.forEach(function (d, k) { d.classList.toggle('on', k === idx); });
|
||||
var cur = cards[idx];
|
||||
if (cur.getAttribute('data-shake') === '1') {
|
||||
void cur.offsetWidth;
|
||||
cur.classList.add('shake');
|
||||
setTimeout(function () { cur.classList.remove('shake'); }, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
carousel.addEventListener('click', function () { show(idx + 1); });
|
||||
|
||||
var _prev = document.querySelector('.tarrow.prev');
|
||||
var _next = document.querySelector('.tarrow.next');
|
||||
if (_prev) _prev.addEventListener('click', function (e) { e.stopPropagation(); show(idx - 1); });
|
||||
if (_next) _next.addEventListener('click', function (e) { e.stopPropagation(); show(idx + 1); });
|
||||
|
||||
var sx = null;
|
||||
carousel.addEventListener('touchstart', function (e) { sx = e.touches[0].clientX; }, { passive: true });
|
||||
carousel.addEventListener('touchend', function (e) {
|
||||
if (sx === null) return;
|
||||
var dx = e.changedTouches[0].clientX - sx;
|
||||
if (Math.abs(dx) > 30) { show(idx + (dx < 0 ? 1 : -1)); }
|
||||
sx = null;
|
||||
});
|
||||
|
||||
show(0);
|
||||
})();
|
||||
|
||||
|
||||
// Mobile navigation: open/close hamburger menu
|
||||
(function () {
|
||||
var container = document.querySelector('.nav-links-hamburger');
|
||||
if (!container) return;
|
||||
|
||||
var button = container.querySelector('.hamburger-btn');
|
||||
|
||||
button.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
container.classList.toggle('open');
|
||||
});
|
||||
|
||||
document.addEventListener('click', function () {
|
||||
container.classList.remove('open');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
+416
-6
@@ -3478,6 +3478,38 @@ body.bg-pattern-sparkles {
|
||||
.continue-btn:hover {
|
||||
opacity:0.8;
|
||||
}
|
||||
|
||||
/* Round-cap "Continue" affordance — a cohesive centered pill at the chat
|
||||
bottom (not the bare red in-message stopped style). */
|
||||
.rounds-exhausted {
|
||||
justify-content:center;
|
||||
gap:12px;
|
||||
width:fit-content;
|
||||
max-width:90%;
|
||||
margin:14px auto 4px;
|
||||
padding:7px 8px 7px 16px;
|
||||
border:1px solid var(--border);
|
||||
border-radius:999px;
|
||||
background:color-mix(in srgb, var(--fg) 4%, transparent);
|
||||
opacity:1;
|
||||
}
|
||||
.rounds-exhausted .rounds-exhausted-label {
|
||||
color:color-mix(in srgb, var(--fg) 60%, transparent);
|
||||
font-size:0.95em;
|
||||
}
|
||||
.rounds-exhausted .continue-btn {
|
||||
font-size:0.9em;
|
||||
font-weight:600;
|
||||
opacity:1;
|
||||
color:var(--bg);
|
||||
background:var(--accent, var(--red));
|
||||
border-radius:999px;
|
||||
padding:4px 14px;
|
||||
line-height:1.3;
|
||||
}
|
||||
.rounds-exhausted .continue-btn:hover {
|
||||
opacity:0.88;
|
||||
}
|
||||
.ctx-indicator {
|
||||
display:inline-flex; align-items:center; gap:1px;
|
||||
font-size:0.75rem;
|
||||
@@ -8835,6 +8867,57 @@ body.hide-thinking .thinking-section { display: none !important; }
|
||||
list-style: none;
|
||||
}
|
||||
.agent-tool-output summary::-webkit-details-marker { display: none; }
|
||||
/* File-write diff — neutral chrome (not the red error tint) + colored lines */
|
||||
.agent-tool-diff {
|
||||
background: color-mix(in srgb, var(--fg) 4%, transparent);
|
||||
border-color: color-mix(in srgb, var(--fg) 18%, transparent);
|
||||
}
|
||||
.agent-tool-diff summary {
|
||||
color: var(--fg);
|
||||
background: color-mix(in srgb, var(--fg) 7%, transparent);
|
||||
border-bottom-color: color-mix(in srgb, var(--fg) 12%, transparent);
|
||||
}
|
||||
.agent-tool-diff .diff-stat {
|
||||
font-weight: 600;
|
||||
opacity: 0.7;
|
||||
font-family: var(--mono, monospace);
|
||||
}
|
||||
/* Collapsed diff summary: filename + +adds/−dels (theme green/red). */
|
||||
.agent-tool-diff summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.agent-tool-diff .diff-file {
|
||||
font-family: var(--mono, monospace);
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.agent-tool-diff .diff-summary-stats {
|
||||
margin-left: auto;
|
||||
font-family: var(--mono, monospace);
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.agent-tool-diff .diff-summary-stats .diff-stat-add { color: var(--green, #2ecc71); }
|
||||
.agent-tool-diff .diff-summary-stats .diff-stat-del { color: var(--red, #e74c3c); }
|
||||
.agent-tool-diff .diff-summary-stats .diff-stat-new { color: var(--accent, var(--red)); opacity: 0.85; }
|
||||
.diff-pre {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
overflow-x: auto;
|
||||
font-family: var(--mono, monospace);
|
||||
font-size: 0.82em;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.diff-pre span { display: block; white-space: pre; }
|
||||
.diff-pre .diff-add { background: color-mix(in srgb, #2ecc71 22%, transparent); }
|
||||
.diff-pre .diff-del { background: color-mix(in srgb, #e74c3c 22%, transparent); }
|
||||
.diff-pre .diff-hunk { color: var(--accent); opacity: 0.85; }
|
||||
.diff-pre .diff-meta { opacity: 0.55; }
|
||||
.diff-pre .diff-ctx { opacity: 0.8; }
|
||||
/* Suppress the global `summary::before { content: '▶' }` left arrow — this
|
||||
section uses a right-side chevron instead. */
|
||||
.agent-tool-output summary::before { content: none; }
|
||||
@@ -13976,8 +14059,28 @@ body:has(.doc-version-panel:not(.hidden)) .hamburger-btn {
|
||||
.admin-model-form-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap; /* let buttons drop to a new row on narrow widths
|
||||
instead of overflowing the modal */
|
||||
align-items: center;
|
||||
}
|
||||
.admin-model-form-row input {
|
||||
flex: 1 1 180px; /* api-key input takes the long axis but allows
|
||||
dropping below the buttons at small widths */
|
||||
min-width: 0; /* don't refuse to shrink past content width */
|
||||
}
|
||||
.admin-model-form-row select {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.admin-model-form-row button {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* On narrow screens, give buttons their own row + push the Add button
|
||||
to the right so it remains the obvious primary action. */
|
||||
@media (max-width: 540px) {
|
||||
.admin-model-form-row input { flex-basis: 100%; }
|
||||
.admin-model-form-row .admin-btn-add { margin-left: auto; }
|
||||
}
|
||||
.admin-model-form-row input { flex: 1; }
|
||||
.adm-ep-inline-msg {
|
||||
min-height: 16px;
|
||||
margin-top: 5px;
|
||||
@@ -18136,8 +18239,12 @@ body.gallery-selecting .gallery-dl-btn,
|
||||
color: var(--fg-muted);
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
/* "running" pill on a Serve-tab card when the model has a live serve task. */
|
||||
.cookbook-serve-running-pill {
|
||||
/* Status pills shown inline in a Serve-tab card title (next to the model
|
||||
name) when the model has a live serve / download task. Shared base
|
||||
class so "running" and "downloading" sit on the same row with the
|
||||
same chrome; only color varies. */
|
||||
.cookbook-serve-running-pill,
|
||||
.cookbook-serve-downloading-pill {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
padding: 1px 7px;
|
||||
@@ -18148,11 +18255,50 @@ body.gallery-selecting .gallery-dl-btn,
|
||||
letter-spacing: 0.3px;
|
||||
vertical-align: 2px;
|
||||
position: relative;
|
||||
top: -1px;
|
||||
top: 1px; /* nudged down 2px from the old -1px so it sits
|
||||
flush with the cap-height of the title text */
|
||||
}
|
||||
.cookbook-serve-running-pill {
|
||||
color: var(--accent, var(--red));
|
||||
background: color-mix(in srgb, var(--accent, var(--red)) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent, var(--red)) 35%, transparent);
|
||||
}
|
||||
.cookbook-serve-downloading-pill {
|
||||
color: var(--accent, var(--red));
|
||||
background: color-mix(in srgb, var(--accent, var(--red)) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent, var(--red)) 35%, transparent);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.cookbook-serve-downloading-pill.is-stalled {
|
||||
/* Stalled downloads stay visible but read as warning, not progress. */
|
||||
color: var(--fg-muted, #888);
|
||||
background: color-mix(in srgb, var(--fg-muted, #888) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--fg-muted, #888) 30%, transparent);
|
||||
opacity: 1;
|
||||
}
|
||||
.cookbook-serve-running-pill.is-clickable {
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, border-color 0.12s;
|
||||
}
|
||||
.cookbook-serve-running-pill.is-clickable:hover {
|
||||
background: color-mix(in srgb, var(--accent, var(--red)) 22%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent, var(--red)) 55%, transparent);
|
||||
}
|
||||
/* Brief highlight on the matched task card when jumping from the
|
||||
running pill, so the user can spot it among a long list. */
|
||||
.cookbook-task-flash {
|
||||
animation: cookbook-task-flash-anim 1.6s ease-out;
|
||||
}
|
||||
@keyframes cookbook-task-flash-anim {
|
||||
0% { box-shadow: 0 0 0 2px var(--accent, var(--red)); }
|
||||
100% { box-shadow: 0 0 0 2px transparent; }
|
||||
}
|
||||
|
||||
/* Cookbook header "downloading" status label sits 2px too far left
|
||||
against the rest of the cookbook chrome — nudge it right. */
|
||||
#cookbook-bg-status {
|
||||
left: 2px;
|
||||
}
|
||||
.cookbook-serve-dir-edit {
|
||||
font-size: 9px;
|
||||
color: var(--fg-muted);
|
||||
@@ -19281,6 +19427,11 @@ body.gallery-selecting .gallery-dl-btn,
|
||||
background: color-mix(in srgb, var(--fg) 7%, transparent);
|
||||
font-size: 12px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--fg) 6%, transparent);
|
||||
/* Pin the row so flex parents + Firefox mobile can't squeeze its height to 0,
|
||||
which hides the type pill + model name and leaves only the sub-line +
|
||||
output visible. */
|
||||
flex-shrink: 0;
|
||||
min-height: 32px;
|
||||
}
|
||||
.cookbook-task-type {
|
||||
text-transform: uppercase;
|
||||
@@ -33447,8 +33598,15 @@ button.cal-view-btn {
|
||||
of days it covers within the row, --slot stacks parallel bars. */
|
||||
.cal-multiday {
|
||||
position: absolute;
|
||||
left: calc(var(--col, 0) * (100% / 7));
|
||||
width: calc(var(--span, 1) * (100% / 7));
|
||||
/* Fractional offsets let timed events that cross midnight render at
|
||||
true proportional width. start-frac shifts the left edge into the
|
||||
first day; end-frac trims the right edge inside the last day.
|
||||
All-day events default to (0, 1) and still fill whole columns. */
|
||||
left: calc((var(--col, 0) + var(--start-frac, 0)) * (100% / 7));
|
||||
width: calc(
|
||||
(var(--span, 1) - var(--start-frac, 0) - (1 - var(--end-frac, 1)))
|
||||
* (100% / 7)
|
||||
);
|
||||
top: calc(2px + var(--slot, 0) * 12px);
|
||||
height: 11px;
|
||||
font-size: 8px;
|
||||
@@ -35731,3 +35889,255 @@ body.theme-frosted .modal {
|
||||
is already ≥16px and never zoomed — leave it so we don't shrink it. */
|
||||
.doc-email-richbody.doc-font-m { font-size: 16px !important; }
|
||||
}
|
||||
|
||||
/* GitHub Copilot device-flow connect block (model endpoints → API) */
|
||||
.adm-copilot-connect {
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.adm-copilot-connect #adm-copilotStatus { flex-basis: 100%; margin-top: 0; }
|
||||
.adm-copilot-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.adm-copilot-wait {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--fg) 70%, transparent);
|
||||
}
|
||||
.adm-copilot-coderow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.adm-copilot-code-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: color-mix(in srgb, var(--fg) 45%, transparent);
|
||||
}
|
||||
.adm-copilot-code {
|
||||
font-family: var(--mono, ui-monospace, monospace);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
padding: 4px 10px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--fg);
|
||||
user-select: all;
|
||||
}
|
||||
.adm-copilot-copy { margin-left: auto; }
|
||||
.adm-copilot-auth {
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
padding: 7px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.adm-copilot-hint {
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: color-mix(in srgb, var(--fg) 45%, transparent);
|
||||
}
|
||||
/* ── Workspace picker ───────────────────────────────────────────── */
|
||||
/* Layout (width/flex column/max-height) inherited from base .modal-content. */
|
||||
/* Editable path/address bar: reuses .styled-prompt-input for border/bg/radius/
|
||||
focus ring (set in the element's class list). Overrides only the deltas:
|
||||
mono font, and full-bleed via flex stretch with no horizontal margin (the
|
||||
modal-content's 10px padding is the gutter) instead of the base width:100%,
|
||||
which overflowed against the overflow:auto scrollbar. */
|
||||
.workspace-cur {
|
||||
align-self: stretch;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
margin: 4px 0 8px;
|
||||
font-family: var(--mono, monospace);
|
||||
font-size: 12px;
|
||||
}
|
||||
/* flex/overflow inherited from base .modal-body; only the padding differs. */
|
||||
.workspace-body { padding: 6px 0; }
|
||||
.workspace-row {
|
||||
padding: 7px 18px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.workspace-row > span {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.workspace-row-icon { flex-shrink: 0; opacity: 0.75; }
|
||||
.workspace-row:hover {
|
||||
background: color-mix(in srgb, var(--border) 20%, transparent);
|
||||
}
|
||||
.workspace-up { opacity: 0.7; }
|
||||
.workspace-empty { padding: 14px 18px; opacity: 0.5; font-size: 13px; }
|
||||
.workspace-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 10px 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
/* Cookbook serve panel: Launch + ^ split button pair */
|
||||
.hwfit-serve-launch-group {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.hwfit-serve-launch-group .hwfit-serve-launch {
|
||||
border-top-right-radius: 0 !important;
|
||||
border-bottom-right-radius: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
.hwfit-serve-launch-group .hwfit-serve-schedule-arrow {
|
||||
border-top-left-radius: 0 !important;
|
||||
border-bottom-left-radius: 0 !important;
|
||||
border-left: 1px solid var(--border) !important;
|
||||
padding: 0 8px !important;
|
||||
min-width: 26px;
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Schedule form — mounted inside the cookbook serve panel. Uses the
|
||||
theme tokens (--bg, --panel, --border, --accent, --red) so it
|
||||
matches the rest of the cookbook chrome instead of inline whites. */
|
||||
.hwfit-schedule-form {
|
||||
margin: 10px 0 4px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.hwfit-schedule-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
opacity: 0.95;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.hwfit-schedule-title svg { opacity: 0.7; flex-shrink: 0; }
|
||||
.hwfit-schedule-title-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.hwfit-schedule-title-spacer { flex: 1; min-width: 8px; }
|
||||
.hwfit-schedule-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.hwfit-schedule-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
font-size: 11px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.hwfit-schedule-field input[type="time"] {
|
||||
font: inherit;
|
||||
min-width: 90px;
|
||||
}
|
||||
.hwfit-schedule-label {
|
||||
font-size: 11px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.hwfit-sched-days {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
.hwfit-sched-day-chip {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s, border-color 0.12s;
|
||||
}
|
||||
.hwfit-sched-day-chip:hover { border-color: var(--accent, var(--red)); }
|
||||
.hwfit-sched-day-chip.is-on {
|
||||
background: var(--accent, var(--red));
|
||||
color: var(--panel, #fff);
|
||||
border-color: var(--accent, var(--red));
|
||||
}
|
||||
.hwfit-schedule-actions-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.hwfit-schedule-actions-spacer { flex: 1; }
|
||||
|
||||
/* Days row hosts the day-chip strip on the left and the Cancel / Save
|
||||
action buttons on the right. The spacer between them pushes the
|
||||
actions to the right edge. */
|
||||
.hwfit-schedule-days-row {
|
||||
align-items: center;
|
||||
}
|
||||
/* Cancel + Save sit on the right side of the days row with matching
|
||||
icon-plus-label chrome. */
|
||||
.hwfit-sched-cancel,
|
||||
.hwfit-sched-save {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
}
|
||||
.hwfit-schedule-mirror-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.hwfit-schedule-mirror-label { white-space: nowrap; }
|
||||
.hwfit-schedule-mirror-switch { transform: scale(0.85); transform-origin: left center; }
|
||||
.hwfit-sched-err {
|
||||
font-size: 12px;
|
||||
color: var(--red, #ff6b6b);
|
||||
display: none;
|
||||
}
|
||||
.hwfit-sched-err.is-visible { display: block; }
|
||||
@media (max-width: 600px) {
|
||||
.hwfit-schedule-row { gap: 8px; }
|
||||
.hwfit-sched-day-chip { width: 36px; height: 36px; font-size: 11px; }
|
||||
}
|
||||
|
||||
/* Brief outline flash on a note card when it's the target of a
|
||||
#note-<id> link click from chat — same pattern as the cookbook
|
||||
task flash, just scoped to .note-card. */
|
||||
.note-card-flash {
|
||||
animation: note-card-flash-anim 1.6s ease-out;
|
||||
}
|
||||
@keyframes note-card-flash-anim {
|
||||
0% { box-shadow: 0 0 0 2px var(--accent, var(--red)); }
|
||||
100% { box-shadow: 0 0 0 2px transparent; }
|
||||
}
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
|
||||
// - API / non-GET: never cached.
|
||||
// Bump CACHE_NAME whenever the precache list or SW logic changes.
|
||||
const CACHE_NAME = 'odysseus-v326';
|
||||
const CACHE_NAME = 'odysseus-v327';
|
||||
|
||||
// Core shell precached on install so repeat opens are instant without any
|
||||
// network wait. Keep this list in sync with the <script type="module"> tags
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Shared loader for CLI scripts under scripts/."""
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_SCRIPTS_DIR = Path(__file__).resolve().parents[2] / "scripts"
|
||||
|
||||
|
||||
def load_script(script_name):
|
||||
"""Load a script from scripts/ by name and return it as a module.
|
||||
|
||||
The module name is derived from the script name (hyphens become underscores,
|
||||
with a _cli suffix) giving each script a stable, unique import identity.
|
||||
|
||||
Any sys.modules stubs the script needs at import time must be injected via
|
||||
monkeypatch before calling this function.
|
||||
"""
|
||||
module_name = script_name.replace("-", "_") + "_cli"
|
||||
path = _SCRIPTS_DIR / script_name
|
||||
loader = importlib.machinery.SourceFileLoader(module_name, str(path))
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Shared database stub helpers for CLI and unit tests."""
|
||||
import sys
|
||||
import types
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
def make_core_db_stub(monkeypatch, models=()):
|
||||
"""Create a core.database stub and inject it via monkeypatch.
|
||||
|
||||
Always sets SessionLocal. Pass model class names via `models` to set
|
||||
each as a MagicMock attribute on the stub.
|
||||
|
||||
Returns the stub module for optional further configuration.
|
||||
"""
|
||||
db = types.ModuleType("core.database")
|
||||
db.SessionLocal = MagicMock()
|
||||
for name in models:
|
||||
setattr(db, name, MagicMock())
|
||||
monkeypatch.setitem(sys.modules, "core.database", db)
|
||||
return db
|
||||
@@ -16,6 +16,10 @@ src = src.replace(
|
||||
/import \{ splitTableRow \} from '\.\/markdown\/tableRow\.js';/,
|
||||
'const splitTableRow = (row) => row.split("|").filter((cell) => cell.trim() !== "");'
|
||||
);
|
||||
src = src.replace(
|
||||
/import \{ replaceEmojiShortcodes, hasEmojiShortcode \} from '\.\/emojiShortcodes\.js';/,
|
||||
'const hasEmojiShortcode = (t) => !!t && t.indexOf(":") !== -1 && /:[a-z0-9_+-]{1,40}:/i.test(t); const replaceEmojiShortcodes = (t) => t;'
|
||||
);
|
||||
src = src.replace(/export function /g, 'function ');
|
||||
src = src.replace(/export const /g, 'const ');
|
||||
src = src.replace(/export default markdownModule;?/g, '');
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user